diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d6a700f --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# 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 +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= diff --git a/.gitignore b/.gitignore index 247a8ba..d97fca7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..9f142a1 --- /dev/null +++ b/.htaccess @@ -0,0 +1,22 @@ + + RewriteEngine On + RewriteBase /accounting_dev + + # Redirect requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^(.*)$ index.php?/$1 [L] + + +Options -Indexes + + + Require all denied + + + + 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=()" + diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..c48b68d --- /dev/null +++ b/.phpunit.result.cache @@ -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},"times":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":0.015,"ApplicationContractTest::testAjaxSessionExpiryReturnsJson401":0,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":0.001,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":0,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":0.9,"FormulaServiceTest::testPayrollFormulaHonorsPrecedenceAndVariables":0,"FormulaServiceTest::testProrataFormulaAndRounding":0,"FormulaServiceTest::testUnknownVariableIsZeroForBackwardCompatibleFormula":0,"FormulaServiceTest::testRejectsDivisionByZero":0.047,"FormulaServiceTest::testRejectsUnsafeTokens":0,"ApplicationContractTest::testMoneyInputsUseCentralParserBeforeAjax":0.001,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":0,"ApplicationContractTest::testBalanceSheetAndProfitLossRemainInNavigation":0,"ApplicationContractTest::testProfessionalReportsHaveIndonesianFinancialStatements":0.001,"ApplicationContractTest::testPurchasePaymentsSeparatePrincipalAndBankCharge":0.001,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":0.005,"AccountingDatabaseTest::testPurchaseAdjustmentInvariantsAgainstDevelopmentDatabase":0.47,"ApplicationContractTest::testPurchaseAdjustmentsSupportReversalAuditAndProfessionalReports":0.001,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":0.615,"ApplicationContractTest::testProfessionalInventoryUsesOneLedgerAndSafeWorkflow":0.001,"AccountingDatabaseTest::testProfessionalInventoryRuntimeAgainstDevelopmentDatabase":0.603,"ApplicationContractTest::testInventoryFinalizationCoversRequiredOperationalScenarios":0.002,"ApplicationContractTest::testInventoryWriteEndpointsAndButtonsArePermissionAware":0.001,"AccountingDatabaseTest::testInventoryIntegrityAndNegativeStockGuardAgainstDevelopmentDatabase":0.611,"AccountingDatabaseTest::testInventoryReconciliationAlwaysProducesAuditableNumbers":0.224,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":0.002,"AccountingDatabaseTest::testFixedAssetIntegrityAndRuntimeQueries":0.473}} \ No newline at end of file diff --git a/application/config/accounting.php b/application/config/accounting.php new file mode 100644 index 0000000..4d73fe8 --- /dev/null +++ b/application/config/accounting.php @@ -0,0 +1,77 @@ + 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) +); + +$config['require_separate_journal_approver'] = filter_var( + getenv('WORKFLOW_REQUIRE_SEPARATE_APPROVER') ?: 'false', + FILTER_VALIDATE_BOOLEAN +); diff --git a/application/config/autoload.php b/application/config/autoload.php index e43f177..cd53332 100644 --- a/application/config/autoload.php +++ b/application/config/autoload.php @@ -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'); /* | ------------------------------------------------------------------- diff --git a/application/config/config.php b/application/config/config.php new file mode 100644 index 0000000..16b0829 --- /dev/null +++ b/application/config/config.php @@ -0,0 +1,537 @@ +]+$/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'] = getenv('APP_ENCRYPTION_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'] = getenv('SESSION_DRIVER') ?: 'database'; +$config['sess_cookie_name'] = 'accounting_session'; +$config['sess_samesite'] = 'Lax'; +$config['sess_expiration'] = 7200; +$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(getenv('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'] = ''; diff --git a/application/config/database.php b/application/config/database.php new file mode 100644 index 0000000..f038532 --- /dev/null +++ b/application/config/database.php @@ -0,0 +1,99 @@ +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' => getenv('DB_HOST') ?: '127.0.0.1', + 'port' => (int) (getenv('DB_PORT') ?: 3306), + 'username' => getenv('DB_USERNAME') ?: '', + 'password' => getenv('DB_PASSWORD') ?: '', + 'database' => getenv('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') +); + diff --git a/application/config/migration.php b/application/config/migration.php index 4b585a6..a65a2b4 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -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; /* |-------------------------------------------------------------------------- diff --git a/application/config/mimes.php b/application/config/mimes.php index b2e989f..1720ea6 100644 --- a/application/config/mimes.php +++ b/application/config/mimes.php @@ -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', diff --git a/application/config/permissions.php b/application/config/permissions.php new file mode 100644 index 0000000..d58ee5e --- /dev/null +++ b/application/config/permissions.php @@ -0,0 +1,27 @@ + 'Dashboard', 'approvals' => 'Daftar Approval', + 'invoices' => 'Penjualan, Invoice & Piutang', 'purchases' => 'Pembelian, Supplier & Hutang', + 'items' => 'Persediaan & Gudang', + 'technician_equipment' => 'Peralatan Teknisi - Akses', + 'technician_equipment_all' => 'Peralatan Teknisi - Lihat Seluruh Teknisi', + 'technician_attachment' => 'Peralatan Teknisi - Lihat Lampiran', + 'technician_handover' => 'Peralatan Teknisi - Serah Terima', + 'technician_installation' => 'Peralatan Teknisi - Pasang & Lepas', + 'technician_transfer' => 'Peralatan Teknisi - Transfer', + 'technician_return' => 'Peralatan Teknisi - Pengembalian', + 'technician_inspection' => 'Peralatan Teknisi - Pemeriksaan', + 'technician_condition' => 'Peralatan Teknisi - Rusak/Hilang/Nonaktif', + 'technician_correction' => 'Peralatan Teknisi - Koreksi', + 'asset' => 'Aset Tetap', + 'jurnal' => 'Jurnal & Periode Accounting', + 'reports' => 'Laporan Profesional', 'budgets' => 'Budget', + 'employees' => 'HR, Absensi & Payroll' +); +$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' +); diff --git a/application/config/routes.php b/application/config/routes.php index 0acd358..1d249b2 100644 --- a/application/config/routes.php +++ b/application/config/routes.php @@ -53,6 +53,9 @@ $route['default_controller'] = 'dashboard'; $route['404_override'] = ''; $route['translate_uri_dashes'] = FALSE; +$route['login'] = 'auth/index'; +$route['logout'] = 'auth/logout'; + // untuk webhook absensi $route['iclock/cdata'] = 'AttendanceWebhook/cdata'; @@ -63,3 +66,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'; + diff --git a/application/config/ui_assets.php b/application/config/ui_assets.php new file mode 100644 index 0000000..debe0c8 --- /dev/null +++ b/application/config/ui_assets.php @@ -0,0 +1,27 @@ +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');$permissions=$this->session->userdata('permissions');$known=is_array($permissions)&&array_key_exists('technician_attachment',$permissions);$fallback=!$known&&(check_permission('technician_equipment','can_view')||check_permission('items','can_view'));if(!is_master_admin_user()&&!check_permission('technician_attachment','can_view')&&!$fallback)show_error('Akses lampiran ditolak.',403,'Akses ditolak');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('items','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('items','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);} +} diff --git a/application/controllers/Accountingcheck.php b/application/controllers/Accountingcheck.php new file mode 100644 index 0000000..171d77f --- /dev/null +++ b/application/controllers/Accountingcheck.php @@ -0,0 +1,158 @@ +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(); + } +} diff --git a/application/controllers/Accountingperiods.php b/application/controllers/Accountingperiods.php new file mode 100644 index 0000000..2ff3159 --- /dev/null +++ b/application/controllers/Accountingperiods.php @@ -0,0 +1,78 @@ +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 = ' ' + . ' ' + . ''; + $rows[] = array( + $period->fiscal_year_name, + $period->period_number, + $period->name, + $period->start_date, + $period->end_date, + ''.strtoupper(str_replace('_', ' ', $period->status)).'', + $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); + } + } +} diff --git a/application/controllers/Accountingrepair.php b/application/controllers/Accountingrepair.php new file mode 100644 index 0000000..714a265 --- /dev/null +++ b/application/controllers/Accountingrepair.php @@ -0,0 +1,27 @@ +load->library('AuditService'); } + public function duplicate_refs() + { + if (strtolower((string)getenv('APP_ENV')) === '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"; } + } +} diff --git a/application/controllers/Accounts.php b/application/controllers/Accounts.php index 1aeaacd..0456ae1 100644 --- a/application/controllers/Accounts.php +++ b/application/controllers/Accounts.php @@ -1,12 +1,14 @@ 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 ? 'Disembunyikan' : 'Ditampilkan', + $row->is_active ? 'Aktif' : 'Nonaktif', ' @@ -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); } } -} \ No newline at end of file +} diff --git a/application/controllers/Approvals.php b/application/controllers/Approvals.php new file mode 100644 index 0000000..be4db0e --- /dev/null +++ b/application/controllers/Approvals.php @@ -0,0 +1,13 @@ +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'); + } +} diff --git a/application/controllers/Asset.php b/application/controllers/Asset.php index 532ad40..20592c1 100644 --- a/application/controllers/Asset.php +++ b/application/controllers/Asset.php @@ -1,505 +1,16 @@ 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 ? '
'.$d->barcode.'' : ''), - $d->nama_lokasi ?? '-', - $d->keterangan ?? '-', - number_format($d->nilai_perolehan), - $d->masa_manfaat . ' bln', - number_format($d->penyusutan_per_bulan), - $d->created_at, - ' - - - - ' - ]; - } - - 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 ' .strtoupper($alasan).' 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 - ]); - } -} \ No newline at end of file +} diff --git a/application/controllers/AttendanceWebhook.php b/application/controllers/AttendanceWebhook.php index f1a99c1..f159db2 100644 --- a/application/controllers/AttendanceWebhook.php +++ b/application/controllers/AttendanceWebhook.php @@ -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) getenv('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"; } -} \ No newline at end of file +} diff --git a/application/controllers/Attendancemonitoring.php b/application/controllers/Attendancemonitoring.php index 8d3d5b2..526a40f 100644 --- a/application/controllers/Attendancemonitoring.php +++ b/application/controllers/Attendancemonitoring.php @@ -1,7 +1,7 @@ $rows ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Attendancemonthly.php b/application/controllers/Attendancemonthly.php index 9da4956..09057f3 100644 --- a/application/controllers/Attendancemonthly.php +++ b/application/controllers/Attendancemonthly.php @@ -1,7 +1,7 @@ $rows ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Auth.php b/application/controllers/Auth.php index 068b2bf..cb29405 100644 --- a/application/controllers/Auth.php +++ b/application/controllers/Auth.php @@ -17,26 +17,50 @@ class Auth extends CI_Controller { $this->load->view('auth/login'); } - 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)) { + if ($user && (int)$user->role_is_active === 1 && password_verify($password, $user->password)) { + + $this->session->sess_regenerate(true); + $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 ]; @@ -53,7 +77,17 @@ class Auth extends CI_Controller { redirect('dashboard'); - } else { + } else { + + $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,7 +97,7 @@ 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'); } } @@ -84,4 +118,4 @@ class Auth extends CI_Controller { redirect('auth'); } } -?> \ No newline at end of file +?> diff --git a/application/controllers/Basejurnal.php b/application/controllers/Basejurnal.php index 5bdb790..748c69c 100644 --- a/application/controllers/Basejurnal.php +++ b/application/controllers/Basejurnal.php @@ -1,7 +1,7 @@ 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'); + } + } + } +} diff --git a/application/controllers/Budgets.php b/application/controllers/Budgets.php new file mode 100644 index 0000000..a450803 --- /dev/null +++ b/application/controllers/Budgets.php @@ -0,0 +1,52 @@ +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(); + $data=array('active_menu'=>'budgets','active_tab'=>$tab,'accounts'=>$accounts,'budget_accounts'=>$budgetAccounts,'budget_account_count'=>count($budgetAccounts),'entries'=>$this->budgetservice->entries($listAccount),'reminders'=>$this->budgetservice->currentReminders(),'list_account_id'=>$listAccount,'filters'=>$filters,'report_rows'=>$tab==='accounts'?$this->budgetservice->report($filters):array(),'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);} +} diff --git a/application/controllers/Bukubesar.php b/application/controllers/Bukubesar.php index caa1817..d5dfcd6 100644 --- a/application/controllers/Bukubesar.php +++ b/application/controllers/Bukubesar.php @@ -1,7 +1,7 @@ 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')); if ($account_id) { $this->db->where('journal_details.account_id', $account_id); @@ -105,4 +106,4 @@ class Bukubesar extends CI_Controller { echo json_encode($result); } -} \ No newline at end of file +} diff --git a/application/controllers/Cameraqrscan.php b/application/controllers/Cameraqrscan.php index d3ee79f..439102c 100644 --- a/application/controllers/Cameraqrscan.php +++ b/application/controllers/Cameraqrscan.php @@ -1,7 +1,7 @@ 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,',','.'));} +} diff --git a/application/controllers/Companies.php b/application/controllers/Companies.php new file mode 100644 index 0000000..2390f53 --- /dev/null +++ b/application/controllers/Companies.php @@ -0,0 +1,11 @@ +load->database();$this->load->library(array('CompanyContext','MultiCurrencyService'));} + public function index(){$d=array('active_menu'=>'companies');$d['companies']=$this->db->get('companies')->result();$d['currencies']=$this->db->where('is_active',1)->get('currencies')->result();$d['rates']=$this->db->select('r.*,c.name company_name')->from('exchange_rates r')->join('companies c','c.id=r.company_id')->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');} + public function switch_company($id){$this->companycontext->assertAccess($id);$this->session->set_userdata('company_id',(int)$id);redirect('professionalreports');} + public function save(){$this->db->insert('companies',array('company_code'=>strtoupper($this->input->post('code',true)),'name'=>$this->input->post('name',true),'legal_name'=>$this->input->post('legal_name',true),'tax_number'=>$this->input->post('tax_number',true),'base_currency'=>$this->input->post('base_currency'),'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));$id=$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));redirect('companies');} + public function rate(){$this->db->insert('exchange_rates',array('company_id'=>$this->companycontext->id(),'rate_date'=>$this->input->post('date'),'from_currency'=>$this->input->post('from_currency'),'to_currency'=>$this->input->post('to_currency'),'rate'=>$this->input->post('rate'),'source'=>$this->input->post('source',true),'created_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s')));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');} +} diff --git a/application/controllers/Cronjob.php b/application/controllers/Cronjob.php index 020ff5a..bc4b29d 100644 --- a/application/controllers/Cronjob.php +++ b/application/controllers/Cronjob.php @@ -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; diff --git a/application/controllers/Customers.php b/application/controllers/Customers.php index 511b866..65f4ea2 100644 --- a/application/controllers/Customers.php +++ b/application/controllers/Customers.php @@ -1,7 +1,7 @@ $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 +118,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 +157,4 @@ class Customers extends CI_Controller { echo json_encode(['status'=>true,'message'=>'Data berhasil dihapus']); } -} \ No newline at end of file +} diff --git a/application/controllers/Dashboard.php b/application/controllers/Dashboard.php index 7e96c2f..6d47b8e 100644 --- a/application/controllers/Dashboard.php +++ b/application/controllers/Dashboard.php @@ -1,12 +1,13 @@ load->library('session'); + $this->load->library('PerformanceCacheService'); // Cek apakah user sudah login if (!$this->session->userdata('logged_in')) { @@ -16,6 +17,12 @@ class Dashboard extends CI_Controller { public function index() { + $companyId=(int)$this->companycontext->id(); + $summary=$this->performancecacheservice->remember('dashboard:summary:'.$companyId,300,function()use($companyId){ + $row=$this->db->query("SELECT COALESCE(SUM(IF(a.tipe='revenue',d.kredit-d.debit,0)),0) total_revenue,COALESCE(SUM(IF(a.tipe='expense',d.debit-d.kredit,0)),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",array($companyId))->row_array(); + $row['net_profit']=(float)$row['total_revenue']-(float)$row['total_expense'];return$row; + }); + goto dashboard_summary_ready; $this->db->select(' accounts.id, accounts.kode_akun, @@ -26,6 +33,8 @@ class Dashboard extends CI_Controller { '); $this->db->from('accounts'); $this->db->join('journal_details','accounts.id = journal_details.account_id','left'); + $this->db->join('journals','journals.id = journal_details.journal_id','left'); + $this->db->where("(journals.status IN ('posted','reversed') OR journal_details.id IS NULL)", null, false); $this->db->where('accounts.kategori','laba_rugi'); $this->db->where('accounts.is_active',1); $this->db->group_by('accounts.id'); @@ -56,6 +65,7 @@ class Dashboard extends CI_Controller { 'net_profit' => $revenue - $expense ]; + dashboard_summary_ready: $today = date('Y-m'); $ai_insight = $this->db ->select('type, severity, message, meta, created_at') @@ -214,6 +224,7 @@ class Dashboard extends CI_Controller { $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_in('journals.status', array('posted', 'reversed')); $this->db->where('accounts.kategori', 'laba_rugi'); $this->db->where('accounts.is_active', 1); @@ -269,6 +280,7 @@ class Dashboard extends CI_Controller { { $this->db->select('DISTINCT YEAR(tanggal) as year'); $this->db->from('journals'); + $this->db->where_in('status', array('posted', 'reversed')); $this->db->order_by('year', 'DESC'); $data = $this->db->get()->result(); diff --git a/application/controllers/Databasesync.php b/application/controllers/Databasesync.php new file mode 100644 index 0000000..0c7f133 --- /dev/null +++ b/application/controllers/Databasesync.php @@ -0,0 +1,51 @@ +input->is_cli_request()) { + show_error('Database sync hanya dapat dijalankan melalui CLI.', 403, 'Akses ditolak'); + } + if (!filter_var(getenv('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; + } +} diff --git a/application/controllers/Dbcompare.php b/application/controllers/Dbcompare.php new file mode 100644 index 0000000..5b5e856 --- /dev/null +++ b/application/controllers/Dbcompare.php @@ -0,0 +1,338 @@ +sourceConfig = $this->readConnectionConfig('DBCOMPARE_SOURCE'); + $this->targetConfig = $this->readConnectionConfig('DBCOMPARE_TARGET'); + } + + private function readConnectionConfig($prefix) + { + $config = array( + 'host' => getenv($prefix . '_HOST'), + 'port' => (int) getenv($prefix . '_PORT'), + 'user' => getenv($prefix . '_USERNAME'), + 'pass' => getenv($prefix . '_PASSWORD'), + 'db' => getenv($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); + } +} diff --git a/application/controllers/Dbmonitor.php b/application/controllers/Dbmonitor.php new file mode 100644 index 0000000..5c61308 --- /dev/null +++ b/application/controllers/Dbmonitor.php @@ -0,0 +1,6 @@ +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;} +} diff --git a/application/controllers/Employees.php b/application/controllers/Employees.php index 7b1f03c..185b081 100644 --- a/application/controllers/Employees.php +++ b/application/controllers/Employees.php @@ -1,12 +1,16 @@ requireAdmin(); + $this->load->database(); $this->load->helper(['url', 'file']); @@ -473,4 +477,4 @@ class Employees extends CI_Controller { ->set_content_type('application/json') ->set_output(json_encode($data)); } -} \ No newline at end of file +} diff --git a/application/controllers/Fixedassetcheck.php b/application/controllers/Fixedassetcheck.php new file mode 100644 index 0000000..2e9a47e --- /dev/null +++ b/application/controllers/Fixedassetcheck.php @@ -0,0 +1,34 @@ +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.005scalar("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(*)'];} +} diff --git a/application/controllers/Fixedassets.php b/application/controllers/Fixedassets.php new file mode 100644 index 0000000..ad6c940 --- /dev/null +++ b/application/controllers/Fixedassets.php @@ -0,0 +1,100 @@ +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,',','.');} +} diff --git a/application/controllers/Generatebuku.php b/application/controllers/Generatebuku.php index 0bb5738..e02423d 100644 --- a/application/controllers/Generatebuku.php +++ b/application/controllers/Generatebuku.php @@ -1,7 +1,7 @@ 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')); if (!empty($account_id)) { $this->db->where('journal_details.account_id', (int) $account_id); @@ -135,4 +136,4 @@ class Generatebuku extends CI_Controller $pdf->Output('D', $filename); exit; } -} \ No newline at end of file +} diff --git a/application/controllers/Generateinvoice.php b/application/controllers/Generateinvoice.php index 555b698..d931b44 100644 --- a/application/controllers/Generateinvoice.php +++ b/application/controllers/Generateinvoice.php @@ -1,7 +1,7 @@ TotalBarang(count($data)); // $pdf->Pembuat($company['nama_bendahara']); $pdf->Output('I','Data_Barang_'.date('YmdHis').'.pdf'); } -} \ No newline at end of file +} diff --git a/application/controllers/Generatelabarugi.php b/application/controllers/Generatelabarugi.php index 3f639a1..2667782 100644 --- a/application/controllers/Generatelabarugi.php +++ b/application/controllers/Generatelabarugi.php @@ -1,7 +1,7 @@ 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; } -} \ No newline at end of file +} diff --git a/application/controllers/Generateneraca.php b/application/controllers/Generateneraca.php index fe038be..e64ec6d 100644 --- a/application/controllers/Generateneraca.php +++ b/application/controllers/Generateneraca.php @@ -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; } -} \ No newline at end of file +} diff --git a/application/controllers/Generateneracasaldo.php b/application/controllers/Generateneracasaldo.php index 10a5c6c..d6dca3a 100644 --- a/application/controllers/Generateneracasaldo.php +++ b/application/controllers/Generateneracasaldo.php @@ -1,7 +1,7 @@ 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; } -} \ No newline at end of file +} diff --git a/application/controllers/Generateschedule.php b/application/controllers/Generateschedule.php index d1de16b..583a669 100644 --- a/application/controllers/Generateschedule.php +++ b/application/controllers/Generateschedule.php @@ -1,7 +1,7 @@ requirePermission('employees');$this->load->database();$this->load->library('PayrollService');} + private function uid(){return(int)$this->session->userdata('user_id');}private function go($ok,$m){$this->session->set_flashdata($ok?'success':'error',$m);redirect('hrpayroll');} + public function index(){$d=array('active_menu'=>'hr_payroll');$d['periods']=$this->db->select('p.*,COUNT(x.id) employee_count,COALESCE(SUM(x.gross_salary),0) gross,COALESCE(SUM(x.total_deductions),0) deductions,COALESCE(SUM(x.total_salary),0) net')->from('k_payroll_periods p')->join('k_payrolls x','x.payroll_period_id=p.id','left')->group_by('p.id')->order_by('p.start_date','DESC')->get()->result();$d['components']=$this->db->order_by('code')->get('payroll_components')->result();$d['locks']=$this->db->order_by('period','DESC')->get('attendance_period_locks')->result();$d['cash_accounts']=$this->db->where('is_active',1)->get('cash_accounts')->result();$this->load->view('partials/header',$d);$this->load->view('hr_payroll/index',$d);$this->load->view('partials/footer');} + public function lock_attendance(){try{$period=$this->input->post('period');if($this->db->get_where('attendance_period_locks',array('period'=>$period,'branch_id'=>null))->row())$this->db->where('period',$period)->where('branch_id IS NULL',null,false)->update('attendance_period_locks',array('status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>$this->input->post('notes',true)));else$this->db->insert('attendance_period_locks',array('period'=>$period,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>$this->input->post('notes',true)));$this->go(true,'Absensi '.$period.' dikunci.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function calculate(){try{$id=$this->payrollservice->calculate($this->input->post('month'),$this->uid());$this->go(true,'Draft payroll dihitung. Periode #'.$id);}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function workflow($id,$action){try{$this->payrollservice->workflow($id,$action,$this->uid());$this->go(true,'Workflow payroll berhasil: '.$action);}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function post($id){try{$this->payrollservice->post($id,$this->input->post('date'),$this->uid());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function pay($id){try{$this->payrollservice->pay($id,$this->input->post('date'),(int)$this->input->post('cash_account_id'),$this->uid());$this->go(true,'Payroll berhasil dibayar dan jurnal pembayaran diposting.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function component(){try{$id=(int)$this->input->post('id');$data=array('code'=>strtoupper(trim($this->input->post('code',true))),'name'=>$this->input->post('name',true),'component_type'=>$this->input->post('component_type'),'category'=>$this->input->post('category'),'formula_expression'=>$this->input->post('formula_expression',true)?:null,'fixed_amount'=>$this->input->post('fixed_amount')!==''?(float)$this->input->post('fixed_amount'):null,'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'=>1);if($id)$this->db->where('id',$id)->update('payroll_components',$data);else$this->db->insert('payroll_components',$data);$this->go(true,'Komponen payroll disimpan.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function slip($payrollId){$p=$this->db->select('x.*,e.full_name,e.employee_code,r.period_name,r.workflow_status')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->join('k_payroll_periods r','r.id=x.payroll_period_id')->where('x.id',$payrollId)->get()->row();if(!$p||!in_array($p->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$p,'lines'=>$lines));} +} diff --git a/application/controllers/Inventorycheck.php b/application/controllers/Inventorycheck.php new file mode 100644 index 0000000..99d66ba --- /dev/null +++ b/application/controllers/Inventorycheck.php @@ -0,0 +1,38 @@ +"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"; + } +} diff --git a/application/controllers/Inventoryprofessional.php b/application/controllers/Inventoryprofessional.php new file mode 100644 index 0000000..07fc872 --- /dev/null +++ b/application/controllers/Inventoryprofessional.php @@ -0,0 +1,205 @@ +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;} +} diff --git a/application/controllers/Inventoryrepair.php b/application/controllers/Inventoryrepair.php new file mode 100644 index 0000000..ce7fb0f --- /dev/null +++ b/application/controllers/Inventoryrepair.php @@ -0,0 +1,9 @@ +load->library('ProfessionalInventoryService');} + public function negative_opening(){if(strtolower((string)getenv('APP_ENV'))==='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)getenv('APP_ENV'))==='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)getenv('APP_ENV'))==='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";} +} diff --git a/application/controllers/Invoices.php b/application/controllers/Invoices.php index 3fde5ba..c5be185 100644 --- a/application/controllers/Invoices.php +++ b/application/controllers/Invoices.php @@ -1,1940 +1,121 @@ load->library('SalesService');} + private function company(){return(int)$this->companycontext->id();} + 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);} + private function permissions(){return array('view'=>$this->allowed('can_view'),'create'=>$this->allowed('can_create'),'update'=>$this->allowed('can_update'),'delete'=>$this->allowed('can_delete'),'submit'=>$this->allowed('can_submit')||$this->allowed('can_create'),'approve'=>$this->allowed('can_approve'),'post'=>$this->allowed('can_post'),'export'=>$this->allowed('can_export'));} + public function index(){$this->page('invoices/index',array('active_menu'=>'sales_invoice','summary'=>$this->salesservice->dashboard(),'permissions'=>$this->permissions()));} + public function draft(){redirect('invoices?workflow_status=draft');} + public function paid(){redirect('invoices?payment_status=paid');} - // ========================= - // 🔥 CONFIG AKUN - // ========================= - private $akun_persediaan = 21; - private $akun_hpp = 63; - private $akun_piutang = 19; - private $pendapatan_dimuka = 78; // Pendapatan Diterima Dimuka - private $persediaan_dijual = 80; // Persediaan Dalam Penjualan - private $pendapatan_barang = 62; - - - public function __construct() + public function create() { - parent::__construct(); - $this->load->database(); - - $this->load->model('DynamicModel', 'dm'); - - if (!$this->session->userdata('logged_in')) { - redirect('auth'); - } + $this->requirePermission('invoices','can_create'); + $this->page('invoices/editor',array('active_menu'=>'sales_invoice','mode'=>'create','invoice'=>null,'permissions'=>$this->permissions(),'source_order'=>(int)$this->input->get('source_order'))); } - - // ========================= - // VIEW - // ========================= - - public function paid() + public function edit($id) { - $data = ["active_menu" => "invoice_paid"]; - $this->load->view('partials/header', $data); - $this->load->view('invoices/layout'); - $this->load->view('partials/footer'); + $this->requirePermission('invoices','can_update'); + try{$invoice=$this->salesservice->invoiceForEditor($id);$this->guardView($invoice);if($invoice->workflow_status!=='draft')redirect('invoices/detail/'.$invoice->id);$this->page('invoices/editor',array('active_menu'=>'sales_invoice','mode'=>'edit','invoice'=>$invoice,'permissions'=>$this->permissions(),'source_order'=>0));}catch(Throwable$e){show_error($e->getMessage(),404,'Invoice tidak ditemukan');} } - - public function draft() - { - $data = ["active_menu" => "invoice_draft"]; - $this->load->view('partials/header', $data); - $this->load->view('invoices/layout'); - $this->load->view('partials/footer'); - } - - // ========================= - // GET DATA (FILTER STATUS) - // ========================= - - public function get_data($status = null) - { - $this->db->select(' - invoices.id, - invoices.customer_id, - invoices.no_invoice, - invoices.tanggal, - invoices.jatuh_tempo, - invoices.total, - invoices.total_bayar, - invoices.sisa_piutang, - invoices.status, - customers.nama - '); - $this->db->from('invoices'); - $this->db->join('customers','customers.id = invoices.customer_id'); - - // ========================= - // FILTER STATUS - // ========================= - if ($status) { - if($status == 'draft'){ - $this->db->where_in('invoices.status', ['unpaid','partial','draft']); - } else { - $this->db->where('invoices.status', $status); - } - } - - $this->db->where('invoices.deleted_at IS NULL', null, false); - $this->db->order_by('invoices.tanggal','DESC'); - $this->db->order_by('customers.nama','ASC'); - - $data = $this->db->get()->result(); - - $result = []; - $no = 1; - - foreach ($data as $row) { - - // ========================= - // CEK TUNGGAKAN SEBELUMNYA - // ========================= - $this->db->select_sum('sisa_piutang'); - $this->db->where('customer_id', $row->customer_id); - $this->db->where('status !=', 'paid'); - $this->db->where('id <', $row->id); - - $tunggakan = $this->db->get('invoices')->row()->sisa_piutang ?? 0; - - // ========================= - // STATUS BADGE - // ========================= - switch ($row->status) { - - case 'paid': - $status_badge = 'PAID'; - break; - - case 'partial': - $status_badge = 'PARTIAL'; - break; - - case 'unpaid': - $status_badge = 'UNPAID'; - break; - - default: - $status_badge = 'DRAFT'; - } - - // ========================= - // AKSI - // ========================= - $aksi = ''; - - // tombol detail - $aksi .= ' '; - - // ========================= - // LOGIC BAYAR - // ========================= - $bolehBayar = ($tunggakan <= 0); - - // DRAFT - if($row->status == 'draft'){ - - if ($row->total > 0 && $bolehBayar) { - $aksi .= ' '; - } - - $aksi .= ''; - } - - // UNPAID + PARTIAL - if(in_array($row->status, ['unpaid','partial'])){ - - if($bolehBayar){ - $aksi .= ' '; - } else { - // indikator tunggakan - $aksi .= ' - Ada tunggakan - '; - } - } - - // ========================= - // FORMAT OUTPUT - // ========================= - $result[] = [ - $no++, - ''.$row->no_invoice.'', - tanggal_indo($row->tanggal), - tanggal_indo($row->jatuh_tempo), - $row->nama, - 'Rp '.number_format($row->total,0,',','.').'', - 'Rp '.number_format($row->total_bayar,0,',','.').'', - 'Rp '.number_format($row->sisa_piutang,0,',','.').'', - $status_badge, - $aksi - ]; - } - - echo json_encode(["data"=>$result]); - } - - // ========================= - // GET CUSTOMER - // ========================= - public function get_customers() - { - echo json_encode($this->db->get('customers')->result()); - } - - // ========================= - // SAVE INVOICE - // ========================= - public function save() - { - $this->output->set_content_type('application/json'); - - $customer_id = $this->input->post('customer_id'); - $tanggal = $this->input->post('tanggal'); - $jatuh_tempo = $this->input->post('jatuh_tempo'); - - // ========================= - // VALIDASI WAJIB FIELD - // ========================= - if (empty($customer_id) || empty($tanggal) || empty($jatuh_tempo)) { - echo json_encode([ - 'status' => false, - 'message' => 'Customer, tanggal, dan jatuh tempo wajib diisi' - ]); - return; - } - - // ========================= - // NORMALISASI TANGGAL - // ========================= - $today = date('Y-m-d'); - $tanggal = date('Y-m-d', strtotime($tanggal)); - $jatuh_tempo = date('Y-m-d', strtotime($jatuh_tempo)); - - - // ❌ jatuh tempo tidak boleh kurang dari tanggal invoice - if ($jatuh_tempo < $tanggal) { - echo json_encode([ - 'status' => false, - 'message' => 'Jatuh tempo tidak boleh lebih kecil dari tanggal invoice' - ]); - return; - } - - // ========================= - // GENERATE NO INVOICE - // ========================= - $tanggal_now = date('Ymd'); - - $this->db->like('no_invoice', 'INV-'.$tanggal_now, 'after'); - $this->db->from('invoices'); - $count = $this->db->count_all_results(); - - $urutan = str_pad($count + 1, 4, '0', STR_PAD_LEFT); - - $no_invoice = 'INV-' . $tanggal_now . '-' . $urutan; - - // ========================= - // HANDEL Adb->query(" - SELECT * - FROM invoice_installments - WHERE customer_id = ? - AND status IN ('unpaid','partial') - AND (nominal - paid_amount) > 0 - AND is_billed = 0 - ORDER BY invoice_detail_id, angsuran_ke ASC - ", [$customer_id])->result(); - - $cicilan = []; - $group = []; - - foreach ($cicilan_raw as $c) { - $group[$c->invoice_detail_id][] = $c; - } - - foreach ($group as $detail_id => $rows) { - - $picked_status = []; - $min_row = null; - - foreach ($rows as $r) { - - // ambil angsuran terkecil - if ($min_row === null) { - $min_row = $r; - $picked_status[$r->status] = true; - $cicilan[] = $r; - continue; - } - - // ambil jika status beda dan belum pernah diambil - if (!isset($picked_status[$r->status])) { - $picked_status[$r->status] = true; - $cicilan[] = $r; - } - } - } - - // ========================= - // TRANSACTION - // ========================= - $this->db->trans_start(); - - $this->db->insert('invoices', [ - 'no_invoice' => $no_invoice, - 'customer_id' => $customer_id, - 'tanggal' => $tanggal, - 'jatuh_tempo' => $jatuh_tempo, - 'total' => 0, - 'status' => 'draft' - ]); - - $invoice_id = $this->db->insert_id(); - - $total_invoice = 0; - - foreach ($cicilan as $c) { - - // ambil data item asli - $inv_items = $this->db - ->select('account_id, items_id, nama_item, keterangan, subtotal_asli, is_cicilan') - ->where('id', $c->invoice_detail_id) - ->get('invoice_details') - ->row(); - - if(!$inv_items) continue; - - // ========================= - // FORMAT NAMA - // ========================= - if($c->paid_amount > 0){ - $priodecicilan = '( Sisa Cicilan ke ' . $c->angsuran_ke . '/' . $c->total_angsuran . ' )'; - }else{ - $priodecicilan = '( Cicilan ke ' . $c->angsuran_ke . '/' . $c->total_angsuran . ' )'; - } - - $nama_item = preg_replace( - '/\s*\(\s*Cicilan.*$/i', - '', - $inv_items->nama_item - ) . ' ' . $priodecicilan; - - // ========================= - // NOMINAL - // ========================= - $harga_cicilan = max(0, $c->nominal - $c->paid_amount); - if($harga_cicilan <= 0) continue; - - // ========================= - // INSERT DETAIL - // ========================= - $insert_data = [ - 'invoice_id' => $invoice_id, - 'tanggal' => $tanggal, - 'account_id' => $inv_items->account_id, - 'items_id' => $inv_items->items_id ?: null, - 'nama_item' => $nama_item, - 'qty' => 1, - 'harga' => $harga_cicilan, - 'subtotal' => $harga_cicilan, - 'warehouse_id' => null, - 'keterangan' => $inv_items->keterangan, - 'subtotal_asli' => $inv_items->subtotal_asli, - 'is_cicilan' => $inv_items->is_cicilan, - 'source_cicilan_id' => $c->id, - 'total_hpp_barang' => $c->cicilan_hpp_barang - ]; - - $this->db->insert('invoice_details', $insert_data); - - // ========================= - // AKUMULASI TOTAL - // ========================= - $total_invoice += $harga_cicilan; - - // ========================= - // MARK SUDAH DITAGIHKAN - // ========================= - $this->db->where('id', $c->id)->update('invoice_installments', [ - 'is_billed' => 1 - ]); - } - - // ========================= - // UPDATE TOTAL INVOICE - // ========================= - $this->update_total($invoice_id); - - $this->db->trans_complete(); - - // ========================= - // RESPONSE - // ========================= - if (!$this->db->trans_status()) { - echo json_encode([ - 'status' => false, - 'message' => 'Gagal membuat invoice' - ]); - return; - } - - log_activity( - 'invoice', - 'create', - 'Membuat invoice ' . $no_invoice . ' untuk customer ID ' . $customer_id, - 'success' - ); - - echo json_encode([ - 'status' => true, - 'invoice_id' => $invoice_id, - 'no_invoice' => $no_invoice - ]); - } - - // ========================= - // DELETE - // ========================= - public function delete($id) - { - // Ambil invoice - $invoice = $this->db->get_where('invoices', ['id' => $id])->row(); - - if (!$invoice) { - echo json_encode(['status'=>false,'message'=>'Invoice tidak ditemukan']); - return; - } - - // ❌ Cek detail - $cekDetail = $this->db->where('invoice_id', $id) - ->count_all_results('invoice_details'); - - if ($cekDetail > 0) { - echo json_encode([ - 'status'=>false, - 'message'=>'Invoice tidak bisa dihapus karena masih memiliki detail' - ]); - return; - } - - if ($invoice->posted == 1) { - echo json_encode([ - 'status'=>false, - 'message'=>'Invoice sudah diposting, tidak bisa dihapus' - ]); - return; - } - - // ❌ Cek sudah dibayar - if (in_array($invoice->status, ['paid','partial'])) { - echo json_encode([ - 'status'=>false, - 'message'=>'Invoice sudah ada pembayaran, tidak bisa dihapus' - ]); - return; - } - - // ✅ Hapus - $this->db->delete('invoices', ['id'=>$id]); - - log_activity( - 'invoice', - 'delete', - 'Menghapus invoice ID ' . $id . ' - ' . $invoice->no_invoice, - 'success' - ); - - echo json_encode(['status'=>true]); - } - - public function detail($id) { - // GET customer_id - $row = $this->db->select('customer_id, no_invoice') - ->where('id',$id) - ->get('invoices') - ->row(); - - $data['active_menu'] = "invoice_draft"; - $data['invoice_id'] = $id; - $data['no_invoice'] = $row ? $row->no_invoice : null; - $data['customer_id'] = $row ? $row->customer_id : null; - - $this->load->view('partials/header', $data); - $this->load->view('invoices/detail', $data); - $this->load->view('partials/footer'); + try{$invoice=$this->salesservice->invoiceForEditor($id);$this->guardView($invoice);$this->page('invoices/editor',array('active_menu'=>'sales_invoice','mode'=>'detail','invoice'=>$invoice,'permissions'=>$this->permissions(),'source_order'=>0));}catch(Throwable$e){show_error($e->getMessage(),404,'Invoice tidak ditemukan');} } - - public function get_invoice($id) + public function data() { - // ================= AMBIL INVOICE ================= - $this->db->select('invoices.*, customers.nama, customers.alamat, customers.telp, customers.email'); - $this->db->from('invoices'); - $this->db->join('customers', 'customers.id = invoices.customer_id'); - $this->db->where('invoices.id', $id); - - $data = $this->db->get()->row(); - - if(!$data){ - echo json_encode(null); - return; - } - - // ================= AMBIL TUNGGAKAN ================= - $this->db->select_sum('total'); - $this->db->select_sum('total_bayar'); - $this->db->where('customer_id', $data->customer_id); - $this->db->where('status !=', 'paid'); - $this->db->where('id <', $id); // exclude invoice sekarang - - $row = $this->db->get('invoices')->row(); - - $tunggakan = ($row->total ?? 0) - ($row->total_bayar ?? 0); - - // ================= MASUKKAN KE DATA ================= - $data->tunggakan = (int)$tunggakan; - - // ================= OUTPUT ================= - echo json_encode($data); + $draw=(int)$this->input->get_post('draw');$start=max(0,(int)$this->input->get_post('start'));$length=max(10,min(100,(int)($this->input->get_post('length')?:25)));$search=$this->input->get_post('search');$term=is_array($search)?trim((string)($search['value']??'')):trim((string)$search); + $this->invoiceQuery(false,'');$recordsTotal=(int)$this->db->count_all_results();$this->invoiceQuery(true,$term);$recordsFiltered=(int)$this->db->count_all_results();$this->invoiceQuery(true,$term); + $rows=$this->db->select("i.id,i.no_invoice,i.tanggal,i.jatuh_tempo,i.recognition_policy,i.total,i.total_bayar,i.sisa_piutang,i.status,i.workflow_status,i.delivery_status,i.created_by,c.nama customer_name,u.nama creator_name,(SELECT dx.line_type FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id ORDER BY dx.id LIMIT 1) invoice_purpose,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory') inventory_line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory' AND dx.delivery_line_id IS NULL) pending_delivery_count",false)->order_by('i.id','DESC')->limit($length,$start)->get()->result(); + return json_response(true,'Daftar invoice ditemukan.',array('draw'=>$draw,'recordsTotal'=>$recordsTotal,'recordsFiltered'=>$recordsFiltered,'data'=>$rows)); + } + private function invoiceQuery($filters,$term) + { + $this->db->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left')->where('i.company_id',$this->company())->where('i.deleted_at IS NULL',null,false); + if(!is_master_admin_user())$this->db->group_start()->where('i.workflow_status !=','draft')->or_where('i.created_by',$this->uid())->group_end(); + if(!$filters)return;$workflow=trim((string)$this->input->get_post('workflow_status'));$payment=trim((string)$this->input->get_post('payment_status'));$dueStatus=trim((string)$this->input->get_post('due_status'));$from=trim((string)$this->input->get_post('date_from'));$to=trim((string)$this->input->get_post('date_to')); + if($workflow!=='')$this->db->where('i.workflow_status',$workflow);if($payment!=='')$this->db->where('i.status',$payment);if($dueStatus==='overdue')$this->db->where('i.jatuh_tempo <',date('Y-m-d'))->where('i.sisa_piutang >',0);elseif($dueStatus==='not_due')$this->db->where('i.jatuh_tempo >=',date('Y-m-d'))->where('i.sisa_piutang >',0);if($from!=='')$this->db->where('i.tanggal >=',$from);if($to!=='')$this->db->where('i.tanggal <=',$to);if($term!=='')$this->db->group_start()->like('i.no_invoice',$term)->or_like('c.nama',$term)->group_end(); } - // ========================= - // GET ITEMS - // ========================= - public function get_items($id) + public function lines($id) { - $this->db->select('invoice_details.*, accounts.kode_akun, accounts.nama_akun, invoices.status, item_barcodes.barcode'); - $this->db->from('invoice_details'); - $this->db->join('accounts', 'accounts.id = invoice_details.account_id', 'left'); - $this->db->join('invoices', 'invoices.id = invoice_details.invoice_id', 'left'); - $this->db->join('item_barcodes', 'item_barcodes.id = invoice_details.barcode_id', 'left'); - $this->db->where('invoice_details.invoice_id', $id); - $this->db->order_by('tanggal','ASC'); - $this->db->order_by('id','ASC'); - - $data = $this->db->get()->result(); - - echo json_encode($data); + try{$invoice=$this->salesservice->invoiceForEditor($id);$this->guardView($invoice);$search=$this->input->get_post('search');$term=is_array($search)?($search['value']??''):$search;$result=$this->salesservice->invoiceLinesPage($id,$this->input->get_post('start'),$this->input->get_post('length')?:25,$term);return json_response(true,'Item invoice ditemukan.',array('draw'=>(int)$this->input->get_post('draw'),'recordsTotal'=>$result['recordsTotal'],'recordsFiltered'=>$result['recordsFiltered'],'data'=>$result['rows'],'summary'=>$result['summary'],'invoice'=>$result['invoice']));}catch(Throwable$e){return business_exception_response($e);} } - - // ========================= - // ADD ITEM - FIXED RESPONSE - // ========================= - public function add_item() + + public function lookup_customers(){return$this->selectLookup('customers','nama',array('nama','alamat'),'Customer ditemukan.');} + public function lookup_accounts() { - $this->db->trans_begin(); - - $invoice_id = $this->input->post('invoice_id'); - $tanggal = $this->input->post('tanggal'); - $item_id = $this->input->post('item_id'); - $nama_item = $this->input->post('nama_item'); - $qty = (float)$this->input->post('qty'); - $harga = (float)$this->input->post('harga'); - $account_id = $this->input->post('account_id'); - $warehouse_id = $this->input->post('warehouse_id'); - $barcode_id = $this->input->post('barcode_id'); - - $total_harga = $qty * $harga; - $total_hpp = 0; - - // jika di bayar cicilan - $is_cicilan = $this->input->post('is_cicilan') ?? null; - $tenor = (int)$this->input->post('tenor') ?? 0; - - // ambil invoice Detail - $invoice = $this->db->get_where('invoices', ['id' => $invoice_id])->row(); - // ========================= - // VALIDASI & AMBIL ITEM - // ========================= - if($item_id){ - $item = $this->db->get_where('items', ['id'=>$item_id])->row(); - if(!$item){ - echo json_encode(['status'=>false, 'message'=>'Item tidak ditemukan']); - $this->db->trans_rollback(); - return; + $q=trim((string)$this->input->get('q'));$scope=trim((string)$this->input->get('scope'));$types=array('revenue');$message='Akun pendapatan ditemukan.';if($scope==='asset'){$types=array('asset');$message='Akun Aktiva ditemukan.';}elseif($scope==='liability'){$types=array('liability');$message='Akun Passiva ditemukan.';}elseif($scope==='settlement'){$types=array('asset','liability');$message='Akun pelunasan ditemukan.';} + $page=max(1,(int)$this->input->get('page'));$limit=20;$this->db->select("id,CONCAT(kode_akun,' - ',nama_akun) text,tipe",false)->from('accounts')->where('company_id',$this->company())->where_in('tipe',$types)->where(array('is_active'=>1,'allow_posting'=>1));if(!is_master_admin_user())$this->db->where('is_hidden',0);if($q!=='')$this->db->group_start()->like('kode_akun',$q)->or_like('nama_akun',$q)->group_end();$rows=$this->db->order_by('kode_akun')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,$message,array('results'=>$rows,'pagination'=>array('more'=>$more))); + } + public function lookup_items() + { + $q=trim((string)$this->input->get('q'));$page=max(1,(int)$this->input->get('page'));$limit=20;$this->db->select("i.id,CONCAT(i.kode_detail,' - ',i.nama_barang) text,i.kode_detail,i.nama_barang,i.harga_jual,i.stok,k.tracking_type,k.unit",false)->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where(array('i.company_id'=>$this->company(),'i.status'=>'active','i.stok >'=>'0'));if($q!=='')$this->db->group_start()->like('i.kode_detail',$q)->or_like('i.nama_barang',$q)->group_end();$rows=$this->db->order_by('i.nama_barang')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,'Barang ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); + } + public function lookup_warehouses(){return$this->selectLookup('warehouses','nama',array('nama'),'Gudang ditemukan.');} + public function lookup_barcodes() + { + $q=trim((string)$this->input->get('q'));$item=(int)$this->input->get('item_id');$warehouse=(int)$this->input->get('warehouse_id');$exact=(int)$this->input->get('exact')===1;$page=max(1,(int)$this->input->get('page'));$limit=20; + if(!$exact&&(!$item||!$warehouse))return json_response(true,'Pilih gudang dan barang terlebih dahulu.',array('results'=>array(),'pagination'=>array('more'=>false))); + $this->db->select("b.id,CONCAT(b.barcode,IF(NULLIF(b.serial_number,'') IS NULL,'',CONCAT(' / ',b.serial_number)),' - tersedia ',ROUND(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0),4)) text,b.barcode,b.serial_number,b.item_id,b.warehouse_id,(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) available,i.kode_detail,i.nama_barang,i.harga_jual,k.tracking_type,k.unit,w.nama warehouse_name",false)->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')->where(array('i.company_id'=>$this->company(),'b.status'=>'available'))->where('(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) >',0,false);if($item)$this->db->where('b.item_id',$item);if($warehouse)$this->db->where('b.warehouse_id',$warehouse);if($q!==''){$this->db->group_start();if($exact)$this->db->where('b.barcode',$q)->or_where('b.serial_number',$q);else$this->db->like('b.barcode',$q)->or_like('b.serial_number',$q);$this->db->group_end();}$rows=$this->db->order_by('b.id')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,'Barcode ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more))); + } + private function selectLookup($table,$label,array$fields,$message) + { + $q=trim((string)$this->input->get('q'));$page=max(1,(int)$this->input->get('page'));$limit=20;$this->db->select('id,'.$label.' text,'.implode(',',$fields))->from($table)->where('company_id',$this->company());if($this->db->field_exists('is_active',$table))$this->db->where('is_active',1);if($q!=='')$this->db->like($label,$q);$rows=$this->db->order_by($label)->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);return json_response(true,$message,array('results'=>$rows,'pagination'=>array('more'=>$more))); + } + + public function create_header(){return$this->mutation('can_create',function(){return$this->salesservice->createInvoiceHeader($this->input->post(),$this->uid());},'Header invoice berhasil disimpan sebagai Draft.');} + public function update_header($id){return$this->mutation('can_update',function()use($id){return$this->salesservice->updateInvoiceHeader($id,$this->input->post(),$this->uid());},'Aturan invoice berhasil diperbarui.');} + public function add_line($id){return$this->mutation('can_update',function()use($id){$row=$this->input->post();$bars=json_decode((string)$this->input->post('barcodes'),true);$row['barcodes']=is_array($bars)?$bars:array();return$this->salesservice->appendInvoiceLine($id,$row,$this->uid());},'Item berhasil ditambahkan ke invoice.');} + public function add_lines($id) + { + return $this->mutation('can_update', function() use ($id) { + $rows=json_decode((string)$this->input->post('rows'),true); + if(!is_array($rows)||!$rows)throw new BusinessException('Tambahkan minimal satu barang, jasa, atau tabungan.'); + if(count($rows)>100)throw new BusinessException('Maksimal 100 item dalam satu kali penambahan.'); + + $serviceDate=trim((string)$this->input->post('service_date')); + $batchKey=trim((string)$this->input->post('idempotency_key')); + foreach($rows as $index=>&$row){ + if(!is_array($row))throw new BusinessException('Format item ke-'.($index+1).' tidak valid.'); + $row['service_date']=$serviceDate; + $row['specification']=''; + $row['barcodes']=isset($row['barcodes'])&&is_array($row['barcodes'])?$row['barcodes']:array(); + $row['idempotency_key']=$batchKey!==''?$batchKey.'-'.($index+1):''; } - - $nama_item = $item->nama_barang; - - // cek stok - // $stok = $this->get_stok($item_id); - // if($stok < $qty){ - // echo json_encode(['status'=>false, 'message'=>'Stok tidak cukup. Sisa: '.$stok]); - // $this->db->trans_rollback(); - // return; - // } + unset($row); - // 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; - } - - $total_sisa = $itemBar->qty_sisa - $qty; - $status_bar = ($total_sisa == 0) ? 'sold_out' : 'available'; - - $this->db->insert('item_movements',[ - 'item_id' => $item_id, - 'barcode_id' => $barcode_id, - 'qty' => $qty, - 'from_type' => $item_move->to_type, //enum('supplier','warehouse','technician','customer','invoice') - 'to_type' => 'invoice', //enum('supplier','warehouse','technician','customer','invoice') - 'movement_type' => 'sold_out', //enum('purchase','transfer','installation','uninstallation','supplier_return','customer_return','adjustment','sold_out') - 'notes' => 'Barang terjual ke INV : ' . $invoice->no_invoice, - 'created_at' => date('Y-m-d H:i:s') - ]); - - $this->db->where('id', $barcode_id)->update('item_barcodes', ['status' => $status_bar, 'qty_sisa' => $total_sisa]); - - if ($status_bar == 'sold_out') { - $this->db->where('barcode', $itemBar->barcode)->where('status', 'active')->update('item_technician', ['status' => 'inactive', 'deleted_at' => date('Y-m-d H:i:s')]); - } - - // END - Item Movement & item barcodes update status - - $total_hpp = $qty * $item->harga_beli; - } - - // ========================= - // HANDLE CICILAN - // ========================= - $harga_simpan = $harga; - $subtotal_simpan= $total_harga; - $harga_asli = $total_harga; - - $ket_cicilan_inv = ''; - $ket_cicilan_jr = ''; - - if ($is_cicilan && $tenor > 0) { - - // harga per cicilan - $harga_cicilan = round($total_harga / $tenor); - - //ngatur HPP Cicilan - $total_hpp = round($total_hpp / $tenor); - - $harga_simpan = $harga_cicilan; - $subtotal_simpan = $harga_cicilan; - - $ket_cicilan_inv = ' ( Cicilan ke 1/' . $tenor . ' )'; - $ket_cicilan_jr = ' ( Cicilan ' . $tenor . ' Periode )'; - } - - // ========================= - // INSERT DETAIL - // ========================= - $insert_data = [ - 'invoice_id' => $invoice_id, - 'tanggal' => $tanggal, - 'account_id' => $account_id, - 'items_id' => $item_id ?: null, - 'nama_item' => $nama_item . $ket_cicilan_inv, - 'qty' => $qty, - 'harga' => $harga_simpan, - 'subtotal' => $subtotal_simpan, - 'warehouse_id' => $warehouse_id, - 'keterangan' => $this->input->post('keterangan'), - 'subtotal_asli' => $harga_asli, - 'is_cicilan' => $is_cicilan ? 1 : 0, - 'total_hpp_barang' => $total_hpp, - 'barcode_id' => $barcode_id - ]; - - $this->db->insert('invoice_details', $insert_data); - $detail_id = $this->db->insert_id(); - - // ========================= - // JURNAL + STOK - // ========================= - if($this->input->post('account_id') != 70) { - $this->generate_jurnal_item($detail_id, $ket_cicilan_jr); - } - - $this->update_total($invoice_id); - - - if ($is_cicilan && $tenor > 0) { - - // generate cicilan - $this->generate_installments( - $invoice_id, - $detail_id, - $invoice->customer_id, - $total_harga, - $tenor, - $tanggal, - $account_id, - $total_hpp, - $item_id ?: null + return $this->salesservice->appendInvoiceLinesBatch( + $id, + $rows, + $this->input->post('batch_discount'), + $this->uid() ); - } - - // ✅ RESPONSE KONSISTEN DENGAN JS - if ($this->db->trans_status() === FALSE){ - $this->db->trans_rollback(); - echo json_encode(['status'=>false, 'message'=>'Transaksi gagal']); - } else { - $this->db->trans_commit(); - - log_activity( - 'invoice', - 'add_item', - 'Tambah item invoice ID ' . $invoice_id . ' - ' . $nama_item, - 'success' - ); - - echo json_encode(['status'=>true, 'message'=>'Item berhasil ditambahkan']); - } + }, 'Semua item berhasil ditambahkan ke invoice.'); } - - // // ========================= - // // UPDATE ITEM - RESPONSE KONSISTEN - // // ========================= - // public function update_item() - // { - // $this->db->trans_begin(); - - // $id = $this->input->post('id'); - // $old = $this->db->get_where('invoice_details', ['id'=>$id])->row(); - - // if(!$old){ - // echo json_encode(['status'=>false, 'message'=>'Item tidak ditemukan']); - // return; - // } - - // $cek = $this->db->get_where('invoice_installments', [ - // 'invoice_detail_id' => $id - // ])->num_rows(); - - // if ($cek > 0) { - // echo json_encode([ - // 'status' => false, - // 'message' => 'Item cicilan tidak bisa diubah. Hapus dan buat ulang.' - // ]); - // return; - // } - - // // 🔥 REVERSE DATA LAMA - // $this->delete_jurnal_item($id); - // $this->delete_stok_item($id); - - // // DATA BARU - // $item_id = $this->input->post('item_id'); - // $nama_item = $this->input->post('nama_item'); - // $tanggal = $this->input->post('tanggal'); - - // $total_hpp = 0; - - // if($item_id){ - // $item = $this->db->get_where('items', ['id'=>$item_id])->row(); - // if($item){ - // $nama_item = $item->nama_barang; - // $stok = $this->get_stok($item_id); - // $qty = (float)$this->input->post('qty'); - // $total_hpp = $this->input->post('qty') * $item->harga_beli; - // if($stok < $qty){ - // echo json_encode(['status'=>false, 'message'=>'Stok tidak cukup']); - // return; - // } - - - // } - - // } - - // $qty = (float)$this->input->post('qty'); - // $harga = (float)$this->input->post('harga'); - - // // UPDATE - // $update_data = [ - // 'tanggal' => $tanggal, - // 'account_id' => $this->input->post('account_id'), - // 'items_id' => $item_id ?: null, - // 'nama_item' => $nama_item, - // 'qty' => $qty, - // 'harga' => $harga, - // 'subtotal' => $qty * $harga, - // 'warehouse_id' => $this->input->post('warehouse_id'), - // 'keterangan' => $this->input->post('keterangan'), - // 'total_hpp_barang' => $total_hpp - // ]; - - // $this->db->where('id', $id)->update('invoice_details', $update_data); - - // // GENERATE ULANG - // if($this->input->post('account_id') != 70) { - // $this->generate_jurnal_item($id); - // } - // $this->update_total($old->invoice_id); - - // // ✅ RESPONSE KONSISTEN DENGAN JS - // if ($this->db->trans_status() === FALSE){ - // $this->db->trans_rollback(); - // echo json_encode(['status'=>false, 'message'=>'Update gagal']); - // } else { - // $this->db->trans_commit(); - - // log_activity( - // 'invoice', - // 'update_item', - // 'Update item invoice detail ID ' . $id, - // 'success' - // ); - - // echo json_encode(['status'=>true, 'message'=>'Item berhasil diupdate']); - // } - // } - - // ========================= - // DELETE ITEM - RESPONSE KONSISTEN - // ========================= - public function delete_item($id, $no_invoice) + public function remove_line($invoiceId,$lineId){return$this->mutation('can_update',function()use($invoiceId,$lineId){return$this->salesservice->removeInvoiceLine($invoiceId,$lineId,$this->uid());},'Item berhasil dihapus, reservasi dilepas, dan jurnal awal dibalik.');} + public function update_line($invoiceId,$lineId){return$this->mutation('can_update',function()use($invoiceId,$lineId){return$this->salesservice->updateNonInventoryLine($invoiceId,$lineId,$this->input->post(),$this->uid());},'Item nonbarang berhasil diperbarui.');} + public function generate_delivery($invoiceId){return$this->mutation('can_update',function()use($invoiceId){return$this->salesservice->generateDelivery($invoiceId,$this->uid());},'Surat Jalan berhasil dibuat sebagai Draft.');} + public function submit($id){return$this->mutation('can_submit',function()use($id){return$this->salesservice->submitInvoice($id,$this->uid());},'Invoice berhasil diajukan.');} + public function approve($id){return$this->mutation('can_approve',function()use($id){return$this->salesservice->approveAndPostInvoice($id,$this->uid(),$this->roleId());},'Invoice disetujui dan otomatis diposting.');} + public function reject($id){return$this->mutation('can_approve',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_draft($id){return$this->mutation('can_delete',function()use($id){return$this->salesservice->deleteDraftInvoice($id,$this->uid(),is_master_admin_user());},'Draft invoice dibatalkan dan seluruh reservasi dilepas.');} + public function pdf($id) { - $this->db->trans_begin(); - - $d = $this->db->get_where('invoice_details', ['id'=>$id])->row(); - - if(!$d){ - echo json_encode(['status'=>false, 'message'=>'Item tidak ditemukan']); - return; - } - - if($d->is_cicilan == 1) { - $this->delete_invoice_installments($id, $d->source_cicilan_id); - } - // reverse stok & hapus - $this->delete_jurnal_item($id); - $this->delete_stok_item($id); - $this->db->delete('invoice_details', ['id'=>$id]); - $this->update_total($d->invoice_id); - - - if ($d->items_id AND $d->is_cicilan != 1) { - // START - Item Movement & item barcodes update status - $this->db->where('item_id', $d->items_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'=>$d->barcode_id])->row(); - - $total_sisa = $itemBar->qty_sisa + $d->qty; - $status_bar = ($total_sisa == 0) ? 'sold_out' : 'available'; - - $this->db->insert('item_movements',[ - 'item_id' => $d->items_id, - 'barcode_id' => $d->barcode_id, - 'qty' => $d->qty, - 'from_type' => $item_move->to_type, //enum('supplier','warehouse','technician','customer','invoice') - 'to_type' => 'warehouse', //enum('supplier','warehouse','technician','customer','invoice') - 'movement_type' => 'purchase', //enum('purchase','transfer','installation','uninstallation','supplier_return','customer_return','adjustment','sold_out') - 'notes' => 'Barang di kembalikan ke gudang dari : ' . $no_invoice, - 'created_at' => date('Y-m-d H:i:s') - ]); - - // $this->db->where('id', $d->barcode_id)->update('item_barcodes', ['status' => 'available']); - $this->db->where('id', $d->barcode_id)->update('item_barcodes', ['status' => $status_bar, 'qty_sisa' => $total_sisa]); - // END - Item Movement & item barcodes update status - } - - // ✅ RESPONSE KONSISTEN DENGAN JS - if ($this->db->trans_status() === FALSE){ - $this->db->trans_rollback(); - echo json_encode(['status'=>false, 'message'=>'Hapus gagal']); - } else { - $this->db->trans_commit(); - - log_activity( - 'invoice', - 'delete_item', - 'Hapus item invoice detail ID ' . $id, - 'success' - ); - - echo json_encode(['status'=>true, 'message'=>'Item berhasil dihapus']); - } + $this->requirePermission('invoices','can_export');$this->guardView($this->salesservice->invoiceForEditor($id));$data=$this->salesservice->invoiceDetail($id);$data['pdf_company']=$this->db->get_where('companies',array('id'=>$this->company()))->row();require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('sales/invoice_pdf',$data,true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream('Invoice-'.$data['invoice']->no_invoice.'.pdf',array('Attachment'=>false)); } - - // ========================= - // AMBIL DATA CICILAN - // ========================= - public function get_cicilan($id) + private function mutation($permission,callable$callback,$message) { - $cicilan_raw = $this->db->query(" - SELECT - ii.*, - it.nama_barang - FROM invoice_installments ii - LEFT JOIN items it ON ii.item_id = it.id - WHERE ii.customer_id = ? - AND ii.status IN ('unpaid','partial') - AND (ii.nominal - ii.paid_amount) > 0 - AND ii.is_billed = 0 - ORDER BY ii.invoice_detail_id, ii.angsuran_ke ASC - ", [$id])->result(); - - echo json_encode($cicilan_raw); + if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);if(!$this->allowed($permission)&&!($permission==='can_submit'&&$this->allowed('can_create')))return json_response(false,'Anda tidak memiliki izin untuk tindakan ini.',array(),403);try{return json_response(true,$message,array('result'=>$callback()));}catch(Throwable$e){return business_exception_response($e);} } - - private function generate_jurnal_item($id, $ket_cicilan_jr = null) - { - // $d = $this->db->get_where('invoice_details', ['id'=>$id])->row(); - - // if(!$d) return; - - $d = $this->db - ->select(' - invoice_details.*, - customers.nama, - invoices.no_invoice, - item_barcodes.barcode - ') - ->from('invoice_details') - ->join('invoices', 'invoices.id = invoice_details.invoice_id') - ->join('customers', 'customers.id = invoices.customer_id') - ->join('item_barcodes', 'item_barcodes.id = invoice_details.barcode_id', 'left') - ->where('invoice_details.id', $id) - ->get() - ->row(); - - if(!$d) return; - - $total_hpp = 0; - $barcode = ''; - - // ========================= - // 🔥 TENTUKAN NILAI JURNAL - // ========================= - $nilai_jurnal = $d->subtotal; - - if (isset($d->is_cicilan) && $d->is_cicilan == 1) { - $nilai_jurnal = $d->subtotal_asli ?? $d->subtotal; - } - - // ========================= - // 🔥 STOK & HPP - // ========================= - if($d->items_id){ - - $item = $this->db->get_where('items',['id'=>$d->items_id])->row(); - - if($item){ - $total_hpp = $item->harga_beli * $d->qty; - } - - // stok keluar - $this->db->insert('stock_logs', [ - 'item_id' => $d->items_id, - 'qty' => $d->qty, - 'tipe' => 'keluar', - 'ref_type' => 'invoice_details', - 'ref_id' => $id, - 'warehouse_id' => $d->warehouse_id, - 'keterangan' => 'Penjualan ' . $item->nama_barang . ' kepada ' . $d->nama . ' - Invoice ' . $d->no_invoice . ' - Tgl ' . $d->tanggal . ' - Kode ' . $d->barcode - ]); - - $barcode = ' - Kode: ' . $d->barcode; - - $this->update_item_stok($d->items_id); - } - - // ========================= - // 🔥 generate no_ref - // ========================= - $no_ref = $this->generate_no_ref(); - - // ========================= - // 🔥 JOURNAL HEADER - // ========================= - $nama_item_jr = preg_replace('/\s*\(\s*Cicilan.*$/i', '', $d->nama_item) . $ket_cicilan_jr; - $this->db->insert('journals', [ - 'tanggal' => $d->tanggal, - 'no_ref' => $no_ref, - 'keterangan' => 'Penjualan '.$nama_item_jr . $barcode . ' - Kepada: ' . $d->nama, - 'ref_type' => 'invoice_details', - 'ref_id' => $id, - 'created_by' => $this->session->userdata('user_id') - ]); - - $jid = $this->db->insert_id(); - - // ========================= - // 🔥 PIUTANG (DEBIT) - // ========================= - $this->db->insert('journal_details', [ - 'journal_id' => $jid, - 'account_id' => $this->akun_piutang, - 'debit' => $nilai_jurnal, - 'kredit' => 0 - ]); - - // ========================= - // 🔥 PENDAPATAN (KREDIT) Pendapatan Diterima Dimuka - // ========================= - $this->db->insert('journal_details', [ - 'journal_id' => $jid, - 'account_id' => $this->pendapatan_dimuka, - 'debit' => 0, - 'kredit' => $nilai_jurnal - ]); - - // ========================= - // 🔥 HPP & PERSEDIAAN - // ========================= - if($total_hpp > 0){ - - // HPP (debit) - $this->db->insert('journal_details', [ - 'journal_id' => $jid, - 'account_id' => $this->persediaan_dijual, - 'debit' => $total_hpp, - 'kredit' => 0 - ]); - - // Persediaan (kredit) - $this->db->insert('journal_details', [ - 'journal_id' => $jid, - 'account_id' => $this->akun_persediaan, - 'debit' => 0, - 'kredit' => $total_hpp - ]); - } - } - - // ========================= - // 🔥 DELETE JURNAL - // ========================= - private function delete_jurnal_item($id) - { - $d = $this->db->get_where('invoice_details', ['id'=>$id])->row(); - - $j = $this->db->get_where('journals', [ - 'ref_type'=>'invoice_details', - 'ref_id'=>$id - ])->result(); - - foreach($j as $row){ - $this->db->delete('journal_details', ['journal_id'=>$row->id]); - $this->db->delete('journals', ['id'=>$row->id]); - } - - if($d && $d->items_id){ - $this->update_item_stok($d->items_id); // ✅ BENAR - } - } - - // ========================= - // 🔥 DELETE STOK - // ========================= - private function delete_stok_item($id) - { - $d = $this->db->get_where('invoice_details', ['id'=>$id])->row(); - - $this->db->delete('stock_logs', [ - 'ref_type'=>'invoice_details', - 'ref_id'=>$id - ]); - - if($d && $d->items_id){ - $this->update_item_stok($d->items_id); // ✅ BENAR - } - } - - // ========================= - // 🔥 DELETE INVOICES INSTALLMENT - // ========================= - private function delete_invoice_installments($invoice_detail_id,$source_cicilan_id) - { - $cek = $this->db - ->where_in('status', ['paid', 'partial']) - ->where('invoice_detail_id', $invoice_detail_id) - ->count_all_results('invoice_installments'); - - // kalau TIDAK ADA paid/partial → hapus - if ($cek === 0) { - $this->db->delete('invoice_installments', [ - 'invoice_detail_id' => $invoice_detail_id - ]); - } - - if ($source_cicilan_id) { - $this->db->update('invoice_installments', [ - 'is_billed' => 0 - ], ['id' => $source_cicilan_id]); - } - } - - // ========================= - // 🔥 GET STOK - // ========================= - private function get_stok($item_id) - { - return $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; - } - - // ========================= - // ADD ITEM - FIXED HELPER - // ========================= - private function commit() - { - if ($this->db->trans_status() === FALSE){ - $this->db->trans_rollback(); - echo json_encode(['status'=>false, 'message'=>'Transaksi gagal']); - } else { - $this->db->trans_commit(); - echo json_encode(['status'=>true, 'message'=>'Berhasil']); - } - } - - private function rollback($message) - { - $this->db->trans_rollback(); - echo json_encode(['status'=>false, 'message'=>$message]); - } - - // ========================= - // UPDATE TOTAL - // ========================= - private function update_total($invoice_id) - { - $this->db->select_sum('subtotal', 'total'); - $this->db->where('invoice_id', $invoice_id); - $total = $this->db->get('invoice_details')->row()->total ?? 0; - - $this->db->where('id', $invoice_id) - ->update('invoices', ['total' => $total]); - } - - // ========================= - // UPDATE TOTAL - // ========================= - private function generate_no_ref() - { - $tanggal = date('Y-m-d'); - $prefix = 'INV-'.date('Ymd').'-'; - - // ambil nomor terakhir hari ini - $last = $this->db->query(" - SELECT no_ref - FROM journals - WHERE DATE(tanggal) = ? - AND no_ref LIKE ? - ORDER BY no_ref DESC - LIMIT 1 - ", [$tanggal, $prefix.'%'])->row(); - - if($last){ - $last_number = (int) substr($last->no_ref, -4); - $next = $last_number + 1; - } else { - $next = 1; - } - - return $prefix . str_pad($next, 4, '0', STR_PAD_LEFT); - } - - // ========================= - // GET ACCOUNTS - // ========================= - public function get_accounts() - { - $data = $this->db - ->group_start() - ->where('tipe', 'revenue') - ->or_where('id', 70) - ->group_end() - ->order_by('kode_akun', 'ASC') - ->get('accounts') - ->result(); - - echo json_encode($data); - } - - public function get_accounts_assets() - { - $data = $this->db - ->where_in('tipe', ['asset','liability']) - ->order_by('kode_akun', 'ASC') - ->get('accounts') - ->result(); - - echo json_encode($data); - } - - public function check_items_by_account($account_id) - { - $exists = $this->db - ->where('account_id', $account_id) - ->limit(1) - ->get('items') - ->num_rows() > 0; - - echo json_encode([ - 'has_item' => $exists - ]); - } - - public function get_items_by_wh_account($warehouse_id, $account_id) - { - $this->db->select(' - items.id, - items.nama_barang, - items.harga_jual, - items.kode_detail, - 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 - AND stock_logs.warehouse_id = '.$this->db->escape($warehouse_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 gudang_list() - { - echo json_encode( - $this->db->get('warehouses')->result() - ); - } - - public function get_payment($invoice_id) - { - $this->db->select(' - id, - tanggal, - jumlah, - metode, - created_at - '); - $this->db->from('payments'); - $this->db->where('invoice_id', $invoice_id); - $this->db->order_by('id','DESC'); - - $data = $this->db->get()->result(); - - $result = []; - - $this->db->from('invoices'); - $this->db->where('invoices.id', $invoice_id); - - $invoice = $this->db->get()->row(); - - foreach ($data as $row) { - $result[] = [ - 'id' => $row->id, - 'tanggal' => date('d-m-Y', strtotime($row->tanggal)), - 'jumlah' => 'Rp '.number_format($row->jumlah,0,',','.'), - 'metode' => $row->metode, - 'created_at' => date('d-m-Y H:i', strtotime($row->created_at)) - ]; - } - - echo json_encode([ - 'data' => $result, - 'invoice' => $invoice - ]); - } - - // Update Stok Cache - 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 - ]); - } - - - private function generate_installments($invoice_id, $detail_id, $customer_id, $total, $tenor, $start_date, $account_id, $total_hpp, $item_id = null) - { - $nominal = round($total / $tenor); - $sisa = $total - ($nominal * $tenor); - - for ($i = 1; $i <= $tenor; $i++) { - - $tgl = date('Y-m-d', strtotime("+".($i-1)." month", strtotime($start_date))); - - $nilai = $nominal; - - if ($i == $tenor) { - $nilai += $sisa; - } - - $is_billed = ($i == 1) ? 1 : 0; - - $this->db->insert('invoice_installments', [ - 'invoice_id' => $invoice_id, - 'invoice_detail_id' => $detail_id, // 🔥 relasi ke item - 'customer_id' => $customer_id, - 'angsuran_ke' => $i, - 'total_angsuran' => $tenor, - 'jatuh_tempo' => $tgl, - 'nominal' => $nilai, - 'account_id' => $account_id, - 'item_id' => $item_id ?: null, - 'cicilan_hpp_barang' => $total_hpp, - 'status' => 'unpaid', - 'is_billed' => $is_billed - ]); - } - } - - public function save_cicilan() - { - $cicilan_ids = $this->input->post('cicilan_id'); - $invoice_id = $this->input->post('invoice_id'); - - if (empty($cicilan_ids)) { - echo json_encode([ - 'status' => false, - 'message' => 'Tidak ada data dipilih' - ]); - return; - } - - $this->db->trans_start(); - - foreach ($cicilan_ids as $id) { - - // ========================= - // AMBIL CICILAN - // ========================= - $cicilan = $this->db->get_where('invoice_installments', [ - 'id' => $id, - 'is_billed' => 0 // penting! - ])->row(); - - if (!$cicilan) continue; - - // ========================= - // CEK DUPLIKAT - // ========================= - $cek = $this->db->get_where('invoice_details', [ - 'source_cicilan_id' => $id - ])->row(); - - if ($cek) continue; - - $sisa = $cicilan->nominal - $cicilan->paid_amount; - - // ========================= - // AMBIL REFERENSI ITEM - // ========================= - $inv_items = $this->db - ->select('account_id, items_id, nama_item, keterangan, subtotal_asli, is_cicilan') - ->where('id', $cicilan->invoice_detail_id) - ->get('invoice_details') - ->row(); - - if(!$inv_items) continue; - - // ========================= - // FORMAT NAMA - // ========================= - if($cicilan->paid_amount > 0){ - $priodecicilan = '( Sisa Cicilan ke ' . $cicilan->angsuran_ke . '/' . $cicilan->total_angsuran . ' )'; - } else { - $priodecicilan = '( Cicilan ke ' . $cicilan->angsuran_ke . '/' . $cicilan->total_angsuran . ' )'; - } - - $nama_item = preg_replace( - '/\s*\(\s*Cicilan.*$/i', - '', - $inv_items->nama_item - ) . ' ' . $priodecicilan; - - // ========================= - // INSERT - // ========================= - $this->db->insert('invoice_details', [ - 'invoice_id' => $invoice_id, - 'tanggal' => $cicilan->jatuh_tempo, - 'account_id' => $inv_items->account_id, - 'items_id' => $inv_items->items_id ?: null, - 'nama_item' => $nama_item, - 'qty' => 1, - 'harga' => $sisa, - 'subtotal' => $sisa, - 'warehouse_id' => null, - 'keterangan' => $inv_items->keterangan, - 'subtotal_asli' => $inv_items->subtotal_asli, - 'is_cicilan' => $inv_items->is_cicilan, - 'source_cicilan_id' => $cicilan->id, - 'total_hpp_barang' => $cicilan->cicilan_hpp_barang - ]); - - // ========================= - // UPDATE STATUS - // ========================= - $this->db->update('invoice_installments', [ - 'is_billed' => 1 - ], ['id' => $id]); - } - - $this->update_total($invoice_id); - - $this->db->trans_complete(); - - echo json_encode([ - 'status' => $this->db->trans_status() - ]); - } - -public function bayar() -{ - $this->output->set_content_type('application/json'); - - $invoice_id = $this->input->post('invoice_id'); - $tanggal_bayar = $this->input->post('tanggal_bayar'); - $detail = $this->input->post('detail'); - - if (!$invoice_id || empty($detail)) { - echo json_encode(['status' => false, 'message' => 'Data tidak valid']); - return; - } - - $invoice = $this->db->get_where('invoices', ['id' => $invoice_id])->row(); - if (!$invoice) { - echo json_encode(['status' => false, 'message' => 'Invoice tidak ditemukan']); - return; - } - - $customer = $this->db->get_where('customers', ['id' => $invoice->customer_id])->row(); - if (!$customer) { - echo json_encode(['status' => false, 'message' => 'Customer tidak ditemukan']); - return; - } - - // ========================= - // HITUNG TOTAL BAYAR - // ========================= - $total_bayar_input = 0; - foreach ($detail as $d) { - $total_bayar_input += (float)$d['amount']; - } - - if ($total_bayar_input <= 0) { - echo json_encode(['status' => false, 'message' => 'Total bayar harus > 0']); - return; - } - - // ========================= - // DATA INVOICE - // ========================= - $paid_before = (float)$invoice->total_bayar; - $total_invoice = (float)$invoice->total; - $new_total_bayar = $paid_before + $total_bayar_input; - $sisa_setelah = $total_invoice - $new_total_bayar; - - if ($new_total_bayar > $total_invoice) { - echo json_encode([ - 'status' => false, - 'message' => 'Pembayaran melebihi total invoice' - ]); - return; - } - - // ========================= - // KONFIGURASI AKUN - // ========================= - $akun_tabungan = 70; - $akun_barang = 62; - $akun_piutang = 19; - $akun_pendapatan_ditahan = 78; - $akun_hpp = isset($this->akun_hpp) ? $this->akun_hpp : 81; - $persediaan_dijual = isset($this->persediaan_dijual) ? $this->persediaan_dijual : 50; - - // ========================= - // HITUNG CICILAN - // ========================= - $cicilan = $this->db->query(" - SELECT ii.* - FROM invoice_installments ii - JOIN ( - SELECT invoice_detail_id, MIN(angsuran_ke) as angsuran_ke - FROM invoice_installments - WHERE customer_id = ? - AND status != 'paid' - GROUP BY invoice_detail_id - ) x - ON x.invoice_detail_id = ii.invoice_detail_id - AND x.angsuran_ke = ii.angsuran_ke - ", [$customer->id])->result(); - - $total_cicilan = 0; - foreach ($cicilan as $c) { - $total_cicilan += ($c->nominal - $c->paid_amount); - } - - // ========================= - // TOTAL PER KATEGORI - // ========================= - $total_tabungan = (float)$this->db - ->select('SUM(subtotal) as total') - ->where('invoice_id', $invoice_id) - ->where('account_id', $akun_tabungan) - ->get('invoice_details')->row()->total ?? 0; - - $total_barang = (float)$this->db - ->select('SUM(subtotal) as total') - ->where('invoice_id', $invoice_id) - ->where('account_id', $akun_barang) - ->get('invoice_details')->row()->total ?? 0; - - $total_other = (float)$this->db - ->select('SUM(subtotal) as total') - ->where('invoice_id', $invoice_id) - ->where_not_in('account_id', [$akun_tabungan, $akun_barang]) - ->get('invoice_details')->row()->total ?? 0; - - // ========================= - // SISA PER KATEGORI - // ========================= - $sisa_other = max(0, $total_other - $paid_before); - $sisa_tabungan = max(0, $total_tabungan - max(0, $paid_before - $total_other)); - $sisa_barang = max(0, $total_barang - max(0, $paid_before - $total_other - $total_tabungan)); - - // ========================= - // ALOKASI PEMBAYARAN - // ========================= - $sisa_bayar = $total_bayar_input; - - $bayar_cicilan = min($sisa_bayar, $total_cicilan); - $sisa_bayar -= $bayar_cicilan; - - $bayar_other = min($sisa_bayar, $sisa_other); - $sisa_bayar -= $bayar_other; - - $bayar_tabungan = min($sisa_bayar, $sisa_tabungan); - $sisa_bayar -= $bayar_tabungan; - - $bayar_barang = min($sisa_bayar, $sisa_barang); - $sisa_bayar -= $bayar_barang; - - // ========================= - // AMBIL INVOICE DETAIL - // ========================= - $inv_detail_items = $this->db - ->select(' - invoice_details.id, - invoice_details.account_id, - invoice_details.subtotal, - invoice_details.recognized_amount, - invoice_details.total_hpp_barang, - invoice_details.recognized_hpp, - invoice_details.is_cicilan, - accounts.priority - ') - ->from('invoice_details') - ->join('accounts', 'accounts.id = invoice_details.account_id', 'left') - ->where('invoice_details.invoice_id', $invoice_id) - ->where('invoice_details.account_id !=', $akun_tabungan) - ->order_by('accounts.priority', 'ASC') - ->order_by('invoice_details.id', 'ASC') - ->get() - ->result(); - - // ========================= - // DEBIT PENDAPATAN DITAHAN - // ========================= - // Tidak termasuk tabungan! - $debit_pendapatan_ditahan = $bayar_cicilan + $bayar_other + $bayar_barang; - - // ========================= - // MULAI TRANSAKSI - // ========================= - $this->db->trans_start(); - - // UPDATE cicilan - if ($bayar_cicilan > 0) { - $sisa = $bayar_cicilan; - foreach ($cicilan as $c) { - if ($sisa <= 0) break; - $kurang = $c->nominal - $c->paid_amount; - $bayar = min($sisa, $kurang); - $sisa -= $bayar; - $new_paid = $c->paid_amount + $bayar; - $status = ($new_paid >= $c->nominal) ? 'paid' : 'partial'; - - $this->db->where('id', $c->id)->update('invoice_installments', [ - 'paid_amount' => $new_paid, - 'status' => $status - ]); - } - } - - $no_ref = 'PAY-' . date('YmdHis'); - - $this->db->insert('journals', [ - 'tanggal' => $tanggal_bayar, - 'no_ref' => $no_ref, - 'keterangan'=> 'Pembayaran Invoice ' . $invoice->no_invoice . ' (' . $customer->nama . ')', - 'ref_type' => 'invoice_payment', - 'ref_id' => $invoice_id, - 'created_by'=> $this->session->userdata('user_id') - ]); - - $journal_id = $this->db->insert_id(); - - // ========================= - // DEBIT SIDE - // ========================= - $total_debit = 0; - $jurnal_debit = []; - - // 1. Kas/Bank (dari detail) - foreach ($detail as $d) { - if (empty($d['account_id']) || $d['amount'] <= 0) continue; - - $this->db->insert('journal_details', [ - 'journal_id' => $journal_id, - 'account_id' => $d['account_id'], - 'debit' => $d['amount'], - 'kredit' => 0 - ]); - - $total_debit += (float)$d['amount']; - $jurnal_debit[] = [ - 'account_id' => $d['account_id'], - 'debit' => (float)$d['amount'], - 'jenis' => 'kas' - ]; - } - - // ========================= - // KREDIT SIDE - // ========================= - $total_kredit = 0; - $jurnal_kredit = []; - - // 1. Piutang (KREDIT) - $kredit_piutang = $total_bayar_input - $bayar_tabungan; - if ($kredit_piutang > 0) { - $this->db->insert('journal_details', [ - 'journal_id' => $journal_id, - 'account_id' => $akun_piutang, - 'debit' => 0, - 'kredit' => $kredit_piutang - ]); - $total_kredit += $kredit_piutang; - $jurnal_kredit[] = [ - 'account_id' => $akun_piutang, - 'kredit' => $kredit_piutang, - 'jenis' => 'piutang' - ]; - } - - // 2. Tabungan (KREDIT) - if ($bayar_tabungan > 0) { - $this->db->insert('journal_details', [ - 'journal_id' => $journal_id, - 'account_id' => $akun_tabungan, - 'debit' => 0, - 'kredit' => $bayar_tabungan - ]); - $total_kredit += $bayar_tabungan; - $jurnal_kredit[] = [ - 'account_id' => $akun_tabungan, - 'kredit' => $bayar_tabungan, - 'jenis' => 'tabungan' - ]; - } - - // ========================= - // PENGAKUAN PENDAPATAN & HPP - // ========================= - $sisa_pengakuan = $bayar_cicilan + $bayar_other + $bayar_barang; - $account_recognition = []; - $total_hpp = 0; - - foreach ($inv_detail_items as $i) { - if ($sisa_pengakuan <= 0) break; - - $belum_diakui = (float)$i->subtotal - (float)$i->recognized_amount; - if ($belum_diakui <= 0) continue; - - $nominal = min($sisa_pengakuan, $belum_diakui); - - if (!isset($account_recognition[$i->account_id])) { - $account_recognition[$i->account_id] = 0; - } - $account_recognition[$i->account_id] += $nominal; - - // Update recognized amount - $this->db->query(" - UPDATE invoice_details - SET recognized_amount = recognized_amount + ? - WHERE id = ? - ", [$nominal, $i->id]); - - if ($i->account_id == $akun_barang ) { - - $persentase = $nominal / (float)$i->subtotal; - $hpp_item = (float)$i->total_hpp_barang * $persentase; - $hpp_item = round($hpp_item, 2); - - $sisa_hpp = max(0, (float)$i->total_hpp_barang - (float)$i->recognized_hpp); - $hpp_item = min($hpp_item, $sisa_hpp); - - if ($hpp_item > 0) { - // Cara 1: Pakai update() method - $this->db->where('id', $i->id); - $this->db->update('invoice_details', [ - 'recognized_hpp' => $i->recognized_hpp + $hpp_item - ]); - - $total_hpp += $hpp_item; - } - } - - $sisa_pengakuan -= $nominal; - } - - // ========================= - // ALOKASI SISA KE AKUN LAIN - // ========================= - $total_diaku = array_sum($account_recognition); - $sisa_yg_harus_diaku = $bayar_cicilan + $bayar_other + $bayar_barang; - $selisih_alokasi = $sisa_yg_harus_diaku - $total_diaku; - - if ($selisih_alokasi > 0) { - $akun_other_list = $this->db - ->select('account_id, SUM(subtotal) as total') - ->from('invoice_details') - ->where('invoice_id', $invoice_id) - ->where_not_in('account_id', [$akun_tabungan, $akun_barang]) - ->group_by('account_id') - ->get() - ->result(); - - foreach ($akun_other_list as $ao) { - if ($selisih_alokasi <= 0) break; - - $belum_diaku_akun = (float)$ao->total; - $alokasi = min($selisih_alokasi, $belum_diaku_akun); - - if (!isset($account_recognition[$ao->account_id])) { - $account_recognition[$ao->account_id] = 0; - } - $account_recognition[$ao->account_id] += $alokasi; - - $selisih_alokasi -= $alokasi; - } - } - - // ========================= - // Insert DEBIT pendapatan - // ========================= - if ($debit_pendapatan_ditahan > 0) { - $this->db->insert('journal_details', [ - 'journal_id' => $journal_id, - 'account_id' => $akun_pendapatan_ditahan, - 'debit' => $debit_pendapatan_ditahan, - 'kredit' => 0 - ]); - - $total_debit += $debit_pendapatan_ditahan; - $jurnal_debit[] = [ - 'account_id' => $akun_pendapatan_ditahan, - 'debit' => $debit_pendapatan_ditahan, - 'jenis' => 'pendapatan_ditahan' - ]; - } - - // ========================= - // Insert KREDIT pendapatan - // ========================= - foreach ($account_recognition as $account_id => $nominal) { - if ($nominal <= 0) continue; - - $this->db->insert('journal_details', [ - 'journal_id' => $journal_id, - 'account_id' => $account_id, - 'debit' => 0, - 'kredit' => $nominal - ]); - - $total_kredit += $nominal; - $jurnal_kredit[] = [ - 'account_id' => $account_id, - 'kredit' => $nominal, - 'jenis' => 'pendapatan' - ]; - } - - // ========================= - // JURNAL HPP - // ========================= - if ($total_hpp > 0) { - $this->db->insert('journal_details', [ - 'journal_id' => $journal_id, - 'account_id' => $akun_hpp, - 'debit' => $total_hpp, - 'kredit' => 0 - ]); - $total_debit += $total_hpp; - - $jurnal_debit[] = [ - 'account_id' => $akun_hpp, - 'debit' => $total_hpp, - 'jenis' => 'hpp_barang' - ]; - - $this->db->insert('journal_details', [ - 'journal_id' => $journal_id, - 'account_id' => $persediaan_dijual, - 'debit' => 0, - 'kredit' => $total_hpp - ]); - $total_kredit += $total_hpp; - - - $jurnal_kredit[] = [ - 'account_id' => $persediaan_dijual, - 'kredit' => $total_hpp, - 'jenis' => 'persediaan_dijual' - ]; - } - - // ========================= - // VALIDASI JURNAL - // ========================= - if (round($total_debit, 2) !== round($total_kredit, 2)) { - $this->db->trans_rollback(); - - echo json_encode([ - 'status' => false, - 'message' => 'Jurnal tidak balance!' - ]); - return; - } - - // ========================= - // INSERT PAYMENTS - // ========================= - $this->db->insert('payments', [ - 'invoice_id' => $invoice_id, - 'tanggal' => $tanggal_bayar, - 'jumlah' => $total_bayar_input, - 'metode' => 'manual', - 'journal_id'=> $journal_id - ]); - - // ========================= - // UPDATE STATUS INVOICE - // ========================= - if ($new_total_bayar >= $total_invoice) { - $status = 'paid'; - $sisa_setelah = 0; - } elseif ($new_total_bayar > 0) { - $status = 'partial'; - } else { - $status = 'unpaid'; - } - - $this->db->where('id', $invoice_id)->update('invoices', [ - 'total_bayar' => $new_total_bayar, - 'sisa_piutang' => $sisa_setelah, - 'status' => $status, - 'updated_at' => date('Y-m-d H:i:s'), - 'updated_by' => $this->session->userdata('user_id') - ]); - - // ========================= - // SELESAIKAN TRANSAKSI - // ========================= - $this->db->trans_complete(); - - // ========================= - // RESPONSE - // ========================= - if (!$this->db->trans_status()) { - echo json_encode([ - 'status' => false, - 'message' => 'Gagal menyelesaikan pembayaran' - ]); - return; - } - - // Logging - log_activity( - 'invoice', - 'create', - 'Pembayaran invoice ' . $invoice->no_invoice . ' - ' . $customer->nama, - 'success' - ); - - echo json_encode([ - 'status' => true, - 'message' => 'Pembayaran berhasil' - ]); + private function guardView($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.');} + private function page($view,array$data){$this->load->view('partials/header',$data);$this->load->view($view,$data);$this->load->view('partials/footer',$data);} } - - -} \ No newline at end of file diff --git a/application/controllers/Items.php b/application/controllers/Items.php index 5bcd923..ce31489 100644 --- a/application/controllers/Items.php +++ b/application/controllers/Items.php @@ -1,7 +1,9 @@ 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 = ''; $btnInsertSn = 'Barcode'; $btnPrintBarcode = ''; - $btnPost = ''; + $btnPost = ''; 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 = ''; - } 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 = ''.number_format($barcodeCount, 0, ',', '.').' / '.number_format($stockAvailable, 0, ',', '.').''; + $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'?'':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,10 @@ 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); + } $logs = $this->db ->select('stock_logs.*, warehouses.nama as gudang') @@ -637,16 +701,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 +744,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 +757,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 +809,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 +939,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 +1032,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 +1042,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 +1106,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 +1118,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 +1128,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 +1152,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 +1193,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 +1205,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 +1442,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 +1548,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 +1573,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 +1712,9 @@ class Items extends CI_Controller { 'message' => 'Barcode berhasil diperbarui.' ]); } -} \ No newline at end of file + + 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))); + } +} diff --git a/application/controllers/Journaldescriptionrepair.php b/application/controllers/Journaldescriptionrepair.php new file mode 100644 index 0000000..af8ceca --- /dev/null +++ b/application/controllers/Journaldescriptionrepair.php @@ -0,0 +1,8 @@ +load->library(array('JournalDescriptionService','AuditService'));} + public function run(){if(strtolower((string)getenv('APP_ENV'))==='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);} +} diff --git a/application/controllers/Jurnal.php b/application/controllers/Jurnal.php index 681d921..9b38165 100644 --- a/application/controllers/Jurnal.php +++ b/application/controllers/Jurnal.php @@ -1,12 +1,15 @@ load->model('DynamicModel', 'dm'); + $this->load->library('JournalService'); + $this->load->library('NumberingService'); + $this->load->library('JournalWorkflowService'); if (!$this->session->userdata('logged_in')) { redirect('auth'); @@ -37,10 +40,10 @@ class Jurnal extends CI_Controller { $search = $_POST['search']['value']; // ================= 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(!empty($search)){ $this->db->group_start(); @@ -58,11 +61,14 @@ class Jurnal extends CI_Controller { journals.tanggal, journals.no_ref, journals.keterangan, + journals.status, + journals.is_reversed, 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(!empty($search)){ $this->db->group_start(); @@ -86,19 +92,22 @@ class Jurnal extends CI_Controller { foreach ($query as $row) { $no++; - if($this->session->userdata('role') != 'Admin'){ - - $aksi = ' - - '; - + $isAdmin = $this->session->userdata('role') === 'Admin'; + $aksi = ' '; + if ($row->status === 'draft') { + $aksi .= ' '; + if ($isAdmin) $aksi .= ' '; } - else { - $aksi = ' - - - '; + if ($isAdmin && $row->status === 'submitted') { + $aksi .= ' '; + $aksi .= ' '; } + if ($isAdmin && $row->status === 'posted') { + $aksi .= ''; + } + + $statusClass = array('draft'=>'secondary','submitted'=>'info','approved'=>'primary','posted'=>'success','rejected'=>'danger','reversed'=>'warning'); + $badge = ''.htmlspecialchars(strtoupper($row->status), ENT_QUOTES, 'UTF-8').''; $data[] = [ $no, @@ -107,6 +116,7 @@ class Jurnal extends CI_Controller { $row->keterangan, number_format($row->total_debit,0,',','.'), number_format($row->total_kredit,0,',','.'), + $badge, $aksi ]; } @@ -124,7 +134,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 +143,59 @@ 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; $idb->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; - + /** 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 +203,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 +222,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 +243,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 +258,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 +320,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 +405,4 @@ class Jurnal extends CI_Controller { 'stok' => $stok ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Kodebarang.php b/application/controllers/Kodebarang.php index 1580737..c54a329 100644 --- a/application/controllers/Kodebarang.php +++ b/application/controllers/Kodebarang.php @@ -1,7 +1,7 @@ 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', - ' - - - ' + (int)$row->is_active===1?'Aktif':'Nonaktif', + ($this->session->userdata('role')==='Admin'||check_permission('items','can_update'))?'':'-' ]; } @@ -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() ); } -} \ No newline at end of file +} diff --git a/application/controllers/Labarugi.php b/application/controllers/Labarugi.php index 6328df7..814b541 100644 --- a/application/controllers/Labarugi.php +++ b/application/controllers/Labarugi.php @@ -1,7 +1,7 @@ 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 ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Leaverequests.php b/application/controllers/Leaverequests.php index 0a5df73..f068348 100644 --- a/application/controllers/Leaverequests.php +++ b/application/controllers/Leaverequests.php @@ -1,7 +1,7 @@ 'Leave request berhasil dihapus' ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Lokasiasset.php b/application/controllers/Lokasiasset.php index 1308531..469f183 100644 --- a/application/controllers/Lokasiasset.php +++ b/application/controllers/Lokasiasset.php @@ -1,145 +1,13 @@ 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, - ' - - - ' - ]; - } - - 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() - ); - } -} \ No newline at end of file +} diff --git a/application/controllers/Lookups.php b/application/controllers/Lookups.php new file mode 100644 index 0000000..0b272aa --- /dev/null +++ b/application/controllers/Lookups.php @@ -0,0 +1,9 @@ +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)))); + } +} diff --git a/application/controllers/Maintenanceworker.php b/application/controllers/Maintenanceworker.php new file mode 100644 index 0000000..f84ab1e --- /dev/null +++ b/application/controllers/Maintenanceworker.php @@ -0,0 +1,6 @@ +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";} +} diff --git a/application/controllers/Migrate.php b/application/controllers/Migrate.php new file mode 100644 index 0000000..833c268 --- /dev/null +++ b/application/controllers/Migrate.php @@ -0,0 +1,126 @@ +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"; + } +} diff --git a/application/controllers/Neraca.php b/application/controllers/Neraca.php index 8b8452b..0a9e56d 100644 --- a/application/controllers/Neraca.php +++ b/application/controllers/Neraca.php @@ -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 ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Neracasaldo.php b/application/controllers/Neracasaldo.php index 2beea77..72c6bc9 100644 --- a/application/controllers/Neracasaldo.php +++ b/application/controllers/Neracasaldo.php @@ -1,7 +1,7 @@ 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); } -} \ No newline at end of file +} diff --git a/application/controllers/Organizationmaster.php b/application/controllers/Organizationmaster.php new file mode 100644 index 0000000..437c163 --- /dev/null +++ b/application/controllers/Organizationmaster.php @@ -0,0 +1,13 @@ +'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){if($this->db->where($employeeField,(int)$id)->count_all_results('k_employees')){$this->db->where('id',(int)$id)->update($table,array('is_active'=>0));return json_response(true,'Data sudah digunakan karyawan, sehingga dinonaktifkan dan tidak dihapus.');}$this->db->where('id',(int)$id)->delete($table);return json_response(true,'Data berhasil dihapus.');} +} diff --git a/application/controllers/Payablecheck.php b/application/controllers/Payablecheck.php new file mode 100644 index 0000000..3b30c5c --- /dev/null +++ b/application/controllers/Payablecheck.php @@ -0,0 +1,10 @@ +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";} +} diff --git a/application/controllers/Payables.php b/application/controllers/Payables.php new file mode 100644 index 0000000..c461111 --- /dev/null +++ b/application/controllers/Payables.php @@ -0,0 +1,331 @@ +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; + } +} diff --git a/application/controllers/Payroll.php b/application/controllers/Payroll.php index defe58e..37971a1 100644 --- a/application/controllers/Payroll.php +++ b/application/controllers/Payroll.php @@ -1,12 +1,14 @@ requireAdmin(); + $this->load->database(); date_default_timezone_set('Asia/Jakarta'); @@ -203,6 +205,9 @@ class Payroll extends CI_Controller { return; } + $periodControl=$this->db->get_where('k_payroll_periods',array('id'=>$payroll->payroll_period_id))->row(); + if($periodControl&&in_array($periodControl->workflow_status,array('final','posted','paid'),true)){echo json_encode(array('status'=>false,'message'=>'Payroll final terkunci. Gunakan pembatalan resmi.'));return;} + $items = $this->db ->where('payroll_id',$id) ->order_by('id','ASC') @@ -247,6 +252,9 @@ class Payroll extends CI_Controller { return; } + $periodControl=$this->db->get_where('k_payroll_periods',array('id'=>$payroll->payroll_period_id))->row(); + if($periodControl&&in_array($periodControl->workflow_status,array('final','posted','paid'),true)){echo json_encode(array('status'=>false,'message'=>'Payroll final terkunci. Gunakan pembatalan resmi.'));return;} + // ========================================= // DELETE OLD ITEMS // ========================================= @@ -595,4 +603,4 @@ class Payroll extends CI_Controller { 'message' => 'Payroll berhasil digenerate' ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Pesan.php b/application/controllers/Pesan.php index 1339d4f..ce8f34f 100644 --- a/application/controllers/Pesan.php +++ b/application/controllers/Pesan.php @@ -1,7 +1,7 @@ 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($code==='balance_sheet')$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);$d=array('active_menu'=>'professional_reports','report'=>$code,'filters'=>$f,'rows'=>$this->professionalreportservice->run($code,$reportFilters),'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);} +} diff --git a/application/controllers/Purchaseadjustmentcheck.php b/application/controllers/Purchaseadjustmentcheck.php new file mode 100644 index 0000000..dbde48b --- /dev/null +++ b/application/controllers/Purchaseadjustmentcheck.php @@ -0,0 +1,73 @@ +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_atnet_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; + } +} diff --git a/application/controllers/Purchasedocuments.php b/application/controllers/Purchasedocuments.php new file mode 100644 index 0000000..dcdfc44 --- /dev/null +++ b/application/controllers/Purchasedocuments.php @@ -0,0 +1,61 @@ +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');} +} diff --git a/application/controllers/Purchasereturns.php b/application/controllers/Purchasereturns.php new file mode 100644 index 0000000..5b7fbb8 --- /dev/null +++ b/application/controllers/Purchasereturns.php @@ -0,0 +1,205 @@ +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);} +} diff --git a/application/controllers/Purchases.php b/application/controllers/Purchases.php new file mode 100644 index 0000000..be7a936 --- /dev/null +++ b/application/controllers/Purchases.php @@ -0,0 +1,95 @@ +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:''));}} +} diff --git a/application/controllers/Qr.php b/application/controllers/Qr.php index b82fe6b..603445f 100644 --- a/application/controllers/Qr.php +++ b/application/controllers/Qr.php @@ -1,7 +1,7 @@ input->is_cli_request())show_404();$this->load->library('JobQueueService');$this->jobqueueservice->recoverStale();$worker=gethostname().':'.getmypid();$done=0;for($i=0;$ijobqueueservice->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']??getenv('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_escape($p['report_code']).'

'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'
');elseif($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h); + } +} diff --git a/application/controllers/Receivableattachments.php b/application/controllers/Receivableattachments.php new file mode 100644 index 0000000..9b62e80 --- /dev/null +++ b/application/controllers/Receivableattachments.php @@ -0,0 +1,7 @@ +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));} +} diff --git a/application/controllers/Receivablecheck.php b/application/controllers/Receivablecheck.php new file mode 100644 index 0000000..dfcdc63 --- /dev/null +++ b/application/controllers/Receivablecheck.php @@ -0,0 +1,19 @@ +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";} +} diff --git a/application/controllers/Receivablereminders.php b/application/controllers/Receivablereminders.php new file mode 100644 index 0000000..5a76767 --- /dev/null +++ b/application/controllers/Receivablereminders.php @@ -0,0 +1,6 @@ +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()));} +} diff --git a/application/controllers/Receivables.php b/application/controllers/Receivables.php new file mode 100644 index 0000000..9fc220b --- /dev/null +++ b/application/controllers/Receivables.php @@ -0,0 +1,73 @@ +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')); + $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; + } +} diff --git a/application/controllers/Reportworker.php b/application/controllers/Reportworker.php new file mode 100644 index 0000000..f4c6082 --- /dev/null +++ b/application/controllers/Reportworker.php @@ -0,0 +1,6 @@ +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_escape($j->report_code).'

'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'
');}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";} +} diff --git a/application/controllers/Rolecheck.php b/application/controllers/Rolecheck.php new file mode 100644 index 0000000..2794d30 --- /dev/null +++ b/application/controllers/Rolecheck.php @@ -0,0 +1,6 @@ +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;} +} diff --git a/application/controllers/Roles.php b/application/controllers/Roles.php new file mode 100644 index 0000000..6237041 --- /dev/null +++ b/application/controllers/Roles.php @@ -0,0 +1,45 @@ +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){$selected=array_values(array_intersect((array)(isset($posted[$key])?$posted[$key]:array()),array_keys($this->actions)));if($selected){if(!in_array('can_view',$selected,true))array_unshift($selected,'can_view');$permissions[$key]=$selected;}} + 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.'); + } +} diff --git a/application/controllers/Sales.php b/application/controllers/Sales.php new file mode 100644 index 0000000..9e104ec --- /dev/null +++ b/application/controllers/Sales.php @@ -0,0 +1,73 @@ +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);$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')->from('payments p')->join('customers c','c.id=p.customer_id')->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')->from('payment_allocations a')->join('invoices i','i.id=a.invoice_id')->where(array('a.payment_id'=>(int)$id,'i.company_id'=>$company))->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))->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){$data['pdf_company']=$this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row();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.');} +} diff --git a/application/controllers/Salescheck.php b/application/controllers/Salescheck.php new file mode 100644 index 0000000..a79cb73 --- /dev/null +++ b/application/controllers/Salescheck.php @@ -0,0 +1,23 @@ +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); + } +} diff --git a/application/controllers/Salesdocuments.php b/application/controllers/Salesdocuments.php new file mode 100644 index 0000000..1504db4 --- /dev/null +++ b/application/controllers/Salesdocuments.php @@ -0,0 +1,14 @@ +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);}} +} diff --git a/application/controllers/Scalabilitycheck.php b/application/controllers/Scalabilitycheck.php new file mode 100644 index 0000000..f442f55 --- /dev/null +++ b/application/controllers/Scalabilitycheck.php @@ -0,0 +1,6 @@ +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);} +} diff --git a/application/controllers/Scheduler.php b/application/controllers/Scheduler.php index be0d549..9e6f784 100644 --- a/application/controllers/Scheduler.php +++ b/application/controllers/Scheduler.php @@ -1,7 +1,7 @@ 'Shift berhasil dihapus' ]); } -} \ No newline at end of file +} diff --git a/application/controllers/Stage1011check.php b/application/controllers/Stage1011check.php new file mode 100644 index 0000000..42dc6ff --- /dev/null +++ b/application/controllers/Stage1011check.php @@ -0,0 +1,6 @@ +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;} +} diff --git a/application/controllers/Stage1214check.php b/application/controllers/Stage1214check.php new file mode 100644 index 0000000..6ec4024 --- /dev/null +++ b/application/controllers/Stage1214check.php @@ -0,0 +1,6 @@ +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;} +} diff --git a/application/controllers/Stage15check.php b/application/controllers/Stage15check.php new file mode 100644 index 0000000..12d4d1e --- /dev/null +++ b/application/controllers/Stage15check.php @@ -0,0 +1,6 @@ +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);} +} diff --git a/application/controllers/Stage16check.php b/application/controllers/Stage16check.php new file mode 100644 index 0000000..3f3fa46 --- /dev/null +++ b/application/controllers/Stage16check.php @@ -0,0 +1,6 @@ +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);} +} diff --git a/application/controllers/Stage17check.php b/application/controllers/Stage17check.php new file mode 100644 index 0000000..b14d578 --- /dev/null +++ b/application/controllers/Stage17check.php @@ -0,0 +1,103 @@ +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_valuezero('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());} + } +} diff --git a/application/controllers/Stage89check.php b/application/controllers/Stage89check.php new file mode 100644 index 0000000..a592e5b --- /dev/null +++ b/application/controllers/Stage89check.php @@ -0,0 +1,6 @@ +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_bukurow()->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;} +} diff --git a/application/controllers/Suppliers.php b/application/controllers/Suppliers.php new file mode 100644 index 0000000..1a85014 --- /dev/null +++ b/application/controllers/Suppliers.php @@ -0,0 +1,15 @@ +load->library(array('NumberingService','PostingService','AccountMappingService','FiscalPeriodService'));} + public function index(){$q=$this->db->order_by('is_active','DESC')->order_by('name');if($this->db->field_exists('company_id','suppliers'))$q->where('company_id',$this->companycontext->id());$data=array('active_menu'=>'suppliers','rows'=>$q->get('suppliers')->result());$this->load->view('partials/header',$data);$this->load->view('suppliers/index',$data);$this->load->view('partials/footer');} + public function save(){if(strtoupper($this->input->method())!=='POST')show_404();$data=$this->payload();$this->assertUnique($data['supplier_code']);if($data['opening_balance']>0&&!$data['opening_balance_date'])throw new BusinessException('Tanggal saldo awal wajib diisi.');$this->db->trans_begin();try{if($this->db->field_exists('company_id','suppliers'))$data['company_id']=$this->companycontext->id();$data['created_by']=(int)$this->session->userdata('user_id');$data['created_at']=$data['updated_at']=date('Y-m-d H:i:s');$this->db->insert('suppliers',$data);$id=(int)$this->db->insert_id();if($data['opening_balance']>0)$this->opening($id,$data);if(!$this->db->trans_status())throw new RuntimeException('Gagal menyimpan supplier.');$this->db->trans_commit();$this->session->set_flashdata('success','Supplier berhasil ditambahkan.');redirect('suppliers');}catch(Throwable$e){$this->db->trans_rollback();show_error($e->getMessage(),422);}} + public function update($id){if(strtoupper($this->input->method())!=='POST')show_404();$old=$this->find((int)$id);$data=$this->payload(false);$this->assertUnique($data['supplier_code'],(int)$old->id);$data['updated_at']=date('Y-m-d H:i:s');$this->scope((int)$id)->update('suppliers',$data);log_activity('suppliers','update','Memperbarui supplier '.$data['supplier_code'].' - '.$data['name'],'success');$this->session->set_flashdata('success','Data supplier berhasil diperbarui.');redirect('suppliers');} + public function update_status($id){if(strtoupper($this->input->method())!=='POST')show_404();$s=$this->find((int)$id);$active=$s->is_active?0:1;$this->scope((int)$id)->update('suppliers',array('is_active'=>$active,'updated_at'=>date('Y-m-d H:i:s')));log_activity('suppliers','update',($active?'Mengaktifkan':'Menonaktifkan').' supplier '.$s->supplier_code.' - '.$s->name,'success');$this->session->set_flashdata('success','Supplier berhasil '.($active?'diaktifkan.':'dinonaktifkan. Histori transaksi tetap tersimpan.'));redirect('suppliers');} + private function payload($opening=true){$email=trim((string)$this->input->post('email',true));if($email!==''&&!filter_var($email,FILTER_VALIDATE_EMAIL))throw new BusinessException('Format email supplier tidak valid.');$d=array('supplier_code'=>strtoupper(trim((string)$this->input->post('supplier_code',true))),'name'=>trim((string)$this->input->post('name',true)),'tax_number'=>trim((string)$this->input->post('tax_number',true)),'address'=>trim((string)$this->input->post('address',true)),'contact_person'=>trim((string)$this->input->post('contact_person',true)),'phone'=>trim((string)$this->input->post('phone',true)),'email'=>$email,'bank_name'=>trim((string)$this->input->post('bank_name',true)),'bank_account_no'=>trim((string)$this->input->post('bank_account_no',true)),'bank_account_name'=>trim((string)$this->input->post('bank_account_name',true)),'payment_term_days'=>max(0,(int)$this->input->post('payment_term_days')),'is_active'=>$this->input->post('is_active')?1:0);if($opening){$d['opening_balance']=max(0,(float)$this->input->post('opening_balance'));$d['opening_balance_date']=$this->input->post('opening_balance_date')?:null;}if($d['supplier_code']===''||$d['name']==='')throw new BusinessException('Kode dan nama supplier wajib diisi.');return$d;} + private function find($id){$r=$this->scope($id)->get('suppliers')->row();if(!$r)show_404();return$r;} + private function scope($id){$this->db->where('id',$id);if($this->db->field_exists('company_id','suppliers'))$this->db->where('company_id',$this->companycontext->id());return$this->db;} + private function assertUnique($code,$ignore=0){$this->db->where('supplier_code',$code);if($ignore)$this->db->where('id !=',$ignore);if($this->db->field_exists('company_id','suppliers'))$this->db->where('company_id',$this->companycontext->id());if($this->db->count_all_results('suppliers'))throw new BusinessException('Kode supplier sudah digunakan.');} + private function opening($id,$d){$this->fiscalperiodservice->assertOpen($d['opening_balance_date']);$internal=$this->numberingservice->next('supplier_invoice',$d['opening_balance_date']);$desc='[AUTO] Saldo awal hutang supplier '.$d['supplier_code'].' - '.$d['name'].' per '.$d['opening_balance_date'];$journal=$this->postingservice->post(array('tanggal'=>$d['opening_balance_date'],'no_ref'=>$internal,'keterangan'=>$desc,'ref_type'=>'supplier_opening_balance','ref_id'=>$id,'created_by'=>$this->session->userdata('user_id')),array(array('account_id'=>$this->accountmappingservice->get('payable_opening_balance'),'debit'=>$d['opening_balance'],'kredit'=>0,'keterangan'=>$desc),array('account_id'=>$this->accountmappingservice->get('accounts_payable'),'debit'=>0,'kredit'=>$d['opening_balance'],'keterangan'=>$desc)),false,true);$x=array('supplier_invoice_no'=>'OPEN-'.$d['supplier_code'],'internal_no'=>$internal,'supplier_id'=>$id,'invoice_date'=>$d['opening_balance_date'],'due_date'=>$d['opening_balance_date'],'status'=>'partial','total'=>$d['opening_balance'],'balance'=>$d['opening_balance'],'journal_id'=>$journal,'created_by'=>$this->session->userdata('user_id'),'posted_by'=>$this->session->userdata('user_id'),'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s'));if($this->db->field_exists('company_id','supplier_invoices'))$x['company_id']=$this->companycontext->id();$this->db->insert('supplier_invoices',$x);} +} diff --git a/application/controllers/Taxes.php b/application/controllers/Taxes.php new file mode 100644 index 0000000..d784a90 --- /dev/null +++ b/application/controllers/Taxes.php @@ -0,0 +1,11 @@ +requirePermission('taxes');$this->load->database();} + private function uid(){return(int)$this->session->userdata('user_id');}private function go($ok,$m){$this->session->set_flashdata($ok?'success':'error',$m);redirect('taxes');} + public function index(){$period=$this->input->get('period')?:date('Y-m');$d=array('active_menu'=>'taxes','period'=>$period);$d['codes']=$this->db->order_by('code')->get('tax_codes')->result();$d['periods']=$this->db->order_by('period','DESC')->get('tax_periods')->result();$d['register']=$this->db->select('t.*,c.code,c.name')->from('tax_transactions t')->join('tax_codes c','c.id=t.tax_code_id')->like('t.tax_date',$period,'after')->order_by('t.tax_date')->get()->result();$d['summary']=$this->db->query("SELECT direction,SUM(tax_base) tax_base,SUM(tax_amount) tax_amount FROM tax_transactions WHERE DATE_FORMAT(tax_date,'%Y-%m')=? AND status='posted' GROUP BY direction",array($period))->result();$this->load->view('partials/header',$d);$this->load->view('taxes/index',$d);$this->load->view('partials/footer');} + public function code(){try{$this->db->insert('tax_codes',array('code'=>strtoupper(trim($this->input->post('code',true))),'name'=>$this->input->post('name',true),'tax_type'=>$this->input->post('tax_type'),'calculation_method'=>$this->input->post('calculation_method'),'rate'=>(float)$this->input->post('rate'),'formula_expression'=>$this->input->post('formula_expression',true)?:null,'input_account_id'=>$this->input->post('input_account_id')?:null,'output_account_id'=>$this->input->post('output_account_id')?:null,'withholding_payable_account_id'=>$this->input->post('withholding_payable_account_id')?:null,'withholding_receivable_account_id'=>$this->input->post('withholding_receivable_account_id')?:null,'effective_from'=>$this->input->post('effective_from'),'effective_to'=>$this->input->post('effective_to')?:null,'is_active'=>1));$this->go(true,'Kode pajak disimpan.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function period(){try{$period=$this->input->post('period');$status=$this->input->post('status');$row=$this->db->get_where('tax_periods',array('period'=>$period,'tax_type'=>$this->input->post('tax_type')))->row();$data=array('period'=>$period,'tax_type'=>$this->input->post('tax_type'),'status'=>$status,'filing_reference'=>$this->input->post('filing_reference',true),'notes'=>$this->input->post('notes',true));if(in_array($status,array('filed','closed'),true)){$data[$status.'_at']=date('Y-m-d H:i:s');$data[$status.'_by']=$this->uid();}if($row)$this->db->where('id',$row->id)->update('tax_periods',$data);else$this->db->insert('tax_periods',$data);$this->go(true,'Status periode pajak diperbarui.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} + public function export($period){$rows=$this->db->select('t.tax_date,c.code,c.tax_type,t.direction,t.source_type,t.source_id,t.tax_invoice_no,t.tax_base,t.tax_amount,t.status')->from('tax_transactions t')->join('tax_codes c','c.id=t.tax_code_id')->like('t.tax_date',$period,'after')->order_by('t.tax_date')->get()->result_array();$name='tax-register-'.$period.'.csv';$this->output->set_content_type('text/csv')->set_header('Content-Disposition: attachment; filename="'.$name.'"');$h=fopen('php://output','w');fputcsv($h,array('tanggal','kode','jenis','arah','sumber','id_sumber','nomor_faktur','dasar_pajak','nilai_pajak','status'));foreach($rows as$r)fputcsv($h,$r);fclose($h);} +} diff --git a/application/controllers/Teknisi.php b/application/controllers/Teknisi.php index a948aa5..0ee0f53 100644 --- a/application/controllers/Teknisi.php +++ b/application/controllers/Teknisi.php @@ -1,13 +1,14 @@ load->model('PeralatanTeknisi_model', 'ptm'); + $this->load->library('TechnicianEquipmentService'); if (!$this->session->userdata('logged_in')) { redirect('auth'); @@ -16,14 +17,7 @@ class Teknisi extends CI_Controller { public function index() { - $data = [ - "active_menu" => "peralatan_teknisi", - "teknisi_list" => $this->ptm->get_teknisi_list() - ]; - - $this->load->view('partials/header', $data); - $this->load->view('peralatan_teknisi/layout', $data); - $this->load->view('partials/footer'); + $this->renderEquipmentWorkbench('overview'); } public function get_data() @@ -102,6 +96,8 @@ class Teknisi extends CI_Controller { public function batch_kembalikan() { + if(strtoupper($this->input->method())==='POST')return json_response(false,'Alur lama dinonaktifkan. Gunakan Scan Peralatan agar pengembalian masuk pemeriksaan gudang.',array(),409); + if (strtoupper($this->input->method()) !== 'POST') show_404(); $barcodes = $this->input->post('barcodes'); if (empty($barcodes) || !is_array($barcodes)) { @@ -115,6 +111,8 @@ class Teknisi extends CI_Controller { public function batch_dibawa() { + if(strtoupper($this->input->method())==='POST')return json_response(false,'Alur lama dinonaktifkan. Gunakan Scan Peralatan dan Serahkan ke Teknisi.',array(),409); + if (strtoupper($this->input->method()) !== 'POST') show_404(); $barcodes = $this->input->post('barcodes'); $user_id = (int)$this->input->post('user_id'); @@ -153,9 +151,9 @@ class Teknisi extends CI_Controller { $item_count = (int)$row->item_count; $item_text = $item_count . ' Item'; - $aksi = ' + '; @@ -193,49 +191,22 @@ class Teknisi extends CI_Controller { public function get_teknisi_items() { - $user_id = (int)$this->input->get('user_id'); - $draw = (int)$this->input->get('draw'); - $start = (int)$this->input->get('start'); - $length = (int)$this->input->get('length'); - $search = $this->input->get('search')['value'] ?? ''; - $order_col = (int)$this->input->get('order')[0]['column'] ?? 2; - $order_dir = $this->input->get('order')[0]['dir'] ?? 'asc'; - - if (!$user_id) { - echo json_encode(['draw' => $draw, 'recordsTotal' => 0, 'recordsFiltered' => 0, 'data' => []]); - return; + $user_id=(int)$this->input->get('user_id');$draw=(int)$this->input->get('draw');$start=(int)$this->input->get('start');$length=(int)$this->input->get('length');$search=$this->input->get('search')['value']??'';$order_col=(int)($this->input->get('order')[0]['column']??2);$order_dir=$this->input->get('order')[0]['dir']??'asc'; + if(!$user_id){echo json_encode(array('draw'=>$draw,'recordsTotal'=>0,'recordsFiltered'=>0,'data'=>array()));return;} + $data=$this->ptm->get_teknisi_items_data($user_id,$start,$length,$search,$order_col,$order_dir);$result=array();$no=$start+1; + foreach($data as$row){ + $carried=(float)$row->carried_qty;$installed=(float)$row->installed_qty;$tracking=strtoupper((string)$row->tracking_type)==='UNIT'?'UNIT':'QTY'; + if($carried>0&&$installed>0)$badge='Sebagian DipasangDibawa '.number_format($carried,0,',','.').' · Dipasang '.number_format($installed,0,',','.').''; + elseif($installed>0)$badge='Dipasang'.htmlspecialchars(($row->customer_name?:'-').' · '.($row->usage_date?:'-'),ENT_QUOTES,'UTF-8').''; + else$badge='Dibawa'; + $action='-'; + if(is_master_admin_user()||check_permission('items','can_update')){ + $action=''; + if(!empty($row->proof_id))$action.=' '; + }elseif(!empty($row->proof_id))$action='Bukti'; + $result[]=array($no++,$row->tanggal??date('d/m/Y'),htmlspecialchars($row->barcode,ENT_QUOTES,'UTF-8'),htmlspecialchars($row->nama_barang,ENT_QUOTES,'UTF-8'),htmlspecialchars($row->serial_number,ENT_QUOTES,'UTF-8')?:'-',number_format((float)$row->assigned_qty,0,',','.'),$badge,$action); } - - $data = $this->ptm->get_teknisi_items_data($user_id, $start, $length, $search, $order_col, $order_dir); - - $total_filtered = $this->ptm->count_filtered_teknisi_items($user_id, $search); - $total_all = $this->ptm->count_all_teknisi_items($user_id); - - $result = []; - $no = $start + 1; - - foreach ($data as $row) { - $badge = $row->status == 'active' - ? 'Dibawa' - : '' . htmlspecialchars($row->status, ENT_QUOTES, 'UTF-8') . ''; - - $result[] = [ - $no++, - $row->tanggal ?? date('d/m/Y'), - htmlspecialchars($row->barcode, ENT_QUOTES, 'UTF-8'), - htmlspecialchars($row->nama_barang, ENT_QUOTES, 'UTF-8'), - htmlspecialchars($row->serial_number, ENT_QUOTES, 'UTF-8') ?: '-', - htmlspecialchars($row->qty_sisa, ENT_QUOTES, 'UTF-8') ?: '0', - $badge - ]; - } - - echo json_encode([ - 'draw' => $draw, - 'recordsTotal' => $total_all, - 'recordsFiltered' => $total_filtered, - 'data' => $result - ]); + echo json_encode(array('draw'=>$draw,'recordsTotal'=>$this->ptm->count_all_teknisi_items($user_id),'recordsFiltered'=>$this->ptm->count_filtered_teknisi_items($user_id,$search),'data'=>$result)); } public function get_form_barang_bawaan_data() @@ -305,6 +276,7 @@ class Teknisi extends CI_Controller { public function generate_pdf_barang_bawaan($user_id = null) { + return $this->technician_inventory_pdf($user_id); $user_id = (int)$user_id; if (!$user_id) { @@ -380,6 +352,10 @@ class Teknisi extends CI_Controller { public function batch_transfer() { + if(strtoupper($this->input->method())==='POST')return json_response(false,'Alur lama dinonaktifkan. Gunakan tindakan Transfer Teknisi pada hasil scan.',array(),409); + if (strtoupper($this->input->method()) !== 'POST') { + show_404(); + } $barcodes = $this->input->post('barcodes'); $from_user_id = (int)$this->input->post('from_user_id'); $to_user_id = (int)$this->input->post('to_user_id'); @@ -407,6 +383,11 @@ class Teknisi extends CI_Controller { return; } + if (!$technician || !$this->ptm->get_teknisi_info($from_user_id)) { + echo json_encode(['status' => false, 'message' => 'Teknisi asal atau tujuan tidak ditemukan pada perusahaan aktif']); + return; + } + $now = date('Y-m-d H:i:s'); $errors = []; @@ -416,16 +397,16 @@ class Teknisi extends CI_Controller { $barcode = trim($barcode); if ($barcode == '') continue; - $item = $this->db - ->select('it.*, ib.id as barcode_id, tec.full_name as teknisi_name') - ->from('item_technician it') - ->join('item_barcodes ib', 'ib.barcode = it.barcode', 'left') - ->join('k_employees tec', 'tec.id = it.user_id', 'left') - ->where('it.barcode', $barcode) - ->where('it.status', 'active') - ->where('it.user_id', $from_user_id) - ->get() - ->row(); + $item = $this->db->query( + "SELECT it.*, ib.id AS barcode_id, ib.qty_sisa, tec.full_name AS teknisi_name + FROM item_technician it + JOIN item_barcodes ib ON ib.barcode = it.barcode + JOIN items i ON i.id = it.item_id AND i.company_id = ? + LEFT JOIN k_employees tec ON tec.id = it.user_id + WHERE it.barcode = ? AND it.status = 'active' AND COALESCE(it.usage_status,'carried') = 'carried' AND it.user_id = ? + FOR UPDATE", + [(int)$this->companycontext->id(), $barcode, $from_user_id] + )->row(); if (!$item) { $errors[] = "Barcode $barcode tidak ditemukan pada teknisi asal"; @@ -439,13 +420,19 @@ class Teknisi extends CI_Controller { ->where('user_id', $from_user_id) ->where('status', 'active') ->update('item_technician', [ - 'user_id' => $to_user_id + 'user_id' => $to_user_id, + 'usage_status' => 'carried', + 'usage_date' => null, + 'customer_id' => null, + 'usage_notes' => null, + 'updated_by' => (int)$this->session->userdata('user_id'), + 'updated_at' => $now ]); $this->db->insert('item_movements', [ 'item_id' => $item->item_id, 'barcode_id' => $item->barcode_id, - 'qty' => 1, + 'qty' => (float)($item->assigned_qty?:$item->qty_sisa), 'from_type' => 'technician', 'from_id' => $from_user_id, 'to_type' => 'technician', @@ -463,8 +450,7 @@ class Teknisi extends CI_Controller { log_message('error', 'batch_transfer gagal: ' . json_encode($db_error)); echo json_encode([ 'status' => false, - 'message' => 'Gagal memproses transfer', - 'debug' => $db_error['message'] ?? 'Unknown database error' + 'message' => 'Gagal memproses transfer barang teknisi.' ]); return; } @@ -480,39 +466,314 @@ class Teknisi extends CI_Controller { public function get_log_data() { - $data = $this->ptm->get_log_barang(); - - $result = []; - $no = 1; - - foreach ($data as $row) { - $status_badge = $row->status === 'active' - ? 'ACTIVE' - : 'INACTIVE'; - - $result[] = [ - $no++, - htmlspecialchars($row->teknisi_name ?? '-', ENT_QUOTES, 'UTF-8'), - htmlspecialchars($row->item_code ?? '-', ENT_QUOTES, 'UTF-8'), - htmlspecialchars($row->barcode, ENT_QUOTES, 'UTF-8'), - htmlspecialchars($row->nama_barang ?? '-', ENT_QUOTES, 'UTF-8'), - $row->created_at ? date('d/m/Y H:i', strtotime($row->created_at)) : '-', - $row->status === 'inactive' && $row->deleted_at ? date('d/m/Y H:i', strtotime($row->deleted_at)) : '-', - $status_badge, - ]; - } - - echo json_encode(['data' => $result]); + $this->equipmentPermission('technician_equipment','can_view');$rows=array();$labels=array('company'=>'Perusahaan','customer'=>'Customer','transferred_non_inventory'=>'Non-Persediaan Diserahkan','unknown'=>'Belum Diketahui','carried'=>'Dibawa','installed'=>'Dipasang','removed'=>'Dilepas','returned'=>'Dikembalikan','good'=>'Baik','inspection_required'=>'Perlu Diperiksa','repair_required'=>'Perlu Perbaikan','under_repair'=>'Dalam Perbaikan','damaged'=>'Rusak','lost'=>'Hilang','scrapped'=>'Dihapuskan'); + foreach($this->technicianequipmentservice->history($this->logFilters())as$index=>$row){$rows[]=array($index+1,html_escape($row['document_no']).'
'.html_escape(date('d/m/Y',strtotime($row['document_date']))).'',html_escape($row['technician_name']?:'-'),html_escape($row['display_name']),html_escape($row['barcode_snapshot']).'
'.html_escape($row['serial_number']?:'-').'',html_escape($labels[$row['ownership_type']]??$row['ownership_type']),number_format((float)$row['qty'],abs((float)$row['qty']-round((float)$row['qty']))<.0001?0:4,',','.'),html_escape($row['customer_name']?:'-'),html_escape(($row['from_custodian_type']?:'-').' → '.($row['to_custodian_type']?:'-')),html_escape($labels[$row['to_usage_status']]??$row['to_usage_status']),html_escape($labels[$row['to_condition_status']]??$row['to_condition_status']),html_escape($row['document_notes']?:$row['notes']?:'-'),'');} + return json_response(true,'Riwayat peralatan.',array('data'=>$rows)); } public function log_barang() { + $this->equipmentPermission('technician_equipment','can_view'); $data = [ "active_menu" => "peralatan_teknisi", + "teknisi_list" => $this->ptm->get_teknisi_list(), + "customers" => $this->db->select('id,nama')->where('company_id',(int)$this->companycontext->id())->order_by('nama')->get('customers')->result(), + "can_export" => is_master_admin_user() || check_permission('technician_equipment','can_export') || check_permission('items','can_export'), + "equipment_permissions" => $this->equipmentPermissionFlags(), + "equipment_dashboard" => $this->scopedEquipmentDashboard() ]; $this->load->view('partials/header', $data); $this->load->view('peralatan_teknisi/log_barang', $data); $this->load->view('partials/footer'); } + + public function detail($userId=0) + { + $this->equipmentPermission('technician_equipment','can_view');$userId=(int)$userId;if($this->restrictedTechnicianEmployee()&&$userId!==$this->restrictedTechnicianEmployee())show_error('Teknisi hanya dapat melihat barang dalam penguasaannya.',403,'Akses ditolak');$technician=$this->ptm->get_teknisi_info($userId);if(!$technician)show_404(); + $data=array('active_menu'=>'peralatan_teknisi','technician'=>$technician,'allocations'=>$this->technicianequipmentservice->allocations(null,null,$userId),'documents'=>$this->technicianequipmentservice->documents(array('technician_id'=>$userId)),'equipment_permissions'=>$this->equipmentPermissionFlags(),'equipment_dashboard'=>$this->scopedEquipmentDashboard()); + $this->load->view('partials/header',$data);$this->load->view('peralatan_teknisi/detail_professional',$data);$this->load->view('partials/footer'); + } + + public function get_status_history_data($userId=0) + { + $this->requirePermission('items','can_view');$userId=(int)$userId;if(!$this->ptm->get_teknisi_info($userId))return json_response(false,'Teknisi tidak ditemukan.',array('data'=>array()),404); + $labels=array('initial'=>'Saldo Awal','carried'=>'Dibawa Teknisi','installed'=>'Dipasang','returned'=>'Kembali ke Gudang','warehouse'=>'Gudang');$rows=array(); + foreach($this->ptm->get_status_history($userId)as$index=>$row){$proof=$row->attachment_id?'':'-';$rows[]=array($index+1,date('d/m/Y',strtotime($row->event_date)),htmlspecialchars(($row->kode_detail?:'-').' - '.($row->nama_barang?:'-'),ENT_QUOTES,'UTF-8'),htmlspecialchars($row->barcode,ENT_QUOTES,'UTF-8'),htmlspecialchars($labels[$row->from_status]??ucfirst($row->from_status),ENT_QUOTES,'UTF-8').' → '.htmlspecialchars($labels[$row->to_status]??ucfirst($row->to_status),ENT_QUOTES,'UTF-8'),number_format((float)$row->qty,0,',','.'),htmlspecialchars($row->customer_name?:'-',ENT_QUOTES,'UTF-8'),htmlspecialchars($row->notes?:'-',ENT_QUOTES,'UTF-8'),htmlspecialchars($row->actor_name?:'-',ENT_QUOTES,'UTF-8'),$proof);} + return json_response(true,'Riwayat status peralatan.',array('data'=>$rows)); + } + + public function update_item_status() + { + if(strtoupper($this->input->method())==='POST')return json_response(false,'Alur status lama dinonaktifkan. Gunakan workbench Peralatan Teknisi agar histori dan saldo tetap sinkron.',array(),409); + if(strtoupper($this->input->method())!=='POST')show_404(); + $this->requirePermission('items','can_update'); + $barcode=trim((string)$this->input->post('barcode',true));$userId=(int)$this->input->post('user_id');$status=trim((string)$this->input->post('status',true));$date=trim((string)$this->input->post('date',true));$customer=(int)$this->input->post('customer_id');$notes=trim((string)$this->input->post('notes',true));$qty=(float)$this->input->post('qty');$source=trim((string)$this->input->post('source_status',true));$key=trim((string)$this->input->post('idempotency_key',true)); + if($barcode===''||$userId<1)return json_response(false,'Barang teknisi tidak valid.',array(),422); + if(!in_array($status,array('installed','carried','warehouse'),true))return json_response(false,'Status tujuan tidak valid.',array(),422); + $parsed=DateTime::createFromFormat('Y-m-d',$date);if(!$parsed||$parsed->format('Y-m-d')!==$date)return json_response(false,'Tanggal perubahan status wajib diisi dengan benar.',array(),422); + if($status==='installed'&&$customer<1)return json_response(false,'Customer wajib dipilih untuk status Dipasang.',array(),422); + if($notes==='')return json_response(false,'Keterangan perubahan status wajib diisi.',array(),422); + if(isset($_FILES['proof'])&&!empty($_FILES['proof']['tmp_name'])){$mime=$this->validInstallationImage($_FILES['proof']);if(!$mime)return json_response(false,'Bukti harus berupa gambar JPG, PNG, WEBP, GIF, BMP, HEIC, atau AVIF maksimal 5 MB.',array(),422);}else$mime=null; + $storedPath=null;$this->db->trans_begin(); + try{ + $result=$this->ptm->change_item_status($barcode,$userId,array('status'=>$status,'date'=>$date,'customer_id'=>$customer,'notes'=>$notes,'qty'=>$qty,'source_status'=>$source,'idempotency_key'=>$key),(int)$this->session->userdata('user_id')); + if($mime&&!empty($result['log_id'])){$attachment=$this->saveInstallationImage((int)$result['log_id'],$_FILES['proof'],$mime);$storedPath=$attachment['path'];$this->db->where('id',(int)$result['log_id'])->update('technician_item_status_logs',array('attachment_id'=>(int)$attachment['id']));} + if($this->db->trans_status()===false)throw new RuntimeException('Perubahan status barang gagal disimpan.');$this->db->trans_commit(); + $labels=array('installed'=>'Dipasang','carried'=>'Dibawa Teknisi','warehouse'=>'Dikembalikan ke Gudang');log_activity('items','update','Mengubah status barcode '.$barcode.' menjadi '.$labels[$status].' sebanyak '.($result['qty']??0),'success'); + return json_response(true,'Status barang berhasil diperbarui menjadi '.$labels[$status].'.',array('result'=>$result)); + }catch(RuntimeException$e){$this->db->trans_rollback();if($storedPath&&is_file($storedPath))unlink($storedPath);return json_response(false,$e->getMessage(),array(),422);}catch(Throwable$e){$this->db->trans_rollback();if($storedPath&&is_file($storedPath))unlink($storedPath);return business_exception_response($e);} + } + + public function log_pdf() + { + $this->equipmentPermission('technician_equipment','can_export');$filters=$this->logFilters();$rows=$this->technicianequipmentservice->history($filters);$company=$this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row(); + require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('peralatan_teknisi/log_pdf',array('rows'=>$rows,'filters'=>$filters,'company'=>$company),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();$pdf->stream('Log-Peralatan-Teknisi-'.date('Ymd-His').'.pdf',array('Attachment'=>false)); + } + + private function logFilters() + { + $status=trim((string)$this->input->get('status',true)); + if(!in_array($status,array('','carried','installed','removed','returned'),true))$status='';$condition=trim((string)$this->input->get('condition',true));$ownership=trim((string)$this->input->get('ownership',true)); + $technicianId=(int)$this->input->get('user_id');if($this->restrictedTechnicianEmployee())$technicianId=$this->restrictedTechnicianEmployee();return array('date_from'=>trim((string)$this->input->get('date_from',true)),'date_to'=>trim((string)$this->input->get('date_to',true)),'customer_id'=>(int)$this->input->get('customer_id'),'technician_id'=>$technicianId,'status'=>$status,'condition'=>$condition,'ownership'=>$ownership,'query'=>trim((string)$this->input->get('query',true))); + } + + private function validInstallationImage(array$file) + { + if(($file['error']??UPLOAD_ERR_OK)!==UPLOAD_ERR_OK||($file['size']??0)>5242880||!is_uploaded_file($file['tmp_name']))return null;$mime=(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','image/bmp'=>'bmp','image/heic'=>'heic','image/heif'=>'heif','image/avif'=>'avif');return isset($allowed[$mime])?$mime:null; + } + + private function saveInstallationImage($statusLogId,array$file,$mime) + { + $extensions=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','image/bmp'=>'bmp','image/heic'=>'heic','image/heif'=>'heif','image/avif'=>'avif');$dir=FCPATH.'uploads/accounting/';if(!is_dir($dir)&&!mkdir($dir,0750,true))throw new RuntimeException('Folder bukti perubahan status tidak tersedia.');$stored=bin2hex(random_bytes(20)).'.'.$extensions[$mime];$path=$dir.$stored;if(!move_uploaded_file($file['tmp_name'],$path))throw new RuntimeException('Bukti perubahan status gagal disimpan.');$row=array('module'=>'inventory','entity_type'=>'technician_status_change','entity_id'=>$statusLogId,'original_name'=>basename($file['name']),'stored_name'=>$stored,'mime_type'=>$mime,'file_size'=>filesize($path),'sha256'=>hash_file('sha256',$path),'uploaded_by'=>(int)$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->companycontext->id();$this->db->insert('transaction_attachments',$row);return array('id'=>(int)$this->db->insert_id(),'path'=>$path); + } + + /* Final operational custody API. Kept separate from the legacy endpoints so + old documents remain readable while new movements use the auditable flow. */ + public function replacement_workflow(){ $this->equipmentPermission('technician_installation','can_create');$this->renderEquipmentWorkbench('replacement'); } + public function inspection_queue(){ $this->equipmentPermission('technician_inspection','can_view');$this->renderEquipmentWorkbench('inspection'); } + public function customer_register(){ $this->renderEquipmentWorkbench('customer'); } + public function bulk_workflow() + { + $this->equipmentPermission('technician_equipment','can_view');$restricted=$this->restrictedTechnicianEmployee();$allTechnicians=$this->ptm->get_teknisi_list();$technicians=$allTechnicians;if($restricted)$technicians=array_values(array_filter($technicians,function($row)use($restricted){return(int)$row->id===$restricted;}));$data=array('active_menu'=>'peralatan_teknisi','teknisi_list'=>$technicians,'target_teknisi_list'=>$allTechnicians,'warehouses'=>$this->db->select('id,nama')->where(array('company_id'=>(int)$this->companycontext->id(),'is_active'=>1))->order_by('nama')->get('warehouses')->result(),'equipment_permissions'=>$this->equipmentPermissionFlags(),'equipment_dashboard'=>$this->scopedEquipmentDashboard());$this->load->view('partials/header',$data);$this->load->view('peralatan_teknisi/bulk_workflow',$data);$this->load->view('partials/footer'); + } + + private function renderEquipmentWorkbench($panel='overview') + { + $this->equipmentPermission('technician_equipment','can_view'); + $dashboard=$this->scopedEquipmentDashboard();$restricted=$this->restrictedTechnicianEmployee();$allTechnicians=$this->ptm->get_teknisi_list();$technicians=$allTechnicians;if($restricted)$technicians=array_values(array_filter($technicians,function($row)use($restricted){return(int)$row->id===$restricted;})); + $data=array( + 'active_menu'=>'peralatan_teknisi','active_panel'=>$panel,'equipment_dashboard'=>$dashboard, + 'teknisi_list'=>$technicians,'target_teknisi_list'=>$allTechnicians, + 'customers'=>$this->db->select('id,nama')->where('company_id',(int)$this->companycontext->id())->order_by('nama')->get('customers')->result(), + 'warehouses'=>$this->db->select('id,nama')->where(array('company_id'=>(int)$this->companycontext->id(),'is_active'=>1))->order_by('nama')->get('warehouses')->result(), + 'equipment_permissions'=>$this->equipmentPermissionFlags() + ); + $this->load->view('partials/header',$data);$this->load->view('peralatan_teknisi/workbench',$data);$this->load->view('partials/footer'); + } + + public function equipment_dashboard() + { + $this->equipmentPermission('technician_equipment','can_view'); + return json_response(true,'Ringkasan peralatan.',array('result'=>$this->scopedEquipmentDashboard())); + } + + public function equipment_scan() + { + $this->equipmentPermission('technician_equipment','can_view'); + try{$result=$this->technicianequipmentservice->scan($this->input->get('code',true));$employee=$this->restrictedTechnicianEmployee();if($employee){$this->assertTechnicianSubjectScope($result['subject_type'],(int)$result['subject_id'],$employee);$result['warehouse_available']=0;$result['allocations']=$this->technicianequipmentservice->allocations($result['subject_type'],(int)$result['subject_id'],$employee);}return json_response(true,'Barang ditemukan.',array('result'=>$result));} + catch(Throwable $e){return business_exception_response($e);} + } + + public function equipment_search() + { + $this->equipmentPermission('technician_equipment','can_view');$mode=trim((string)$this->input->get('mode',true));$technicianId=(int)$this->input->get('technician_id');$restricted=$this->restrictedTechnicianEmployee();if($restricted)$technicianId=$restricted;try{return json_response(true,'Hasil pencarian peralatan.',array('data'=>$this->technicianequipmentservice->search($this->input->get('q',true),$mode?:'all',$technicianId,20)));}catch(Throwable$e){return business_exception_response($e);} + } + + public function equipment_technician_items() + { + $this->equipmentPermission('technician_equipment','can_view');$technicianId=(int)$this->input->get('technician_id');$restricted=$this->restrictedTechnicianEmployee();if($restricted){if($technicianId&&$technicianId!==$restricted)show_error('Teknisi hanya dapat melihat barang dalam penguasaannya.',403,'Akses ditolak');$technicianId=$restricted;}if($technicianId<1)return json_response(false,'Pilih teknisi asal terlebih dahulu.',array('data'=>array()),422); + try{$rows=$this->technicianequipmentservice->technicianHoldings($technicianId);return json_response(true,'Daftar barang teknisi.',array('data'=>$rows,'total'=>count($rows)));}catch(Throwable$e){return business_exception_response($e);} + } + + public function equipment_allocations() + { + $this->equipmentPermission('technician_equipment','can_view'); + $technicianId=(int)$this->input->get('technician_id');if($this->restrictedTechnicianEmployee())$technicianId=$this->restrictedTechnicianEmployee();try{return json_response(true,'Posisi peralatan.',array('data'=>$this->technicianequipmentservice->allocations(null,null,$technicianId)));} + catch(Throwable $e){return business_exception_response($e);} + } + + public function equipment_documents() + { + $this->equipmentPermission('technician_equipment','can_view'); + $technicianId=(int)$this->input->get('technician_id');if($this->restrictedTechnicianEmployee())$technicianId=$this->restrictedTechnicianEmployee();$filters=array('date_from'=>$this->input->get('date_from',true),'date_to'=>$this->input->get('date_to',true),'customer_id'=>(int)$this->input->get('customer_id'),'technician_id'=>$technicianId,'document_type'=>$this->input->get('document_type',true),'status'=>$this->input->get('status',true)); + try{return json_response(true,'Riwayat peralatan.',array('data'=>$this->technicianequipmentservice->documents($filters)));} + catch(Throwable $e){return business_exception_response($e);} + } + + public function equipment_document($id=0) + { + $this->equipmentPermission('technician_equipment','can_view');$this->assertTechnicianDocumentScope((int)$id); + try{$result=$this->technicianequipmentservice->document((int)$id);$flags=$this->equipmentPermissionFlags();if(empty($flags['attachment']))$result['attachments']=array();return json_response(true,'Detail dokumen.',array('result'=>$result));} + catch(Throwable $e){return business_exception_response($e);} + } + + public function customer_equipment_data() + { + $this->equipmentPermission('technician_equipment','can_view'); + try{$rows=$this->technicianequipmentservice->customerEquipmentSummary($this->restrictedTechnicianEmployee());return json_response(true,'Ringkasan barang per customer.',array('data'=>$rows));} + catch(Throwable $e){return business_exception_response($e);} + } + + public function customer_equipment_customer_data($customerId=0) + { + $this->equipmentPermission('technician_equipment','can_view');$customerId=(int)$customerId;$customer=$this->db->select('id,nama')->get_where('customers',array('id'=>$customerId,'company_id'=>(int)$this->companycontext->id()))->row();if(!$customer)return json_response(false,'Customer tidak ditemukan.',array('data'=>array()),404);$status=trim((string)$this->input->get('status',true)); + try{$rows=$this->technicianequipmentservice->customerEquipmentRows($customerId,$status,$this->restrictedTechnicianEmployee());return json_response(true,'Detail barang customer.',array('data'=>$rows,'customer'=>$customer));}catch(Throwable$e){return business_exception_response($e);} + } + + public function customer_equipment_customer_pdf($customerId=0) + { + $this->equipmentPermission('technician_equipment','can_export');$customerId=(int)$customerId;$customer=$this->db->select('id,nama')->get_where('customers',array('id'=>$customerId,'company_id'=>(int)$this->companycontext->id()))->row();if(!$customer)show_404();$status=trim((string)$this->input->get('status',true));$labels=array('all'=>'Semua Status','installed'=>'Terpasang','stored'=>'Tersimpan di Customer','technician'=>'Dibawa Teknisi','inspection'=>'Perlu Pemeriksaan/Perbaikan','good'=>'Kondisi Baik','damaged'=>'Rusak/Hilang');if(!isset($labels[$status]))$status='all';$rows=$this->technicianequipmentservice->customerEquipmentRows($customerId,$status,$this->restrictedTechnicianEmployee());$company=$this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row();require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('peralatan_teknisi/customer_equipment_pdf',array('rows'=>$rows,'customer'=>$customer,'company'=>$company,'status_label'=>$labels[$status],'printed_by'=>$this->session->userdata('nama')),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();$pdf->stream('Barang-Customer-'.preg_replace('/[^A-Za-z0-9_-]+/','-',(string)$customer->nama).'-'.$status.'-'.date('Ymd').'.pdf',array('Attachment'=>false)); + } + + public function customer_equipment_notes_data($customerId=0) + { + $this->equipmentPermission('technician_equipment','can_view');$customerId=(int)$customerId;$customer=$this->db->select('id,nama')->get_where('customers',array('id'=>$customerId,'company_id'=>(int)$this->companycontext->id()))->row();if(!$customer)return json_response(false,'Customer tidak ditemukan.',array('data'=>array()),404); + try{return json_response(true,'Log keterangan barang customer.',array('data'=>$this->technicianequipmentservice->customerEquipmentNotes($customerId,$this->restrictedTechnicianEmployee()),'customer'=>$customer));}catch(Throwable$e){return business_exception_response($e);} + } + + public function customer_equipment_notes_pdf($customerId=0) + { + $this->equipmentPermission('technician_equipment','can_export');$customerId=(int)$customerId;$customer=$this->db->select('id,nama')->get_where('customers',array('id'=>$customerId,'company_id'=>(int)$this->companycontext->id()))->row();if(!$customer)show_404();$rows=$this->technicianequipmentservice->customerEquipmentNotes($customerId,$this->restrictedTechnicianEmployee());$company=$this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row();require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('peralatan_teknisi/customer_equipment_notes_pdf',array('rows'=>$rows,'customer'=>$customer,'company'=>$company,'printed_by'=>$this->session->userdata('nama')),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();$pdf->stream('Log-Keterangan-Barang-'.preg_replace('/[^A-Za-z0-9_-]+/','-',(string)$customer->nama).'-'.date('Ymd').'.pdf',array('Attachment'=>true)); + } + + public function customer_equipment_detail($id=0) + { + $this->equipmentPermission('technician_equipment','can_view');try{$employee=$this->restrictedTechnicianEmployee();if($employee)$this->assertTechnicianSubjectScope('customer_equipment',(int)$id,$employee);$result=$this->technicianequipmentservice->external((int)$id);$flags=$this->equipmentPermissionFlags();if(empty($flags['attachment']))$result['attachments']=array();return json_response(true,'Detail barang customer.',array('result'=>$result));}catch(Throwable$e){return business_exception_response($e);} + } + + public function register_customer_equipment() + { + if(strtoupper($this->input->method())!=='POST')show_404(); + $this->equipmentPermission('technician_equipment','can_create'); + $saved=array();try{$files=$this->validatedEquipmentFiles();$post=$this->input->post(NULL,true);if((($post['ownership_type']??'')==='transferred_non_inventory'||!empty($post['deployment_allowed']))&&!$files)throw new BusinessException('Bukti penyerahan/otorisasi wajib dilampirkan untuk status yang dipilih.');$this->db->trans_begin();$result=$this->technicianequipmentservice->registerExternal($post,(int)$this->session->userdata('user_id'));foreach($files as$file)$saved[]=$this->storeEquipmentFile((int)$result['id'],$file,'customer_equipment_registry');if($this->db->trans_status()===false)throw new RuntimeException('Register barang customer gagal disimpan.');$this->db->trans_commit();log_activity('items','equipment_register','Mendaftarkan barang customer non-accounting '.$result['external_barcode'],'success');return json_response(true,'Barang customer berhasil dicatat tanpa masuk persediaan accounting.',array('result'=>$result));} + catch(Throwable $e){if($this->db->trans_active())$this->db->trans_rollback();foreach($saved as$path)if(is_file($path))@unlink($path);return business_exception_response($e);} + } + + public function process_equipment() + { + if(strtoupper($this->input->method())!=='POST')show_404(); + $data=$this->input->post(NULL,true);$action=(string)($data['action']??'');$map=array('handover'=>array('technician_handover','can_create'),'install'=>array('technician_installation','can_create'),'remove'=>array('technician_installation','can_update'),'transfer'=>array('technician_transfer','can_update'),'return'=>array('technician_return','can_update'),'return_customer'=>array('technician_return','can_update')); + if(!isset($map[$action]))return json_response(false,'Tindakan tidak valid.',array(),422);if($action==='install'&&trim((string)($data['installation_location']??''))==='')return json_response(false,'Lokasi pemasangan wajib diisi.',array(),422);if(in_array($action,array('remove','return','transfer'),true)&&trim((string)($data['reason']??''))==='')return json_response(false,'Alasan perpindahan wajib diisi.',array(),422);$this->equipmentPermission($map[$action][0],$map[$action][1]);$this->assertTechnicianActionScope($data,$action); + return $this->runEquipmentPosting(function()use($data){return$this->technicianequipmentservice->process($data,(int)$this->session->userdata('user_id'));},$action); + } + + public function process_equipment_batch() + { + if(strtoupper($this->input->method())!=='POST')show_404();$data=$this->input->post(NULL,true);$action=(string)($data['action']??'');$map=array('handover'=>array('technician_handover','can_create'),'transfer'=>array('technician_transfer','can_update'),'return'=>array('technician_return','can_update'));if(!isset($map[$action]))return json_response(false,'Jenis perpindahan batch tidak valid.',array(),422);$items=json_decode((string)$this->input->post('items_json',false),true);if(!is_array($items)||!$items)return json_response(false,'Tambahkan minimal satu barang.',array(),422);$data['items']=$items;$this->equipmentPermission($map[$action][0],$map[$action][1]);$restricted=$this->restrictedTechnicianEmployee();if($restricted&&(int)($data['technician_id']??0)!==$restricted)show_error('Teknisi hanya dapat memproses barang untuk dirinya sendiri.',403,'Akses ditolak');if($restricted&&$action!=='handover')foreach($items as$item)$this->assertTechnicianActionScope(array('allocation_id'=>(int)($item['allocation_id']??0)),$action);return$this->runEquipmentPosting(function()use($data){return$this->technicianequipmentservice->processBatch($data,(int)$this->session->userdata('user_id'));},$action); + } + + public function process_replacement() + { + if(strtoupper($this->input->method())!=='POST')show_404();$this->equipmentPermission('technician_installation','can_create');$data=$this->input->post(NULL,true);if(trim((string)($data['installation_location']??''))===''||trim((string)($data['reason']??''))==='')return json_response(false,'Lokasi pemasangan dan alasan penggantian wajib diisi.',array(),422);$this->assertTechnicianActionScope($data,'replacement'); + return $this->runEquipmentPosting(function()use($data){return$this->technicianequipmentservice->replacement($data,(int)$this->session->userdata('user_id'));},'replacement'); + } + + public function process_inspection() + { + if(strtoupper($this->input->method())!=='POST')show_404();$this->equipmentPermission('technician_inspection','can_post');$data=$this->input->post(NULL,true);if(in_array(($data['result_status']??''),array('damaged','lost','scrapped'),true))$this->equipmentPermission('technician_condition','can_update');$this->assertTechnicianActionScope($data,'inspection'); + return $this->runEquipmentPosting(function()use($data){return$this->technicianequipmentservice->inspect($data,(int)$this->session->userdata('user_id'));},'inspection'); + } + + public function process_correction() + { + if(strtoupper($this->input->method())!=='POST')show_404();$this->equipmentPermission('technician_correction','can_update');$data=$this->input->post(NULL,true);$this->assertTechnicianDocumentScope((int)($data['document_id']??0)); + return $this->runEquipmentPosting(function()use($data){return$this->technicianequipmentservice->reverse($data,(int)$this->session->userdata('user_id'));},'correction'); + } + + public function inspection_data() + { + $this->equipmentPermission('technician_inspection','can_view'); + $technicianId=(int)$this->input->get('technician_id');if($this->restrictedTechnicianEmployee())$technicianId=$this->restrictedTechnicianEmployee();$filters=array('date_from'=>$this->input->get('date_from',true),'date_to'=>$this->input->get('date_to',true),'technician_id'=>$technicianId,'customer_id'=>(int)$this->input->get('customer_id'),'ownership'=>$this->input->get('ownership',true),'condition'=>$this->input->get('condition',true),'status'=>$this->input->get('status',true));try{return json_response(true,'Antrean pemeriksaan.',array('data'=>$this->technicianequipmentservice->inspectionQueue($filters)));} + catch(Throwable $e){return business_exception_response($e);} + } + + private function runEquipmentPosting(Closure $callback,$action) + { + $saved=array();$files=array(); + try{ + $files=$this->validatedEquipmentFiles();if(in_array($action,array('remove','return','inspection','replacement'),true)&&!$files)throw new BusinessException('Bukti foto atau PDF wajib dilampirkan untuk proses ini.');$this->db->trans_begin();$result=$callback(); + if(empty($result['duplicate']))foreach($files as$file)$saved[]=$this->storeEquipmentFile((int)$result['document_id'],$file); + if($this->db->trans_status()===false)throw new RuntimeException('Transaksi peralatan gagal disimpan.');$this->db->trans_commit(); + log_activity('items','equipment_'.$action,'Memproses dokumen peralatan '.$result['document_no'].' tanpa jurnal accounting','success'); + return json_response(true,(!empty($result['duplicate'])?'Permintaan ini sudah pernah diproses.':'Dokumen '.$result['document_no'].' berhasil diposting.'),array('result'=>$result)); + }catch(Throwable $e){if($this->db->trans_active())$this->db->trans_rollback();foreach($saved as$path)if(is_file($path))@unlink($path);return business_exception_response($e);} + } + + private function validatedEquipmentFiles() + { + if(empty($_FILES['attachments']))return array();$input=$_FILES['attachments'];$files=array();$count=is_array($input['name'])?count($input['name']):1;if($count>10)throw new BusinessException('Maksimal 10 lampiran per dokumen.');$allowed=array('application/pdf'=>'pdf','image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','image/bmp'=>'bmp','image/heic'=>'heic','image/heif'=>'heif','image/avif'=>'avif'); + for($i=0;$i<$count;$i++){$file=array('name'=>is_array($input['name'])?$input['name'][$i]:$input['name'],'tmp_name'=>is_array($input['tmp_name'])?$input['tmp_name'][$i]:$input['tmp_name'],'error'=>is_array($input['error'])?$input['error'][$i]:$input['error'],'size'=>is_array($input['size'])?$input['size'][$i]:$input['size']);if($file['error']===UPLOAD_ERR_NO_FILE)continue;if($file['error']!==UPLOAD_ERR_OK||!is_uploaded_file($file['tmp_name']))throw new BusinessException('Salah satu lampiran gagal diterima server.');if((int)$file['size']>5242880)throw new BusinessException('Ukuran tiap lampiran maksimal 5 MB.');$mime=(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);if(!isset($allowed[$mime]))throw new BusinessException('Lampiran harus berupa gambar atau PDF yang valid.');$file['mime']=$mime;$file['extension']=$allowed[$mime];$files[]=$file;} + return$files; + } + + private function storeEquipmentFile($documentId,array$file,$entityType='technician_equipment_document') + { + $dir=FCPATH.'uploads/accounting/';if(!is_dir($dir)&&!mkdir($dir,0750,true))throw new RuntimeException('Folder lampiran tidak tersedia.');$stored=bin2hex(random_bytes(20)).'.'.$file['extension'];$path=$dir.$stored;if(!move_uploaded_file($file['tmp_name'],$path))throw new RuntimeException('Lampiran gagal disimpan.');$row=array('module'=>'inventory','entity_type'=>$entityType,'entity_id'=>$documentId,'original_name'=>basename($file['name']),'stored_name'=>$stored,'mime_type'=>$file['mime'],'file_size'=>filesize($path),'sha256'=>hash_file('sha256',$path),'uploaded_by'=>(int)$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->companycontext->id();$this->db->insert('transaction_attachments',$row);if(!$this->db->insert_id())throw new RuntimeException('Metadata lampiran gagal disimpan.');return$path; + } + + public function equipment_document_pdf($id=0) + { + $this->equipmentPermission('technician_equipment','can_export');$this->assertTechnicianDocumentScope((int)$id); + try{$document=$this->technicianequipmentservice->document((int)$id);$company=$this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row();require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('peralatan_teknisi/document_pdf',array('document'=>$document,'company'=>$company),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream($document['document_no'].'.pdf',array('Attachment'=>false));} + catch(Throwable $e){show_error($e->getMessage(),422,'Dokumen tidak dapat dibuka');} + } + + public function technician_inventory_pdf($id=0) + { + $this->equipmentPermission('technician_equipment','can_export');$id=(int)$id;$restricted=$this->restrictedTechnicianEmployee();if($restricted&&$id!==$restricted)show_error('PDF bukan milik teknisi yang sedang login.',403,'Akses ditolak');$technician=$this->ptm->get_teknisi_info($id);if(!$technician)show_404(); + $status=trim((string)$this->input->get('status',true));$labels=array('carried'=>'Sedang Dibawa','installed'=>'Terpasang di Customer','removed'=>'Hasil Pelepasan','inspection'=>'Menunggu Pemeriksaan','all'=>'Semua Posisi');if(!isset($labels[$status]))$status='carried'; + $rows=$this->technicianequipmentservice->allocations(null,null,$id);if($status!=='all')$rows=array_values(array_filter($rows,function($row)use($status){if($status==='carried')return$row['custodian_type']==='technician'&&$row['usage_status']==='carried';if($status==='installed')return$row['custodian_type']==='customer'&&$row['usage_status']==='installed';if($status==='removed')return$row['usage_status']==='removed';return$status==='inspection'&&$row['condition_status']==='inspection_required';})); + $company=$this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row();require_once FCPATH.'vendor/autoload.php';$html=$this->load->view('peralatan_teknisi/technician_inventory_pdf',array('technician'=>$technician,'rows'=>$rows,'company'=>$company,'printed_by'=>$this->session->userdata('nama'),'status_label'=>$labels[$status]),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();$pdf->stream('Daftar-Peralatan-'.preg_replace('/[^A-Za-z0-9_-]+/','-',(string)$technician->full_name).'-'.$status.'-'.date('Ymd').'.pdf',array('Attachment'=>false)); + } + + private function equipmentPermission($feature,$action) + { + if(is_master_admin_user())return true;if(check_permission($feature,$action))return true;$permissions=$this->session->userdata('permissions');if(is_array($permissions)&&array_key_exists($feature,$permissions))return$this->requirePermission($feature,$action); + // Hanya session lama yang belum mengenal feature baru yang memakai fallback. + if(check_permission('items',$action))return true; + return$this->requirePermission($feature,$action); + } + + private function equipmentPermissionFlags() + { + $pairs=array('view'=>array('technician_equipment','can_view'),'attachment'=>array('technician_attachment','can_view'),'register'=>array('technician_equipment','can_create'),'handover'=>array('technician_handover','can_create'),'install'=>array('technician_installation','can_create'),'remove'=>array('technician_installation','can_update'),'transfer'=>array('technician_transfer','can_update'),'return'=>array('technician_return','can_update'),'inspect_view'=>array('technician_inspection','can_view'),'inspect'=>array('technician_inspection','can_post'),'condition'=>array('technician_condition','can_update'),'correct'=>array('technician_correction','can_update'),'export'=>array('technician_equipment','can_export'));$flags=array();$permissions=$this->session->userdata('permissions');foreach($pairs as$key=>$pair){$known=is_array($permissions)&&array_key_exists($pair[0],$permissions);$flags[$key]=is_master_admin_user()||check_permission($pair[0],$pair[1])||(!$known&&check_permission('items',$pair[1]));}return$flags; + } + + private function restrictedTechnicianEmployee() + { + if(is_master_admin_user()||check_permission('technician_equipment_all','can_view'))return 0;$role=strtolower((string)$this->session->userdata('role'));if(strpos($role,'teknisi')===false)return 0;if(!$this->db->field_exists('employee_id','users'))show_error('Migration tautan pengguna-karyawan belum dijalankan.',500,'Konfigurasi belum lengkap');$user=$this->db->select('employee_id')->get_where('users',array('id'=>(int)$this->session->userdata('user_id')))->row();if(!$user||(int)$user->employee_id<1)show_error('Akun teknisi belum ditautkan ke Data Karyawan. Hubungi administrator.',403,'Akses ditolak');return(int)$user->employee_id; + } + + private function scopedEquipmentDashboard() + { + $dashboard=$this->technicianequipmentservice->dashboard();$employee=$this->restrictedTechnicianEmployee();if(!$employee)return$dashboard;$allocations=$this->technicianequipmentservice->allocations(null,null,$employee);$summary=array('carried'=>0,'installed'=>0,'inspection'=>0,'repair'=>0,'damaged'=>0,'lost'=>0);foreach($allocations as$a){$qty=(float)$a['qty'];if($a['custodian_type']==='technician')$summary['carried']+=$qty;if($a['usage_status']==='installed')$summary['installed']+=$qty;if(in_array($a['condition_status'],array('repair_required','under_repair'),true))$summary['repair']+=$qty;if($a['condition_status']==='damaged')$summary['damaged']+=$qty;if($a['condition_status']==='lost')$summary['lost']+=$qty;}$inspectionStats=$this->technicianequipmentservice->inspectionQueueStats($employee);$summary['inspection']=(float)$inspectionStats['qty'];$dashboard['summary']=$summary;$dashboard['inspection_count']=(int)$inspectionStats['row_count'];$dashboard['technicians']=array_values(array_filter($dashboard['technicians'],function($row)use($employee){return(int)$row->id===$employee;}));return$dashboard; + } + + private function assertTechnicianActionScope(array$data,$action) + { + $employee=$this->restrictedTechnicianEmployee();if(!$employee)return true;if(in_array($action,array('handover','remove','replacement'),true)&&(int)($data['technician_id']??0)!==$employee)show_error('Teknisi hanya dapat memproses barang untuk dirinya sendiri.',403,'Akses ditolak');$allocationId=(int)($action==='replacement'?($data['outgoing_allocation_id']??0):($data['allocation_id']??0));if(!$allocationId)return true;$allocation=$this->db->select('subject_type,subject_id,custodian_type,custodian_id')->get_where('technician_custody_allocations',array('id'=>$allocationId,'company_id'=>(int)$this->companycontext->id()))->row();if(!$allocation)show_error('Posisi barang tidak ditemukan.',404,'Data tidak ditemukan');if(in_array($action,array('remove','inspection'),true))return$this->assertTechnicianSubjectScope($allocation->subject_type,(int)$allocation->subject_id,$employee);if($allocation->custodian_type!=='technician'||(int)$allocation->custodian_id!==$employee)show_error('Barang bukan berada dalam penguasaan teknisi yang sedang login.',403,'Akses ditolak');return true; + } + + private function assertTechnicianSubjectScope($subjectType,$subjectId,$employee) + { + $company=(int)$this->companycontext->id();$subjectId=(int)$subjectId;$employee=(int)$employee; + $current=$this->db->get_where('technician_custody_allocations',array('company_id'=>$company,'subject_type'=>$subjectType,'subject_id'=>$subjectId,'custodian_type'=>'technician','custodian_id'=>$employee))->row();if($current)return true; + $related=$this->db->select('l.id')->from('technician_equipment_document_lines l')->join('technician_equipment_documents d','d.id=l.document_id')->where(array('l.company_id'=>$company,'l.subject_type'=>$subjectType,'l.subject_id'=>$subjectId))->group_start()->where('d.technician_id',$employee)->or_where('d.target_technician_id',$employee)->group_end()->limit(1)->get()->row();if(!$related)show_error('Barang bukan bagian dari penguasaan atau riwayat teknisi yang sedang login.',403,'Akses ditolak');return true; + } + + private function assertTechnicianDocumentScope($id) + { + $employee=$this->restrictedTechnicianEmployee();if(!$employee)return true;$owned=$this->db->group_start()->where('technician_id',$employee)->or_where('target_technician_id',$employee)->group_end()->get_where('technician_equipment_documents',array('id'=>(int)$id,'company_id'=>(int)$this->companycontext->id()))->row();if(!$owned)show_error('Dokumen bukan bagian dari penguasaan teknisi yang sedang login.',403,'Akses ditolak');return true; + } } diff --git a/application/controllers/Tenantschemacheck.php b/application/controllers/Tenantschemacheck.php new file mode 100644 index 0000000..74d1dc5 --- /dev/null +++ b/application/controllers/Tenantschemacheck.php @@ -0,0 +1,32 @@ +input->is_cli_request()) show_404(); + $tables = array( + 'accounts','journals','journal_details','accounting_periods','fiscal_years', + 'document_sequences','system_account_mappings','customers','invoices','payments', + 'suppliers','purchase_orders','supplier_invoices','supplier_payments','items', + 'warehouses','assets','cash_accounts','cash_transactions','k_employees', + 'k_payroll_periods','k_payrolls','tax_codes','tax_transactions','business_dimensions', + 'budgets','budget_lines','budget_revisions','saved_report_filters', + 'report_export_jobs','report_snapshots','closing_checklists','exchange_rates', + 'fx_revaluations','intercompany_transactions','user_companies' + ); + $missingTables = array(); + $missingColumns = array(); + $nullRows = array(); + foreach ($tables as $table) { + if (!$this->db->table_exists($table)) {$missingTables[]=$table;continue;} + if (!$this->db->field_exists('company_id',$table)) {$missingColumns[]=$table;continue;} + $count=(int)$this->db->where('company_id IS NULL',null,false)->count_all_results($table); + if ($count) $nullRows[$table]=$count; + } + $result=array('missing_tables'=>$missingTables,'missing_company_id'=>$missingColumns,'null_company_rows'=>$nullRows,'status'=>!$missingTables&&!$missingColumns&&!$nullRows?'ok':'failed'); + echo json_encode($result,JSON_PRETTY_PRINT).PHP_EOL; + if($result['status']!=='ok')exit(1); + } +} diff --git a/application/controllers/Users.php b/application/controllers/Users.php index aab5763..4ff1a56 100644 --- a/application/controllers/Users.php +++ b/application/controllers/Users.php @@ -1,7 +1,7 @@ db->select(' users.id, - users.username, - users.nama, - roles.nama_role + users.username, + users.nama, + roles.nama_role, + employees.full_name AS employee_name '); - $this->db->from('users'); - $this->db->join('roles', 'roles.id = users.role_id', 'left'); + $this->db->from('users'); + $this->db->join('roles', 'roles.id = users.role_id', 'left'); + $this->db->join('k_employees employees','employees.id=users.employee_id','left'); - $column_order = ['users.id', 'users.username', 'users.nama', 'roles.nama_role']; - $column_search = ['users.username', 'users.nama', 'roles.nama_role']; + $column_order = ['users.id', 'users.username', 'users.nama', 'roles.nama_role','employees.full_name']; + $column_search = ['users.username', 'users.nama', 'roles.nama_role','employees.full_name']; $order = ['users.id' => 'DESC']; $search_value = $this->input->post('search')['value'] ?? ''; @@ -99,8 +101,9 @@ class Users extends CI_Controller { $row->id, $row->username, $row->nama, - $row->nama_role, - $action + $row->nama_role, + $row->employee_name ?: '-', + $action ]; } @@ -109,14 +112,16 @@ class Users extends CI_Controller { private function _count_filtered($search_value) { - $this->db->from('users'); - $this->db->join('roles', 'roles.id = users.role_id', 'left'); + $this->db->from('users'); + $this->db->join('roles', 'roles.id = users.role_id', 'left'); + $this->db->join('k_employees employees','employees.id=users.employee_id','left'); if (!empty($search_value)) { $this->db->group_start(); $this->db->like('users.username', $search_value); $this->db->or_like('users.nama', $search_value); - $this->db->or_like('roles.nama_role', $search_value); + $this->db->or_like('roles.nama_role', $search_value); + $this->db->or_like('employees.full_name', $search_value); $this->db->group_end(); } @@ -141,11 +146,12 @@ class Users extends CI_Controller { return; } - $data = [ + $data = [ 'username' => $this->input->post('username'), 'nama' => $this->input->post('nama'), 'role_id' => $this->input->post('role_id'), - 'password' => password_hash($this->input->post('password'), PASSWORD_DEFAULT) + 'password' => password_hash($this->input->post('password'), PASSWORD_DEFAULT), + 'employee_id' => $this->validatedEmployeeId($this->input->post('employee_id')) ]; $this->dm->setTable('users')->insert($data); @@ -206,10 +212,11 @@ class Users extends CI_Controller { return; } - $data = [ + $data = [ 'username' => $this->input->post('username'), 'nama' => $this->input->post('nama'), - 'role_id' => $this->input->post('role_id') + 'role_id' => $this->input->post('role_id'), + 'employee_id' => $this->validatedEmployeeId($this->input->post('employee_id')) ]; if (!empty($this->input->post('password'))) { @@ -254,10 +261,20 @@ class Users extends CI_Controller { // ========================= // GET ROLES // ========================= - public function get_role() + public function get_role() { - $roles = $this->db->get('roles')->result(); + $roles = $this->db->where('is_active',1)->order_by('nama_role','ASC')->get('roles')->result(); echo json_encode($roles); - } -} \ No newline at end of file + } + + public function get_employee() + { + return json_response(true,'Daftar karyawan.',array('data'=>$this->db->select('id,employee_code,full_name')->where(array('company_id'=>(int)$this->companycontext->id(),'is_active'=>1))->order_by('full_name')->get('k_employees')->result())); + } + + private function validatedEmployeeId($value) + { + $id=(int)$value;if($id<1)return null;$row=$this->db->select('id')->where(array('id'=>$id,'company_id'=>(int)$this->companycontext->id(),'is_active'=>1))->get('k_employees')->row();return$row?$id:null; + } +} diff --git a/application/controllers/Warehouses.php b/application/controllers/Warehouses.php index 5c8f9b0..820081e 100644 --- a/application/controllers/Warehouses.php +++ b/application/controllers/Warehouses.php @@ -1,7 +1,7 @@ "warehouses"]; - $this->load->view('partials/header', $data); - $this->load->view('warehouses/layout'); - $this->load->view('partials/footer'); + redirect('inventoryprofessional/locations'); } // ========================= @@ -25,7 +22,7 @@ class Warehouses extends CI_Controller { // ========================= public function get_data() { - $data = $this->db->get('warehouses')->result(); + $data = $this->db->where('company_id',(int)$this->session->userdata('company_id'))->get('warehouses')->result(); $result = []; $no = 1; @@ -34,10 +31,7 @@ class Warehouses extends CI_Controller { $result[] = [ $no++, $row->nama, - ' - - - ' + ($this->session->userdata('role')==='Admin'||check_permission('items','can_update'))?'':'-' ]; } @@ -50,7 +44,7 @@ class Warehouses extends CI_Controller { public function detail($id) { echo json_encode( - $this->db->get_where('warehouses',['id'=>$id])->row() + $this->db->get_where('warehouses',['id'=>$id,'company_id'=>(int)$this->session->userdata('company_id')])->row() ); } @@ -67,7 +61,10 @@ class Warehouses extends CI_Controller { } $this->db->insert('warehouses', [ - 'nama' => $nama + 'nama' => $nama, + 'company_id'=>(int)$this->session->userdata('company_id'), + 'code'=>'GD-'.strtoupper(substr(sha1(uniqid('',true)),0,6)), + 'is_active'=>1 ]); log_activity( @@ -93,7 +90,7 @@ class Warehouses extends CI_Controller { return; } - $this->db->where('id',$id)->update('warehouses', [ + $this->db->where(array('id'=>$id,'company_id'=>(int)$this->session->userdata('company_id')))->update('warehouses', [ 'nama' => $nama ]); @@ -112,34 +109,25 @@ class Warehouses extends CI_Controller { // ========================= public function delete($id) { - // optional: cek relasi ke items - $cek = $this->db->get_where('items',['warehouse_id'=>$id])->num_rows(); - - if($cek > 0){ - echo json_encode([ - 'status'=>false, - 'message'=>'Gudang tidak bisa dihapus karena masih dipakai item' - ]); - return; + if (strtoupper($this->input->method()) !== 'POST') { + $this->output->set_status_header(405); + } else { + $this->output->set_status_header(409); } - $this->db->delete('warehouses',['id'=>$id]); - - log_activity( - 'kode_barang', - 'delete', - 'Delete gudang '.$nama, - 'success' - ); - - echo json_encode(['status'=>true,'message'=>'Gudang berhasil dihapus']); + return $this->output + ->set_content_type('application/json') + ->set_output(json_encode(array( + 'status' => false, + 'message' => 'Gudang tidak dapat dihapus. Nonaktifkan melalui menu Gudang & Lokasi Rak.' + ))); } public function list() { echo json_encode( - $this->db->get('warehouses')->result() + $this->db->where(array('company_id'=>(int)$this->session->userdata('company_id'),'is_active'=>1))->order_by('nama')->get('warehouses')->result() ); } -} \ No newline at end of file +} diff --git a/application/controllers/Welcome.php b/application/controllers/Welcome.php index e76d042..e0d3fb3 100644 --- a/application/controllers/Welcome.php +++ b/application/controllers/Welcome.php @@ -1,7 +1,7 @@ + - - 403 Forbidden - - - -

Directory access is forbidden.

- - + + 403 Forbidden + + +

Directory access is forbidden.

+ diff --git a/application/core/MY_Controller.php b/application/core/MY_Controller.php new file mode 100644 index 0000000..b0ba940 --- /dev/null +++ b/application/core/MY_Controller.php @@ -0,0 +1,162 @@ +session->userdata('logged_in')) { + $this->rejectUnauthenticatedRequest(); + } + if ($this->db->table_exists('companies')) { + $this->load->library('CompanyContext'); + $this->companycontext->id(); + } + $this->enforceRoutePermission(); + } + + /** Permission endpoint terpusat; menu tersembunyi bukan satu-satunya kontrol akses. */ + protected function enforceRoutePermission() + { + if ($this->session->userdata('role') === 'Admin' || is_master_admin_user()) return; + $class = strtolower((string)$this->router->fetch_class()); + $method = strtolower((string)$this->router->fetch_method()); + // Draft PR boleh dihapus oleh pembuatnya tanpa can_delete. Otorisasi pemilik + // dan status tetap wajib diperiksa dengan row lock di PurchaseService. + if ($class === 'purchases' && in_array($method, array('submit_request','delete_request'), true)) return; + // Pembuat Draft invoice boleh membatalkan miliknya sendiri; validasi pemilik + // dan status dilakukan lagi dengan row lock di SalesService. + if ($class === 'sales' && $method === 'delete_invoice') return; + $features = array( + 'dashboard'=>'dashboard','approvals'=>'approvals','customers'=>'invoices', + 'invoices'=>'invoices','receivables'=>'invoices','salesdocuments'=>'invoices','sales'=>'invoices', + 'suppliers'=>'purchases','purchases'=>'purchases','purchasedocuments'=>'purchases','payables'=>'purchases', + 'items'=>'items','warehouses'=>'items','kodebarang'=>'items','teknisi'=>'technician_equipment', + 'inventoryprofessional'=>'items','asset'=>'asset','fixedassets'=>'asset','lokasiasset'=>'asset', + 'cashbank'=>'cash_bank','jurnal'=>'jurnal','basejurnal'=>'jurnal','accounts'=>'jurnal', + 'accountingperiods'=>'jurnal','professionalreports'=>'reports','budgets'=>'budgets', + 'taxes'=>'taxes','roles'=>'setting','users'=>'setting','employees'=>'employees','generateschedule'=>'employees', + 'attendancemonitoring'=>'employees','attendancemonthly'=>'employees','attendancmonthly'=>'employees', + 'shifts'=>'employees','holidays'=>'employees','leaverequests'=>'employees','hrpayroll'=>'employees','organizationmaster'=>'employees' + ); + if (!isset($features[$class])) return; + $action = 'can_view'; + $isReadMethod = $method==='index'||$method==='list'||$method==='statement'||$method==='detail'||$method==='edit'||strpos($method,'get_')===0||strpos($method,'open_')===0; + if (strpos($method,'export')!==false || strpos($method,'pdf')!==false) { + $action='can_export'; + } elseif (!$isReadMethod && strtoupper($this->input->method()) !== 'GET') { + if (strpos($method,'export')!==false) $action='can_export'; + elseif (preg_match('/approve|reject|submit|workflow/',$method)) $action='can_approve'; + elseif (preg_match('/post|payment|pay|reconcile|final|lock|depreciat|revalue|dispos/',$method)) $action='can_post'; + elseif (preg_match('/delete|remove/',$method)) $action='can_delete'; + elseif (preg_match('/update|adjust|transfer|return|reverse|cancel/',$method)) $action='can_update'; + else $action='can_create'; + } + if ($class === 'inventoryprofessional') { + if ($method === 'submit') $action = 'can_create'; + if (in_array($method, array('save_warehouse','save_bin','save_batch','update_reservation'), true)) $action = 'can_update'; + if (in_array($method, array('reverse','reconciliation'), true)) $action = 'can_post'; + } + if ($class === 'fixedassets') { + if (in_array($method, array('data','detail','master_data','lookup_inventory','lookup_employees','lookup_journals','opname_detail','report_data','reconciliation','register','qr'), true)) $action = 'can_view'; + // save menentukan create/update dari ID di controller; parent cukup + // memastikan user dapat melihat modul sebelum pemeriksaan dinamis. + if ($method === 'save') $action = 'can_view'; + if (in_array($method, array('submit','opname_create'), true)) $action = 'can_create'; + if (in_array($method, array('approve','reject'), true)) $action = 'can_approve'; + if (in_array($method, array('capitalize','depreciate','mutate','reverse','opname_state'), true)) $action = 'can_post'; + if (in_array($method, array('reopen','transfer','responsibility','maintenance','save_category','save_location'), true)) $action = 'can_update'; + } + if ($class === 'budgets') { + if ($method === 'detail') $action = 'can_view'; + if ($method === 'save_entry') $action = 'can_view'; + if ($method === 'complete_reminder') $action = 'can_update'; + if ($method === 'workflow') { + $budgetAction = strtolower((string)$this->uri->segment(4)); + $action = $budgetAction === 'submit' ? 'can_create' : 'can_approve'; + } + if (in_array($method, array('save_lines','revise'), true)) $action = 'can_update'; + } + if ($class === 'sales') { + if (in_array($method, array('invoice_detail','delivery_detail','return_detail','lookup_customers','lookup_items','lookup_barcodes','warehouses'), true)) $action = 'can_view'; + if (in_array($method, array('append_invoice','delete_invoice','update_delivery'), true)) $action = 'can_update'; + if (in_array($method, array('submit_invoice','create_invoice','create_return','submit_return'), true)) $action = 'can_create'; + if (in_array($method, array('approve_invoice','reject_invoice','approve_return','reject_return'), true)) $action = 'can_approve'; + if (in_array($method, array('post_delivery','payment','receive_return','post_return'), true)) $action = 'can_post'; + } + if ($class === 'invoices') { + if (in_array($method, array('data','lines','lookup_customers','lookup_accounts','lookup_items','lookup_warehouses','lookup_barcodes','detail'), true)) $action = 'can_view'; + if ($method === 'create_header') $action = 'can_create'; + if (in_array($method, array('add_line','add_lines','remove_line','update_line','update_header','generate_delivery'), true)) $action = 'can_update'; + if ($method === 'submit') $action = 'can_submit'; + if (in_array($method, array('approve','reject'), true)) $action = 'can_approve'; + if ($method === 'delete_draft') $action = 'can_delete'; + } + if ($class === 'salesdocuments' && $method === 'workflow') { + $targetState = strtolower((string)$this->uri->segment(4)); + $action = $targetState === 'sent' ? 'can_create' : 'can_approve'; + } + if ($class === 'items' && in_array($method, array('save_serial_number','delete_serial_number'), true)) $action = 'can_update'; + // Teknisi melakukan validasi granular per endpoint di controllernya. + // Fallback items menjaga session lama tetap dapat dipakai sampai login ulang. + if ($class === 'teknisi' && (is_master_admin_user() || check_permission('technician_equipment','can_view') || check_permission('items','can_view'))) return; + $this->requirePermission($features[$class],$action); + } + + protected function rejectUnauthenticatedRequest() + { + if ($this->input->is_ajax_request()) { + $this->output + ->set_status_header(401) + ->set_content_type('application/json') + ->set_output(json_encode([ + 'status' => false, + 'message' => 'Sesi telah berakhir. Silakan login kembali.' + ])) + ->_display(); + exit; + } + + $this->session->set_flashdata('error', 'Silakan login untuk melanjutkan.'); + redirect('auth'); + exit; + } + + protected function requirePermission($feature, $action = 'can_view') + { + if (is_master_admin_user()) { + return true; + } + + if (!check_permission($feature, $action)) { + if ($this->input->is_ajax_request()) { + $this->output->set_status_header(403)->set_content_type('application/json','utf-8')->set_output(json_encode(array('status'=>false,'message'=>'Anda tidak memiliki izin untuk tindakan ini.')))->_display(); + exit; + } + show_error('Anda tidak memiliki izin untuk melakukan tindakan ini.', 403, 'Akses ditolak'); + } + + return true; + } + + protected function requireAdmin() + { + if (!is_master_admin_user()) { + show_error('Akses hanya tersedia untuk administrator.', 403, 'Akses ditolak'); + } + + return true; + } +} + +class MY_Admin_Controller extends MY_Controller +{ + public function __construct() + { + parent::__construct(); + $this->requireAdmin(); + } +} diff --git a/application/exceptions/BusinessException.php b/application/exceptions/BusinessException.php new file mode 100644 index 0000000..1ef5205 --- /dev/null +++ b/application/exceptions/BusinessException.php @@ -0,0 +1,18 @@ +context = $context; + } + + public function getContext() + { + return $this->context; + } +} diff --git a/application/helpers/activity_helper.php b/application/helpers/activity_helper.php index d754b15..04aea98 100644 --- a/application/helpers/activity_helper.php +++ b/application/helpers/activity_helper.php @@ -8,19 +8,17 @@ function log_activity($module, $action, $desc, $status = 'success') $ip = $CI->input->ip_address(); - if (!$ip || $ip == '0.0.0.0') { - $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? - $_SERVER['REMOTE_ADDR'] ?? - 'UNKNOWN'; - } + if (!$ip || $ip == '0.0.0.0') { + $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'UNKNOWN'; + } $CI->db->insert('activity_logs', [ 'user_id' => $CI->session->userdata('user_id') ?? 0, 'module' => strtolower($module), 'action' => strtolower($action), - 'description' => $desc, + 'description' => substr(strip_tags((string) $desc), 0, 1000), 'status' => strtolower($status), 'ip_address' => $ip, 'created_at' => date('Y-m-d H:i:s') ]); -} \ No newline at end of file +} diff --git a/application/helpers/permission_helper.php b/application/helpers/permission_helper.php index ba8a83c..88ec145 100644 --- a/application/helpers/permission_helper.php +++ b/application/helpers/permission_helper.php @@ -7,9 +7,35 @@ defined('BASEPATH') OR exit('No direct script access allowed'); * @param string $action => action yang dicek (misal: 'can_view', 'can_export') * @return bool */ -function check_permission($key, $action = 'can_view') { - $CI =& get_instance(); - $permissions = $CI->session->userdata('permissions'); +function is_master_admin_user() { + static $resolved = array(); + $CI =& get_instance(); + $roleId = (int) $CI->session->userdata('role_id'); + $roleName = strtolower(trim((string) $CI->session->userdata('role'))); + + if ((int) $CI->session->userdata('is_super_admin') === 1) { + return true; + } + // Kompatibilitas untuk session lama sebelum flag super-admin disimpan. + if (in_array($roleName, array('admin', 'master admin', 'master admin/admin'), true)) { + return true; + } + if ($roleId < 1 || !isset($CI->db) || !$CI->db->table_exists('roles')) { + return false; + } + if (!array_key_exists($roleId, $resolved)) { + $role = $CI->db->select('is_super_admin,is_active')->get_where('roles', array('id' => $roleId))->row(); + $resolved[$roleId] = $role && (int)$role->is_active === 1 && (int)$role->is_super_admin === 1; + } + return $resolved[$roleId]; +} + +function check_permission($key, $action = 'can_view') { + $CI =& get_instance(); + if (is_master_admin_user()) { + return true; + } + $permissions = $CI->session->userdata('permissions'); if (!$permissions || !is_array($permissions)) { return false; diff --git a/application/helpers/response_helper.php b/application/helpers/response_helper.php new file mode 100644 index 0000000..670a019 --- /dev/null +++ b/application/helpers/response_helper.php @@ -0,0 +1,34 @@ + (bool) $success, + 'message' => (string) $message + ), $data); + + return $CI->output + ->set_status_header((int) $httpStatus) + ->set_content_type('application/json', 'utf-8') + ->set_output(json_encode($payload)); +} + +function business_exception_response(Throwable $exception) +{ + $status = $exception instanceof BusinessException ? $exception->getCode() : 500; + if ($status < 400 || $status > 599) { + $status = 500; + } + + if (!($exception instanceof BusinessException)) { + log_message('error', get_class($exception) . ': ' . $exception->getMessage()); + } + + $message = $exception instanceof BusinessException + ? $exception->getMessage() + : 'Terjadi kesalahan saat memproses transaksi.'; + + return json_response(false, $message, array(), $status); +} diff --git a/application/libraries/AccountMappingService.php b/application/libraries/AccountMappingService.php new file mode 100644 index 0000000..4c03bfa --- /dev/null +++ b/application/libraries/AccountMappingService.php @@ -0,0 +1,50 @@ +CI =& get_instance(); + $this->CI->config->load('accounting', true); + $this->tableAvailable = $this->CI->db->table_exists('system_account_mappings'); + } + + public function get($key, $required = true) + { + if (array_key_exists($key, $this->cache)) { + return $this->cache[$key]; + } + + $accountId = null; + if ($this->tableAvailable) { + $row = $this->CI->db + ->select('account_id') + ->where('mapping_key', $key) + ->where('is_active', 1) + ->get('system_account_mappings') + ->row(); + if ($row) { + $accountId = (int) $row->account_id; + } + } + + if (!$accountId) { + $fallbacks = (array) $this->CI->config->item('system_accounts', 'accounting'); + $accountId = isset($fallbacks[$key]) ? (int) $fallbacks[$key] : null; + } + + if (!$accountId && $required) { + throw new BusinessException('Mapping akun sistem "' . $key . '" belum dikonfigurasi.'); + } + + $this->cache[$key] = $accountId; + return $accountId; + } +} diff --git a/application/libraries/ApprovalService.php b/application/libraries/ApprovalService.php new file mode 100644 index 0000000..456bc67 --- /dev/null +++ b/application/libraries/ApprovalService.php @@ -0,0 +1,89 @@ +CI =& get_instance(); } + + public function create($module, $entityType, $entityId, $amount, $userId, $snapshot) + { + $existing = $this->CI->db->where(array('entity_type'=>$entityType,'entity_id'=>(int)$entityId,'status'=>'pending'))->get('approval_requests')->row(); + if ($existing) return $existing; + $workflow = $this->CI->db->where(array('module'=>$module,'entity_type'=>$entityType,'is_active'=>1)) + ->group_start()->where('min_amount IS NULL', null, false)->or_where('min_amount <=', $amount)->group_end() + ->group_start()->where('max_amount IS NULL', null, false)->or_where('max_amount >=', $amount)->group_end() + ->order_by('min_amount','DESC')->get('approval_workflows')->row(); + if (!$workflow) throw new BusinessException('Workflow persetujuan belum dikonfigurasi.'); + $requestNo = 'APR-'.date('Ymd-His').'-'.$entityId.'-'.random_int(100,999); + $this->CI->db->insert('approval_requests', array('request_no'=>$requestNo,'workflow_id'=>$workflow->id,'entity_type'=>$entityType,'entity_id'=>(int)$entityId,'amount'=>$amount,'requested_by'=>(int)$userId,'requested_at'=>date('Y-m-d H:i:s'),'current_step'=>1,'status'=>'pending','snapshot'=>json_encode($snapshot, JSON_UNESCAPED_UNICODE))); + $id = $this->CI->db->insert_id(); + $this->CI->db->insert('approval_actions', array('request_id'=>$id,'step_order'=>0,'action'=>'submitted','user_id'=>(int)$userId,'created_at'=>date('Y-m-d H:i:s'))); + return $this->find($id); + } + + public function act($requestId, $action, $userId, $roleId, $notes = null) + { + $request = $this->CI->db->query('SELECT * FROM approval_requests WHERE id=? FOR UPDATE', array((int)$requestId))->row(); + if (!$request || $request->status !== 'pending') throw new BusinessException('Permintaan approval tidak aktif.'); + $step = $this->CI->db->get_where('approval_workflow_steps', array('workflow_id'=>$request->workflow_id,'step_order'=>$request->current_step))->row(); + $role = $this->CI->db->get_where('roles', array('id'=>(int)$roleId))->row(); + $masterAdmin = $role && (!empty($role->is_super_admin) || $role->nama_role === 'Admin'); + if (!$step || (!$masterAdmin && (($step->approver_user_id && (int)$step->approver_user_id !== (int)$userId) || (!$step->approver_user_id && (int)$step->approver_role_id !== (int)$roleId)))) throw new BusinessException('Anda bukan approver pada tahap ini.', array(), 403); + $workflow = $this->CI->db->get_where('approval_workflows', array('id'=>$request->workflow_id))->row(); + if (!$masterAdmin && $workflow->require_separation && (int)$request->requested_by === (int)$userId) throw new BusinessException('Pembuat transaksi tidak boleh menyetujui transaksinya sendiri.'); + if (!in_array($action,array('approved','rejected'),true)) throw new BusinessException('Tindakan approval tidak valid.'); + if ($action === 'rejected' && trim((string)$notes) === '') throw new BusinessException('Alasan penolakan wajib diisi.'); + $this->CI->db->insert('approval_actions', array('request_id'=>$request->id,'step_order'=>$request->current_step,'action'=>$action,'user_id'=>(int)$userId,'notes'=>$notes,'created_at'=>date('Y-m-d H:i:s'))); + $next = $this->CI->db->get_where('approval_workflow_steps',array('workflow_id'=>$request->workflow_id,'step_order'=>$request->current_step+1))->row(); + $final = $action === 'rejected' ? 'rejected' : ($next ? 'pending' : 'approved'); + $update = array('status'=>$final); + if ($next) $update['current_step'] = $request->current_step+1; + if ($final !== 'pending') $update['completed_at'] = date('Y-m-d H:i:s'); + $this->CI->db->where('id',$request->id)->update('approval_requests',$update); + return $this->find($request->id); + } + + public function pendingFor($userId, $roleId) + { + $role=$this->CI->db->get_where('roles',array('id'=>(int)$roleId))->row(); + $masterAdmin=$role&&(!empty($role->is_super_admin)||$role->nama_role==='Admin'); + if($masterAdmin){$rows=$this->CI->db->select('approval_requests.*, approval_workflows.name workflow_name, approval_workflows.module workflow_module, users.nama requester_name')->from('approval_requests')->join('approval_workflows','approval_workflows.id=approval_requests.workflow_id')->join('users','users.id=approval_requests.requested_by','left')->where('approval_requests.status','pending')->order_by('approval_requests.requested_at','ASC')->get()->result();return $this->withDestinations($rows);} + $rows=$this->CI->db->select('approval_requests.*, approval_workflows.name workflow_name, approval_workflows.module workflow_module, users.nama requester_name') + ->from('approval_requests')->join('approval_workflows','approval_workflows.id=approval_requests.workflow_id') + ->join('approval_workflow_steps','approval_workflow_steps.workflow_id=approval_requests.workflow_id AND approval_workflow_steps.step_order=approval_requests.current_step') + ->join('users','users.id=approval_requests.requested_by','left')->where('approval_requests.status','pending') + ->group_start()->where('approval_workflow_steps.approver_user_id',(int)$userId)->or_group_start()->where('approval_workflow_steps.approver_user_id IS NULL',null,false)->where('approval_workflow_steps.approver_role_id',(int)$roleId)->group_end()->group_end() + ->order_by('approval_requests.requested_at','ASC')->get()->result(); + return $this->withDestinations($rows); + } + private function withDestinations(array $rows) + { + foreach($rows as $row){$destination=$this->destinationFor($row->entity_type,$row->entity_id,$row->workflow_module);$row->destination_url=base_url($destination[0]);$row->destination_label=$destination[1];} + return $rows; + } + private function destinationFor($type,$id,$module) + { + $id=(int)$id; + $map=array( + 'journal'=>array('jurnal?focus='.$id,'Buka Jurnal'), + 'invoice'=>array('invoices/detail/'.$id,'Buka Invoice'), + 'purchase_request'=>array('purchases?step=request&focus='.$id,'Buka Purchase Request'), + 'purchase_order'=>array('purchases?step=order&focus='.$id,'Buka Purchase Order'), + 'purchase_return'=>array('purchases/returns?section=return&focus='.$id,'Buka Pengajuan Retur'), + 'supplier_refund'=>array('purchases/returns?section=refund&focus='.$id,'Buka Pengajuan Refund'), + 'supplier_invoice'=>array('purchases?step=invoice&focus='.$id,'Buka Tagihan Supplier'), + 'stock_document'=>array('inventoryprofessional?focus='.$id,'Buka Dokumen Persediaan'), + 'cash_transaction'=>array('cashbank?focus='.$id,'Buka Transaksi Kas/Bank'), + 'payroll_period'=>array('hrpayroll?focus='.$id,'Buka Payroll'), + 'attendance_correction'=>array('attendancemonitoring?focus='.$id,'Buka Koreksi Absensi'), + 'budget'=>array('budgets?focus='.$id,'Buka Budget') + ); + if(isset($map[$type]))return $map[$type]; + $fallback=array('purchase'=>'purchases','payable'=>'purchases?step=invoice','inventory'=>'inventoryprofessional','invoice'=>'invoices','journal'=>'jurnal','cash_bank'=>'cashbank','payroll'=>'hrpayroll','hr'=>'attendancemonitoring','budget'=>'budgets'); + return array(isset($fallback[$module])?$fallback[$module]:'dashboard','Buka Dokumen'); + } + public function find($id) { return $this->CI->db->get_where('approval_requests',array('id'=>(int)$id))->row(); } + public function pendingForEntity($type,$id) { return $this->CI->db->where(array('entity_type'=>$type,'entity_id'=>(int)$id,'status'=>'pending'))->get('approval_requests')->row(); } +} diff --git a/application/libraries/AuditService.php b/application/libraries/AuditService.php new file mode 100644 index 0000000..1ae621d --- /dev/null +++ b/application/libraries/AuditService.php @@ -0,0 +1,35 @@ +CI =& get_instance(); } + + public function record($module, $entityType, $entityId, $action, $before = null, $after = null, $userId = null) + { + $previous = $this->CI->db->select('record_hash')->order_by('id','DESC')->limit(1)->get('immutable_audit_logs')->row(); + $created = date('Y-m-d H:i:s'); + $data = array( + 'module'=>(string)$module, 'entity_type'=>(string)$entityType, 'entity_id'=>(string)$entityId, + 'action'=>(string)$action, 'before_data'=>$this->encode($before), 'after_data'=>$this->encode($after), + 'user_id'=>$userId ? (int)$userId : null, + 'ip_address'=>isset($this->CI->input) ? $this->CI->input->ip_address() : null, + 'user_agent'=>isset($this->CI->input) ? substr((string)$this->CI->input->user_agent(),0,255) : null, + 'request_id'=>function_exists('getenv') ? (getenv('HTTP_X_REQUEST_ID') ?: null) : null, + 'previous_hash'=>$previous ? $previous->record_hash : null, 'created_at'=>$created + ); + $data['record_hash'] = hash('sha256', ($data['previous_hash'] ?: '') . json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + if (!$this->CI->db->insert('immutable_audit_logs', $data)) throw new RuntimeException('Gagal menulis audit trail.'); + return (int)$this->CI->db->insert_id(); + } + + private function encode($value) + { + if ($value === null) return null; + $value = json_decode(json_encode($value), true); + $walk = function (&$item, $key) { if (preg_match('/password|token|secret|authorization/i', (string)$key)) $item = '[REDACTED]'; }; + array_walk_recursive($value, $walk); + return json_encode($value, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + } +} diff --git a/application/libraries/BudgetService.php b/application/libraries/BudgetService.php new file mode 100644 index 0000000..812cecb --- /dev/null +++ b/application/libraries/BudgetService.php @@ -0,0 +1,125 @@ +CI=&get_instance();$this->CI->load->library(array('CompanyContext','TransactionService'));} + public function money($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){$d=strlen($value)-$comma-1;$value=$d>0&&$d<=2?str_replace(',','.',str_replace('.','',$value)):str_replace(',','',$value);}elseif($dot!==false&&strlen($value)-$dot-1===3)$value=str_replace('.','',$value);if(!is_numeric($value))throw new BusinessException('Nominal budget tidak valid.');return round((float)$value,2);} + public function budgets(){return$this->CI->db->select('b.*,u.nama creator_name,ap.nama approver_name,(SELECT COALESCE(SUM(bl.amount),0) FROM budget_lines bl WHERE bl.budget_id=b.id) annual_total,(SELECT COUNT(DISTINCT bl.account_id) FROM budget_lines bl WHERE bl.budget_id=b.id) account_count',false)->from('budgets b')->join('users u','u.id=b.created_by','left')->join('users ap','ap.id=b.approved_by','left')->where('b.company_id',$this->CI->companycontext->id())->order_by('b.fiscal_year','DESC')->order_by('b.version_no','DESC')->get()->result();} + public function find($id,$lock=false){return$this->CI->db->query('SELECT * FROM budgets WHERE id=? AND company_id=?'.($lock?' FOR UPDATE':''),array((int)$id,$this->CI->companycontext->id()))->row();} + public function create(array$d,$user){$year=(int)($d['fiscal_year']??0);$name=trim((string)($d['name']??''));if($year<2000||$year>2200||$name==='')throw new BusinessException('Nama dan tahun budget wajib diisi dengan benar.');return$this->CI->transactionservice->run(function()use($year,$name,$d,$user){$data=array('budget_no'=>'BDG-'.$year.'-'.date('YmdHis').'-'.random_int(10,99),'fiscal_year'=>$year,'name'=>$name,'version_no'=>1,'revision_no'=>1,'status'=>'draft','control_mode'=>'none','description'=>trim((string)($d['description']??'')),'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'),'company_id'=>$this->CI->companycontext->id());$this->CI->db->insert('budgets',$data);$id=(int)$this->CI->db->insert_id();$this->audit($id,'create',null,$data,null,$user);return$id;});} + public function saveAnnualLines($budgetId,array$rows,$user){return$this->CI->transactionservice->run(function()use($budgetId,$rows,$user){$b=$this->find($budgetId,true);if(!$b||$b->status!=='draft')throw new BusinessException('Hanya budget Draft yang dapat diubah.');if((int)$b->created_by!==(int)$user&&!$this->isAdmin())throw new BusinessException('Budget Draft hanya dapat diubah pembuatnya.');$clean=array();$seen=array();foreach($rows as$i=>$row){$accountId=(int)($row['account_id']??0);if(!$accountId)continue;$account=$this->CI->db->get_where('accounts',array('id'=>$accountId,'company_id'=>$this->CI->companycontext->id(),'tipe'=>'expense','is_active'=>1,'allow_posting'=>1))->row();if(!$account)throw new BusinessException('Akun pengeluaran pada baris '.($i+1).' tidak valid.');if(isset($seen[$accountId]))throw new BusinessException('Satu akun hanya boleh muncul sekali dalam satu budget.');$seen[$accountId]=true;for($month=1;$month<=12;$month++){$amount=$this->money($row['month'][$month]??0);if($amount<0)throw new BusinessException('Budget tidak boleh negatif.');if($amount==0.0)continue;$clean[]=array('budget_id'=>(int)$budgetId,'period'=>sprintf('%04d-%02d',(int)$b->fiscal_year,$month),'account_id'=>$accountId,'amount'=>$amount,'notes'=>trim((string)($row['notes']??'')),'updated_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'),'company_id'=>$this->CI->companycontext->id());}}if(!$clean)throw new BusinessException('Isi minimal satu nominal budget bulanan.');$old=$this->CI->db->where('budget_id',$budgetId)->get('budget_lines')->result_array();$this->CI->db->where(array('budget_id'=>$budgetId,'company_id'=>$this->CI->companycontext->id()))->delete('budget_lines');$this->CI->db->insert_batch('budget_lines',$clean);$this->CI->db->where('id',$budgetId)->update('budgets',array('updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s')));$this->audit($budgetId,'save_lines',$old,array('account_count'=>count($seen),'annual_total'=>array_sum(array_column($clean,'amount'))),null,$user);return count($seen);});} + public function workflow($id,$action,$user,$reason=''){return$this->CI->transactionservice->run(function()use($id,$action,$user,$reason){$b=$this->find($id,true);if(!$b)throw new BusinessException('Budget tidak ditemukan.');$now=date('Y-m-d H:i:s');$old=(array)$b;$up=array('updated_by'=>$user,'updated_at'=>$now);if($action==='submit'){if($b->status!=='draft')throw new BusinessException('Hanya budget Draft yang dapat diajukan.');if((int)$b->created_by!==(int)$user&&!$this->isAdmin())throw new BusinessException('Hanya pembuat budget yang dapat mengajukan.');if(!$this->CI->db->where('budget_id',$id)->count_all_results('budget_lines'))throw new BusinessException('Budget belum mempunyai rincian.');$up+=array('status'=>'submitted','submitted_by'=>$user,'submitted_at'=>$now,'rejection_reason'=>null);}elseif($action==='approve'){if($b->status!=='submitted')throw new BusinessException('Budget tidak sedang menunggu persetujuan.');if((int)$b->created_by===(int)$user&&!$this->isAdmin())throw new BusinessException('Pembuat tidak dapat menyetujui budget sendiri.');$this->CI->db->where(array('company_id'=>$b->company_id,'fiscal_year'=>$b->fiscal_year,'status'=>'active'))->where('id !=',$id)->update('budgets',array('status'=>'superseded','updated_at'=>$now,'updated_by'=>$user));$up+=array('status'=>'active','approved_by'=>$user,'approved_at'=>$now,'rejected_by'=>null,'rejected_at'=>null,'rejection_reason'=>null);}elseif($action==='reject'){if($b->status!=='submitted')throw new BusinessException('Budget tidak sedang menunggu persetujuan.');if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');$up+=array('status'=>'rejected','rejected_by'=>$user,'rejected_at'=>$now,'rejection_reason'=>trim($reason));}else throw new BusinessException('Aksi budget tidak valid.');$this->CI->db->where(array('id'=>$id,'company_id'=>$b->company_id))->update('budgets',$up);if(!empty($b->parent_budget_id)){$revision=array('status'=>$action==='approve'?'approved':($action==='reject'?'rejected':'submitted'));if($action==='approve')$revision+=array('approved_by'=>$user,'approved_at'=>$now);$this->CI->db->where(array('budget_id'=>$b->parent_budget_id,'revision_no'=>$b->revision_no,'company_id'=>$b->company_id))->update('budget_revisions',$revision);}$this->audit($id,$action,$old,$up,$reason,$user);return$up['status'];});} + public function revise($id,$reason,$user){$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan revisi wajib diisi.');return$this->CI->transactionservice->run(function()use($id,$reason,$user){$source=$this->find($id,true);if(!$source||!in_array($source->status,array('active','superseded'),true))throw new BusinessException('Hanya budget aktif yang dapat direvisi.');if($this->CI->db->where(array('parent_budget_id'=>$source->id,'status'=>'draft','company_id'=>$source->company_id))->count_all_results('budgets'))throw new BusinessException('Masih ada revisi Draft yang belum diselesaikan.');$version=(int)$this->CI->db->select_max('version_no','v')->where(array('company_id'=>$source->company_id,'fiscal_year'=>$source->fiscal_year))->get('budgets')->row()->v+1;$data=(array)$source;unset($data['id']);$data=array_merge($data,array('budget_no'=>'BDG-'.$source->fiscal_year.'-REV'.$version.'-'.date('His'),'version_no'=>$version,'revision_no'=>$version,'parent_budget_id'=>$source->id,'status'=>'draft','description'=>trim($source->description."\nRevisi: ".$reason),'submitted_by'=>null,'submitted_at'=>null,'approved_by'=>null,'approved_at'=>null,'rejected_by'=>null,'rejected_at'=>null,'rejection_reason'=>null,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'),'updated_by'=>null,'updated_at'=>null));$this->CI->db->insert('budgets',$data);$newId=(int)$this->CI->db->insert_id();$lines=$this->CI->db->where(array('budget_id'=>$source->id,'company_id'=>$source->company_id))->get('budget_lines')->result_array();foreach($lines as&$line){unset($line['id']);$line['budget_id']=$newId;$line['created_at']=date('Y-m-d H:i:s');$line['updated_by']=$user;}if($lines)$this->CI->db->insert_batch('budget_lines',$lines);$this->CI->db->insert('budget_revisions',array('budget_id'=>$source->id,'revision_no'=>$version,'reason'=>$reason,'status'=>'draft','snapshot'=>json_encode(array('source_budget'=>(array)$source,'lines'=>$lines)),'requested_by'=>$user,'requested_at'=>date('Y-m-d H:i:s'),'company_id'=>$source->company_id));$this->audit($newId,'revise',array('source_budget_id'=>$source->id),$data,$reason,$user);return$newId;});} + public function lines($id){return$this->CI->db->select('bl.*,a.kode_akun,a.nama_akun')->from('budget_lines bl')->join('accounts a','a.id=bl.account_id')->where(array('bl.budget_id'=>(int)$id,'bl.company_id'=>$this->CI->companycontext->id()))->order_by('a.kode_akun')->order_by('bl.period')->get()->result();} + public function entries($accountId=0) + { + $this->CI->db->select('e.*,a.kode_akun,a.nama_akun,a.tipe,u.nama creator_name,uu.nama updater_name') + ->from('account_budget_entries e')->join('accounts a','a.id=e.account_id') + ->join('users u','u.id=e.created_by','left')->join('users uu','uu.id=e.updated_by','left') + ->where(array('e.company_id'=>$this->CI->companycontext->id(),'e.is_active'=>1)); + if((int)$accountId>0)$this->CI->db->where('e.account_id',(int)$accountId); + $rows=$this->CI->db->order_by('e.period','DESC')->order_by('a.kode_akun')->order_by('e.title')->get()->result(); + $completed=array();if($rows&&$this->CI->db->table_exists('account_budget_reminder_completions'))foreach($this->CI->db->where(array('company_id'=>$this->CI->companycontext->id(),'period'=>date('Y-m')))->get('account_budget_reminder_completions')->result()as$c)$completed[$c->budget_entry_id]=$c; + foreach($rows as$row)$this->decorateReminder($row,isset($completed[$row->id])?$completed[$row->id]:null); + return$rows; + } + public function saveEntry(array$d,$user) + { + $id=(int)($d['id']??0);$accountId=(int)($d['account_id']??0);$period=trim((string)($d['period']??'')); + $type=in_array(($d['entry_type']??''),array('recurring','override'),true)?$d['entry_type']:'recurring'; + $title=trim((string)($d['title']??''));$reminder=!empty($d['reminder_enabled']);$reminderDay=$reminder?(int)($d['reminder_day']??0):null; + $amount=$this->money($d['amount']??0);$company=$this->CI->companycontext->id(); + if($title==='')throw new BusinessException('Nama budget wajib diisi agar setiap kebutuhan mudah dibedakan.'); + if(!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/',$period))throw new BusinessException('Bulan mulai budget tidak valid.'); + if($amount<0)throw new BusinessException('Nominal budget tidak boleh negatif.'); + if($reminder&&($reminderDay<1||$reminderDay>31))throw new BusinessException('Tanggal pengingat harus antara tanggal 1 sampai 31.'); + $account=$this->CI->db->get_where('accounts',array('id'=>$accountId,'company_id'=>$company,'is_active'=>1))->row(); + if(!$account)throw new BusinessException('Akun budget tidak valid atau sudah tidak aktif.'); + return$this->CI->transactionservice->run(function()use($id,$accountId,$title,$period,$type,$amount,$reminder,$reminderDay,$d,$user,$company){ + $old=$id?$this->CI->db->query('SELECT * FROM account_budget_entries WHERE id=? AND company_id=? FOR UPDATE',array($id,$company))->row():null; + if($id&&!$old)throw new BusinessException('Budget yang akan diubah tidak ditemukan.'); + $data=array('company_id'=>$company,'account_id'=>$accountId,'title'=>$title,'period'=>$period,'entry_type'=>$type,'amount'=>$amount,'reminder_enabled'=>$reminder?1:0,'reminder_day'=>$reminder?$reminderDay:null,'notes'=>trim((string)($d['notes']??'')),'is_active'=>1,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')); + if($id)$this->CI->db->where(array('id'=>$id,'company_id'=>$company))->update('account_budget_entries',$data); + else{$data['created_by']=(int)$user;$data['created_at']=date('Y-m-d H:i:s');$this->CI->db->insert('account_budget_entries',$data);$id=(int)$this->CI->db->insert_id();} + $this->auditEntry($id,$old?'update':'create',$old,$data,$user);return$id; + }); + } + public function currentReminders() + { + $rows=array_values(array_filter($this->entries(),function($row){return in_array($row->reminder_state,array('upcoming','due','overdue'),true);})); + usort($rows,function($a,$b){return strcmp((string)$a->reminder_due_date,(string)$b->reminder_due_date);});return$rows; + } + public function completeReminder($id,$user) + { + $company=$this->CI->companycontext->id();$period=date('Y-m'); + return$this->CI->transactionservice->run(function()use($id,$user,$company,$period){ + $row=$this->CI->db->query('SELECT * FROM account_budget_entries WHERE id=? AND company_id=? AND is_active=1 FOR UPDATE',array((int)$id,$company))->row(); + if(!$row||!(int)$row->reminder_enabled)throw new BusinessException('Pengingat budget tidak ditemukan atau tidak aktif.'); + if(($row->entry_type==='recurring'&&$row->period>$period)||($row->entry_type==='override'&&$row->period!==$period))throw new BusinessException('Budget ini tidak berlaku pada bulan berjalan.'); + $exists=$this->CI->db->get_where('account_budget_reminder_completions',array('company_id'=>$company,'budget_entry_id'=>(int)$id,'period'=>$period))->row(); + if(!$exists)$this->CI->db->insert('account_budget_reminder_completions',array('company_id'=>$company,'budget_entry_id'=>(int)$id,'period'=>$period,'completed_by'=>(int)$user,'completed_at'=>date('Y-m-d H:i:s'))); + $this->auditEntry((int)$id,'complete_reminder',$row,array('period'=>$period,'completed_by'=>(int)$user),$user);return true; + }); + } + public function deleteEntry($id,$user) + { + $company=$this->CI->companycontext->id(); + return$this->CI->transactionservice->run(function()use($id,$user,$company){ + $row=$this->CI->db->query('SELECT * FROM account_budget_entries WHERE id=? AND company_id=? AND is_active=1 FOR UPDATE',array((int)$id,$company))->row(); + if(!$row)throw new BusinessException('Budget tidak ditemukan atau sudah dinonaktifkan.'); + $data=array('is_active'=>0,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')); + $this->CI->db->where('id',(int)$id)->update('account_budget_entries',$data);$this->auditEntry((int)$id,'delete',$row,$data,$user);return true; + }); + } + public function report(array$f) + { + $from=substr((string)($f['from']??date('Y-m')),0,7);$to=substr((string)($f['to']??date('Y-m')),0,7); + return$this->accountReport(array('from'=>$from,'to'=>$to,'account_id'=>(int)($f['account_id']??0))); + } + public function accountReport(array$f) + { + $company=$this->CI->companycontext->id();$from=$this->validPeriod($f['from']??date('Y-m'));$to=$this->validPeriod($f['to']??date('Y-m')); + if($from>$to)throw new BusinessException('Periode awal tidak boleh melewati periode akhir.'); + $periods=$this->periodRange($from,$to);if(count($periods)>120)throw new BusinessException('Rentang laporan maksimal 10 tahun.'); + $this->CI->db->select('e.account_id,e.period,e.entry_type,e.amount,a.kode_akun,a.nama_akun,a.tipe')->from('account_budget_entries e')->join('accounts a','a.id=e.account_id')->where(array('e.company_id'=>$company,'e.is_active'=>1))->where('e.period <=',$to)->group_start()->where('e.entry_type','recurring')->or_group_start()->where('e.entry_type','override')->where('e.period >=',$from)->group_end()->group_end(); + if(!empty($f['account_id']))$this->CI->db->where('e.account_id',(int)$f['account_id']); + $entries=$this->CI->db->order_by('e.period')->get()->result();$grouped=array(); + foreach($entries as$e){if(!isset($grouped[$e->account_id]))$grouped[$e->account_id]=array('account'=>$e,'recurring'=>array(),'override'=>array());if($e->entry_type==='recurring')$grouped[$e->account_id]['recurring'][]=array('period'=>$e->period,'amount'=>(float)$e->amount);else$grouped[$e->account_id]['override'][$e->period]=($grouped[$e->account_id]['override'][$e->period]??0)+(float)$e->amount;} + if(!$grouped)return array();$ids=array_map('intval',array_keys($grouped));$idSql=implode(',',$ids); + $fromDate=$from.'-01';$toDate=date('Y-m-t',strtotime($to.'-01')); + $actualRows=$this->CI->db->query("SELECT jd.account_id,SUM(jd.debit) debit,SUM(jd.kredit) credit FROM journal_details jd JOIN journals j ON j.id=jd.journal_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ? AND ? AND jd.account_id IN($idSql) GROUP BY jd.account_id",array($company,$fromDate,$toDate))->result(); + $actual=array();foreach($actualRows as$x)$actual[$x->account_id]=array('debit'=>(float)$x->debit,'credit'=>(float)$x->credit); + $commitment=$this->purchaseCommitments($ids,$fromDate,$toDate,$company);$rows=array(); + foreach($grouped as$accountId=>$g){$budget=0.0;foreach($periods as$p){foreach($g['recurring']as$schedule)if($schedule['period']<=$p)$budget+=$schedule['amount'];if(isset($g['override'][$p]))$budget+=$g['override'][$p];} + $movement=$actual[$accountId]??array('debit'=>0,'credit'=>0);$normalDebit=in_array($g['account']->tipe,array('asset','expense'),true);$realized=$normalDebit?$movement['debit']-$movement['credit']:$movement['credit']-$movement['debit'];$committed=(float)($commitment[$accountId]??0);$usage=round($realized+$committed,2);$remaining=round($budget-$usage,2);$percentage=$budget>0?round($usage/$budget*100,2):($usage>0?100:0); + $rows[]=array('account_id'=>(int)$accountId,'kode_akun'=>$g['account']->kode_akun,'nama_akun'=>$g['account']->nama_akun,'tipe'=>$g['account']->tipe,'budget'=>round($budget,2),'actual'=>round($realized,2),'commitment'=>round($committed,2),'usage'=>$usage,'remaining'=>$remaining,'percentage'=>$percentage,'indicator'=>$remaining<0?'over':($percentage>=80?'warning':'safe')); + } + usort($rows,function($a,$b){return strnatcasecmp($a['kode_akun'],$b['kode_akun']);});return$rows; + } + private function purchaseCommitments(array$accountIds,$from,$to,$company) + { + if(!$accountIds||!$this->CI->db->table_exists('purchase_orders')||!$this->CI->db->table_exists('purchase_order_lines')||!$this->CI->db->field_exists('budget_account_id','purchase_order_lines'))return array(); + $ids=implode(',',array_map('intval',$accountIds));$joins='';$where='';$params=array(); + if($this->CI->db->field_exists('company_id','purchase_orders')){$where=' AND po.company_id=?';$params[]=$company;} + elseif($this->CI->db->table_exists('purchase_requests')&&$this->CI->db->field_exists('request_id','purchase_orders')&&$this->CI->db->field_exists('company_id','purchase_requests')){$joins=' LEFT JOIN purchase_requests pr ON pr.id=po.request_id';$where=' AND pr.company_id=?';$params[]=$company;} + $invoiced='0';if($this->CI->db->table_exists('supplier_invoice_lines')&&$this->CI->db->table_exists('supplier_invoices')&&$this->CI->db->field_exists('po_line_id','supplier_invoice_lines'))$invoiced="COALESCE((SELECT SUM(sil.qty) FROM supplier_invoice_lines sil JOIN supplier_invoices si ON si.id=sil.supplier_invoice_id WHERE sil.po_line_id=pol.id AND si.status IN('verified','posted','partial','paid')),0)"; + $sql="SELECT pol.budget_account_id account_id,SUM(GREATEST(pol.qty-($invoiced),0)*pol.unit_price) amount FROM purchase_order_lines pol JOIN purchase_orders po ON po.id=pol.purchase_order_id $joins WHERE po.status IN('approved','partially_received','received') AND po.order_date BETWEEN ? AND ? $where AND pol.budget_account_id IN($ids) GROUP BY pol.budget_account_id"; + $params=array_merge(array($from,$to),$params);$result=array();foreach($this->CI->db->query($sql,$params)->result()as$row)$result[$row->account_id]=(float)$row->amount;return$result; + } + private function validPeriod($value){$value=substr(trim((string)$value),0,7);if(!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/',$value))throw new BusinessException('Format periode budget tidak valid.');return$value;} + private function periodRange($from,$to){$out=array();$cursor=new DateTime($from.'-01');$end=new DateTime($to.'-01');while($cursor<=$end){$out[]=$cursor->format('Y-m');$cursor->modify('+1 month');}return$out;} + private function decorateReminder($row,$completion) + { + $row->reminder_state='none';$row->reminder_due_date=null;$row->reminder_days=null;$row->reminder_completed_at=$completion?$completion->completed_at:null; + if(!(int)$row->reminder_enabled||!(int)$row->reminder_day)return; + $period=date('Y-m');$applies=$row->entry_type==='recurring'?$row->period<=$period:$row->period===$period;if(!$applies){$row->reminder_state=$row->period>$period?'future':'inactive_period';return;} + $day=min((int)$row->reminder_day,(int)date('t'));$due=sprintf('%s-%02d',$period,$day);$row->reminder_due_date=$due; + if($completion){$row->reminder_state='completed';return;} + $days=(int)floor((strtotime($due)-strtotime(date('Y-m-d')))/86400);$row->reminder_days=$days;$row->reminder_state=$days<0?'overdue':($days===0?'due':($days<=7?'upcoming':'scheduled')); + } + private function auditEntry($id,$action,$old,$new,$user){if(!$this->CI->db->table_exists('budget_audit_logs'))return;$this->CI->db->insert('budget_audit_logs',array('company_id'=>$this->CI->companycontext->id(),'budget_id'=>null,'entity_type'=>'account_budget_entry','entity_id'=>$id,'action'=>$action,'old_values'=>$old?json_encode((array)$old):null,'new_values'=>$new?json_encode((array)$new):null,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));} + private function isAdmin(){return is_master_admin_user();} + private function audit($id,$action,$old,$new,$reason,$user){if(!$this->CI->db->table_exists('budget_audit_logs'))return;$this->CI->db->insert('budget_audit_logs',array('company_id'=>$this->CI->companycontext->id(),'budget_id'=>$id,'entity_type'=>'budget','entity_id'=>$id,'action'=>$action,'old_values'=>$old===null?null:json_encode($old),'new_values'=>$new===null?null:json_encode($new),'reason'=>$reason?:null,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));} +} diff --git a/application/libraries/CashBankService.php b/application/libraries/CashBankService.php new file mode 100644 index 0000000..de1cfd9 --- /dev/null +++ b/application/libraries/CashBankService.php @@ -0,0 +1,10 @@ +CI=&get_instance();$this->CI->load->library(array('PostingService','FiscalPeriodService'));} + public function post($data,$uid){$db=$this->CI->db;$db->trans_begin();try{$date=$data['transaction_date'];$this->CI->fiscalperiodservice->assertOpen($date);$account=$db->query('SELECT * FROM cash_accounts WHERE id=? AND is_active=1 FOR UPDATE',array($data['cash_account_id']))->row();if(!$account)throw new BusinessException('Rekening kas/bank tidak ditemukan.');$amount=(float)$data['amount'];if($amount<=0)throw new BusinessException('Jumlah harus lebih dari nol.');$key=trim($data['idempotency_key']);if($key===''||$db->get_where('cash_transactions',array('idempotency_key'=>$key))->row())throw new BusinessException('Kunci transaksi kosong atau sudah digunakan.');$threshold=(float)(getenv('CASH_APPROVAL_THRESHOLD')?:10000000);$requires=$data['direction']==='out'&&$amount>=$threshold;$status=$requires?'submitted':'posted';$no='CB-'.date('YmdHis').'-'.substr(sha1($key),0,6);$row=array('transaction_no'=>$no,'transaction_date'=>$date,'cash_account_id'=>$account->id,'transaction_type'=>$data['transaction_type'],'direction'=>$data['direction'],'amount'=>$amount,'counter_account_id'=>$data['counter_account_id'],'reference_no'=>$data['reference_no'],'description'=>$data['description'],'status'=>$status,'requires_approval'=>$requires?1:0,'idempotency_key'=>$key,'created_by'=>$uid,'created_at'=>date('Y-m-d H:i:s'));$db->insert('cash_transactions',$row);$id=$db->insert_id();if(!$requires)$this->journal($id,$account,$row,$uid);if(!$db->trans_status())throw new RuntimeException('Transaksi kas/bank gagal.');$db->trans_commit();return array('id'=>$id,'transaction_no'=>$no,'status'=>$status);}catch(Throwable$e){$db->trans_rollback();throw$e;}} + private function journal($id,$account,$row,$uid){$amount=$row['amount'];$lines=$row['direction']==='in'?array(array('account_id'=>$account->gl_account_id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$row['counter_account_id'],'debit'=>0,'kredit'=>$amount)):array(array('account_id'=>$row['counter_account_id'],'debit'=>$amount,'kredit'=>0),array('account_id'=>$account->gl_account_id,'debit'=>0,'kredit'=>$amount));$jid=$this->CI->postingservice->post(array('tanggal'=>$row['transaction_date'],'no_ref'=>$row['transaction_no'],'keterangan'=>$row['description'].'; rekening '.$account->name.'; referensi '.($row['reference_no']?:'-'),'ref_type'=>'cash_transaction','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$this->CI->db->where('id',$id)->update('cash_transactions',array('journal_id'=>$jid,'posted_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s'),'status'=>'posted'));return$jid;} + public function approve($id,$uid){$db=$this->CI->db;$db->trans_begin();try{$tx=$db->query('SELECT * FROM cash_transactions WHERE id=? FOR UPDATE',array($id))->row();if(!$tx||$tx->status!=='submitted')throw new BusinessException('Transaksi tidak menunggu persetujuan.');if((int)$tx->created_by===(int)$uid)throw new BusinessException('Pembuat transaksi tidak boleh menyetujui pembayarannya sendiri.');$account=$db->get_where('cash_accounts',array('id'=>$tx->cash_account_id))->row();$row=(array)$tx;$this->journal($id,$account,$row,$uid);$db->where('id',$id)->update('cash_transactions',array('approved_by'=>$uid,'approved_at'=>date('Y-m-d H:i:s')));$db->trans_commit();}catch(Throwable$e){$db->trans_rollback();throw$e;}} + public function transfer($from,$to,$date,$amount,$charge,$chargeAccount,$ref,$uid,$key){if($from==$to)throw new BusinessException('Rekening asal dan tujuan harus berbeda.');$db=$this->CI->db;$db->trans_begin();try{$a=$db->query('SELECT * FROM cash_accounts WHERE id IN(?,?) ORDER BY id FOR UPDATE',array($from,$to))->result();if(count($a)!==2)throw new BusinessException('Rekening transfer tidak valid.');$map=array();foreach($a as$x)$map[$x->id]=$x;$this->CI->fiscalperiodservice->assertOpen($date);$no='TRF-'.date('YmdHis');$lines=array(array('account_id'=>$map[$to]->gl_account_id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$map[$from]->gl_account_id,'debit'=>0,'kredit'=>$amount+$charge));if($charge>0)$lines[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Transfer antar rekening dari '.$map[$from]->name.' ke '.$map[$to]->name.'; referensi '.($ref?:'-'),'ref_type'=>'cash_transfer','ref_id'=>0,'created_by'=>$uid),$lines,false,true);foreach(array(array($from,'transfer_out','out',$amount+$charge),array($to,'transfer_in','in',$amount))as$i=>$x){$db->insert('cash_transactions',array('transaction_no'=>$no.'-'.($i?'IN':'OUT'),'transaction_date'=>$date,'cash_account_id'=>$x[0],'transaction_type'=>$x[1],'direction'=>$x[2],'amount'=>$x[3],'reference_no'=>$ref,'description'=>'Transfer antar rekening '.$no,'status'=>'posted','source_type'=>'cash_transfer','journal_id'=>$jid,'idempotency_key'=>$key.'-'.$i,'created_by'=>$uid,'posted_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));}$db->insert('cash_transfers',array('transfer_no'=>$no,'transfer_date'=>$date,'from_cash_account_id'=>$from,'to_cash_account_id'=>$to,'amount'=>$amount,'bank_charge'=>$charge,'reference_no'=>$ref,'status'=>'posted','journal_id'=>$jid,'idempotency_key'=>$key,'created_by'=>$uid,'approved_by'=>$uid));$db->trans_commit();return$no;}catch(Throwable$e){$db->trans_rollback();throw$e;}} +} diff --git a/application/libraries/ChartOfAccountsService.php b/application/libraries/ChartOfAccountsService.php new file mode 100644 index 0000000..8e17003 --- /dev/null +++ b/application/libraries/ChartOfAccountsService.php @@ -0,0 +1,112 @@ +CI =& get_instance(); + $this->CI->load->library('AuditService'); + } + + public function save(array $input, $id = null, $userId = null) + { + $code = trim(isset($input['kode_akun']) ? $input['kode_akun'] : ''); + $name = trim(isset($input['nama_akun']) ? $input['nama_akun'] : ''); + $type = isset($input['tipe']) ? $input['tipe'] : ''; + $parentId = !empty($input['parent_id']) ? (int) $input['parent_id'] : null; + $isHeader = !empty($input['is_header']) ? 1 : 0; + $allowPosting = $isHeader ? 0 : (!empty($input['allow_posting']) ? 1 : 0); + + if ($code === '' || $name === '') { + throw new BusinessException('Kode dan nama akun wajib diisi.'); + } + if (!preg_match('/^[0-9]{4}$/', $code)) { + throw new BusinessException('Kode akun wajib terdiri dari tepat 4 angka.'); + } + if (!in_array($type, array('asset', 'liability', 'equity', 'revenue', 'expense'), true)) { + throw new BusinessException('Tipe akun tidak valid.'); + } + $duplicateQuery = $this->CI->db->where('kode_akun', $code); + if ($id) $duplicateQuery->where('id !=', (int) $id); + if ($duplicateQuery->count_all_results('accounts') > 0) { + throw new BusinessException('Kode akun sudah digunakan.'); + } + if ($parentId) { + if ($id && $parentId === (int) $id) { + throw new BusinessException('Akun tidak dapat menjadi parent untuk dirinya sendiri.'); + } + $parent = $this->CI->db->get_where('accounts', array('id' => $parentId))->row(); + if (!$parent) { + throw new BusinessException('Parent akun tidak ditemukan.'); + } + if ($id && $this->wouldCreateCycle((int) $id, $parentId)) { + throw new BusinessException('Parent akun tersebut akan membentuk hierarki melingkar.'); + } + } + if ($id && $isHeader && $this->CI->db->where('account_id', (int) $id)->count_all_results('journal_details') > 0) { + throw new BusinessException('Akun yang sudah mempunyai transaksi tidak dapat diubah menjadi akun header.'); + } + + $data = array( + 'kode_akun' => $code, + 'nama_akun' => $name, + 'tipe' => $type, + 'posisi' => in_array($type, array('asset', 'expense'), true) ? 'debit' : 'kredit', + 'kategori' => in_array($type, array('asset', 'liability', 'equity'), true) ? 'neraca' : 'laba_rugi', + 'parent_id' => $parentId, + 'priority' => isset($input['priority']) && $input['priority'] !== '' ? (int) $input['priority'] : null, + 'is_header' => $isHeader, + 'allow_posting' => $allowPosting, + 'is_active' => isset($input['is_active']) ? (!empty($input['is_active']) ? 1 : 0) : 1, + 'is_hidden' => !empty($input['is_hidden']) ? 1 : 0, + 'updated_at' => date('Y-m-d H:i:s'), + 'updated_by' => $userId ? (int) $userId : null + ); + + if ($id) { + $before = $this->CI->db->get_where('accounts', array('id'=>(int)$id))->row(); + $this->CI->db->where('id', (int) $id)->update('accounts', $data); + $this->CI->auditservice->record('chart_of_accounts','account',(int)$id,'update',$before,$this->CI->db->get_where('accounts',array('id'=>(int)$id))->row(),$userId); + return (int) $id; + } + $this->CI->db->insert('accounts', $data); + $newId=(int)$this->CI->db->insert_id(); + $this->CI->auditservice->record('chart_of_accounts','account',$newId,'create',null,$this->CI->db->get_where('accounts',array('id'=>$newId))->row(),$userId); + return $newId; + } + + public function delete($id, $userId = null) + { + $id = (int) $id; + if ($this->CI->db->where('parent_id', $id)->count_all_results('accounts') > 0) { + throw new BusinessException('Akun memiliki child dan tidak dapat dihapus.'); + } + if ($this->CI->db->where('account_id', $id)->count_all_results('journal_details') > 0) { + throw new BusinessException('Akun sudah mempunyai transaksi dan tidak dapat dihapus. Nonaktifkan akun sebagai gantinya.'); + } + if ($this->CI->db->where('account_id', $id)->count_all_results('system_account_mappings') > 0) { + throw new BusinessException('Akun masih digunakan sebagai mapping akun sistem.'); + } + $before=$this->CI->db->get_where('accounts',array('id'=>$id))->row(); + $result=$this->CI->db->where('id', $id)->delete('accounts'); + if($result) $this->CI->auditservice->record('chart_of_accounts','account',$id,'delete',$before,null,$userId); + return $result; + } + + private function wouldCreateCycle($accountId, $parentId) + { + $visited = array(); + while ($parentId) { + if ($parentId === $accountId || isset($visited[$parentId])) return true; + $visited[$parentId] = true; + $row = $this->CI->db->select('parent_id')->get_where('accounts', array('id' => $parentId))->row(); + $parentId = $row && $row->parent_id ? (int) $row->parent_id : null; + } + return false; + } +} diff --git a/application/libraries/CompanyContext.php b/application/libraries/CompanyContext.php new file mode 100644 index 0000000..973d99e --- /dev/null +++ b/application/libraries/CompanyContext.php @@ -0,0 +1,9 @@ +CI=&get_instance();} + public function id(){if($this->id)return$this->id;$uid=(int)$this->CI->session->userdata('user_id');$selected=(int)$this->CI->session->userdata('company_id');if($selected&&$this->CI->db->get_where('user_companies',array('user_id'=>$uid,'company_id'=>$selected))->row())return$this->id=$selected;$row=$this->CI->db->where('user_id',$uid)->order_by('is_default','DESC')->get('user_companies')->row();if(!$row)throw new BusinessException('User belum diberi akses perusahaan.');$this->CI->session->set_userdata('company_id',$row->company_id);return$this->id=(int)$row->company_id;} + public function assertAccess($companyId){if(!$this->CI->db->get_where('user_companies',array('user_id'=>(int)$this->CI->session->userdata('user_id'),'company_id'=>(int)$companyId))->row())throw new BusinessException('Akses perusahaan ditolak.');return true;} + public function scope($builder,$column='company_id'){$builder->where($column,$this->id());return$builder;} +} diff --git a/application/libraries/DatabaseSyncService.php b/application/libraries/DatabaseSyncService.php new file mode 100644 index 0000000..d3f3b5b --- /dev/null +++ b/application/libraries/DatabaseSyncService.php @@ -0,0 +1,490 @@ +CI =& get_instance(); + $this->sourceConfig = $this->readConfig('DBSYNC_SOURCE'); + $this->targetConfig = $this->readConfig('DBSYNC_TARGET'); + + if ($this->sourceConfig['host'] === $this->targetConfig['host'] + && $this->sourceConfig['port'] === $this->targetConfig['port'] + && $this->sourceConfig['database'] === $this->targetConfig['database']) { + throw new RuntimeException('Database sumber dan target tidak boleh sama.'); + } + } + + public function check() + { + list($source, $target) = $this->connectPair(); + try { + $result = array( + 'status' => true, + 'source' => $this->databaseInfo($source, $this->sourceConfig['database']), + 'target' => $this->databaseInfo($target, $this->targetConfig['database']), + ); + $result['target']['is_empty'] = $result['target']['objects'] === 0; + return $result; + } finally { + $source->close(); + $target->close(); + } + } + + public function snapshot() + { + list($source, $target) = $this->connectPair(); + $target->close(); + try { + $path = $this->createSnapshot($source); + return array( + 'status' => true, + 'backup' => $path, + 'size' => filesize($path), + 'sha256' => hash_file('sha256', $path), + ); + } finally { + $source->close(); + } + } + + public function bootstrap() + { + // Both connections must succeed before the first target write. + list($source, $target) = $this->connectPair(); + try { + $targetInfo = $this->databaseInfo($target, $this->targetConfig['database']); + if ($targetInfo['objects'] !== 0) { + throw new RuntimeException( + 'Bootstrap ditolak: target tidak kosong (' . $targetInfo['objects'] . ' objek).' + ); + } + + $backup = trim((string) getenv('DBSYNC_BACKUP_FILE')); + if ($backup === '') { + $backup = $this->createSnapshot($source); + } elseif (!is_file($backup) || !is_readable($backup)) { + throw new RuntimeException('File backup tidak ditemukan atau tidak dapat dibaca.'); + } + + $expectedHash = strtolower(trim((string) getenv('DBSYNC_BACKUP_SHA256'))); + $actualHash = hash_file('sha256', $backup); + if ($expectedHash !== '' && !hash_equals($expectedHash, strtolower($actualHash))) { + throw new RuntimeException('Checksum backup tidak sesuai. Target belum diubah.'); + } + } finally { + $source->close(); + $target->close(); + } + + $sanitizedBackup = $this->sanitizeBackup($backup); + try { + $this->importBackup($sanitizedBackup); + $this->runTargetMigrations(); + $verification = $this->verify(); + } finally { + if (is_file($sanitizedBackup)) { + @unlink($sanitizedBackup); + } + } + + return array( + 'status' => true, + 'message' => 'Snapshot legacy berhasil dimuat dan struktur target berhasil dimodernisasi.', + 'backup' => $backup, + 'backup_sha256' => $actualHash, + 'verification' => $verification, + ); + } + + public function verify() + { + list($source, $target) = $this->connectPair(); + try { + $required = array('accounts', 'users', 'journals', 'journal_details', 'items', 'item_barcodes'); + $missing = array(); + foreach ($required as $table) { + if (!$this->tableExists($target, $this->targetConfig['database'], $table)) { + $missing[] = $table; + } + } + if ($missing) { + throw new RuntimeException('Verifikasi gagal, tabel wajib tidak tersedia: ' . implode(', ', $missing)); + } + + $latestFileVersion = $this->latestMigrationVersion(); + $migrationVersion = 0; + if ($this->tableExists($target, $this->targetConfig['database'], 'migrations')) { + $row = $target->query('SELECT COALESCE(MAX(version), 0) AS version FROM migrations')->fetch_assoc(); + $migrationVersion = (int) $row['version']; + } + + $invalidAccounts = (int) $target->query( + "SELECT COUNT(*) AS total FROM accounts WHERE kode_akun IS NULL OR kode_akun NOT REGEXP '^[0-9]{4}$'" + )->fetch_assoc()['total']; + $journal = $target->query( + 'SELECT COALESCE(SUM(debit),0) AS debit, COALESCE(SUM(kredit),0) AS kredit FROM journal_details' + )->fetch_assoc(); + $journalDifference = round((float) $journal['debit'] - (float) $journal['kredit'], 2); + + $orphans = array( + 'journal_details' => $this->scalar($target, + 'SELECT COUNT(*) FROM journal_details d LEFT JOIN journals j ON j.id=d.journal_id WHERE j.id IS NULL'), + 'invoice_details' => $this->tableExists($target, $this->targetConfig['database'], 'invoice_details') + ? $this->scalar($target, + 'SELECT COUNT(*) FROM invoice_details d LEFT JOIN invoices i ON i.id=d.invoice_id WHERE i.id IS NULL') + : 0, + 'item_barcodes' => $this->scalar($target, + 'SELECT COUNT(*) FROM item_barcodes b LEFT JOIN items i ON i.id=b.item_id WHERE i.id IS NULL'), + ); + + $passed = $migrationVersion === $latestFileVersion + && $invalidAccounts === 0 + && abs($journalDifference) < 0.01 + && array_sum($orphans) === 0; + + if (!$passed) { + throw new RuntimeException('Verifikasi target gagal. Periksa hasil audit sebelum cutover.'); + } + + return array( + 'status' => true, + 'migration_version' => $migrationVersion, + 'latest_migration_file' => $latestFileVersion, + 'account_codes_invalid' => $invalidAccounts, + 'journal_debit' => (float) $journal['debit'], + 'journal_credit' => (float) $journal['kredit'], + 'journal_difference' => $journalDifference, + 'orphans' => $orphans, + 'target' => $this->databaseInfo($target, $this->targetConfig['database']), + ); + } finally { + $source->close(); + $target->close(); + } + } + + private function readConfig($prefix) + { + $config = array( + 'host' => trim((string) getenv($prefix . '_HOST')), + 'port' => (int) (getenv($prefix . '_PORT') ?: 3306), + 'username' => trim((string) getenv($prefix . '_USERNAME')), + 'password' => (string) getenv($prefix . '_PASSWORD'), + 'database' => trim((string) getenv($prefix . '_DATABASE')), + ); + + foreach (array('host', 'port', 'username', 'password', 'database') as $key) { + if ($config[$key] === '' || $config[$key] === 0) { + throw new RuntimeException('Konfigurasi ' . $prefix . '_' . strtoupper($key) . ' belum lengkap.'); + } + } + return $config; + } + + private function connectPair() + { + $source = $this->connect($this->sourceConfig, 'sumber'); + try { + $target = $this->connect($this->targetConfig, 'target'); + } catch (Throwable $exception) { + $source->close(); + throw $exception; + } + return array($source, $target); + } + + private function connect($config, $label) + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + $connection = mysqli_init(); + $connection->options(MYSQLI_OPT_CONNECT_TIMEOUT, 10); + try { + $connection->real_connect( + $config['host'], + $config['username'], + $config['password'], + $config['database'], + $config['port'] + ); + $connection->set_charset('utf8mb4'); + return $connection; + } catch (Throwable $exception) { + throw new RuntimeException('Koneksi database ' . $label . ' gagal. Proses dihentikan.', 0, $exception); + } + } + + private function databaseInfo($connection, $database) + { + $statement = $connection->prepare( + 'SELECT COUNT(*) AS total FROM information_schema.tables WHERE table_schema=?' + ); + $statement->bind_param('s', $database); + $statement->execute(); + $objects = (int) $statement->get_result()->fetch_assoc()['total']; + $statement->close(); + + return array( + 'database' => $database, + 'server_version' => $connection->server_info, + 'objects' => $objects, + ); + } + + private function tableExists($connection, $database, $table) + { + $statement = $connection->prepare( + 'SELECT COUNT(*) AS total FROM information_schema.tables WHERE table_schema=? AND table_name=?' + ); + $statement->bind_param('ss', $database, $table); + $statement->execute(); + $exists = (int) $statement->get_result()->fetch_assoc()['total'] > 0; + $statement->close(); + return $exists; + } + + private function createSnapshot($source) + { + $directory = trim((string) getenv('DBSYNC_BACKUP_DIRECTORY')); + if ($directory === '') { + $directory = dirname(FCPATH) . DIRECTORY_SEPARATOR . 'private_backups' . DIRECTORY_SEPARATOR . 'accounting_dev'; + } + if (!is_dir($directory) && !mkdir($directory, 0700, true)) { + throw new RuntimeException('Folder backup tidak dapat dibuat.'); + } + $path = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR + . $this->sourceConfig['database'] . '-cutover-' . date('Ymd-His') . '.sql'; + $file = fopen($path, 'xb'); + if (!$file) { + throw new RuntimeException('File snapshot tidak dapat dibuat.'); + } + + try { + fwrite($file, "-- One-way database cutover snapshot\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\nSET UNIQUE_CHECKS=0;\n\n"); + $tables = array(); + $views = array(); + $result = $source->query('SHOW FULL TABLES'); + while ($row = $result->fetch_row()) { + if (strtoupper($row[1]) === 'VIEW') { + $views[] = $row[0]; + } else { + $tables[] = $row[0]; + } + } + + $source->query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $source->query('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY'); + foreach ($tables as $table) { + $quotedTable = $this->identifier($table); + $createRow = $source->query('SHOW CREATE TABLE ' . $quotedTable)->fetch_row(); + fwrite($file, 'DROP TABLE IF EXISTS ' . $quotedTable . ";\n" . $createRow[1] . ";\n\n"); + + $selectColumns = array(); + $columnResult = $source->query('SHOW FULL COLUMNS FROM ' . $quotedTable); + while ($column = $columnResult->fetch_assoc()) { + if (stripos((string) $column['Extra'], 'GENERATED') === false) { + $selectColumns[] = $this->identifier($column['Field']); + } + } + $rows = $source->query( + 'SELECT ' . implode(',', $selectColumns) . ' FROM ' . $quotedTable, + MYSQLI_USE_RESULT + ); + $columns = array(); + foreach ($rows->fetch_fields() as $field) { + $columns[] = $this->identifier($field->name); + } + $batch = array(); + while ($row = $rows->fetch_row()) { + $values = array(); + foreach ($row as $value) { + $values[] = $value === null ? 'NULL' : "'" . $source->real_escape_string($value) . "'"; + } + $batch[] = '(' . implode(',', $values) . ')'; + if (count($batch) >= 200) { + $this->writeInsertBatch($file, $quotedTable, $columns, $batch); + $batch = array(); + } + } + $rows->free(); + if ($batch) { + $this->writeInsertBatch($file, $quotedTable, $columns, $batch); + } + fwrite($file, "\n"); + } + $source->commit(); + + foreach ($views as $view) { + $quotedView = $this->identifier($view); + $create = $source->query('SHOW CREATE VIEW ' . $quotedView)->fetch_assoc(); + fwrite($file, 'DROP VIEW IF EXISTS ' . $quotedView . ";\n" . $create['Create View'] . ";\n\n"); + } + fwrite($file, "SET UNIQUE_CHECKS=1;\nSET FOREIGN_KEY_CHECKS=1;\n"); + } catch (Throwable $exception) { + @$source->rollback(); + fclose($file); + @unlink($path); + throw $exception; + } + + fclose($file); + @chmod($path, 0600); + return $path; + } + + private function writeInsertBatch($file, $table, $columns, $batch) + { + fwrite( + $file, + 'INSERT INTO ' . $table . '(' . implode(',', $columns) . ") VALUES\n" + . implode(",\n", $batch) . ";\n" + ); + } + + private function sanitizeBackup($sourcePath) + { + $targetPath = tempnam(sys_get_temp_dir(), 'accounting-cutover-'); + $input = fopen($sourcePath, 'rb'); + $output = fopen($targetPath, 'wb'); + if (!$input || !$output) { + throw new RuntimeException('File sementara untuk import tidak dapat dibuat.'); + } + $sqlModeInjected = false; + $generatedScheduleInsert = false; + while (($line = fgets($input)) !== false) { + $line = preg_replace('/\\sDEFINER=`[^`]+`@`[^`]+`/i', '', $line); + + // Older snapshots produced before this service included the + // generated schedule_date column. MySQL only accepts DEFAULT for + // generated values, so retain the row and let MySQL calculate it. + if (stripos($line, 'INSERT INTO `k_employee_shift_schedules`') === 0 + && stripos($line, '`schedule_date`') !== false) { + $generatedScheduleInsert = true; + } elseif ($generatedScheduleInsert && isset($line[0]) && $line[0] === '(') { + $line = preg_replace( + "/^(\\('[^']*','[^']*',(?:'[^']*'|NULL),'[^']*','[^']*'),(?:'[^']*'|NULL)(,)/", + '$1,DEFAULT$2', + $line + ); + if (substr(rtrim($line), -1) === ';') { + $generatedScheduleInsert = false; + } + } + fwrite($output, $line); + // Legacy MySQL ENUM columns contain empty-string values that are + // valid in the old data but rejected by modern strict SQL mode. + // Compatibility mode preserves those rows; later migrations map + // them to the modern representation without deleting data. + if (!$sqlModeInjected && preg_match('/^SET NAMES /i', trim($line))) { + fwrite($output, "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION';\n"); + $sqlModeInjected = true; + } + } + fclose($input); + fclose($output); + return $targetPath; + } + + private function importBackup($backup) + { + $mysql = trim((string) getenv('DBSYNC_MYSQL_BIN')); + if ($mysql === '') { + $mysql = 'C:\\xampp\\mysql\\bin\\mysql.exe'; + } + if (!is_file($mysql)) { + throw new RuntimeException('mysql client tidak ditemukan: ' . $mysql); + } + + $command = array( + $mysql, + '--host=' . $this->targetConfig['host'], + '--port=' . $this->targetConfig['port'], + '--user=' . $this->targetConfig['username'], + '--default-character-set=utf8mb4', + '--database=' . $this->targetConfig['database'], + ); + $this->runProcess($command, $backup, array('MYSQL_PWD' => $this->targetConfig['password'])); + } + + private function runTargetMigrations() + { + $environment = array( + 'DB_HOST' => $this->targetConfig['host'], + 'DB_PORT' => (string) $this->targetConfig['port'], + 'DB_USERNAME' => $this->targetConfig['username'], + 'DB_PASSWORD' => $this->targetConfig['password'], + 'DB_DATABASE' => $this->targetConfig['database'], + ); + $this->runProcess( + array(PHP_BINARY, FCPATH . 'index.php', 'migrate/latest'), + null, + $environment, + FCPATH + ); + } + + private function runProcess($command, $stdinFile = null, $environment = array(), $workingDirectory = null) + { + $descriptors = array( + 0 => $stdinFile ? array('file', $stdinFile, 'rb') : array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ); + $processEnvironment = getenv(); + if (!is_array($processEnvironment)) { + $processEnvironment = array(); + } + foreach ($environment as $key => $value) { + $processEnvironment[$key] = $value; + } + + $process = proc_open($command, $descriptors, $pipes, $workingDirectory, $processEnvironment); + if (!is_resource($process)) { + throw new RuntimeException('Proses database tidak dapat dijalankan.'); + } + if (!$stdinFile) { + fclose($pipes[0]); + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + if ($exitCode !== 0) { + throw new RuntimeException(trim($stderr ?: $stdout ?: 'Proses database gagal.') . ' (exit ' . $exitCode . ')'); + } + return trim($stdout); + } + + private function latestMigrationVersion() + { + $versions = array(); + foreach (glob(APPPATH . 'migrations' . DIRECTORY_SEPARATOR . '*.php') as $file) { + if (preg_match('/^(\\d+)_/', basename($file), $match)) { + $versions[] = (int) $match[1]; + } + } + return $versions ? max($versions) : 0; + } + + private function scalar($connection, $sql) + { + $row = $connection->query($sql)->fetch_row(); + return (int) $row[0]; + } + + private function identifier($value) + { + return '`' . str_replace('`', '``', $value) . '`'; + } +} diff --git a/application/libraries/FiscalPeriodService.php b/application/libraries/FiscalPeriodService.php new file mode 100644 index 0000000..8161dcf --- /dev/null +++ b/application/libraries/FiscalPeriodService.php @@ -0,0 +1,149 @@ +CI =& get_instance(); + $this->CI->load->model('FiscalPeriodModel', 'fiscalPeriodModel'); + $this->CI->load->library('TransactionService'); + $this->CI->load->library('AuditService'); + } + + public function periodForDate($date) + { + $parsed = DateTime::createFromFormat('Y-m-d', (string) $date); + if (!$parsed || $parsed->format('Y-m-d') !== $date) { + throw new BusinessException('Tanggal periode accounting tidak valid.'); + } + + $period = $this->CI->fiscalPeriodModel->findByDate($date); + if (!$period) { + throw new BusinessException('Periode accounting untuk tanggal ' . $date . ' belum tersedia.'); + } + return $period; + } + + public function assertOpen($date) + { + $period = $this->periodForDate($date); + if ($period->status !== 'open') { + throw new BusinessException('Periode ' . $period->name . ' berstatus ' . $period->status . ' dan tidak menerima transaksi.'); + } + return $period; + } + + public function changeStatus($periodId, $targetStatus, $userId, $reason = '') + { + $allowed = array('open', 'soft_closed', 'closed'); + if (!in_array($targetStatus, $allowed, true)) { + throw new BusinessException('Status periode tidak valid.'); + } + + return $this->CI->transactionservice->run(function () use ($periodId, $targetStatus, $userId, $reason) { + $period = $this->CI->db->query('SELECT * FROM accounting_periods WHERE id = ? FOR UPDATE', array((int) $periodId))->row(); + if (!$period) { + throw new BusinessException('Periode accounting tidak ditemukan.', array(), 404); + } + if ($period->status === $targetStatus) { + return $period; + } + + $transitions = array( + 'open' => array('soft_closed', 'closed'), + 'soft_closed' => array('open', 'closed'), + 'closed' => array('open') + ); + if (!in_array($targetStatus, $transitions[$period->status], true)) { + throw new BusinessException('Perubahan status periode tidak diizinkan.'); + } + if ($targetStatus === 'open' && $period->status !== 'open' && trim($reason) === '') { + throw new BusinessException('Alasan membuka kembali periode wajib diisi.'); + } + if (in_array($targetStatus, array('soft_closed', 'closed'), true)) { + $pending = $this->CI->db + ->where('tanggal >=', $period->start_date) + ->where('tanggal <=', $period->end_date) + ->where_in('status', array('draft', 'submitted', 'approved')) + ->count_all_results('journals'); + if ($pending > 0) { + throw new BusinessException('Periode masih mempunyai ' . $pending . ' jurnal yang belum diposting atau ditolak.'); + } + } + + $update = array('status' => $targetStatus, 'updated_at' => date('Y-m-d H:i:s')); + if ($targetStatus === 'closed') { + $update['closed_at'] = date('Y-m-d H:i:s'); + $update['closed_by'] = (int) $userId; + } elseif ($targetStatus === 'open') { + $update['reopened_at'] = date('Y-m-d H:i:s'); + $update['reopened_by'] = (int) $userId; + } + $this->CI->db->where('id', $period->id)->update('accounting_periods', $update); + $this->CI->db->insert('accounting_period_logs', array( + 'accounting_period_id' => $period->id, + 'from_status' => $period->status, + 'to_status' => $targetStatus, + 'reason' => trim($reason) ?: null, + 'changed_by' => (int) $userId, + 'changed_at' => date('Y-m-d H:i:s') + )); + $notClosed = $this->CI->db + ->where('fiscal_year_id', $period->fiscal_year_id) + ->where('status !=', 'closed') + ->count_all_results('accounting_periods'); + $this->CI->db->where('id', $period->fiscal_year_id)->update('fiscal_years', array( + 'status' => $notClosed === 0 ? 'closed' : 'open' + )); + $after=$this->CI->fiscalPeriodModel->find($period->id); + $this->CI->auditservice->record('accounting_period','accounting_period',$period->id,'change_status',$period,$after,$userId); + return $after; + }); + } + + public function createCalendarYear($year, $userId) + { + $year = (int) $year; + if ($year < 2000 || $year > 2200) { + throw new BusinessException('Tahun buku tidak valid.'); + } + return $this->CI->transactionservice->run(function () use ($year, $userId) { + $start = $year . '-01-01'; + $end = $year . '-12-31'; + $overlap = $this->CI->db + ->where('start_date <=', $end) + ->where('end_date >=', $start) + ->count_all_results('fiscal_years'); + if ($overlap > 0) { + throw new BusinessException('Tahun buku tersebut sudah tersedia atau bertumpang tindih.'); + } + $this->CI->db->insert('fiscal_years', array( + 'name' => 'Tahun Buku ' . $year, + 'start_date' => $start, + 'end_date' => $end, + 'status' => 'open', + 'created_at' => date('Y-m-d H:i:s'), + 'created_by' => (int) $userId + )); + $fiscalId = (int) $this->CI->db->insert_id(); + for ($month = 1; $month <= 12; $month++) { + $periodStart = sprintf('%04d-%02d-01', $year, $month); + $this->CI->db->insert('accounting_periods', array( + 'fiscal_year_id' => $fiscalId, + 'period_number' => $month, + 'name' => date('F Y', strtotime($periodStart)), + 'start_date' => $periodStart, + 'end_date' => date('Y-m-t', strtotime($periodStart)), + 'status' => 'open' + )); + } + $this->CI->auditservice->record('fiscal_year','fiscal_year',$fiscalId,'create',null,array('year'=>$year),$userId); + return $fiscalId; + }); + } +} diff --git a/application/libraries/FixedAssetService.php b/application/libraries/FixedAssetService.php new file mode 100644 index 0000000..5bdce24 --- /dev/null +++ b/application/libraries/FixedAssetService.php @@ -0,0 +1,303 @@ +CI =& get_instance(); + $this->CI->load->library(array('PostingService','FiscalPeriodService','NumberingService','AccountMappingService','AuditService')); + } + + private function company() + { + return (int) $this->CI->session->userdata('company_id'); + } + + private function transaction(callable $callback) + { + $db=$this->CI->db;$db->trans_begin(); + try { + $result=$callback(); + if(!$db->trans_status())throw new RuntimeException('Transaksi aset gagal disimpan.'); + $db->trans_commit();return$result; + } catch(Throwable $e) { + $db->trans_rollback();throw$e; + } + } + + private function asset($id, $forUpdate = true) + { + $sql='SELECT * FROM assets WHERE id=? AND company_id=?'.($forUpdate?' FOR UPDATE':''); + $asset=$this->CI->db->query($sql,array((int)$id,$this->company()))->row(); + if(!$asset)throw new BusinessException('Aset tidak ditemukan.',array(),404); + return$asset; + } + + private function account($id, $label) + { + $row=$this->CI->db->get_where('accounts',array('id'=>(int)$id,'company_id'=>$this->company()))->row(); + if(!$row)throw new BusinessException($label.' tidak valid untuk perusahaan aktif.'); + if(!(int)$row->is_active)throw new BusinessException($label.' '.$row->kode_akun.' - '.$row->nama_akun.' sudah nonaktif. Pilih akun aktif.'); + if(!(int)$row->allow_posting)throw new BusinessException($label.' '.$row->kode_akun.' - '.$row->nama_akun.' merupakan akun induk/non-posting. Pilih akun detail yang dapat menerima jurnal.'); + return$row; + } + + private function location($id) + { + $row=$this->CI->db->get_where('lokasi_asset',array('id'=>(int)$id,'company_id'=>$this->company(),'is_active'=>1))->row(); + if(!$row)throw new BusinessException('Lokasi aset tidak valid atau sudah nonaktif.'); + return$row; + } + + private function history($assetId,$from,$to,$notes,$userId) + { + $this->CI->db->insert('asset_workflow_history',array('company_id'=>$this->company(),'asset_id'=>(int)$assetId,'from_status'=>$from?:null,'to_status'=>$to,'notes'=>trim((string)$notes)?:null,'user_id'=>(int)$userId,'created_at'=>date('Y-m-d H:i:s'))); + } + + private function event($asset,$type,$date,$amount,$before,$after,$notes,$journalId,$key,$userId,$from=null,$to=null,$reversalOf=null,$sourceType=null,$sourceId=null) + { + if($this->CI->db->get_where('asset_events',array('idempotency_key'=>$key))->row())throw new BusinessException('Transaksi aset yang sama sudah pernah diproses.'); + $this->CI->db->insert('asset_events',array( + 'company_id'=>$this->company(),'asset_id'=>$asset->id,'event_no'=>$this->CI->numberingservice->next('asset_event',$date),'event_type'=>$type,'event_date'=>$date,'amount'=>round((float)$amount,2),'book_value_before'=>round((float)$before,2),'book_value_after'=>round((float)$after,2),'from_location_id'=>$from?:null,'to_location_id'=>$to?:null,'notes'=>trim((string)$notes)?:null,'source_type'=>$sourceType?:$asset->source_type,'source_id'=>$sourceId?:$asset->source_id,'journal_id'=>$journalId?:null,'idempotency_key'=>$key,'reversal_of_event_id'=>$reversalOf?:null,'created_by'=>(int)$userId,'created_at'=>date('Y-m-d H:i:s') + )); + return(int)$this->CI->db->insert_id(); + } + + private function calculateMonthly($cost,$residual,$life,$method='straight_line') + { + $life=max(1,(int)$life);$base=max(0,(float)$cost-(float)$residual); + if($method==='declining_balance')return round($base*(2/$life),2); + return round($base/$life,2); + } + + private function validateDraft(array $data) + { + foreach(array('nama_asset','tanggal_perolehan','category_id','lokasi_asset_id','asset_account_id','accumulated_depreciation_account_id','depreciation_expense_account_id','masa_manfaat','idempotency_key')as$key)if(empty($data[$key]))throw new BusinessException('Data '.$key.' wajib diisi.'); + $date=DateTime::createFromFormat('Y-m-d',$data['tanggal_perolehan']);if(!$date||$date->format('Y-m-d')!==$data['tanggal_perolehan'])throw new BusinessException('Tanggal perolehan tidak valid.'); + if(!in_array($data['source_type'],array('manual','warehouse','purchase','opening','donation','other'),true))throw new BusinessException('Sumber perolehan tidak valid.'); + if(!in_array($data['source_journal_mode'],array('post_journal','linked_journal','no_journal'),true))throw new BusinessException('Perlakuan jurnal tidak valid.'); + if((float)$data['nilai_residu']<0||(int)$data['masa_manfaat']<1)throw new BusinessException('Nilai residu atau masa manfaat tidak valid.'); + $this->location($data['lokasi_asset_id']); + $category=$this->CI->db->get_where('asset_categories',array('id'=>(int)$data['category_id'],'company_id'=>$this->company(),'is_active'=>1))->row();if(!$category)throw new BusinessException('Kategori aset tidak valid.'); + $this->account($data['asset_account_id'],'Akun aset');$this->account($data['accumulated_depreciation_account_id'],'Akun akumulasi penyusutan');$this->account($data['depreciation_expense_account_id'],'Akun beban penyusutan'); + if($data['source_type']==='warehouse'){ + if(empty($data['item_id'])||empty($data['barcode_id'])||(float)$data['qty']<=0)throw new BusinessException('Barang, barcode, dan qty gudang wajib diisi.'); + }else{ + if((float)$data['nilai_perolehan']<=0)throw new BusinessException('Nilai perolehan harus lebih dari nol.'); + if((float)$data['nilai_residu']>(float)$data['nilai_perolehan'])throw new BusinessException('Nilai residu tidak boleh melebihi nilai perolehan.'); + if($data['source_journal_mode']==='post_journal')$this->account($data['account_kredit_id'],'Akun lawan perolehan'); + if($data['source_journal_mode']==='linked_journal'){ + $journal=$this->CI->db->get_where('journals',array('id'=>(int)$data['source_journal_id'],'company_id'=>$this->company(),'status'=>'posted'))->row();if(!$journal)throw new BusinessException('Jurnal sumber tidak ditemukan atau belum posted.'); + if($this->CI->db->where('source_journal_id',(int)$journal->id)->where('workflow_status','capitalized')->count_all_results('assets')>0)throw new BusinessException('Jurnal sumber sudah digunakan oleh aset lain.'); + } + } + } + + private function warehouseValuation(array $data) + { + $barcode=$this->CI->db->query("SELECT b.*,i.company_id,i.status item_status,i.harga_beli FROM item_barcodes b JOIN items i ON i.id=b.item_id WHERE b.id=? AND b.item_id=? FOR UPDATE",array((int)$data['barcode_id'],(int)$data['item_id']))->row(); + $qty=(float)$data['qty']; + if(!$barcode||(int)$barcode->company_id!==$this->company()||$barcode->item_status!=='active'||$barcode->status!=='available'||!$barcode->warehouse_id)throw new BusinessException('Barang/barcode gudang tidak tersedia.'); + if((int)$barcode->warehouse_id!==(int)$data['warehouse_id'])throw new BusinessException('Gudang barang tidak sesuai dengan barcode yang dipilih.'); + $available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)$barcode->return_reserved_qty; + if($qty<=0||$qty>$available+.0001)throw new BusinessException('Qty aset melebihi stok barcode yang tersedia.'); + $balance=$this->CI->db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty,COALESCE(SUM(IF(direction='in',value,-value)),0) value FROM inventory_ledger WHERE item_id=? AND warehouse_id=?",array((int)$barcode->item_id,(int)$barcode->warehouse_id))->row(); + $unitCost=(float)$balance->qty>0?(float)$balance->value/(float)$balance->qty:(float)$barcode->harga_beli; + return array('unit_cost'=>round($unitCost,4),'cost'=>round($unitCost*$qty,2)); + } + + public function createDraft(array $data,$userId) + { + return$this->transaction(function()use($data,$userId){ + $this->validateDraft($data); + $key=preg_replace('/[^A-Za-z0-9_.-]/','',trim($data['idempotency_key']));if($key==='')throw new BusinessException('Kunci transaksi wajib tersedia.'); + $existing=$this->CI->db->get_where('assets',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id; + $number=$this->CI->numberingservice->next('fixed_asset',$data['tanggal_perolehan']); + $valuation=$data['source_type']==='warehouse'?$this->warehouseValuation($data):null;$cost=$valuation?$valuation['cost']:round((float)$data['nilai_perolehan'],2);$unitCost=$valuation?$valuation['unit_cost']:$cost;$residual=round((float)$data['nilai_residu'],2);if($residual>$cost)throw new BusinessException('Nilai residu tidak boleh melebihi nilai perolehan.');$life=(int)$data['masa_manfaat'];$method=$data['depreciation_method']==='declining_balance'?'declining_balance':'straight_line'; + $row=array( + 'company_id'=>$this->company(),'document_no'=>$number,'kode_asset'=>$number,'nama_asset'=>trim($data['nama_asset']),'category_id'=>(int)$data['category_id'],'responsible_employee_id'=>!empty($data['responsible_employee_id'])?(int)$data['responsible_employee_id']:null,'source_type'=>$data['source_type'],'source_id'=>!empty($data['source_id'])?(int)$data['source_id']:null,'source_reference_no'=>trim((string)$data['source_reference_no'])?:null,'supplier_id'=>!empty($data['supplier_id'])?(int)$data['supplier_id']:null,'source_journal_mode'=>$data['source_journal_mode'],'source_journal_id'=>!empty($data['source_journal_id'])?(int)$data['source_journal_id']:null,'item_id'=>!empty($data['item_id'])?(int)$data['item_id']:null,'barcode_id'=>!empty($data['barcode_id'])?(int)$data['barcode_id']:null,'warehouse_id'=>!empty($data['warehouse_id'])?(int)$data['warehouse_id']:null,'bin_id'=>!empty($data['bin_id'])?(int)$data['bin_id']:null,'qty'=>$data['source_type']==='warehouse'?(float)$data['qty']:1,'harga_per_unit'=>$unitCost,'nilai_perolehan'=>$cost,'tanggal_perolehan'=>$data['tanggal_perolehan'],'capitalization_date'=>!empty($data['capitalization_date'])?$data['capitalization_date']:$data['tanggal_perolehan'],'masa_manfaat'=>$life,'nilai_residu'=>$residual,'penyusutan_per_bulan'=>$this->calculateMonthly($cost,$residual,$life,$method),'akumulasi_penyusutan'=>0,'nilai_buku'=>$cost,'sumber'=>$data['source_type']==='warehouse'?'gudang':'pembelian','account_debit_id'=>(int)$data['asset_account_id'],'account_kredit_id'=>!empty($data['account_kredit_id'])?(int)$data['account_kredit_id']:null,'asset_account_id'=>(int)$data['asset_account_id'],'accumulated_depreciation_account_id'=>(int)$data['accumulated_depreciation_account_id'],'depreciation_expense_account_id'=>(int)$data['depreciation_expense_account_id'],'lokasi_asset_id'=>(int)$data['lokasi_asset_id'],'department_id'=>!empty($data['department_id'])?(int)$data['department_id']:null,'project_id'=>!empty($data['project_id'])?(int)$data['project_id']:null,'cost_center_id'=>!empty($data['cost_center_id'])?(int)$data['cost_center_id']:null,'depreciation_method'=>$method,'metode'=>'garis_lurus','workflow_status'=>'draft','lifecycle_status'=>'draft','qr_code'=>'ASSET:'.$number,'idempotency_key'=>$key,'keterangan'=>trim((string)$data['keterangan']),'created_by'=>(int)$userId,'created_at'=>date('Y-m-d H:i:s'),'version'=>1 + ); + $this->CI->db->insert('assets',$row);$id=(int)$this->CI->db->insert_id();$this->history($id,null,'draft','Dokumen perolehan dibuat.',$userId);$this->CI->auditservice->record('fixed_asset','asset',$id,'create_draft',null,$row,$userId);return$id; + }); + } + + public function updateDraft($id,array$data,$userId) + { + return $this->transaction(function()use($id,$data,$userId){ + $asset=$this->asset($id); + if($asset->workflow_status!=='draft')throw new BusinessException('Hanya draft yang dapat diubah.'); + $this->validateDraft($data); + $before=clone$asset; + $valuation=$data['source_type']==='warehouse'?$this->warehouseValuation($data):null; + $cost=$valuation?$valuation['cost']:round((float)$data['nilai_perolehan'],2); + $unitCost=$valuation?$valuation['unit_cost']:$cost; + $residual=round((float)$data['nilai_residu'],2); + if($residual>$cost)throw new BusinessException('Nilai residu tidak boleh melebihi nilai perolehan.'); + $method=$data['depreciation_method']==='declining_balance'?'declining_balance':'straight_line'; + $update=array( + 'nama_asset'=>trim($data['nama_asset']), + 'category_id'=>(int)$data['category_id'], + 'responsible_employee_id'=>!empty($data['responsible_employee_id'])?(int)$data['responsible_employee_id']:null, + 'source_type'=>$data['source_type'], + 'source_reference_no'=>trim((string)$data['source_reference_no'])?:null, + 'source_journal_mode'=>$data['source_journal_mode'], + 'source_journal_id'=>!empty($data['source_journal_id'])?(int)$data['source_journal_id']:null, + 'item_id'=>!empty($data['item_id'])?(int)$data['item_id']:null, + 'barcode_id'=>!empty($data['barcode_id'])?(int)$data['barcode_id']:null, + 'warehouse_id'=>!empty($data['warehouse_id'])?(int)$data['warehouse_id']:null, + 'bin_id'=>!empty($data['bin_id'])?(int)$data['bin_id']:null, + 'qty'=>$data['source_type']==='warehouse'?(float)$data['qty']:1, + 'nilai_perolehan'=>$cost, + 'harga_per_unit'=>$unitCost, + 'nilai_buku'=>$cost, + 'nilai_residu'=>$residual, + 'tanggal_perolehan'=>$data['tanggal_perolehan'], + 'capitalization_date'=>!empty($data['capitalization_date'])?$data['capitalization_date']:$data['tanggal_perolehan'], + 'masa_manfaat'=>(int)$data['masa_manfaat'], + 'penyusutan_per_bulan'=>$this->calculateMonthly($cost,$residual,$data['masa_manfaat'],$method), + 'depreciation_method'=>$method, + 'asset_account_id'=>(int)$data['asset_account_id'], + 'account_debit_id'=>(int)$data['asset_account_id'], + 'account_kredit_id'=>!empty($data['account_kredit_id'])?(int)$data['account_kredit_id']:null, + 'accumulated_depreciation_account_id'=>(int)$data['accumulated_depreciation_account_id'], + 'depreciation_expense_account_id'=>(int)$data['depreciation_expense_account_id'], + 'lokasi_asset_id'=>(int)$data['lokasi_asset_id'], + 'keterangan'=>trim((string)$data['keterangan']), + 'updated_by'=>(int)$userId, + 'updated_at'=>date('Y-m-d H:i:s'), + 'version'=>(int)$asset->version+1 + ); + $this->CI->db->where(array('id'=>$asset->id,'company_id'=>$this->company(),'version'=>$asset->version))->update('assets',$update); + if($this->CI->db->affected_rows()!==1)throw new BusinessException('Draft telah berubah pada sesi lain. Muat ulang halaman.'); + $this->CI->auditservice->record('fixed_asset','asset',$asset->id,'update_draft',$before,$update,$userId); + return$asset->id; + }); + } + + public function submit($id,$userId) + { + return$this->transaction(function()use($id,$userId){$a=$this->asset($id);if($a->workflow_status!=='draft')throw new BusinessException('Aset tidak berstatus draft.');$attachments=$this->CI->db->where(array('entity_type'=>'asset','entity_id'=>$a->id,'company_id'=>$this->company()))->count_all_results('transaction_attachments');if($attachments<1)throw new BusinessException('Minimal satu dokumen pendukung wajib diunggah sebelum pengajuan.');$this->CI->db->where('id',$a->id)->update('assets',array('workflow_status'=>'submitted','submitted_by'=>(int)$userId,'submitted_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>null,'version'=>(int)$a->version+1));$this->history($a->id,'draft','submitted','Dokumen perolehan diajukan.',$userId);return$a->id;}); + } + + public function approve($id,$userId,$isMaster=false) + { + return$this->transaction(function()use($id,$userId,$isMaster){$a=$this->asset($id);if($a->workflow_status!=='submitted')throw new BusinessException('Aset tidak menunggu persetujuan.');if(!$isMaster&&(int)$a->created_by===(int)$userId)throw new BusinessException('Pembuat dokumen tidak boleh menyetujui dokumennya sendiri.');$this->CI->db->where('id',$a->id)->update('assets',array('workflow_status'=>'approved','approved_by'=>(int)$userId,'approved_at'=>date('Y-m-d H:i:s'),'version'=>(int)$a->version+1));$this->history($a->id,'submitted','approved','Dokumen perolehan disetujui.',$userId);return$a->id;}); + } + + public function reject($id,$reason,$userId,$isMaster=false) + { + return$this->transaction(function()use($id,$reason,$userId,$isMaster){$a=$this->asset($id);if($a->workflow_status!=='submitted')throw new BusinessException('Aset tidak menunggu persetujuan.');if(!$isMaster&&(int)$a->created_by===(int)$userId)throw new BusinessException('Pembuat dokumen tidak boleh menolak dokumennya sendiri.');if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');$this->CI->db->where('id',$a->id)->update('assets',array('workflow_status'=>'rejected','rejected_by'=>(int)$userId,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>trim($reason),'version'=>(int)$a->version+1));$this->history($a->id,'submitted','rejected',$reason,$userId);return$a->id;}); + } + + public function reopenRejected($id,$userId) + { + return$this->transaction(function()use($id,$userId){$a=$this->asset($id);if($a->workflow_status!=='rejected')throw new BusinessException('Dokumen tidak berstatus ditolak.');if((int)$a->created_by!==(int)$userId&&$this->CI->session->userdata('role')!=='Admin')throw new BusinessException('Hanya pembuat atau Master Admin yang dapat membuka kembali draft.');$this->CI->db->where('id',$a->id)->update('assets',array('workflow_status'=>'draft','version'=>(int)$a->version+1));$this->history($a->id,'rejected','draft','Dibuka kembali untuk diperbaiki.',$userId);return$a->id;}); + } + + public function capitalize($id,$date,$userId) + { + return$this->transaction(function()use($id,$date,$userId){$a=$this->asset($id);if($a->workflow_status!=='approved')throw new BusinessException('Aset harus disetujui sebelum kapitalisasi.');$date=$date?:$a->capitalization_date;if(!$date)throw new BusinessException('Tanggal kapitalisasi wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$journalId=null;$cost=(float)$a->nilai_perolehan;$unitCost=(float)$a->harga_per_unit;$barcode=null; + if($a->source_type==='warehouse'){ + $this->CI->fiscalperiodservice->assertOpen($date); + $barcode=$this->CI->db->query("SELECT b.*,i.company_id,i.status item_status,i.kode_detail,i.nama_barang,k.tracking_type 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.id=? AND b.item_id=? FOR UPDATE",array((int)$a->barcode_id,(int)$a->item_id))->row(); + if(!$barcode||(int)$barcode->company_id!==$this->company()||$barcode->item_status!=='active'||$barcode->status!=='available'||!$barcode->warehouse_id||(float)$barcode->reserved_qty>0||(float)$barcode->return_reserved_qty>0)throw new BusinessException('Barang/barcode gudang tidak lagi tersedia.'); + $qty=(float)$a->qty;if(strtoupper((string)$barcode->tracking_type)==='UNIT'&&abs($qty-1)>.0001)throw new BusinessException('Barang UNIT wajib dikapitalisasi satu unit per aset.');if($qty<=0||$qty>(float)$barcode->qty_sisa)throw new BusinessException('Qty kapitalisasi melebihi saldo barcode.'); + $balance=$this->CI->db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty,COALESCE(SUM(IF(direction='in',value,-value)),0) value FROM inventory_ledger WHERE item_id=? AND warehouse_id=? FOR UPDATE",array($a->item_id,$barcode->warehouse_id))->row();if((float)$balance->qty<$qty)throw new BusinessException('Saldo inventory ledger tidak mencukupi.'); + $unitCost=(float)$balance->qty>0?round((float)$balance->value/(float)$balance->qty,4):0;if($unitCost<=0)throw new BusinessException('Nilai biaya inventory ledger tidak tersedia.');$cost=round($unitCost*$qty,2);if((float)$a->nilai_residu>$cost)throw new BusinessException('Nilai residu melebihi nilai perolehan dari ledger.'); + $inventory=$this->account($this->CI->accountmappingservice->get('inventory'),'Akun persediaan');$assetAccount=$this->account($a->asset_account_id,'Akun aset'); + $description='[AUTO] Kapitalisasi persediaan menjadi aset | Dokumen '.$a->document_no.' | Aset '.$a->kode_asset.' - '.$a->nama_asset.' | Barang '.$barcode->kode_detail.' - '.$barcode->nama_barang.' | Barcode '.$barcode->barcode.' | Qty '.number_format($qty,4,',','.').' | Biaya ledger Rp '.number_format($cost,2,',','.').' | Tanggal '.$date.' | Alasan '.($a->keterangan?:'-'); + $journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'CAP-'.$a->document_no.'-V'.$a->version,'keterangan'=>$description,'ref_type'=>'asset_capitalization','ref_id'=>$a->id,'created_by'=>(int)$userId),array(array('account_id'=>$assetAccount->id,'debit'=>$cost,'kredit'=>0),array('account_id'=>$inventory->id,'debit'=>0,'kredit'=>$cost)),false,false); + $ledgerKey='ASSET-CAP-'.$a->id.'-V'.$a->version;$this->CI->db->insert('inventory_ledger',array('item_id'=>$a->item_id,'warehouse_id'=>$barcode->warehouse_id,'bin_id'=>$barcode->bin_id?:null,'barcode_id'=>$barcode->id,'batch_id'=>$barcode->batch_id?:null,'movement_date'=>$date,'direction'=>'out','qty'=>$qty,'unit_cost'=>$unitCost,'value'=>$cost,'document_type'=>'asset_capitalization','document_id'=>$a->id,'document_line_id'=>$a->id,'idempotency_key'=>$ledgerKey,'created_at'=>date('Y-m-d H:i:s'))); + $this->CI->db->insert('stock_logs',array('company_id'=>$this->company(),'item_id'=>$a->item_id,'warehouse_id'=>$barcode->warehouse_id,'barcode_id'=>$barcode->id,'qty'=>$qty,'tipe'=>'keluar','unit_cost'=>$unitCost,'keterangan'=>$description,'ref_type'=>'asset_capitalization','ref_id'=>$a->id,'idempotency_key'=>$ledgerKey)); + $this->CI->db->insert('item_movements',array('item_id'=>$a->item_id,'barcode_id'=>$barcode->id,'qty'=>$qty,'from_type'=>'warehouse','from_id'=>$barcode->warehouse_id,'to_type'=>'asset','to_id'=>$a->id,'movement_type'=>'installation','notes'=>$description,'created_at'=>date('Y-m-d H:i:s'))); + $remaining=round((float)$barcode->qty_sisa-$qty,4);$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('qty_sisa'=>$remaining,'status'=>$remaining<=0?'installed':'available','version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode berubah saat kapitalisasi.');$this->synchronizeItem($a->item_id); + }else{ + if($a->source_journal_mode==='post_journal'){ + $this->CI->fiscalperiodservice->assertOpen($date);$assetAccount=$this->account($a->asset_account_id,'Akun aset');$counter=$this->account($a->account_kredit_id,'Akun lawan perolehan');$description='[AUTO] Kapitalisasi aset manual | Dokumen '.$a->document_no.' | Aset '.$a->kode_asset.' - '.$a->nama_asset.' | Sumber '.strtoupper($a->source_type).' | Referensi '.($a->source_reference_no?:'-').' | Nilai Rp '.number_format($cost,2,',','.').' | Tanggal '.$date.' | Alasan '.($a->keterangan?:'-');$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'CAP-'.$a->document_no.'-V'.$a->version,'keterangan'=>$description,'ref_type'=>'asset_capitalization','ref_id'=>$a->id,'created_by'=>(int)$userId),array(array('account_id'=>$assetAccount->id,'debit'=>$cost,'kredit'=>0),array('account_id'=>$counter->id,'debit'=>0,'kredit'=>$cost)),false,false); + }elseif($a->source_journal_mode==='linked_journal'){$source=$this->CI->db->get_where('journals',array('id'=>$a->source_journal_id,'company_id'=>$this->company(),'status'=>'posted'))->row();if(!$source)throw new BusinessException('Jurnal sumber tidak lagi valid.');$journalId=(int)$source->id;} + } + $monthly=$this->calculateMonthly($cost,$a->nilai_residu,$a->masa_manfaat,$a->depreciation_method);$update=array('workflow_status'=>'capitalized','lifecycle_status'=>'active','capitalization_date'=>$date,'warehouse_id'=>$barcode?$barcode->warehouse_id:$a->warehouse_id,'bin_id'=>$barcode?$barcode->bin_id:$a->bin_id,'harga_per_unit'=>$a->source_type==='warehouse'?$unitCost:$a->harga_per_unit,'nilai_perolehan'=>$cost,'nilai_buku'=>$cost,'penyusutan_per_bulan'=>$monthly,'acquisition_journal_id'=>$journalId,'capitalized_by'=>(int)$userId,'capitalized_at'=>date('Y-m-d H:i:s'),'version'=>(int)$a->version+1);$this->CI->db->where('id',$a->id)->update('assets',$update);$fresh=(object)array_merge((array)$a,$update);$eventJournal=$a->source_journal_mode==='linked_journal'?null:$journalId;$this->event($fresh,'capitalization',$date,$cost,0,$cost,'Kapitalisasi dokumen '.$a->document_no.': '.($a->keterangan?:'-'),$eventJournal,'ASSET-CAP-EVENT-'.$a->id.'-V'.$a->version,$userId,null,$a->lokasi_asset_id,null,$a->source_type,$a->source_id);$this->history($a->id,'approved','capitalized','Aset dikapitalisasi dan aktif.',$userId);return$a->id; + }); + } + + private function synchronizeItem($itemId) + { + $qty=(float)$this->CI->db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty FROM inventory_ledger WHERE item_id=?",array((int)$itemId))->row()->qty;$this->CI->db->where('id',(int)$itemId)->update('items',array('stok'=>$qty)); + } + + public function depreciate($id,$through,$userId,$catchup=true) + { + return$this->transaction(function()use($id,$through,$userId,$catchup){$asset=$this->asset($id);if($asset->workflow_status!=='capitalized'||$asset->lifecycle_status!=='active')throw new BusinessException('Hanya aset aktif yang dapat disusutkan.');$end=date('Y-m',strtotime($through));$last=$this->CI->db->where(array('asset_id'=>$asset->id,'company_id'=>$this->company(),'status'=>'posted'))->order_by('period','DESC')->limit(1)->get('asset_depreciation_schedule')->row();$start=$last?date('Y-m',strtotime($last->period.'-01 +1 month')):date('Y-m',strtotime($asset->capitalization_date.' +1 month'));$period=$catchup?$start:$end;$posted=array();while($period<=$end){if($this->CI->db->get_where('asset_depreciation_schedule',array('asset_id'=>$asset->id,'period'=>$period))->row()){$period=date('Y-m',strtotime($period.'-01 +1 month'));continue;}$date=date('Y-m-t',strtotime($period.'-01'));$this->CI->fiscalperiodservice->assertOpen($date);$asset=$this->asset($id);$before=(float)$asset->nilai_buku;$maximum=max(0,$before-(float)$asset->nilai_residu);if($maximum<=.001)break;$calculated=$asset->depreciation_method==='declining_balance'?round($before*(2/max(1,(int)$asset->masa_manfaat)),2):(float)$asset->penyusutan_per_bulan;$amount=min($calculated,$maximum);$acc=$this->account($asset->accumulated_depreciation_account_id,'Akun akumulasi penyusutan');$expense=$this->account($asset->depreciation_expense_account_id,'Akun beban penyusutan');$this->CI->db->insert('asset_depreciation_schedule',array('company_id'=>$this->company(),'asset_id'=>$asset->id,'period'=>$period,'due_date'=>$date,'opening_book_value'=>$before,'depreciation_amount'=>$amount,'closing_book_value'=>$before-$amount,'status'=>'scheduled'));$scheduleId=(int)$this->CI->db->insert_id();$description='[AUTO] Penyusutan aset | '.$asset->kode_asset.' - '.$asset->nama_asset.' | Periode '.$period.' | Metode '.$asset->depreciation_method.' | Nilai Rp '.number_format($amount,2,',','.').' | Nilai buku sebelum Rp '.number_format($before,2,',','.').' | Nilai buku sesudah Rp '.number_format($before-$amount,2,',','.');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'DEP-'.str_replace('-','',$period).'-'.$asset->id,'keterangan'=>$description,'ref_type'=>'asset_depreciation','ref_id'=>$scheduleId,'created_by'=>(int)$userId),array(array('account_id'=>$expense->id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$acc->id,'debit'=>0,'kredit'=>$amount)),false,true);$after=$before-$amount;$event=$this->event($asset,'depreciation',$date,$amount,$before,$after,'Penyusutan periode '.$period.' metode '.$asset->depreciation_method,$journal,'ASSET-DEP-'.$asset->id.'-'.$period,$userId);$this->CI->db->where('id',$scheduleId)->update('asset_depreciation_schedule',array('status'=>'posted','journal_id'=>$journal,'asset_event_id'=>$event,'posted_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$asset->id)->update('assets',array('akumulasi_penyusutan'=>(float)$asset->akumulasi_penyusutan+$amount,'nilai_buku'=>$after,'lifecycle_status'=>$after<=(float)$asset->nilai_residu+.001?'fully_depreciated':'active','version'=>(int)$asset->version+1));$posted[]=$period;$period=date('Y-m',strtotime($period.'-01 +1 month'));}return$posted;}); + } + + public function mutate($id,$type,$date,$amount,$accountId,$notes,$userId) + { + return$this->transaction(function()use($id,$type,$date,$amount,$accountId,$notes,$userId){$allowed=array('addition','impairment','revaluation','disposal','lost','damaged');if(!in_array($type,$allowed,true))throw new BusinessException('Jenis mutasi aset tidak valid.');if(trim($notes)==='')throw new BusinessException('Alasan/rincian mutasi wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$asset=$this->asset($id);if($asset->workflow_status!=='capitalized'||in_array($asset->lifecycle_status,array('disposed','lost','damaged'),true))throw new BusinessException('Aset tidak dapat menerima mutasi ini.');$amount=round((float)$amount,2);$before=(float)$asset->nilai_buku;$cost=(float)$asset->nilai_perolehan;$accum=(float)$asset->akumulasi_penyusutan;$assetAccount=$this->account($asset->asset_account_id,'Akun aset');$lines=array();$after=$before;$status=$asset->lifecycle_status;$update=array(); + if($type==='addition'){$counter=$this->account($accountId,'Akun lawan penambahan nilai');if($amount<=0)throw new BusinessException('Nilai penambahan harus lebih dari nol.');$after=$before+$amount;$lines=array(array('account_id'=>$assetAccount->id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$counter->id,'debit'=>0,'kredit'=>$amount));$update['nilai_perolehan']=$cost+$amount;} + elseif($type==='impairment'){$expense=$this->account($accountId,'Akun rugi impairment');$amount=min($amount,max(0,$before-(float)$asset->nilai_residu));if($amount<=0)throw new BusinessException('Nilai impairment tidak tersedia.');$after=$before-$amount;$lines=array(array('account_id'=>$expense->id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$assetAccount->id,'debit'=>0,'kredit'=>$amount));$update['impairment_total']=(float)$asset->impairment_total+$amount;} + elseif($type==='revaluation'){$counter=$this->account($accountId,'Akun surplus revaluasi');if($amount<=0)throw new BusinessException('Nilai revaluasi harus lebih dari nol.');$after=$before+$amount;$lines=array(array('account_id'=>$assetAccount->id,'debit'=>$amount,'kredit'=>0),array('account_id'=>$counter->id,'debit'=>0,'kredit'=>$amount));$update['revaluation_total']=(float)$asset->revaluation_total+$amount;$update['nilai_perolehan']=$cost+$amount;} + else{$proceeds=$type==='disposal'?$amount:0;if($proceeds<0)throw new BusinessException('Nilai hasil pelepasan tidak valid.');$accAccount=$this->account($asset->accumulated_depreciation_account_id,'Akun akumulasi penyusutan');if($proceeds>0){$cash=$this->account($accountId,'Akun penerimaan pelepasan');$lines[]=array('account_id'=>$cash->id,'debit'=>$proceeds,'kredit'=>0);}if($accum>0)$lines[]=array('account_id'=>$accAccount->id,'debit'=>$accum,'kredit'=>0);$gainLoss=$proceeds-$before;if($gainLoss<0)$lines[]=array('account_id'=>$this->account($this->CI->accountmappingservice->get('asset_disposal_loss'),'Akun rugi pelepasan')->id,'debit'=>abs($gainLoss),'kredit'=>0);if($gainLoss>0)$lines[]=array('account_id'=>$this->account($this->CI->accountmappingservice->get('asset_disposal_gain'),'Akun laba pelepasan')->id,'debit'=>0,'kredit'=>$gainLoss);$lines[]=array('account_id'=>$assetAccount->id,'debit'=>0,'kredit'=>$cost);$after=0;$status=$type==='disposal'?'disposed':$type;$update['disposal_date']=$date;$update['disposal_proceeds']=$proceeds;$update['status']=$type==='disposal'?'dijual':'dihapus';} + $key='ASSET-'.strtoupper($type).'-'.$asset->id.'-'.sha1($date.'|'.$amount.'|'.$notes.'|'.$asset->version);$description='[AUTO] '.strtoupper($type).' aset | '.$asset->kode_asset.' - '.$asset->nama_asset.' | Dokumen '.$asset->document_no.' | Tanggal '.$date.' | Nilai Rp '.number_format($amount,2,',','.').' | Nilai buku sebelum Rp '.number_format($before,2,',','.').' | Nilai buku sesudah Rp '.number_format($after,2,',','.').' | Alasan '.$notes;$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'AST-'.strtoupper(substr($type,0,3)).'-'.$asset->id.'-'.date('YmdHis'),'keterangan'=>$description,'ref_type'=>'asset_'.$type,'ref_id'=>$asset->id,'created_by'=>(int)$userId),$lines,false,false);$this->event($asset,$type,$date,$amount,$before,$after,$notes,$journal,$key,$userId);$update=array_merge($update,array('nilai_buku'=>$after,'lifecycle_status'=>$status,'penyusutan_per_bulan'=>$this->calculateMonthly(isset($update['nilai_perolehan'])?$update['nilai_perolehan']:$cost,$asset->nilai_residu,$asset->masa_manfaat,$asset->depreciation_method),'version'=>(int)$asset->version+1));$this->CI->db->where('id',$asset->id)->update('assets',$update);return$journal;}); + } + + public function transfer($id,$date,$toLocation,$notes,$userId) + { + return$this->transaction(function()use($id,$date,$toLocation,$notes,$userId){$asset=$this->asset($id);if($asset->workflow_status!=='capitalized'||!in_array($asset->lifecycle_status,array('active','fully_depreciated'),true))throw new BusinessException('Aset tidak aktif.');$to=$this->location($toLocation);if((int)$asset->lokasi_asset_id===(int)$to->id)throw new BusinessException('Lokasi tujuan sama dengan lokasi saat ini.');if(trim($notes)==='')throw new BusinessException('Alasan transfer wajib diisi.');$key='ASSET-TRANSFER-'.$asset->id.'-'.$date.'-'.$to->id.'-V'.$asset->version;$this->event($asset,'transfer',$date,0,$asset->nilai_buku,$asset->nilai_buku,$notes,null,$key,$userId,$asset->lokasi_asset_id,$to->id);$this->CI->db->where('id',$asset->id)->update('assets',array('lokasi_asset_id'=>$to->id,'version'=>(int)$asset->version+1,'updated_by'=>$userId,'updated_at'=>date('Y-m-d H:i:s')));return$asset->id;}); + } + + public function changeResponsibility($id,$date,$employeeId,$notes,$userId) + { + return$this->transaction(function()use($id,$date,$employeeId,$notes,$userId){$asset=$this->asset($id);$employee=$this->CI->db->get_where('k_employees',array('id'=>(int)$employeeId,'company_id'=>$this->company(),'is_active'=>1))->row();if(!$employee)throw new BusinessException('Penanggung jawab tidak valid.');if(trim($notes)==='')throw new BusinessException('Alasan pergantian penanggung jawab wajib diisi.');$key='ASSET-RESP-'.$asset->id.'-'.$date.'-'.$employee->id.'-V'.$asset->version;$event=$this->event($asset,'responsibility_change',$date,0,$asset->nilai_buku,$asset->nilai_buku,$notes,null,$key,$userId);$this->CI->db->insert('asset_responsibility_history',array('company_id'=>$this->company(),'asset_id'=>$asset->id,'effective_date'=>$date,'from_employee_id'=>$asset->responsible_employee_id?:null,'to_employee_id'=>$employee->id,'notes'=>$notes,'asset_event_id'=>$event,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$asset->id)->update('assets',array('responsible_employee_id'=>$employee->id,'version'=>(int)$asset->version+1));return$asset->id;}); + } + + public function maintenance($id,array$data,$userId) + { + return$this->transaction(function()use($id,$data,$userId){$asset=$this->asset($id);if($asset->workflow_status!=='capitalized')throw new BusinessException('Aset belum dikapitalisasi.');if(empty($data['date'])||trim((string)$data['description'])==='')throw new BusinessException('Tanggal dan deskripsi maintenance wajib diisi.');$key=preg_replace('/[^A-Za-z0-9_.-]/','',trim((string)$data['idempotency_key']));if($key===''||$this->CI->db->get_where('asset_maintenance',array('idempotency_key'=>$key))->row())throw new BusinessException('Kunci maintenance kosong atau sudah dipakai.');$number=$this->CI->numberingservice->next('asset_maintenance',$data['date']);$this->CI->db->insert('asset_maintenance',array('company_id'=>$this->company(),'asset_id'=>$asset->id,'maintenance_no'=>$number,'maintenance_date'=>$data['date'],'vendor'=>trim((string)$data['vendor'])?:null,'description'=>trim($data['description']),'cost'=>round((float)$data['cost'],2),'next_due_date'=>!empty($data['next_due_date'])?$data['next_due_date']:null,'status'=>'posted','idempotency_key'=>$key,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$maintenanceId=(int)$this->CI->db->insert_id();$event=$this->event($asset,'maintenance',$data['date'],$data['cost'],$asset->nilai_buku,$asset->nilai_buku,'Maintenance '.$number.': '.$data['description'],null,'ASSET-MAINT-EVENT-'.$maintenanceId,$userId,null,null,null,'asset_maintenance',$maintenanceId);$this->CI->db->where('id',$maintenanceId)->update('asset_maintenance',array('asset_event_id'=>$event));return$maintenanceId;}); + } + + public function createOpname($locationId,$date,$notes,$key,$userId) + { + return$this->transaction(function()use($locationId,$date,$notes,$key,$userId){$location=$this->location($locationId);$key=preg_replace('/[^A-Za-z0-9_.-]/','',trim($key));if($key===''||$this->CI->db->get_where('asset_opnames',array('idempotency_key'=>$key))->row())throw new BusinessException('Kunci opname kosong atau sudah dipakai.');$no=$this->CI->numberingservice->next('asset_opname',$date);$this->CI->db->insert('asset_opnames',array('company_id'=>$this->company(),'opname_no'=>$no,'opname_date'=>$date,'location_id'=>$location->id,'status'=>'draft','notes'=>trim((string)$notes)?:null,'snapshot_at'=>date('Y-m-d H:i:s'),'idempotency_key'=>$key,'created_by'=>$userId));$id=(int)$this->CI->db->insert_id();$assets=$this->CI->db->where(array('company_id'=>$this->company(),'workflow_status'=>'capitalized','lokasi_asset_id'=>$location->id))->where_in('lifecycle_status',array('active','fully_depreciated'))->get('assets')->result();foreach($assets as$a)$this->CI->db->insert('asset_opname_lines',array('company_id'=>$this->company(),'asset_opname_id'=>$id,'asset_id'=>$a->id,'expected_status'=>$a->lifecycle_status,'actual_status'=>$a->lifecycle_status,'expected_location_id'=>$location->id,'actual_location_id'=>$location->id,'is_found'=>1));return$id;}); + } + + public function saveOpnameLines($id,array$lines,$userId) + { + return$this->transaction(function()use($id,$lines,$userId){$opname=$this->CI->db->query('SELECT * FROM asset_opnames WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$opname||$opname->status!=='draft')throw new BusinessException('Hanya draft opname yang dapat diubah.');foreach($lines as$line){$existing=$this->CI->db->select('ol.*')->from('asset_opname_lines ol')->join('assets a','a.id=ol.asset_id')->where(array('ol.id'=>(int)$line['id'],'ol.asset_opname_id'=>$opname->id,'ol.company_id'=>$this->company(),'a.company_id'=>$this->company()))->get()->row();if(!$existing)throw new BusinessException('Baris opname tidak valid.');$status=in_array($line['actual_status'],array('active','fully_depreciated','lost','damaged'),true)?$line['actual_status']:$existing->expected_status;$this->CI->db->where('id',$existing->id)->update('asset_opname_lines',array('actual_status'=>$status,'actual_location_id'=>!empty($line['actual_location_id'])?(int)$line['actual_location_id']:null,'is_found'=>!empty($line['is_found'])?1:0,'notes'=>trim((string)$line['notes'])?:null,'checked_at'=>date('Y-m-d H:i:s'),'checked_by'=>$userId));}return$opname->id;}); + } + + public function opnameState($id,$action,$reason,$userId,$isMaster=false) + { + return$this->transaction(function()use($id,$action,$reason,$userId,$isMaster){$o=$this->CI->db->query('SELECT * FROM asset_opnames WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$o)throw new BusinessException('Asset opname tidak ditemukan.');if($action==='submit'){if($o->status!=='draft')throw new BusinessException('Opname tidak berstatus draft.');if(!$this->CI->db->where(array('entity_type'=>'asset_opname','entity_id'=>$o->id,'company_id'=>$this->company()))->count_all_results('transaction_attachments'))throw new BusinessException('Bukti opname wajib diunggah sebelum diajukan.');$update=array('status'=>'submitted','submitted_by'=>$userId,'submitted_at'=>date('Y-m-d H:i:s'));}elseif($action==='approve'){if($o->status!=='submitted')throw new BusinessException('Opname tidak menunggu persetujuan.');if(!$isMaster&&(int)$o->created_by===(int)$userId)throw new BusinessException('Pembuat opname tidak boleh menyetujui sendiri.');$update=array('status'=>'approved','approved_by'=>$userId,'approved_at'=>date('Y-m-d H:i:s'));}elseif($action==='reject'){if($o->status!=='submitted'||trim($reason)==='')throw new BusinessException('Status atau alasan penolakan tidak valid.');$update=array('status'=>'rejected','rejected_by'=>$userId,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>trim($reason));}elseif($action==='post'){if($o->status!=='approved')throw new BusinessException('Opname harus disetujui sebelum posting.');$lines=$this->CI->db->where(array('asset_opname_id'=>$o->id,'company_id'=>$this->company()))->get('asset_opname_lines')->result();foreach($lines as$line){$asset=$this->asset($line->asset_id);$notes='Opname '.$o->opname_no.' | '.($line->is_found?'Ditemukan':'Tidak ditemukan').' | Status fisik '.$line->actual_status.' | '.($line->notes?:'-');$to=$line->actual_location_id?:$asset->lokasi_asset_id;$this->event($asset,'opname_adjustment',$o->opname_date,0,$asset->nilai_buku,$asset->nilai_buku,$notes,null,'ASSET-OPNAME-'.$o->id.'-'.$line->id,$userId,$asset->lokasi_asset_id,$to,null,'asset_opname',$o->id);if($line->is_found&&$to)$this->CI->db->where('id',$asset->id)->update('assets',array('lokasi_asset_id'=>$to,'version'=>(int)$asset->version+1));} $update=array('status'=>'posted','posted_by'=>$userId,'posted_at'=>date('Y-m-d H:i:s'));}else throw new BusinessException('Aksi opname tidak valid.');$this->CI->db->where('id',$o->id)->update('asset_opnames',$update);return$o->id;}); + } + + public function reverseEvent($eventId,$date,$reason,$userId) + { + return$this->transaction(function()use($eventId,$date,$reason,$userId){if(trim($reason)==='')throw new BusinessException('Alasan reversal wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$event=$this->CI->db->query('SELECT e.* FROM asset_events e WHERE e.id=? AND e.company_id=? FOR UPDATE',array((int)$eventId,$this->company()))->row();if(!$event||$event->event_type==='reversal')throw new BusinessException('Event aset tidak valid untuk reversal.');if($this->CI->db->get_where('asset_events',array('reversal_of_event_id'=>$event->id))->row())throw new BusinessException('Event aset sudah pernah direversal.');$latest=$this->CI->db->where(array('asset_id'=>$event->asset_id,'company_id'=>$this->company()))->where('event_type !=','reversal')->order_by('event_date','DESC')->order_by('id','DESC')->limit(1)->get('asset_events')->row();if(!$latest||(int)$latest->id!==(int)$event->id)throw new BusinessException('Hanya event terakhir aset yang dapat direversal.');$asset=$this->asset($event->asset_id);$journal=null;if($event->journal_id){$original=$this->CI->db->get_where('journals',array('id'=>$event->journal_id,'company_id'=>$this->company()))->row();$details=$this->CI->db->where('journal_id',$event->journal_id)->get('journal_details')->result();if(!$original||!$details)throw new BusinessException('Jurnal event tidak ditemukan.');$lines=array();foreach($details as$d)$lines[]=array('account_id'=>$d->account_id,'debit'=>$d->kredit,'kredit'=>$d->debit);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'REV-AST-'.$event->id.'-'.date('YmdHis'),'keterangan'=>'[AUTO] Reversal event '.$event->event_no.' aset '.$asset->kode_asset.' - '.$asset->nama_asset.' | Alasan '.$reason,'ref_type'=>'asset_event_reversal','ref_id'=>$event->id,'reversal_of'=>$original->id,'reversal_reason'=>$reason,'created_by'=>$userId),$lines,false,true);$this->CI->db->where('id',$original->id)->update('journals',array('is_reversed'=>1,'status'=>'reversed'));} + $update=array('nilai_buku'=>$event->book_value_before,'version'=>(int)$asset->version+1); + if($event->event_type==='depreciation'){$update['akumulasi_penyusutan']=max(0,(float)$asset->akumulasi_penyusutan-(float)$event->amount);$update['lifecycle_status']='active';$this->CI->db->where('asset_event_id',$event->id)->update('asset_depreciation_schedule',array('status'=>'reversed','reversal_journal_id'=>$journal,'reversed_at'=>date('Y-m-d H:i:s')));} + elseif($event->event_type==='transfer')$update['lokasi_asset_id']=$event->from_location_id; + elseif($event->event_type==='responsibility_change'){$h=$this->CI->db->get_where('asset_responsibility_history',array('asset_event_id'=>$event->id))->row();if($h)$update['responsible_employee_id']=$h->from_employee_id;} + elseif($event->event_type==='addition')$update['nilai_perolehan']=max(0,(float)$asset->nilai_perolehan-(float)$event->amount); + elseif($event->event_type==='revaluation'){$update['nilai_perolehan']=max(0,(float)$asset->nilai_perolehan-(float)$event->amount);$update['revaluation_total']=max(0,(float)$asset->revaluation_total-(float)$event->amount);} + elseif($event->event_type==='impairment')$update['impairment_total']=max(0,(float)$asset->impairment_total-(float)$event->amount); + elseif(in_array($event->event_type,array('disposal','lost','damaged'),true)){$update['lifecycle_status']='active';$update['status']='aktif';$update['disposal_date']=null;$update['disposal_proceeds']=0;} + elseif($event->event_type==='capitalization'){$update['workflow_status']='approved';$update['lifecycle_status']='draft';$update['acquisition_journal_id']=null;if($asset->source_type==='warehouse'){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$asset->barcode_id))->row();if(!$barcode)throw new BusinessException('Barcode sumber aset tidak ditemukan.');$key='ASSET-CAP-REV-'.$asset->id.'-V'.$asset->version;$this->CI->db->insert('inventory_ledger',array('item_id'=>$asset->item_id,'warehouse_id'=>$asset->warehouse_id,'bin_id'=>$asset->bin_id?:null,'barcode_id'=>$barcode->id,'batch_id'=>$barcode->batch_id?:null,'movement_date'=>$date,'direction'=>'in','qty'=>$asset->qty,'unit_cost'=>$asset->harga_per_unit,'value'=>$asset->nilai_perolehan,'document_type'=>'asset_capitalization_reversal','document_id'=>$asset->id,'document_line_id'=>$event->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('stock_logs',array('company_id'=>$this->company(),'item_id'=>$asset->item_id,'warehouse_id'=>$asset->warehouse_id,'barcode_id'=>$barcode->id,'qty'=>$asset->qty,'tipe'=>'masuk','unit_cost'=>$asset->harga_per_unit,'keterangan'=>'[AUTO] Reversal kapitalisasi aset '.$asset->kode_asset.' | '.$reason,'ref_type'=>'asset_capitalization_reversal','ref_id'=>$asset->id,'idempotency_key'=>$key));$this->CI->db->insert('item_movements',array('item_id'=>$asset->item_id,'barcode_id'=>$barcode->id,'qty'=>$asset->qty,'from_type'=>'asset','from_id'=>$asset->id,'to_type'=>'warehouse','to_id'=>$asset->warehouse_id,'movement_type'=>'uninstallation','notes'=>'Reversal kapitalisasi: '.$reason,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>(float)$barcode->qty_sisa+(float)$asset->qty,'status'=>'available','version'=>(int)$barcode->version+1));$this->synchronizeItem($asset->item_id);}} + $this->CI->db->where('id',$asset->id)->update('assets',$update);$this->event($asset,'reversal',$date,$event->amount,$event->book_value_after,$event->book_value_before,'Reversal '.$event->event_no.': '.$reason,$journal,'ASSET-REV-'.$event->id,$userId,$event->to_location_id,$event->from_location_id,$event->id,'asset_event',$event->id);return$journal; + }); + } + + public function saveCategory(array$data,$userId) + { + $id=(int)($data['id']??0);$code=strtoupper(preg_replace('/[^A-Z0-9_-]/i','',trim($data['code'])));$name=trim($data['name']);if($code===''||$name==='')throw new BusinessException('Kode dan nama kategori wajib diisi.');$row=array('company_id'=>$this->company(),'code'=>$code,'name'=>$name,'default_useful_life_months'=>max(1,(int)$data['life']),'default_residual_percent'=>max(0,(float)$data['residual_percent']),'depreciation_method'=>$data['method']==='declining_balance'?'declining_balance':'straight_line','asset_account_id'=>(int)$data['asset_account_id']?:null,'accumulated_depreciation_account_id'=>(int)$data['accumulated_account_id']?:null,'depreciation_expense_account_id'=>(int)$data['expense_account_id']?:null,'is_active'=>!empty($data['is_active'])?1:0,'updated_at'=>date('Y-m-d H:i:s'),'updated_by'=>$userId);if($id){$this->CI->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('asset_categories',$row);if(!$this->CI->db->affected_rows()&&!$this->CI->db->get_where('asset_categories',array('id'=>$id,'company_id'=>$this->company()))->row())throw new BusinessException('Kategori tidak ditemukan.');return$id;}$this->CI->db->insert('asset_categories',$row);return(int)$this->CI->db->insert_id(); + } + + public function saveLocation(array$data,$userId) + { + $id=(int)($data['id']??0);$code=strtoupper(preg_replace('/[^A-Z0-9_-]/i','',trim($data['code'])));$name=trim($data['name']);if($code===''||$name==='')throw new BusinessException('Kode dan nama lokasi wajib diisi.');$row=array('company_id'=>$this->company(),'code'=>$code,'nama'=>$name,'address'=>trim((string)$data['address'])?:null,'responsible_employee_id'=>!empty($data['responsible_employee_id'])?(int)$data['responsible_employee_id']:null,'is_active'=>!empty($data['is_active'])?1:0,'updated_at'=>date('Y-m-d H:i:s'),'updated_by'=>$userId);if($id){$this->CI->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('lokasi_asset',$row);if(!$this->CI->db->affected_rows()&&!$this->CI->db->get_where('lokasi_asset',array('id'=>$id,'company_id'=>$this->company()))->row())throw new BusinessException('Lokasi tidak ditemukan.');return$id;}$this->CI->db->insert('lokasi_asset',$row);return(int)$this->CI->db->insert_id(); + } +} diff --git a/application/libraries/FormulaService.php b/application/libraries/FormulaService.php new file mode 100644 index 0000000..af2939d --- /dev/null +++ b/application/libraries/FormulaService.php @@ -0,0 +1,6 @@ +1,'-'=>1,'*'=>2,'/'=>2);foreach($tokens as$t){if(is_numeric($t)||preg_match('/^[A-Za-z_]/',$t))$out[]=$t;elseif($t==='(')$ops[]=$t;elseif($t===')'){while($ops&&end($ops)!=='(')$out[]=array_pop($ops);if(!$ops)throw new BusinessException('Kurung formula tidak seimbang.');array_pop($ops);}else{while($ops&&end($ops)!=='('&&$prec[end($ops)]>=$prec[$t])$out[]=array_pop($ops);$ops[]=$t;}}while($ops){$x=array_pop($ops);if($x==='(')throw new BusinessException('Kurung formula tidak seimbang.');$out[]=$x;}$stack=array();foreach($out as$t){if(isset($prec[$t])){$b=array_pop($stack);$a=array_pop($stack);if($t==='/'&&abs($b)<.0000001)throw new BusinessException('Pembagian nol pada formula.');$stack[]=$t==='+'?$a+$b:($t==='-'?$a-$b:($t==='*'?$a*$b:$a/$b));}else$stack[]=is_numeric($t)?(float)$t:(isset($vars[$t])?(float)$vars[$t]:0);}if(count($stack)!==1)throw new BusinessException('Formula tidak valid.');return round($stack[0],2);} +} diff --git a/application/libraries/JobQueueService.php b/application/libraries/JobQueueService.php new file mode 100644 index 0000000..b2279dc --- /dev/null +++ b/application/libraries/JobQueueService.php @@ -0,0 +1,11 @@ +CI=&get_instance();} + public function enqueue($type,array$payload,$key,$queue='default',$priority=5,$max=3,$companyId=null){$row=$this->CI->db->get_where('app_jobs',array('idempotency_key'=>$key))->row();if($row)return(int)$row->id;$this->CI->db->insert('app_jobs',array('company_id'=>$companyId,'queue_name'=>$queue,'job_type'=>$type,'payload'=>json_encode($payload),'priority'=>$priority,'max_attempts'=>$max,'available_at'=>date('Y-m-d H:i:s'),'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));return(int)$this->CI->db->insert_id();} + public function reserve($worker,$queue='default'){$db=$this->CI->db;$db->trans_begin();try{$job=$db->query("SELECT * FROM app_jobs WHERE queue_name=? AND status IN('queued','retry') AND available_at<=? ORDER BY priority ASC,id ASC LIMIT 1 FOR UPDATE",array($queue,date('Y-m-d H:i:s')))->row();if(!$job){$db->trans_commit();return null;}$db->where('id',$job->id)->where_in('status',array('queued','retry'))->update('app_jobs',array('status'=>'processing','attempts'=>$job->attempts+1,'locked_at'=>date('Y-m-d H:i:s'),'locked_by'=>$worker));if($db->affected_rows()!==1)throw new RuntimeException('Job telah diambil worker lain.');$db->trans_commit();$job->attempts++;return$job;}catch(Throwable$e){$db->trans_rollback();throw$e;}} + public function recoverStale($seconds=900){$this->CI->db->where('status','processing')->where('locked_at <',date('Y-m-d H:i:s',time()-max(60,(int)$seconds)))->update('app_jobs',array('status'=>'retry','available_at'=>date('Y-m-d H:i:s'),'locked_at'=>null,'locked_by'=>null,'last_error'=>'Worker timeout; job dikembalikan ke antrean.'));return$this->CI->db->affected_rows();} + public function complete($id){$this->CI->db->where('id',$id)->update('app_jobs',array('status'=>'completed','completed_at'=>date('Y-m-d H:i:s'),'locked_at'=>null,'locked_by'=>null));} + public function fail($job,Throwable$error){$message=substr($error->getMessage(),0,4000);if((int)$job->attempts>=(int)$job->max_attempts){$this->CI->db->trans_start();$this->CI->db->insert('app_dead_letters',array('job_id'=>$job->id,'company_id'=>$job->company_id,'job_type'=>$job->job_type,'payload'=>$job->payload,'attempts'=>$job->attempts,'last_error'=>$message,'failed_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$job->id)->update('app_jobs',array('status'=>'dead','last_error'=>$message,'locked_at'=>null,'locked_by'=>null));$this->CI->db->trans_complete();return;}$delay=min(3600,pow(2,(int)$job->attempts)*30);$this->CI->db->where('id',$job->id)->update('app_jobs',array('status'=>'retry','last_error'=>$message,'available_at'=>date('Y-m-d H:i:s',time()+$delay),'locked_at'=>null,'locked_by'=>null));} +} diff --git a/application/libraries/JournalDescriptionService.php b/application/libraries/JournalDescriptionService.php new file mode 100644 index 0000000..a2c07a9 --- /dev/null +++ b/application/libraries/JournalDescriptionService.php @@ -0,0 +1,35 @@ +CI=&get_instance();} + public function build(array $header,array $lines) + { + $type=isset($header['ref_type'])?(string)$header['ref_type']:''; + if($type===''||$type==='manual_journal')return trim((string)$header['keterangan']); + $total=0;foreach($lines as$line)$total+=isset($line['debit'])?(float)$line['debit']:0; + $labels=array('payroll'=>'Posting Payroll','payroll_payment'=>'Pembayaran Payroll','invoice'=>'Posting Invoice Penjualan','customer_payment'=>'Penerimaan Pembayaran Pelanggan','credit_note'=>'Credit Note Penjualan','customer_refund'=>'Refund Pelanggan','receivable_writeoff'=>'Penghapusan Piutang','invoice_reversal'=>'Reversal Invoice','journal_reversal'=>'Reversal Jurnal','stock_logs'=>'Mutasi Persediaan','invoice_details'=>'Penjualan Item Invoice','asset_acquisition'=>'Perolehan Aset','asset_depreciation'=>'Penyusutan Aset','asset_disposal'=>'Pelepasan/Penjualan Aset','asset_addition'=>'Penambahan Nilai Aset','asset_impairment'=>'Impairment Aset','asset_revaluation'=>'Revaluasi Aset','asset_lost'=>'Aset Hilang','asset_damaged'=>'Aset Rusak','cash_transaction'=>'Transaksi Kas/Bank','cash_transfer'=>'Transfer Antar Rekening','supplier_invoice'=>'Posting Tagihan Supplier','supplier_payment'=>'Pembayaran Supplier','supplier_opening_balance'=>'Saldo Awal Hutang Supplier','purchase_return'=>'Retur Pembelian','supplier_debit_note'=>'Debit Note Supplier'); + $parts=array('[AUTO] '.(isset($labels[$type])?$labels[$type]:ucwords(str_replace('_',' ',$type)))); + $document=$this->document($type,isset($header['ref_id'])?$header['ref_id']:null); + if(!$document['number']&&!empty($header['no_ref']))$document['number']=$header['no_ref']; + if($type==='customer_payment'&&!$document['party']&&preg_match('/#(\d+)/',(string)$header['keterangan'],$match)){$customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>(int)$match[1]))->row();if($customer)$document['party']=$customer->nama;} + if($type==='supplier_payment'&&!$document['party']&&preg_match('/#(\d+)/',(string)$header['keterangan'],$match)){$supplier=$this->CI->db->select('name')->get_where('suppliers',array('id'=>(int)$match[1]))->row();if($supplier)$document['party']=$supplier->name;} + if($document['number'])$parts[]='Dokumen: '.$document['number']; + if($document['party'])$parts[]='Pihak: '.$document['party']; + $parts[]='Tanggal transaksi: '.$header['tanggal']; + $parts[]='Nilai jurnal: Rp '.number_format($total,2,',','.'); + $parts[]='Sumber: '.$type.'#'.(isset($header['ref_id'])&&$header['ref_id']!==null?$header['ref_id']:'-'); + $original=trim((string)$header['keterangan']);if($original!==''&&stripos($original,'[AUTO]')!==0)$parts[]='Rincian: '.$original; + return implode(' | ',$parts); + } + private function document($type,$id) + { + $result=array('number'=>null,'party'=>null);if(!$id)return$result; + if(in_array($type,array('invoice','invoice_reversal','receivable_writeoff','credit_note'),true)){$row=$this->CI->db->select('invoices.no_invoice,customers.nama')->from('invoices')->join('customers','customers.id=invoices.customer_id','left')->where('invoices.id',(int)$id)->get()->row();if($row)return array('number'=>$row->no_invoice,'party'=>$row->nama);} + if($type==='journal_reversal'){$row=$this->CI->db->select('no_ref')->get_where('journals',array('id'=>(int)$id))->row();if($row)$result['number']=$row->no_ref;} + if($type==='supplier_invoice'){$row=$this->CI->db->select('supplier_invoices.internal_no,supplier_invoices.supplier_invoice_no,suppliers.name')->from('supplier_invoices')->join('suppliers','suppliers.id=supplier_invoices.supplier_id')->where('supplier_invoices.id',(int)$id)->get()->row();if($row)return array('number'=>$row->internal_no.'/'.$row->supplier_invoice_no,'party'=>$row->name);} + if($type==='supplier_opening_balance'){$row=$this->CI->db->select('supplier_code,name')->get_where('suppliers',array('id'=>(int)$id))->row();if($row)return array('number'=>'OPEN-'.$row->supplier_code,'party'=>$row->name);} + return$result; + } +} diff --git a/application/libraries/JournalService.php b/application/libraries/JournalService.php new file mode 100644 index 0000000..1f01bcf --- /dev/null +++ b/application/libraries/JournalService.php @@ -0,0 +1,122 @@ +CI =& get_instance(); + $this->CI->load->model('JournalModel', 'journalModel'); + $this->CI->load->model('AccountModel', 'accountModel'); + $this->CI->load->library('TransactionService'); + $this->CI->load->library('FiscalPeriodService'); + $this->CI->load->library('JournalDescriptionService'); + if ($this->CI->db->table_exists('companies')) $this->CI->load->library('CompanyContext'); + } + + public function create(array $header, array $lines, $manageTransaction = true) + { + $normalized = $this->validateAndNormalize($header, $lines); + $operation = function () use ($normalized) { + $journalId = $this->CI->journalModel->createHeader($normalized['header']); + $details = array(); + foreach ($normalized['lines'] as $line) { + $line['journal_id'] = $journalId; + $details[] = $line; + } + $this->CI->journalModel->addLines($details); + if ($this->CI->db->table_exists('journal_status_histories')) { + $this->CI->journalModel->addStatusHistory( + $journalId, + null, + $normalized['header']['status'], + $normalized['header']['created_by'], + 'Jurnal dibuat' + ); + } + return $journalId; + }; + + return $manageTransaction + ? $this->CI->transactionservice->run($operation) + : $operation(); + } + + public function totals(array $lines) + { + $debit = 0.0; + $credit = 0.0; + foreach ($lines as $line) { + $debit += isset($line['debit']) ? (float) $line['debit'] : 0; + $credit += isset($line['kredit']) ? (float) $line['kredit'] : 0; + } + return array('debit' => round($debit, 2), 'kredit' => round($credit, 2)); + } + + private function validateAndNormalize(array $header, array $lines) + { + foreach (array('tanggal', 'no_ref', 'keterangan', 'created_by') as $required) { + if (!isset($header[$required]) || trim((string) $header[$required]) === '') { + throw new BusinessException('Data header jurnal "' . $required . '" wajib diisi.'); + } + } + + $date = DateTime::createFromFormat('Y-m-d', $header['tanggal']); + if (!$date || $date->format('Y-m-d') !== $header['tanggal']) { + throw new BusinessException('Tanggal jurnal tidak valid.'); + } + if ($this->CI->journalModel->referenceExists($header['no_ref'])) { + throw new BusinessException('Nomor referensi jurnal sudah digunakan.'); + } + + $period = $this->CI->fiscalperiodservice->assertOpen($header['tanggal']); + if (count($lines) < 2) { + throw new BusinessException('Jurnal minimal mempunyai dua baris transaksi.'); + } + + $normalized = array(); + $accountIds = array(); + foreach ($lines as $index => $line) { + $accountId = isset($line['account_id']) ? (int) $line['account_id'] : 0; + $debit = round(isset($line['debit']) ? (float) $line['debit'] : 0, 2); + $credit = round(isset($line['kredit']) ? (float) $line['kredit'] : 0, 2); + + if ($accountId <= 0 || $debit < 0 || $credit < 0 || ($debit > 0 && $credit > 0) || ($debit <= 0 && $credit <= 0)) { + throw new BusinessException('Baris jurnal ke-' . ($index + 1) . ' tidak valid.'); + } + + $detail=array('account_id'=>$accountId,'debit'=>$debit,'kredit'=>$credit); + foreach(array('currency_amount','currency_code','exchange_rate')as$currencyField)if(isset($line[$currencyField])&&$line[$currencyField]!=='')$detail[$currencyField]=$line[$currencyField]; + if($this->CI->db->field_exists('company_id','journal_details'))$detail['company_id']=$this->CI->companycontext->id(); + $normalized[]=$detail; + $accountIds[] = $accountId; + } + + $existingIds = $this->CI->accountModel->existingIds($accountIds); + $missingIds = array_diff(array_unique($accountIds), $existingIds); + if (!empty($missingIds)) { + throw new BusinessException('Terdapat akun jurnal yang tidak ditemukan: ' . implode(', ', $missingIds)); + } + + $totals = $this->totals($normalized); + if ($totals['debit'] <= 0 || abs($totals['debit'] - $totals['kredit']) > 0.009) { + throw new BusinessException('Total debit dan kredit jurnal harus seimbang.'); + } + + $header['keterangan'] = $this->CI->journaldescriptionservice->build($header, $normalized); + + $header['status'] = isset($header['status']) ? $header['status'] : 'draft'; + $header['accounting_period_id'] = (int) $period->id; + $allowedHeader = array_intersect_key($header, array_flip(array( + 'tanggal', 'no_ref', 'keterangan', 'ref_type', 'ref_id', 'created_by', + 'status', 'accounting_period_id', 'posted_at', 'posted_by', 'reversal_of', 'reversal_reason','company_id','transaction_currency','exchange_rate','base_amount' + ))); + if($this->CI->db->field_exists('company_id','journals'))$allowedHeader['company_id']=$this->CI->companycontext->id(); + + return array('header' => $allowedHeader, 'lines' => $normalized, 'totals' => $totals); + } +} diff --git a/application/libraries/JournalWorkflowService.php b/application/libraries/JournalWorkflowService.php new file mode 100644 index 0000000..359b9c9 --- /dev/null +++ b/application/libraries/JournalWorkflowService.php @@ -0,0 +1,170 @@ +CI =& get_instance(); + $this->CI->load->model('JournalModel', 'workflowJournalModel'); + $this->CI->load->library('TransactionService'); + $this->CI->load->library('FiscalPeriodService'); + $this->CI->load->library('NumberingService'); + $this->CI->load->library('PostingService'); + $this->CI->load->library('ApprovalService'); + $this->CI->load->library('AuditService'); + $this->CI->config->load('accounting', true); + } + + public function submit($journalId, $userId) + { + return $this->CI->transactionservice->run(function () use ($journalId, $userId) { + $journal = $this->lockedJournal($journalId, 'draft'); + if ((int)$journal->created_by !== (int)$userId && $this->CI->session->userdata('role') !== 'Admin') throw new BusinessException('Anda hanya dapat mengajukan jurnal yang Anda buat.'); + $this->CI->fiscalperiodservice->assertOpen($journal->tanggal); + $lines=$this->CI->workflowJournalModel->lines($journal->id); $amount=0; + foreach($lines as $line) $amount+=(float)$line['debit']; + $this->CI->approvalservice->create('journal','journal',$journal->id,$amount,$userId,array('journal'=>$journal,'lines'=>$lines)); + $this->CI->workflowJournalModel->updateStatus($journal->id,'submitted',array('submitted_at'=>date('Y-m-d H:i:s'),'submitted_by'=>(int)$userId)); + $this->CI->workflowJournalModel->addStatusHistory($journal->id,'draft','submitted',$userId); + $after=$this->CI->workflowJournalModel->find($journal->id); + $this->CI->auditservice->record('journal','journal',$journal->id,'submit',$journal,$after,$userId); + return $after; + }); + } + + public function approve($journalId, $userId) + { + return $this->CI->transactionservice->run(function () use ($journalId, $userId) { + $journal = $this->lockedJournal($journalId, 'submitted'); + $request=$this->CI->approvalservice->pendingForEntity('journal',$journal->id); + if(!$request) throw new BusinessException('Permintaan approval jurnal tidak ditemukan.'); + $roleId=$this->roleId($userId); + $approval=$this->CI->approvalservice->act($request->id,'approved',$userId,$roleId); + if($approval->status!=='approved') return $journal; + $this->CI->fiscalperiodservice->assertOpen($journal->tanggal); + $now=date('Y-m-d H:i:s'); + $this->CI->workflowJournalModel->updateStatus($journal->id, 'posted', array( + 'approved_at'=>$now,'approved_by'=>(int)$userId,'posted_at'=>$now,'posted_by'=>(int)$userId + )); + $this->CI->workflowJournalModel->addStatusHistory($journal->id, 'submitted', 'approved', $userId); + $this->CI->workflowJournalModel->addStatusHistory($journal->id, 'approved', 'posted', $userId, 'Otomatis diposting setelah approval final.'); + $after=$this->CI->workflowJournalModel->find($journal->id); + $this->CI->auditservice->record('journal','journal',$journal->id,'approve_and_post',$journal,$after,$userId); + return $after; + }); + } + + public function post($journalId, $userId) + { + return $this->CI->transactionservice->run(function () use ($journalId, $userId) { + $journal = $this->lockedJournal($journalId, 'approved'); + $this->CI->fiscalperiodservice->assertOpen($journal->tanggal); + $this->CI->workflowJournalModel->updateStatus($journal->id, 'posted', array( + 'posted_at' => date('Y-m-d H:i:s'), 'posted_by' => (int) $userId + )); + $this->CI->workflowJournalModel->addStatusHistory($journal->id, 'approved', 'posted', $userId); + $after=$this->CI->workflowJournalModel->find($journal->id); + $this->CI->auditservice->record('journal','journal',$journal->id,'post',$journal,$after,$userId); + return $after; + }); + } + + public function reject($journalId, $userId, $reason) + { + $reason = trim((string) $reason); + if ($reason === '') { + throw new BusinessException('Alasan penolakan wajib diisi.'); + } + return $this->CI->transactionservice->run(function () use ($journalId, $userId, $reason) { + $journal = $this->CI->workflowJournalModel->findForUpdate($journalId); + if (!$journal || !in_array($journal->status, array('submitted', 'approved'), true)) { + throw new BusinessException('Jurnal tidak berada pada status yang dapat ditolak.'); + } + if($journal->status==='submitted') { + $request=$this->CI->approvalservice->pendingForEntity('journal',$journal->id); + if($request) $this->CI->approvalservice->act($request->id,'rejected',$userId,$this->roleId($userId),$reason); + } + $this->CI->workflowJournalModel->updateStatus($journal->id, 'rejected', array( + 'rejected_at' => date('Y-m-d H:i:s'), 'rejected_by' => (int) $userId, 'rejection_reason' => $reason + )); + $this->CI->workflowJournalModel->addStatusHistory($journal->id, $journal->status, 'rejected', $userId, $reason); + $after=$this->CI->workflowJournalModel->find($journal->id); + $this->CI->auditservice->record('journal','journal',$journal->id,'reject',$journal,$after,$userId); + return $after; + }); + } + + public function reverse($journalId, $date, $userId, $reason) + { + $reason = trim((string) $reason); + if ($reason === '') { + throw new BusinessException('Alasan reversal wajib diisi.'); + } + return $this->CI->transactionservice->run(function () use ($journalId, $date, $userId, $reason) { + $journal = $this->lockedJournal($journalId, 'posted'); + if ((int) $journal->is_reversed === 1) { + throw new BusinessException('Jurnal sudah pernah direversal.'); + } + $this->CI->fiscalperiodservice->assertOpen($date); + $lines = array(); + foreach ($this->CI->workflowJournalModel->lines($journal->id) as $line) { + $lines[] = array('account_id' => $line['account_id'], 'debit' => $line['kredit'], 'kredit' => $line['debit']); + } + $number = $this->CI->numberingservice->next('reversal', $date); + $reversalId = $this->CI->postingservice->post(array( + 'tanggal' => $date, + 'no_ref' => $number, + 'keterangan' => 'Reversal ' . $journal->no_ref . ': ' . $reason, + 'ref_type' => 'journal_reversal', + 'ref_id' => $journal->id, + 'reversal_of' => $journal->id, + 'reversal_reason' => $reason, + 'created_by' => (int) $userId + ), $lines, false, true); + $this->CI->workflowJournalModel->updateStatus($journal->id, 'reversed', array( + 'is_reversed' => 1, 'reversal_reason' => $reason, 'updated_by' => (int) $userId + )); + $this->CI->workflowJournalModel->addStatusHistory($journal->id, 'posted', 'reversed', $userId, $reason); + $this->CI->auditservice->record('journal','journal',$journal->id,'reverse',$journal,$this->CI->workflowJournalModel->find($journal->id),$userId); + return array('journal' => $this->CI->workflowJournalModel->find($journal->id), 'reversal_id' => $reversalId, 'reversal_no' => $number); + }); + } + + private function transition($journalId, $from, $to, $userId, array $extra) + { + return $this->CI->transactionservice->run(function () use ($journalId, $from, $to, $userId, $extra) { + $journal = $this->lockedJournal($journalId, $from); + if ($from === 'draft' && (int) $journal->created_by !== (int) $userId && $this->CI->session->userdata('role') !== 'Admin') { + throw new BusinessException('Anda hanya dapat mengajukan jurnal yang Anda buat.'); + } + $this->CI->fiscalperiodservice->assertOpen($journal->tanggal); + $this->CI->workflowJournalModel->updateStatus($journal->id, $to, $extra); + $this->CI->workflowJournalModel->addStatusHistory($journal->id, $from, $to, $userId); + return $this->CI->workflowJournalModel->find($journal->id); + }); + } + + private function lockedJournal($journalId, $expectedStatus) + { + $journal = $this->CI->workflowJournalModel->findForUpdate($journalId); + if (!$journal) { + throw new BusinessException('Jurnal tidak ditemukan.', array(), 404); + } + if ($journal->status !== $expectedStatus) { + throw new BusinessException('Jurnal harus berstatus ' . $expectedStatus . ' untuk tindakan ini.'); + } + return $journal; + } + + private function roleId($userId) + { + $roleId=(int)$this->CI->session->userdata('role_id'); + if(!$roleId) { $user=$this->CI->db->select('role_id')->get_where('users',array('id'=>(int)$userId))->row(); $roleId=$user?(int)$user->role_id:0; } + return $roleId; + } +} diff --git a/application/libraries/MultiCurrencyService.php b/application/libraries/MultiCurrencyService.php new file mode 100644 index 0000000..5a92a9c --- /dev/null +++ b/application/libraries/MultiCurrencyService.php @@ -0,0 +1,8 @@ +CI=&get_instance();$this->CI->load->library(array('CompanyContext','PostingService','AccountMappingService','FiscalPeriodService'));} + public function rate($currency,$date){$company=$this->CI->db->get_where('companies',array('id'=>$this->CI->companycontext->id()))->row();if($currency===$company->base_currency)return 1;$r=$this->CI->db->where('company_id',$company->id)->where('from_currency',$currency)->where('to_currency',$company->base_currency)->where('rate_date <=',$date)->order_by('rate_date','DESC')->get('exchange_rates')->row();if(!$r)throw new BusinessException('Kurs '.$currency.'/'.$company->base_currency.' belum tersedia pada '.$date);return(float)$r->rate;} + public function revalue($date,$uid){$db=$this->CI->db;$db->trans_begin();try{$cid=$this->CI->companycontext->id();$this->CI->fiscalperiodservice->assertOpen($date);$db->insert('fx_revaluations',array('company_id'=>$cid,'revaluation_no'=>'FXR-'.date('YmdHis'),'revaluation_date'=>$date,'created_by'=>$uid));$id=$db->insert_id();$gain=$loss=0;$lines=array();$ar=$this->CI->accountmappingservice->get('accounts_receivable');foreach($db->where('company_id',$cid)->where('workflow_status','posted')->where('sisa_piutang >',0)->where('transaction_currency !=','IDR')->get('invoices')->result()as$i){$foreign=(float)($i->foreign_balance?:$i->foreign_total);if($foreign<=0)continue;$diff=round($foreign*$this->rate($i->transaction_currency,$date)-(float)$i->sisa_piutang,2);if(abs($diff)<.01)continue;$lines[]=array('account_id'=>$ar,'debit'=>$diff>0?$diff:0,'kredit'=>$diff<0?abs($diff):0);if($diff>0)$gain+=$diff;else$loss+=abs($diff);}$ap=$this->CI->accountmappingservice->get('accounts_payable');foreach($db->where('company_id',$cid)->where('status','partial')->where('balance >',0)->where('transaction_currency !=','IDR')->get('supplier_invoices')->result()as$i){$foreign=(float)($i->foreign_balance?:$i->foreign_total);if($foreign<=0)continue;$diff=round($foreign*$this->rate($i->transaction_currency,$date)-(float)$i->balance,2);if(abs($diff)<.01)continue;$lines[]=array('account_id'=>$ap,'debit'=>$diff<0?abs($diff):0,'kredit'=>$diff>0?$diff:0);if($diff>0)$loss+=$diff;else$gain+=abs($diff);}if(!$lines)throw new BusinessException('Tidak ada saldo valuta asing yang perlu direvaluasi.');if($loss>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('unrealized_fx_loss'),'debit'=>$loss,'kredit'=>0);if($gain>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('unrealized_fx_gain'),'debit'=>0,'kredit'=>$gain);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'FXR-'.$id.'-'.str_replace('-','',$date),'keterangan'=>'Revaluasi saldo valuta asing pada '.$date,'ref_type'=>'fx_revaluation','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$db->where('id',$id)->update('fx_revaluations',array('status'=>'posted','gain_amount'=>$gain,'loss_amount'=>$loss,'journal_id'=>$jid,'posted_at'=>date('Y-m-d H:i:s')));$db->trans_commit();return$jid;}catch(Throwable$e){$db->trans_rollback();throw$e;}} +} diff --git a/application/libraries/NavigationService.php b/application/libraries/NavigationService.php new file mode 100644 index 0000000..125da53 --- /dev/null +++ b/application/libraries/NavigationService.php @@ -0,0 +1,39 @@ +CI=&get_instance();} + private function allowed($feature){if(is_master_admin_user())return true;foreach((array)$feature as$name)if(check_permission($name,'can_view'))return true;return false;} + public function items(){ + $items=array( + array('key'=>'dashboard','label'=>'Dashboard','icon'=>'bi-speedometer2','url'=>'','feature'=>'dashboard'), + array('key'=>'approvals','label'=>'Approval','icon'=>'bi-inbox','url'=>'approvals','feature'=>'approvals','badge'=>$this->approvalBadge()), + array('key'=>'sales_menu','label'=>'Penjualan','icon'=>'bi-receipt','feature'=>'invoices','badge'=>$this->salesBadge('all'),'badge_class'=>$this->salesBadge('overdue')?'nav-badge-danger':'nav-badge-warning','badge_title'=>'Invoice, piutang, pengiriman, atau retur membutuhkan tindakan','children'=>array(array('key'=>'sales','label'=>'Ringkasan Penjualan','url'=>'sales'),array('key'=>'sales_invoice','label'=>'Invoice','url'=>'invoices','badge'=>$this->salesBadge('draft')+$this->salesBadge('approval'),'badge_class'=>'nav-badge-warning'),array('key'=>'sales_delivery','label'=>'Pengiriman / Surat Jalan','url'=>'sales/deliveries','badge'=>$this->salesBadge('delivery'),'badge_class'=>'nav-badge-warning'),array('key'=>'receivables','label'=>'Piutang & Pembayaran','url'=>'receivables','badge'=>$this->salesBadge('receivable'),'badge_class'=>$this->salesBadge('overdue')?'nav-badge-danger':'nav-badge-warning'),array('key'=>'sales_return','label'=>'Retur Penjualan & Refund','url'=>'sales/returns','badge'=>$this->salesBadge('return'),'badge_class'=>'nav-badge-warning'),array('key'=>'customers','label'=>'Customer','url'=>'customers'))), + array('key'=>'purchase','label'=>'Pembelian','icon'=>'bi-cart','feature'=>array('purchases','purchase_returns','purchase_refunds','supplier_debit_notes'),'badge'=>$this->purchaseBadge(),'badge_class'=>$this->purchaseBadgeClass(),'badge_title'=>'Tagihan dan dokumen Retur/Refund yang membutuhkan tindakan','children'=>array(array('key'=>'suppliers','label'=>'Master Supplier','url'=>'suppliers','feature'=>'purchases'),array('key'=>'purchases','label'=>'Purchase Workflow','url'=>'purchases','feature'=>'purchases'),array('key'=>'purchase_returns','label'=>'Retur & Refund','url'=>'purchases/returns','feature'=>array('purchase_returns','purchase_refunds','supplier_debit_notes'),'badge'=>$this->returnBadge(),'badge_class'=>$this->returnBadgeClass(),'badge_title'=>'Dokumen Retur/Refund yang membutuhkan tindakan'),array('key'=>'payables','label'=>'Hutang & Pembayaran','url'=>'payables','feature'=>'purchases','badge'=>$this->payableBadge(),'badge_class'=>$this->payableBadgeClass(),'badge_title'=>$this->payableBadgeTitle()))), + array('key'=>'inventory','label'=>'Persediaan','icon'=>'bi-boxes','feature'=>array('items','technician_equipment'),'badge'=>$this->inventoryBadge(),'badge_class'=>($this->inventoryDocumentBadge()+$this->technicianActionBadge())>0?'nav-badge-danger':'nav-badge-warning','badge_title'=>'Barang pending, dokumen stok, atau peralatan teknisi membutuhkan tindakan','children'=>array(array('key'=>'inventory_overview','label'=>'Ringkasan Persediaan','url'=>'inventoryprofessional','feature'=>'items'),array('key'=>'draft_items','label'=>'Barang Pending','url'=>'items/draft_items','feature'=>'items','badge'=>$this->pendingItemBadge(),'badge_class'=>'nav-badge-warning'),array('key'=>'items','label'=>'Daftar Barang','url'=>'items','feature'=>'items'),array('key'=>'inventory_operations','label'=>'Operasional Stok','url'=>'inventoryprofessional/operations','feature'=>'items','badge'=>$this->inventoryDocumentBadge(),'badge_class'=>'nav-badge-danger','badge_title'=>'Dokumen stok menunggu persetujuan atau posting'),array('key'=>'inventory_locations','label'=>'Gudang & Lokasi Rak','url'=>'inventoryprofessional/locations','feature'=>'items'),array('key'=>'kode_barang','label'=>'Jenis / Kode Barang','url'=>'kodebarang','feature'=>'items'),array('key'=>'peralatan_teknisi','label'=>'Peralatan Teknisi','url'=>'teknisi','feature'=>array('technician_equipment','items'),'badge'=>$this->technicianActionBadge(),'badge_class'=>'nav-badge-danger','badge_title'=>'Barang menunggu pemeriksaan atau tindak lanjut'),array('key'=>'inventory_reports','label'=>'Laporan Persediaan','url'=>'inventoryprofessional/reports','feature'=>'items'))), + array('key'=>'fixed_assets','label'=>'Aset Tetap','icon'=>'bi-building-gear','url'=>'fixedassets','feature'=>'asset'), + array('key'=>'accounting','label'=>'Accounting','icon'=>'bi-journal-text','feature'=>'jurnal','children'=>array(array('key'=>'jurnal','label'=>'Jurnal Umum','url'=>'jurnal'),array('key'=>'accounts','label'=>'Daftar Akun','url'=>'accounts'),array('key'=>'basejurnal','label'=>'Base Jurnal','url'=>'basejurnal'),array('key'=>'accounting_periods','label'=>'Periode Accounting','url'=>'accountingperiods'))), + array('key'=>'professional_reports','label'=>'Laporan Profesional','icon'=>'bi-bar-chart','url'=>'professionalreports','feature'=>'reports'), + array('key'=>'budgets','label'=>'Budget','icon'=>'bi-bullseye','url'=>'budgets','feature'=>'budgets','badge'=>$this->budgetApprovalBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengingat pembayaran budget yang perlu diperhatikan'), + array('key'=>'hr','label'=>'HR & Payroll','icon'=>'bi-people','feature'=>'employees','children'=>array(array('key'=>'organization_master','label'=>'Departemen & Posisi','url'=>'organizationmaster'),array('key'=>'employees','label'=>'Data Karyawan','url'=>'employees'),array('key'=>'generate_schedule','label'=>'Jadwal Kerja','url'=>'generateschedule'),array('key'=>'attendance_monitoring','label'=>'Monitoring Absensi','url'=>'attendancemonitoring'),array('key'=>'attendance_monthly','label'=>'Absensi Bulanan','url'=>'attendancemonthly'),array('key'=>'shift','label'=>'Shift','url'=>'shifts'),array('key'=>'holidays','label'=>'Hari Libur','url'=>'holidays'),array('key'=>'leave_requests','label'=>'Cuti & Izin','url'=>'leaverequests'),array('key'=>'hr_payroll','label'=>'Payroll Control','url'=>'hrpayroll'))), + array('key'=>'companies','label'=>'Company & Currency','icon'=>'bi-buildings','url'=>'companies','feature'=>'companies','admin'=>true), + array('key'=>'settings','label'=>'Pengaturan','icon'=>'bi-gear','feature'=>'setting','admin'=>true,'children'=>array(array('key'=>'users','label'=>'Pengguna','url'=>'users'),array('key'=>'roles','label'=>'Role & Permission','url'=>'roles'))) + );$out=array();foreach($items as$item){if(!empty($item['admin'])&&!is_master_admin_user())continue;if(!$this->allowed($item['feature']))continue;if(!empty($item['children'])){$children=array();foreach($item['children']as$c){if(!empty($c['admin'])&&!is_master_admin_user())continue;if(!empty($c['feature'])&&!$this->allowed($c['feature']))continue;$children[]=$c;}$item['children']=$children;if(!$children)continue;}$out[]=$item;}return$out; + } + private function approvalBadge(){if(!$this->CI->db->table_exists('approval_requests'))return 0;return(int)$this->CI->db->where('status','pending')->count_all_results('approval_requests');} + private function salesBadge($name){if($this->salesBadges===null){$c=(int)$this->CI->session->userdata('company_id');$this->salesBadges=array('draft'=>0,'approval'=>0,'delivery'=>0,'receivable'=>0,'overdue'=>0,'return'=>0);if($this->CI->db->table_exists('invoices')){$where=$this->CI->db->field_exists('company_id','invoices')?' AND company_id='.(int)$c:'';$r=$this->CI->db->query("SELECT SUM(workflow_status='draft') draft_count,SUM(workflow_status='submitted') approval_count,SUM(workflow_status='posted' AND sisa_piutang>0) receivable_count,SUM(workflow_status='posted' AND sisa_piutang>0 AND jatuh_temporow();if($r){$this->salesBadges['draft']=(int)$r->draft_count;$this->salesBadges['approval']=(int)$r->approval_count;$this->salesBadges['receivable']=(int)$r->receivable_count;$this->salesBadges['overdue']=(int)$r->overdue_count;}}if($this->CI->db->table_exists('sales_deliveries')){$this->CI->db->where('status','draft');if($this->CI->db->field_exists('company_id','sales_deliveries'))$this->CI->db->where('company_id',$c);$this->salesBadges['delivery']=(int)$this->CI->db->count_all_results('sales_deliveries');}if($this->CI->db->table_exists('sales_returns')){$this->CI->db->where_in('status',array('draft','submitted','approved','received'));if($this->CI->db->field_exists('company_id','sales_returns'))$this->CI->db->where('company_id',$c);$this->salesBadges['return']=(int)$this->CI->db->count_all_results('sales_returns');}}if($name==='all')return$this->salesBadges['draft']+$this->salesBadges['approval']+$this->salesBadges['delivery']+$this->salesBadges['receivable']+$this->salesBadges['return'];return$this->salesBadges[$name]??0;} + private function budgetApprovalBadge(){if($this->budgetBadge!==null)return$this->budgetBadge;if(!$this->allowed('budgets'))return$this->budgetBadge=0;$company=(int)$this->CI->session->userdata('company_id');$total=0;if($this->CI->db->table_exists('budgets')&&(is_master_admin_user()||check_permission('budgets','can_approve'))){$this->CI->db->where('status','submitted');if($this->CI->db->field_exists('company_id','budgets'))$this->CI->db->where('company_id',$company);$total+=(int)$this->CI->db->count_all_results('budgets');}if($this->CI->db->table_exists('account_budget_entries')&&$this->CI->db->table_exists('account_budget_reminder_completions')){$period=date('Y-m');$row=$this->CI->db->query("SELECT COUNT(*) total FROM account_budget_entries e WHERE e.company_id=? AND e.is_active=1 AND e.reminder_enabled=1 AND ((e.entry_type='recurring' AND e.period<=?) OR (e.entry_type='override' AND e.period=?)) AND LEAST(e.reminder_day,DAY(LAST_DAY(CURDATE())))<=LEAST(DAY(LAST_DAY(CURDATE())),DAY(CURDATE())+7) AND NOT EXISTS(SELECT 1 FROM account_budget_reminder_completions c WHERE c.company_id=e.company_id AND c.budget_entry_id=e.id AND c.period=?)",array($company,$period,$period,$period))->row();$total+=(int)($row?$row->total:0);}return$this->budgetBadge=$total;} + private function pendingItemBadge(){if($this->pendingBadge!==null)return$this->pendingBadge;if(!$this->CI->db->table_exists('items'))return$this->pendingBadge=0;$company=(int)$this->CI->session->userdata('company_id');if($this->CI->db->table_exists('item_barcodes'))return$this->pendingBadge=(int)$this->CI->db->query("SELECT COUNT(DISTINCT i.id) total FROM items i LEFT JOIN item_barcodes ib ON ib.item_id=i.id AND ib.status='pending' WHERE i.company_id=? AND EXISTS(SELECT 1 FROM goods_receipt_lines grl WHERE grl.item_id=i.id) AND(i.status='draft' OR ib.id IS NOT NULL)",array($company))->row()->total;return$this->pendingBadge=(int)$this->CI->db->where(array('status'=>'draft','company_id'=>$company))->count_all_results('items');} + private function inventoryDocumentBadge(){if($this->inventoryDocumentBadge!==null)return$this->inventoryDocumentBadge;if(!$this->CI->db->table_exists('stock_documents'))return$this->inventoryDocumentBadge=0;$this->CI->db->where_in('status',array('submitted','approved'));if($this->CI->db->field_exists('company_id','stock_documents'))$this->CI->db->where('company_id',(int)$this->CI->session->userdata('company_id'));return$this->inventoryDocumentBadge=(int)$this->CI->db->count_all_results('stock_documents');} + private function technicianActionBadge(){if($this->technicianBadge!==null)return$this->technicianBadge;if(!$this->CI->db->table_exists('technician_custody_allocations'))return$this->technicianBadge=0;$this->CI->db->where('company_id',(int)$this->CI->session->userdata('company_id'))->where_in('condition_status',array('inspection_required','repair_required','damaged','lost'));return$this->technicianBadge=(int)$this->CI->db->count_all_results('technician_custody_allocations');} + private function inventoryBadge(){return$this->pendingItemBadge()+$this->inventoryDocumentBadge()+$this->technicianActionBadge();} + private function payableBadge(){if($this->payableBadge!==null)return$this->payableBadge;if(!$this->CI->db->table_exists('supplier_invoices'))return$this->payableBadge=0;$this->CI->db->select("COUNT(DISTINCT id) total,SUM(CASE WHEN due_date<=CURDATE() THEN 1 ELSE 0 END) overdue",false)->where('status','partial')->where('balance >',0);if($this->CI->db->field_exists('company_id','supplier_invoices'))$this->CI->db->where('company_id',(int)$this->CI->session->userdata('company_id'));$row=$this->CI->db->get('supplier_invoices')->row();$this->payableBadge=$row?(int)$row->total:0;$this->payableBadgeClass=$row&&(int)$row->overdue>0?'nav-badge-danger':($this->payableBadge>0?'nav-badge-warning':'');return$this->payableBadge;} + private function payableBadgeClass(){$this->payableBadge();return$this->payableBadgeClass;} + private function payableBadgeTitle(){$this->payableBadge();return$this->payableBadgeClass==='nav-badge-danger'?'Ada tagihan yang sudah jatuh tempo':'Tagihan belum lunas dan belum jatuh tempo';} + private function returnBadge(){if($this->returnBadge!==null)return$this->returnBadge;if(!$this->CI->db->table_exists('purchase_returns')||!$this->CI->db->table_exists('supplier_refund_claims'))return$this->returnBadge=0;$company=(int)$this->CI->session->userdata('company_id');$returns=null;$refunds=null;$debit=0;if($this->allowed('purchase_returns')){$whereCompany=$this->CI->db->field_exists('company_id','purchase_returns')?' AND company_id='.(int)$company:'';$returns=$this->CI->db->query("SELECT COUNT(*) total,SUM(CASE WHEN created_atrow();}if($this->allowed('purchase_refunds')){$refundWhere=$this->CI->db->field_exists('company_id','supplier_refund_claims')?' AND company_id='.(int)$company:'';$refunds=$this->CI->db->query("SELECT COUNT(*) total,SUM(CASE WHEN expected_date IS NOT NULL AND expected_daterow();}if($this->allowed('supplier_debit_notes')&&$this->CI->db->table_exists('supplier_debit_notes')){$this->CI->db->where('status','draft');if($this->CI->db->field_exists('company_id','supplier_debit_notes'))$this->CI->db->where('company_id',$company);$debit=(int)$this->CI->db->count_all_results('supplier_debit_notes');}$this->returnBadge=(int)($returns?$returns->total:0)+(int)($refunds?$refunds->total:0)+$debit;$overdue=(int)($returns?$returns->overdue:0)+(int)($refunds?$refunds->overdue:0);$this->returnBadgeClass=$overdue>0?'nav-badge-danger':($this->returnBadge>0?'nav-badge-warning':'');return$this->returnBadge;} + private function returnBadgeClass(){$this->returnBadge();return$this->returnBadgeClass;} + private function purchaseBadge(){return($this->allowed('purchases')?$this->payableBadge():0)+$this->returnBadge();} + private function purchaseBadgeClass(){return($this->allowed('purchases')&&$this->payableBadgeClass()==='nav-badge-danger')||$this->returnBadgeClass()==='nav-badge-danger'?'nav-badge-danger':($this->purchaseBadge()>0?'nav-badge-warning':'');} + public function breadcrumb($active){foreach($this->items()as$i){if($i['key']===$active)return array($i['label']);foreach(isset($i['children'])?$i['children']:array()as$c)if($c['key']===$active)return array($i['label'],$c['label']);}return array(ucwords(str_replace('_',' ',$active)));} + public function quickActions(){$defs=array(array('label'=>'Jurnal','icon'=>'bi-journal-plus','url'=>'jurnal','feature'=>'jurnal'),array('label'=>'Terima Pembayaran','icon'=>'bi-cash-coin','url'=>'receivables','feature'=>'invoices'),array('label'=>'Bayar Supplier','icon'=>'bi-wallet2','url'=>'payables','feature'=>'purchases'),array('label'=>'Scan QR','icon'=>'bi-qr-code-scan','url'=>'inventoryprofessional#scanner','feature'=>'items'));$out=array();foreach($defs as$d)if($this->allowed($d['feature']))$out[]=$d;return$out;} +} diff --git a/application/libraries/NumberingService.php b/application/libraries/NumberingService.php new file mode 100644 index 0000000..fe7a271 --- /dev/null +++ b/application/libraries/NumberingService.php @@ -0,0 +1,82 @@ +CI =& get_instance(); + $this->CI->config->load('accounting', true); + $this->CI->load->library('CompanyContext'); + $this->sequenceTableAvailable = $this->CI->db->table_exists('document_sequences'); + } + + public function next($type, $date = null) + { + $date = $date ?: date('Y-m-d'); + $timestamp = strtotime($date); + if ($timestamp === false) { + throw new BusinessException('Tanggal penomoran dokumen tidak valid.'); + } + + $settings = (array) $this->CI->config->item('document_numbering', 'accounting'); + if (!isset($settings[$type])) { + throw new BusinessException('Jenis penomoran "' . $type . '" belum dikonfigurasi.'); + } + + $prefix = $settings[$type]['prefix'] . '-' . date('Ymd', $timestamp) . '-'; + $padding = (int) $settings[$type]['padding']; + $period = date('Ymd', $timestamp); + + if ($this->sequenceTableAvailable) { + $initialValue = $this->nextLegacyValue($type, $prefix); + $number = $this->nextAtomicValue($type, $period, $prefix, $initialValue); + } else { + $number = $this->nextLegacyValue($type, $prefix); + } + + return $prefix . str_pad($number, $padding, '0', STR_PAD_LEFT); + } + + private function nextAtomicValue($type, $period, $prefix, $initialValue) + { + $companyId = (int) $this->CI->companycontext->id(); + $sql = 'INSERT INTO document_sequences (company_id, document_type, period_key, prefix, current_value, updated_at) ' + . 'VALUES (?, ?, ?, ?, ?, NOW()) ' + . 'ON DUPLICATE KEY UPDATE prefix = VALUES(prefix), current_value = LAST_INSERT_ID(current_value + 1), updated_at = NOW()'; + + if (!$this->CI->db->query($sql, array($companyId, $type, $period, $prefix, $initialValue))) { + throw new RuntimeException('Gagal mengambil nomor dokumen berikutnya.'); + } + + if ($this->CI->db->affected_rows() === 1) { + return (int) $initialValue; + } + + return (int) $this->CI->db->insert_id(); + } + + private function nextLegacyValue($type, $prefix) + { + $source = $type === 'invoice' ? array('invoices', 'no_invoice') : array('journals', 'no_ref'); + $builder = $this->CI->db->select($source[1]); + if ($this->CI->db->field_exists('company_id', $source[0])) $builder->where('company_id', (int)$this->CI->companycontext->id()); + $row = $builder->like($source[1], $prefix, 'after') + ->order_by($source[1], 'DESC') + ->limit(1) + ->get($source[0]) + ->row(); + + if (!$row) { + return 1; + } + + $value = $row->{$source[1]}; + return ((int) substr($value, strlen($prefix))) + 1; + } +} diff --git a/application/libraries/PayrollService.php b/application/libraries/PayrollService.php new file mode 100644 index 0000000..1bcf2de --- /dev/null +++ b/application/libraries/PayrollService.php @@ -0,0 +1,11 @@ +CI=&get_instance();$this->CI->load->library(array('FormulaService','PostingService','FiscalPeriodService','AccountMappingService'));} + private function period($id){$p=$this->CI->db->query('SELECT * FROM k_payroll_periods WHERE id=? FOR UPDATE',array($id))->row();if(!$p)throw new BusinessException('Periode payroll tidak ditemukan.');return$p;} + public function calculate($month,$uid){$start=$month.'-01';$end=date('Y-m-t',strtotime($start));$db=$this->CI->db;$db->trans_begin();try{$locked=$db->get_where('attendance_period_locks',array('period'=>$month,'status'=>'locked'))->row();if(!$locked)throw new BusinessException('Absensi bulan '.$month.' harus dikunci sebelum payroll dihitung.');$p=$db->get_where('k_payroll_periods',array('start_date'=>$start,'end_date'=>$end))->row();if($p&&$p->workflow_status!=='draft')throw new BusinessException('Periode payroll bukan draft.');if(!$p){$db->insert('k_payroll_periods',array('period_name'=>date('F Y',strtotime($start)),'start_date'=>$start,'end_date'=>$end,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s')));$pid=$db->insert_id();}else$pid=$p->id;$components=$db->where('is_active',1)->get('payroll_components')->result();foreach($db->where('is_active',1)->get('k_employees')->result()as$e){$att=$db->query("SELECT COUNT(*) work_days,SUM(attendance_status IN('present','late')) present_days,SUM(attendance_status='alpha') unpaid_days,COALESCE(SUM(overtime_hours),0) overtime_hours FROM k_attendances WHERE employee_id=? AND attendance_date BETWEEN ? AND ?",array($e->id,$start,$end))->row();$vars=array('basic_salary'=>(float)$e->basic_salary,'calendar_days'=>(int)date('t',strtotime($start)),'work_days'=>(float)$att->work_days,'present_days'=>(float)$att->present_days,'unpaid_days'=>(float)$att->unpaid_days,'overtime_hours'=>(float)$att->overtime_hours,'overtime_rate'=>(float)(getenv('PAYROLL_OVERTIME_RATE')?:25000),'daily_rate'=>(float)$e->basic_salary/max(1,(int)date('t',strtotime($start))));$pay=$db->get_where('k_payrolls',array('payroll_period_id'=>$pid,'employee_id'=>$e->id))->row();$data=array('payroll_period_id'=>$pid,'employee_id'=>$e->id,'basic_salary'=>$e->basic_salary,'total_work_days'=>$att->work_days,'total_present'=>$att->present_days,'total_alpha'=>$att->unpaid_days,'total_overtime_hours'=>$att->overtime_hours);if($pay){$db->where('id',$pay->id)->update('k_payrolls',$data);$payid=$pay->id;$db->where('payroll_id',$payid)->delete('payroll_calculation_lines');}else{$db->insert('k_payrolls',$data);$payid=$db->insert_id();}$earn=$deduct=$employer=0;foreach($components as$c){$override=$db->where('employee_id',$e->id)->where('component_id',$c->id)->where('is_active',1)->where('effective_from <=',$end)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$start)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();$amount=$override&&$override->amount_override!==null?(float)$override->amount_override:($c->fixed_amount!==null?(float)$c->fixed_amount:$this->CI->formulaservice->calculate($c->formula_expression,$vars));if($c->code==='BASIC'&&$c->prorated&&$e->join_date>$start&&$e->join_date<=$end)$amount=round($amount*(date('t',strtotime($start))-date('d',strtotime($e->join_date))+1)/date('t',strtotime($start)),2);if(abs($amount)<.01)continue;$db->insert('payroll_calculation_lines',array('payroll_id'=>$payid,'component_id'=>$c->id,'component_code'=>$c->code,'component_name'=>$c->name,'component_type'=>$c->component_type,'base_amount'=>$e->basic_salary,'amount'=>$amount,'formula_snapshot'=>$c->formula_expression,'expense_account_id'=>$c->expense_account_id,'liability_account_id'=>$c->liability_account_id));if($c->component_type==='earning')$earn+=$amount;elseif($c->component_type==='deduction')$deduct+=$amount;else$employer+=$amount;}$net=$earn-$deduct;$snap=json_encode(array('variables'=>$vars,'gross'=>$earn,'deductions'=>$deduct,'net'=>$net));$db->where('id',$payid)->update('k_payrolls',array('gross_salary'=>$earn,'total_deductions'=>$deduct,'total_salary'=>$net,'calculation_snapshot'=>$snap,'version'=>isset($pay->version)?$pay->version+1:1));}$hash=hash('sha256',json_encode($db->select('p.id,p.total_salary,p.version')->from('k_payrolls p')->where('payroll_period_id',$pid)->order_by('p.id')->get()->result_array()));$db->where('id',$pid)->update('k_payroll_periods',array('calculation_hash'=>$hash));$db->trans_commit();return$pid;}catch(Throwable$e){$db->trans_rollback();throw$e;}} + public function workflow($id,$action,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);$map=array('review'=>array('draft','reviewed'), 'approve'=>array('reviewed','approved'), 'finalize'=>array('approved','final'));if(!isset($map[$action])||$p->workflow_status!==$map[$action][0])throw new BusinessException('Transisi payroll tidak valid.');if($action!=='review'&&(int)($action==='approve'?$p->reviewed_by:$p->approved_by)===(int)$uid)throw new BusinessException('Reviewer/approver berikutnya harus user berbeda.');$up=array('workflow_status'=>$map[$action][1],$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by')=>$uid,$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at')=>date('Y-m-d H:i:s'));$db->where('id',$id)->update('k_payroll_periods',$up);$db->trans_commit();}catch(Throwable$e){$db->trans_rollback();throw$e;}} + public function post($id,$date,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='final'||$p->journal_id)throw new BusinessException('Payroll belum final atau sudah diposting.');$this->CI->fiscalperiodservice->assertOpen($date);$gross=(float)$db->select('COALESCE(SUM(gross_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$ded=(float)$db->select('COALESCE(SUM(total_deductions),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$net=$gross-$ded;$lines=array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_expense'),'debit'=>$gross,'kredit'=>0));if($ded>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_tax_payable'),'debit'=>0,'kredit'=>$ded);$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>0,'kredit'=>$net);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.str_replace('-','',$p->start_date),'keterangan'=>'Payroll register '.$p->period_name.'; karyawan '.$db->where('payroll_period_id',$id)->count_all_results('k_payrolls').'; bruto Rp '.number_format($gross,2,'.','').' neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$jid));$db->trans_commit();return$jid;}catch(Throwable$e){$db->trans_rollback();throw$e;}} + public function pay($id,$date,$cashAccountId,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($db->get_where('payroll_payments',array('payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cash=$db->query('SELECT * FROM cash_accounts WHERE id=? FOR UPDATE',array($cashAccountId))->row();if(!$cash)throw new BusinessException('Rekening pembayaran tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$no='PRP-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Pembayaran payroll '.$p->period_name.' melalui '.$cash->name.'; total neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('payroll_period_id'=>$id,'payment_no'=>$no,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=$db->insert_id();$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId));$db->trans_commit();return$journal;}catch(Throwable$e){$db->trans_rollback();throw$e;}} +} diff --git a/application/libraries/PerformanceCacheService.php b/application/libraries/PerformanceCacheService.php new file mode 100644 index 0000000..74b8ee6 --- /dev/null +++ b/application/libraries/PerformanceCacheService.php @@ -0,0 +1,9 @@ +CI=&get_instance();} + public function remember($key,$ttl,callable$loader){$row=$this->CI->db->where('cache_key',$key)->where('expires_at >',date('Y-m-d H:i:s'))->get('app_cache')->row();if($row){$value=json_decode($row->payload,true);if(json_last_error()===JSON_ERROR_NONE)return$value;}$value=$loader();$data=array('payload'=>json_encode($value),'expires_at'=>date('Y-m-d H:i:s',time()+max(1,(int)$ttl)),'created_at'=>date('Y-m-d H:i:s'));if($this->CI->db->get_where('app_cache',array('cache_key'=>$key))->row())$this->CI->db->where('cache_key',$key)->update('app_cache',$data);else{$data['cache_key']=$key;$this->CI->db->insert('app_cache',$data);}return$value;} + public function forgetPrefix($prefix){$this->CI->db->like('cache_key',$prefix,'after')->delete('app_cache');} + public function purgeExpired(){return$this->CI->db->where('expires_at <',date('Y-m-d H:i:s'))->delete('app_cache');} +} diff --git a/application/libraries/PostingService.php b/application/libraries/PostingService.php new file mode 100644 index 0000000..badec10 --- /dev/null +++ b/application/libraries/PostingService.php @@ -0,0 +1,30 @@ +CI =& get_instance(); + $this->CI->load->library('JournalService'); + $this->CI->load->model('JournalModel', 'postingJournalModel'); + } + + public function post(array $header, array $lines, $manageTransaction = true, $preventDuplicateSource = true) + { + if ($preventDuplicateSource && !empty($header['ref_type']) && isset($header['ref_id'])) { + if ($this->CI->postingJournalModel->sourceExists($header['ref_type'], $header['ref_id'])) { + throw new BusinessException('Transaksi sumber ini sudah pernah diposting ke jurnal.'); + } + } + + $header['status'] = 'posted'; + $header['posted_at'] = isset($header['posted_at']) ? $header['posted_at'] : date('Y-m-d H:i:s'); + $header['posted_by'] = isset($header['posted_by']) ? $header['posted_by'] : $header['created_by']; + return $this->CI->journalservice->create($header, $lines, $manageTransaction); + } +} diff --git a/application/libraries/ProfessionalInventoryService.php b/application/libraries/ProfessionalInventoryService.php new file mode 100644 index 0000000..f88c85d --- /dev/null +++ b/application/libraries/ProfessionalInventoryService.php @@ -0,0 +1,235 @@ +CI=&get_instance(); + $this->CI->load->library(array('TransactionService','NumberingService','ApprovalService','AuditService','PostingService','AccountMappingService','FiscalPeriodService','CompanyContext')); + $this->CI->load->model('InventoryModel','professionalInventoryModel'); + } + + public function createDocument(array $data,$user) + { + return $this->CI->transactionservice->run(function()use($data,$user){ + $company=$this->CI->companycontext->id(); + $types=array('opening','in','out','transfer','adjustment','opname','sales_return','purchase_return','damaged','lost'); + $type=trim((string)($data['type']??''));$date=$data['date']??date('Y-m-d'); + if(!in_array($type,$types,true))throw new BusinessException('Jenis mutasi tidak valid.'); + if(!$this->validDate($date))throw new BusinessException('Tanggal dokumen tidak valid.'); + $key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Kunci idempotensi tidak tersedia.'); + $existing=$this->CI->db->get_where('stock_documents',array('company_id'=>$company,'idempotency_key'=>$key))->row();if($existing)return$existing->id; + $from=$this->nullableId($data['from_warehouse_id']??null);$to=$this->nullableId($data['to_warehouse_id']??null); + $this->validateWarehouses($type,$from,$to,$company); + $lines=!empty($data['lines'])&&is_array($data['lines'])?$data['lines']:array($data); + if(!$lines)throw new BusinessException('Minimal satu barang wajib diisi.'); + $no=$this->CI->numberingservice->next('stock_document',$date); + $this->CI->db->insert('stock_documents',array('company_id'=>$company,'document_no'=>$no,'document_type'=>$type,'document_date'=>$date,'from_warehouse_id'=>$from,'to_warehouse_id'=>$to,'status'=>'draft','reason'=>trim((string)($data['reason']??'')),'source_type'=>trim((string)($data['source_type']??''))?:null,'source_id'=>$this->nullableId($data['source_id']??null),'idempotency_key'=>$key,'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'))); + $id=$this->CI->db->insert_id();$lineNo=0; + foreach($lines as$line){$lineNo++;$this->insertLine($id,$lineNo,$type,$from,$to,$line,$company,$data['reason']??'');} + $this->history($id,null,'draft','Dokumen dibuat',$user); + return$id; + }); + } + + public function submit($id,$user) + { + return$this->CI->transactionservice->run(function()use($id,$user){ + $doc=$this->lockDoc($id,'draft');$needsApproval=in_array($doc->document_type,array('adjustment','opname','damaged','lost'),true); + $next=$needsApproval?'submitted':'approved'; + if($needsApproval){$amount=$this->documentAmount($id);$this->CI->approvalservice->create('inventory','stock_document',$id,$amount,$user,array('document'=>$doc,'lines'=>$this->CI->db->get_where('stock_document_lines',array('stock_document_id'=>$id))->result()));} + $update=array('status'=>$next,'submitted_by'=>(int)$user,'submitted_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')); + if(!$needsApproval){$update['approved_by']=(int)$user;$update['approved_at']=date('Y-m-d H:i:s');} + $this->CI->db->where('id',$id)->update('stock_documents',$update);$this->history($id,'draft',$next,$needsApproval?'Diajukan untuk persetujuan':'Disetujui otomatis sesuai jenis mutasi',$user);return$id; + }); + } + + public function approve($id,$user,$role) + { + return$this->CI->transactionservice->run(function()use($id,$user,$role){ + $doc=$this->lockDoc($id,'submitted');$request=$this->CI->approvalservice->pendingForEntity('stock_document',$id);if(!$request)throw new BusinessException('Approval dokumen stok tidak ditemukan.'); + $approval=$this->CI->approvalservice->act($request->id,'approved',$user,$role); + if($approval->status==='approved'){$this->CI->db->where('id',$id)->update('stock_documents',array('status'=>'approved','approved_by'=>(int)$user,'approved_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->history($id,'submitted','approved','Dokumen disetujui',$user);}return$id; + }); + } + + public function reject($id,$user,$role,$reason) + { + return$this->CI->transactionservice->run(function()use($id,$user,$role,$reason){ + $reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');$this->lockDoc($id,'submitted'); + $request=$this->CI->approvalservice->pendingForEntity('stock_document',$id);if(!$request)throw new BusinessException('Approval dokumen stok tidak ditemukan.'); + $this->CI->approvalservice->act($request->id,'rejected',$user,$role,$reason); + $this->CI->db->where('id',$id)->update('stock_documents',array('status'=>'rejected','rejected_by'=>(int)$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->history($id,'submitted','rejected',$reason,$user);return$id; + }); + } + + public function post($id,$user) + { + return$this->CI->transactionservice->run(function()use($id,$user){$doc=$this->lockDoc($id,'approved');$this->applyDocument($doc,$user);return$id;}); + } + + public function reverse($id,$date,$reason,$user) + { + return$this->CI->transactionservice->run(function()use($id,$date,$reason,$user){ + $original=$this->lockDoc($id,'posted');$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan reversal wajib diisi.');if(!$this->validDate($date))throw new BusinessException('Tanggal reversal tidak valid.'); + if($this->CI->db->where('reversal_of_id',$id)->where_in('status',array('approved','posted'))->get('stock_documents')->row())throw new BusinessException('Dokumen ini sudah mempunyai reversal.'); + $type=$original->document_type;$from=$original->from_warehouse_id;$to=$original->to_warehouse_id; + if($type==='transfer'){$tmp=$from;$from=$to;$to=$tmp;}elseif(in_array($type,array('opening','in','sales_return'),true))$type='out';elseif(in_array($type,array('out','purchase_return','damaged','lost'),true))$type='in';else$type='adjustment'; + $no=$this->CI->numberingservice->next('stock_document',$date);$key='REV-STOCK-'.$id; + $this->CI->db->insert('stock_documents',array('company_id'=>$original->company_id,'document_no'=>$no,'document_type'=>$type,'document_date'=>$date,'from_warehouse_id'=>$from,'to_warehouse_id'=>$to,'status'=>'approved','reason'=>'Reversal '.$original->document_no.': '.$reason,'source_type'=>'stock_document','source_id'=>$id,'idempotency_key'=>$key,'created_by'=>(int)$user,'submitted_by'=>(int)$user,'submitted_at'=>date('Y-m-d H:i:s'),'approved_by'=>(int)$user,'approved_at'=>date('Y-m-d H:i:s'),'reversal_of_id'=>$id,'created_at'=>date('Y-m-d H:i:s')));$reverseId=$this->CI->db->insert_id(); + $lines=$this->CI->db->get_where('stock_document_lines',array('stock_document_id'=>$id))->result();$n=0; + foreach($lines as$l){$n++;$direction=null;if($type==='adjustment'){$ledger=$this->CI->db->where(array('document_id'=>$id,'document_line_id'=>$l->id))->get('inventory_ledger')->row();$direction=$ledger&&$ledger->direction==='in'?'out':'in';}$this->CI->db->insert('stock_document_lines',array('stock_document_id'=>$reverseId,'line_no'=>$n,'item_id'=>$l->item_id,'barcode_id'=>$l->barcode_id,'batch_id'=>$l->batch_id,'from_bin_id'=>$original->document_type==='transfer'?$l->to_bin_id:$l->from_bin_id,'to_bin_id'=>$original->document_type==='transfer'?$l->from_bin_id:$l->to_bin_id,'qty'=>$original->document_type==='opname'?abs((float)$l->counted_qty-(float)$l->system_qty):$l->qty,'movement_direction'=>$direction,'unit_cost'=>$l->unit_cost,'reason'=>'Reversal baris #'.$l->id,'source_line_type'=>'stock_document_line','source_line_id'=>$l->id));} + $this->history($reverseId,null,'approved','Dokumen reversal dibuat dari '.$original->document_no,$user);$reverse=$this->lockDoc($reverseId,'approved');$this->applyDocument($reverse,$user); + $this->CI->db->where('id',$id)->update('stock_documents',array('status'=>'reversed','reversed_by'=>(int)$user,'reversed_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->history($id,'posted','reversed','Dibalik oleh '.$no.': '.$reason,$user);return$reverseId; + }); + } + + public function reserve($item,$warehouse,$barcode,$qty,$sourceType,$sourceId,$expires,$user,$notes=null) + { + return$this->CI->transactionservice->run(function()use($item,$warehouse,$barcode,$qty,$sourceType,$sourceId,$expires,$user,$notes){ + $company=$this->CI->companycontext->id();$item=(int)$item;$warehouse=(int)$warehouse;$barcode=$this->nullableId($barcode);$qty=(float)$qty;if($qty<=0)throw new BusinessException('Qty reservasi tidak valid.'); + $this->assertItem($item,$company);$this->assertWarehouse($warehouse,$company);if($qty>$this->available($item,$warehouse,true)+.0001)throw new BusinessException('Stok tersedia tidak mencukupi reservasi.'); + $sourceType=trim((string)$sourceType);$sourceId=(int)$sourceId;if($sourceType===''||$sourceId<=0)throw new BusinessException('Dokumen sumber reservasi wajib diisi.'); + $existing=$this->CI->db->where(array('company_id'=>$company,'source_type'=>$sourceType,'source_id'=>$sourceId,'item_id'=>$item,'warehouse_id'=>$warehouse,'barcode_id'=>$barcode))->where_in('status',array('active','partially_released'))->get('stock_reservations')->row();if($existing)return$existing->id; + if($barcode)$this->changeBarcodeReservation($barcode,$item,$warehouse,$qty); + $no=$this->CI->numberingservice->next('stock_reservation',date('Y-m-d'));$this->CI->db->insert('stock_reservations',array('company_id'=>$company,'reservation_no'=>$no,'item_id'=>$item,'warehouse_id'=>$warehouse,'barcode_id'=>$barcode,'source_type'=>$sourceType,'source_id'=>$sourceId,'qty'=>$qty,'status'=>'active','expires_at'=>$expires?:null,'notes'=>$notes,'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$this->reservationHistory($id,null,'active',$qty,'Reservasi dibuat',$user);return$id; + }); + } + + public function reservationAction($id,$action,$qty,$notes,$user) + { + return$this->CI->transactionservice->run(function()use($id,$action,$qty,$notes,$user){ + $r=$this->CI->db->query('SELECT * FROM stock_reservations WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->CI->companycontext->id()))->row();if(!$r||!in_array($r->status,array('active','partially_released'),true))throw new BusinessException('Reservasi sudah tidak aktif.'); + if(!in_array($action,array('release','consume','cancel'),true))throw new BusinessException('Tindakan reservasi tidak valid.');$remaining=(float)$r->qty-(float)$r->released_qty;$qty=$action==='cancel'?$remaining:(float)$qty;if($qty<=0||$qty>$remaining+.0001)throw new BusinessException('Qty penyelesaian reservasi tidak valid.'); + if($r->barcode_id)$this->changeBarcodeReservation($r->barcode_id,$r->item_id,$r->warehouse_id,-$qty); + $released=(float)$r->released_qty+$qty;$status=$released+0.0001>=(float)$r->qty?($action==='consume'?'consumed':($action==='cancel'?'cancelled':'released')):'partially_released'; + $this->CI->db->where('id',$r->id)->update('stock_reservations',array('released_qty'=>$released,'status'=>$status,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'),'completed_at'=>in_array($status,array('released','consumed','cancelled'),true)?date('Y-m-d H:i:s'):null,'notes'=>trim((string)$notes)?:$r->notes));$this->reservationHistory($r->id,$r->status,$status,$qty,$notes,$user);return$r->id; + }); + } + + public function expireReservations($user=0) + { + $rows=$this->CI->db->where('company_id',$this->CI->companycontext->id())->where_in('status',array('active','partially_released'))->where('expires_at IS NOT NULL',null,false)->where('expires_at <',date('Y-m-d H:i:s'))->get('stock_reservations')->result();$count=0; + foreach($rows as$r){try{$this->reservationAction($r->id,'cancel',0,'Kedaluwarsa otomatis',$user);$count++;}catch(Throwable$e){log_message('error','Gagal menutup reservasi #'.$r->id.': '.$e->getMessage());}}return$count; + } + + public function balance($item,$warehouse,$lock=false) + { + $sql="SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty FROM inventory_ledger WHERE item_id=? AND warehouse_id=?".($lock?' FOR UPDATE':'');return(float)$this->CI->db->query($sql,array((int)$item,(int)$warehouse))->row()->qty; + } + + public function available($item,$warehouse,$lock=false) + { + $onHand=$this->balance($item,$warehouse,$lock);$r=(float)$this->CI->db->select('COALESCE(SUM(qty-released_qty),0) qty',false)->where(array('company_id'=>$this->CI->companycontext->id(),'item_id'=>(int)$item,'warehouse_id'=>(int)$warehouse))->where_in('status',array('active','partially_released'))->get('stock_reservations')->row()->qty;$ledgerAvailable=$onHand-$r; + if($lock)$this->CI->db->query('SELECT id FROM item_barcodes WHERE item_id=? AND warehouse_id=? AND qty_sisa>0 FOR UPDATE',array((int)$item,(int)$warehouse)); + $barcode=$this->CI->db->query("SELECT COUNT(*) row_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_qty FROM item_barcodes WHERE item_id=? AND warehouse_id=? AND qty_sisa>0",array((int)$item,(int)$warehouse))->row(); + return$barcode&&(int)$barcode->row_count>0?min($ledgerAvailable,(float)$barcode->available_qty):$ledgerAvailable; + } + + public function valuation($filters=array()){return$this->CI->professionalInventoryModel->valuation($this->CI->companycontext->id(),$filters);} + + private function insertLine($documentId,$lineNo,$type,$from,$to,array$line,$company,$defaultReason) + { + $itemId=(int)($line['item_id']??0);$item=$this->assertItem($itemId,$company);$catalog=$this->CI->db->get_where('kode_barang',array('id'=>$item->kode_id))->row();$qty=(float)($line['qty']??0);if($qty<=0)throw new BusinessException('Qty baris '.$lineNo.' harus lebih dari nol.');if(empty($item->allow_decimal)&&abs($qty-round($qty))>.0001)throw new BusinessException('Barang '.$item->nama_barang.' tidak mengizinkan qty pecahan.'); + $barcode=$this->nullableId($line['barcode_id']??null);if($barcode){$b=$this->CI->db->get_where('item_barcodes',array('id'=>$barcode,'item_id'=>$itemId))->row();if(!$b)throw new BusinessException('Barcode baris '.$lineNo.' tidak sesuai barang.');} + $unit=$catalog&&strtoupper((string)$catalog->tracking_type)==='UNIT';$outgoing=in_array($type,array('out','transfer','purchase_return','damaged','lost'),true)||($type==='adjustment'&&($line['movement_direction']??'')==='out');if($unit&&$outgoing&&!$barcode)throw new BusinessException('Barcode wajib dipilih untuk barang UNIT pada baris '.$lineNo.'.');if($unit&&$barcode&&abs($qty-1)>.0001)throw new BusinessException('Satu baris barcode UNIT harus berjumlah 1 pada baris '.$lineNo.'.'); + $system=null;$counted=null;if($type==='opname'){$system=$this->balance($itemId,$from,true);$counted=$qty;} + $direction=$line['movement_direction']??null;if($type==='adjustment'&&!in_array($direction,array('in','out'),true))throw new BusinessException('Arah penyesuaian baris '.$lineNo.' wajib dipilih.'); + $this->CI->db->insert('stock_document_lines',array('stock_document_id'=>$documentId,'line_no'=>$lineNo,'item_id'=>$itemId,'barcode_id'=>$barcode,'batch_id'=>$this->nullableId($line['batch_id']??null),'from_bin_id'=>$this->nullableId($line['from_bin_id']??null),'to_bin_id'=>$this->nullableId($line['to_bin_id']??null),'qty'=>$qty,'movement_direction'=>$direction,'unit_cost'=>(float)($line['unit_cost']??0),'system_qty'=>$system,'counted_qty'=>$counted,'reason'=>trim((string)($line['reason']??$defaultReason)),'source_line_type'=>trim((string)($line['source_line_type']??''))?:null,'source_line_id'=>$this->nullableId($line['source_line_id']??null))); + } + + private function applyDocument($doc,$user) + { + $this->CI->fiscalperiodservice->assertOpen($doc->document_date);$lines=$this->CI->db->get_where('stock_document_lines',array('stock_document_id'=>$doc->id))->result();if(!$lines)throw new BusinessException('Detail mutasi tidak ditemukan.');$journal=array();$items=array(); + foreach($lines as$line){list($direction,$warehouse,$qty)=$this->movement($doc,$line);if($qty<=.0001){$items[$line->item_id]=true;continue;}$cost=$direction==='out'?$this->outCost($line->item_id,$warehouse,$qty):((float)$line->unit_cost?:$this->averageCost($line->item_id,$warehouse)); + if($doc->document_type==='transfer'){$cost=$this->outCost($line->item_id,$doc->from_warehouse_id,$qty);$this->writeLedger($doc,$line,$doc->from_warehouse_id,'out',$qty,$cost,'OUT');$this->writeLedger($doc,$line,$doc->to_warehouse_id,'in',$qty,$cost,'IN');$this->moveBarcode($doc,$line,$doc->from_warehouse_id,$doc->to_warehouse_id,$qty,'transfer');$this->writeMovement($doc,$line,$qty,'transfer',$doc->from_warehouse_id,$doc->to_warehouse_id);} + else{$this->writeLedger($doc,$line,$warehouse,$direction,$qty,$cost,strtoupper($direction));$this->moveBarcode($doc,$line,$direction==='out'?$warehouse:null,$direction==='in'?$warehouse:null,$qty,$direction);$this->collectJournal($journal,$doc,$qty*$cost,$direction);}$items[$line->item_id]=true; + } + $this->postJournal($doc,$journal,$user);foreach(array_keys($items)as$item)$this->CI->professionalInventoryModel->synchronizeStock($item); + $this->CI->db->where('id',$doc->id)->update('stock_documents',array('status'=>'posted','posted_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->history($doc->id,'approved','posted','Mutasi dan jurnal diposting',$user);$this->CI->auditservice->record('inventory','stock_document',$doc->id,'post',$doc,$this->CI->db->get_where('stock_documents',array('id'=>$doc->id))->row(),$user); + } + + private function movement($doc,$line) + { + if($doc->document_type==='opname'){$current=$this->balance($line->item_id,$doc->from_warehouse_id,true);if(abs($current-(float)$line->system_qty)>.0001)throw new BusinessException('Stok berubah setelah snapshot opname pada baris '.$line->line_no.'. Buat ulang dokumen opname agar hasil tidak salah.');$delta=(float)$line->counted_qty-(float)$line->system_qty;return array($delta>=0?'in':'out',$doc->from_warehouse_id,abs($delta));} + $direction=in_array($doc->document_type,array('out','purchase_return','damaged','lost'),true)?'out':'in';if($doc->document_type==='adjustment')$direction=$line->movement_direction;return array($direction,$direction==='in'?($doc->to_warehouse_id?:$doc->from_warehouse_id):$doc->from_warehouse_id,(float)$line->qty); + } + + private function writeLedger($doc,$line,$warehouse,$direction,$qty,$cost,$suffix) + { + if($direction==='out'&&$this->available($line->item_id,$warehouse,true)<$qty-.0001)throw new BusinessException('Stok tersedia tidak cukup untuk '.$line->item_id.'; stok negatif ditolak.');$key=$doc->idempotency_key.'-'.$suffix.'-'.$line->id;if($this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row())return; + $this->CI->db->insert('inventory_ledger',array('item_id'=>$line->item_id,'warehouse_id'=>$warehouse,'bin_id'=>$direction==='in'?$line->to_bin_id:$line->from_bin_id,'barcode_id'=>$line->barcode_id,'batch_id'=>$line->batch_id,'movement_date'=>$doc->document_date,'direction'=>$direction,'qty'=>$qty,'unit_cost'=>$cost,'value'=>$qty*$cost,'document_type'=>$doc->document_type,'document_id'=>$doc->id,'document_line_id'=>$line->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$ledger=$this->CI->db->insert_id(); + $this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'barcode_id'=>$line->barcode_id,'warehouse_id'=>$warehouse,'qty'=>$qty,'tipe'=>$direction==='in'?'masuk':'keluar','unit_cost'=>$cost,'keterangan'=>'[AUTO] '.strtoupper($doc->document_type).' '.$doc->document_no.' | '.$doc->reason.' | Baris '.$line->line_no,'ref_type'=>'stock_document','ref_id'=>$doc->id,'idempotency_key'=>$key)); + if($direction==='in')$this->CI->db->insert('inventory_cost_layers',array('item_id'=>$line->item_id,'warehouse_id'=>$warehouse,'source_ledger_id'=>$ledger,'received_date'=>$doc->document_date,'original_qty'=>$qty,'remaining_qty'=>$qty,'unit_cost'=>$cost,'status'=>'open')); + if($doc->document_type!=='transfer'){$movementType=$doc->document_type==='sales_return'?'customer_return':($doc->document_type==='purchase_return'?'purchase_return':'adjustment');$this->writeMovement($doc,$line,$qty,$movementType,$direction==='out'?$warehouse:0,$direction==='in'?$warehouse:0);} + } + + private function writeMovement($doc,$line,$qty,$movementType,$from,$to) + { + if(!$this->CI->db->where(array('reference_type'=>'stock_document','reference_id'=>$doc->id,'item_id'=>$line->item_id,'barcode_id'=>$line->barcode_id,'qty'=>$qty,'movement_type'=>$movementType))->get('item_movements')->row())$this->CI->db->insert('item_movements',array('item_id'=>$line->item_id,'barcode_id'=>$line->barcode_id,'qty'=>$qty,'from_type'=>'warehouse','from_id'=>$from,'to_type'=>'warehouse','to_id'=>$to,'movement_type'=>$movementType,'reference_type'=>'stock_document','reference_id'=>$doc->id,'notes'=>'[AUTO] '.$doc->document_no.' | '.$doc->reason,'created_at'=>date('Y-m-d H:i:s'))); + } + + private function outCost($item,$warehouse,$qty) + { + $row=$this->CI->db->select('costing_method')->get_where('items',array('id'=>$item))->row();$method=$row?$row->costing_method:'average';if($method==='average')return$this->averageCost($item,$warehouse);$remaining=$qty;$value=0; + foreach($this->CI->db->query("SELECT * FROM inventory_cost_layers WHERE item_id=? AND warehouse_id=? AND status='open' AND remaining_qty>0 ORDER BY received_date,id FOR UPDATE",array($item,$warehouse))->result()as$l){$take=min($remaining,(float)$l->remaining_qty);$value+=$take*(float)$l->unit_cost;$left=(float)$l->remaining_qty-$take;$this->CI->db->where('id',$l->id)->update('inventory_cost_layers',array('remaining_qty'=>$left,'status'=>$left<=.0001?'consumed':'open'));$remaining-=$take;if($remaining<=.0001)break;}if($remaining>.0001)throw new BusinessException('Layer FIFO tidak mencukupi.');return$value/$qty; + } + + private function averageCost($item,$warehouse) + { + $r=$this->CI->db->query("SELECT SUM(IF(direction='in',qty,-qty)) qty,SUM(IF(direction='in',value,-value)) value FROM inventory_ledger WHERE item_id=? AND warehouse_id=?",array($item,$warehouse))->row();if($r&&(float)$r->qty>0)return(float)$r->value/(float)$r->qty;$i=$this->CI->db->get_where('items',array('id'=>$item))->row();return$i?(float)$i->harga_beli:0; + } + + private function moveBarcode($doc,$line,$from,$to,$qty,$mode) + { + if(!$line->barcode_id){if($mode==='in')$this->createIncomingBarcodes($doc,$line,$to,$qty);elseif(in_array($mode,array('out','transfer'),true))$this->moveUnspecifiedBarcodes($doc,$line,$from,$to,$qty,$mode);return;}$b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($line->barcode_id))->row();if(!$b)throw new BusinessException('Barcode tidak ditemukan.');if($from&&(int)$b->warehouse_id!==(int)$from)throw new BusinessException('Barcode sudah berada di lokasi lain.');$available=(float)$b->qty_sisa-(float)$b->reserved_qty-(float)($b->return_reserved_qty??0);if(in_array($mode,array('out','transfer'),true)&&$available<$qty-.0001)throw new BusinessException('Qty barcode tidak cukup atau sedang direservasi.');if($mode==='transfer'&&((float)$b->reserved_qty>.0001||(float)($b->return_reserved_qty??0)>.0001))throw new BusinessException('Barcode yang sedang direservasi tidak dapat dipindahkan.'); + if($mode==='transfer'&&$qty<(float)$b->qty_sisa-.0001){$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('qty_sisa'=>(float)$b->qty_sisa-$qty,'version'=>(int)$b->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses transaksi lain.');$copy=(array)$b;unset($copy['id']);$copy['barcode']=$b->barcode.'-T'.$doc->id.'-'.$line->line_no;$copy['warehouse_id']=$to;$copy['bin_id']=$line->to_bin_id;$copy['qty_awal']=$qty;$copy['qty_sisa']=$qty;$copy['reserved_qty']=0;$copy['return_reserved_qty']=0;$copy['version']=1;$copy['created_at']=date('Y-m-d H:i:s');$this->CI->db->insert('item_barcodes',$copy);return;} + $data=array('version'=>(int)$b->version+1);if($to){$data['warehouse_id']=$to;$data['bin_id']=$line->to_bin_id;}if($mode==='out'){$data['qty_sisa']=(float)$b->qty_sisa-$qty;$terminal=$doc->document_type==='damaged'?'closed':($doc->document_type==='lost'?'closed':($doc->document_type==='purchase_return'?'returned':'sold_out'));$data['status']=$data['qty_sisa']<=.0001?$terminal:((float)$b->reserved_qty>.0001?'reserved':'available');}elseif($mode==='in'){$data['qty_sisa']=(float)$b->qty_sisa+$qty;$data['status']='available';}$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',$data);if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses transaksi lain.'); + } + + private function moveUnspecifiedBarcodes($doc,$line,$from,$to,$qty,$mode) + { + $remaining=$qty;$rows=$this->CI->db->query("SELECT * FROM item_barcodes WHERE item_id=? AND warehouse_id=? AND qty_sisa-reserved_qty-return_reserved_qty>0 AND status IN('available','reserved') ORDER BY id FOR UPDATE",array($line->item_id,$from))->result(); + foreach($rows as$b){$available=(float)$b->qty_sisa-(float)$b->reserved_qty-(float)$b->return_reserved_qty;if($available<=.0001)continue;$take=min($remaining,$available);$copy=clone$line;$copy->barcode_id=$b->id;$this->moveBarcode($doc,$copy,$from,$to,$take,$mode);$remaining-=$take;if($remaining<=.0001)break;} + if($remaining>.0001)throw new BusinessException('Barcode aktif tidak mencukupi qty mutasi.'); + } + + private function createIncomingBarcodes($doc,$line,$warehouse,$qty) + { + $item=$this->CI->db->select('i.*,k.tracking_type,k.need_serial_number')->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where('i.id',$line->item_id)->get()->row();$unit=$item&&strtoupper((string)$item->tracking_type)==='UNIT';if($unit&&abs($qty-round($qty))>.0001)throw new BusinessException('Barang UNIT tidak menerima qty pecahan.');$count=$unit?(int)round($qty):1;$each=$unit?1:$qty; + for($n=1;$n<=$count;$n++){$code=strtoupper(preg_replace('/[^A-Za-z0-9]/','',(string)$item->kode_detail)).'-'.$doc->id.'-'.$line->line_no.'-'.str_pad($n,3,'0',STR_PAD_LEFT);$pending=$item&&strtolower((string)$item->need_serial_number)==='true';$this->CI->db->insert('item_barcodes',array('item_id'=>$line->item_id,'barcode'=>$code,'warehouse_id'=>$warehouse,'qty_awal'=>$each,'qty_sisa'=>$each,'sale_price'=>$item->harga_jual,'status'=>$pending?'pending':'available','activated_at'=>$pending?null:date('Y-m-d H:i:s'),'activated_by'=>$pending?null:$doc->created_by,'batch_id'=>$line->batch_id,'bin_id'=>$line->to_bin_id,'version'=>1,'created_at'=>date('Y-m-d H:i:s')));} + } + + private function collectJournal(array&$entries,$doc,$value,$direction) + { + if($value<=0||in_array($doc->document_type,array('transfer','sales_return','purchase_return'),true))return;$inventory=$this->CI->accountmappingservice->get('inventory');$other=$this->CI->accountmappingservice->get($direction==='in'?'inventory_adjustment_gain':'inventory_adjustment_loss');$lines=$direction==='in'?array(array($inventory,$value,0),array($other,0,$value)):array(array($other,$value,0),array($inventory,0,$value));foreach($lines as$l){if(!isset($entries[$l[0]]))$entries[$l[0]]=array('account_id'=>$l[0],'debit'=>0,'kredit'=>0);$entries[$l[0]]['debit']+=$l[1];$entries[$l[0]]['kredit']+=$l[2];} + } + + private function postJournal($doc,array$entries,$user) + { + if(!$entries)return;$this->CI->postingservice->post(array('tanggal'=>$doc->document_date,'no_ref'=>'INV-'.$doc->document_no,'keterangan'=>'[AUTO] Mutasi persediaan '.$doc->document_no.' | Jenis '.strtoupper($doc->document_type).' | Alasan: '.$doc->reason,'ref_type'=>'stock_document','ref_id'=>$doc->id,'company_id'=>$doc->company_id,'created_by'=>$user),array_values($entries),false,true); + } + + private function changeBarcodeReservation($barcodeId,$item,$warehouse,$delta) + { + $b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$b||(int)$b->item_id!==(int)$item||(int)$b->warehouse_id!==(int)$warehouse)throw new BusinessException('Barcode reservasi tidak sesuai.');$new=(float)$b->reserved_qty+$delta;if($new<-.0001||$new>(float)$b->qty_sisa+.0001)throw new BusinessException('Qty reservasi barcode tidak valid.');$status=$new>.0001?'reserved':((float)$b->qty_sisa>.0001?'available':'sold_out');$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('reserved_qty'=>max(0,$new),'status'=>$status,'version'=>(int)$b->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses transaksi lain.'); + } + + private function validateWarehouses($type,$from,$to,$company) + { + if(in_array($type,array('out','purchase_return','damaged','lost','adjustment','opname'),true)&&!$from)throw new BusinessException('Gudang asal wajib dipilih.');if(in_array($type,array('opening','in','sales_return'),true)&&!$to)throw new BusinessException('Gudang tujuan wajib dipilih.');if($type==='transfer'&&(!$from||!$to||$from===$to))throw new BusinessException('Gudang asal dan tujuan transfer wajib berbeda.');if($from)$this->assertWarehouse($from,$company);if($to)$this->assertWarehouse($to,$company); + } + + private function assertWarehouse($id,$company){$w=$this->CI->db->get_where('warehouses',array('id'=>(int)$id,'company_id'=>(int)$company,'is_active'=>1))->row();if(!$w)throw new BusinessException('Gudang tidak valid atau tidak aktif.');return$w;} + private function assertItem($id,$company){$i=$this->CI->db->get_where('items',array('id'=>(int)$id,'company_id'=>(int)$company))->row();if(!$i)throw new BusinessException('Barang tidak ditemukan pada perusahaan aktif.');return$i;} + private function lockDoc($id,$status){$d=$this->CI->db->query('SELECT * FROM stock_documents WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->CI->companycontext->id()))->row();if(!$d)throw new BusinessException('Dokumen stok tidak ditemukan.');if($d->status!==$status)throw new BusinessException('Status dokumen harus '.$status.'.');return$d;} + private function history($id,$from,$to,$notes,$user){$this->CI->db->insert('stock_document_status_history',array('stock_document_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));} + private function reservationHistory($id,$from,$to,$qty,$notes,$user){$this->CI->db->insert('stock_reservation_status_history',array('stock_reservation_id'=>$id,'from_status'=>$from,'to_status'=>$to,'qty'=>$qty,'notes'=>$notes,'user_id'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));} + private function documentAmount($id){$r=$this->CI->db->select('COALESCE(SUM(CASE WHEN counted_qty IS NOT NULL THEN ABS(counted_qty-system_qty)*unit_cost ELSE qty*unit_cost END),0) total',false)->get_where('stock_document_lines',array('stock_document_id'=>$id))->row();return$r?(float)$r->total:0;} + private function nullableId($value){$value=(int)$value;return$value>0?$value:null;} + private function validDate($value){$d=DateTime::createFromFormat('Y-m-d',(string)$value);return$d&&$d->format('Y-m-d')===$value;} +} diff --git a/application/libraries/ProfessionalReportService.php b/application/libraries/ProfessionalReportService.php new file mode 100644 index 0000000..0639a28 --- /dev/null +++ b/application/libraries/ProfessionalReportService.php @@ -0,0 +1,20 @@ +CI=&get_instance();$this->CI->load->library('CompanyContext');} + private function filters(array$f){return array(' j.company_id=? AND j.status IN(\'posted\',\'reversed\') AND j.tanggal BETWEEN ? AND ?',array($this->CI->companycontext->id(),$f['from'],$f['to']));} + public function trialBalance(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,COALESCE(SUM(d.debit),0) debit,COALESCE(SUM(d.kredit),0) credit,COALESCE(SUM(d.debit-d.kredit),0) balance FROM accounts a LEFT JOIN journal_details d ON d.account_id=a.id LEFT JOIN journals j ON j.id=d.journal_id AND j.company_id=a.company_id WHERE a.company_id=? AND (j.id IS NULL OR ($w)) GROUP BY a.id ORDER BY a.priority ASC,a.kode_akun ASC,a.id ASC",array_merge(array($this->CI->companycontext->id()),$b))->result_array();} + public function statement($type,array$f,$priorityOrder=true){list($w,$b)=$this->filters($f);$types=$type==='profit_loss'?array('revenue','expense'):array('asset','liability','equity');$marks=implode(',',array_fill(0,count($types),'?'));$company=$this->CI->companycontext->id();$order=$priorityOrder?'a.priority ASC,a.kode_akun ASC,a.id ASC':'a.kode_akun ASC';$sql="SELECT a.id account_id,a.kode_akun,a.nama_akun,a.tipe,COALESCE(x.amount,0) amount FROM accounts a LEFT JOIN (SELECT d.account_id,SUM(IF(ac.tipe IN('asset','expense'),d.debit-d.kredit,d.kredit-d.debit)) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts ac ON ac.id=d.account_id WHERE $w GROUP BY d.account_id)x ON x.account_id=a.id WHERE a.company_id=? AND a.tipe IN($marks) ORDER BY $order";return$this->CI->db->query($sql,array_merge($b,array($company),$types))->result_array();} + public function ledger(array$f){list($w,$b)=$this->filters($f);$sql="SELECT j.id journal_id,j.tanggal,j.no_ref,j.keterangan,j.ref_type,j.ref_id,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 $w";if(!empty($f['account_id'])){$sql.=' AND d.account_id=?';$b[]=(int)$f['account_id'];}$sql.=' ORDER BY j.tanggal,j.id,d.id';return$this->CI->db->query($sql,$b)->result_array();} + public function cashFlow(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT CASE WHEN j.ref_type IN('invoice','customer_payment','supplier_invoice','supplier_payment','payroll','payroll_payment') THEN 'operating' WHEN j.ref_type LIKE 'asset_%' THEN 'investing' ELSE 'financing_or_other' END section,SUM(IF(a.sub_tipe='kas',d.debit-d.kredit,0)) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w GROUP BY section",$b)->result_array();} + public function equity(array$f){list($w,$b)=$this->filters($f);return$this->CI->db->query("SELECT a.kode_akun,a.nama_akun,SUM(d.kredit-d.debit) amount FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $w AND a.tipe='equity' GROUP BY a.id ORDER BY a.kode_akun",$b)->result_array();} + public function actualBudget(array$f){$this->CI->load->library('BudgetService');$source=$this->CI->budgetservice->accountReport(array('from'=>substr($f['from'],0,7),'to'=>substr($f['to'],0,7),'account_id'=>(int)($f['account_id']??0)));$rows=array();foreach($source as$r)$rows[]=array('kode_akun'=>$r['kode_akun'],'nama_akun'=>$r['nama_akun'],'actual'=>$r['actual'],'budget'=>$r['budget'],'variance'=>$r['actual']-$r['budget']);return$rows;} + public function comparative(array$f){$current=$this->statement('profit_loss',$f,false);$pm=array_merge($f,array('from'=>date('Y-m-d',strtotime($f['from'].' -1 month')),'to'=>date('Y-m-d',strtotime($f['to'].' -1 month'))));$py=array_merge($f,array('from'=>date('Y-m-d',strtotime($f['from'].' -1 year')),'to'=>date('Y-m-d',strtotime($f['to'].' -1 year'))));$idx=array();foreach($this->statement('profit_loss',$pm,false)as$r)$idx[$r['account_id']]['previous_month']=$r['amount'];foreach($this->statement('profit_loss',$py,false)as$r)$idx[$r['account_id']]['previous_year']=$r['amount'];foreach($current as&$r){$r['previous_month']=isset($idx[$r['account_id']]['previous_month'])?$idx[$r['account_id']]['previous_month']:0;$r['previous_year']=isset($idx[$r['account_id']]['previous_year'])?$idx[$r['account_id']]['previous_year']:0;$r['variance_month']=$r['amount']-$r['previous_month'];$r['variance_year']=$r['amount']-$r['previous_year'];}return$current;} + public function purchaseReturns(array$f){return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,s.supplier_code,s.name supplier,po.po_no,r.problem_category,r.requested_resolution,r.status,r.amount,r.journal_id')->from('purchase_returns r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->where('r.company_id',$this->CI->companycontext->id())->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->order_by('r.return_date')->order_by('r.id')->get()->result_array();} + public function supplierRefunds(array$f,$outstanding=false){$this->CI->db->select('r.id record_id,r.refund_no,r.claim_date,r.expected_date,s.supplier_code,s.name supplier,po.po_no,si.internal_no invoice_supplier,r.source_type,r.status,r.claim_amount,r.received_amount,r.balance,r.entitlement_journal_id journal_id')->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',$this->CI->companycontext->id())->where('r.claim_date >=',$f['from'])->where('r.claim_date <=',$f['to']);if($outstanding)$this->CI->db->where('r.balance >',0)->where_not_in('r.status',array('draft','rejected','cancelled','reversed','reconciled'));return$this->CI->db->order_by('r.claim_date')->order_by('r.id')->get()->result_array();} + public function supplierDebitNotes(array$f){return$this->CI->db->select('d.id record_id,d.debit_note_no,d.note_date,s.supplier_code,s.name supplier,po.po_no,si.internal_no invoice_supplier,d.correction_type,d.status,d.base_amount,d.tax_amount,d.amount,d.journal_id')->from('supplier_debit_notes d')->join('suppliers s','s.id=d.supplier_id')->join('purchase_orders po','po.id=d.purchase_order_id','left')->join('supplier_invoices si','si.id=d.supplier_invoice_id','left')->where('d.company_id',$this->CI->companycontext->id())->where('d.note_date >=',$f['from'])->where('d.note_date <=',$f['to'])->order_by('d.note_date')->order_by('d.id')->get()->result_array();} + public function salesReport($code,array$f){$c=$this->CI->companycontext->id();if($code==='sales_summary')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,c.nama customer,i.invoice_type,i.recognition_policy,i.status,i.total,i.total_bayar,i.sisa_piutang')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='sales_by_customer')return$this->CI->db->select('c.id customer_id,c.nama customer,COUNT(i.id) invoice_count,SUM(i.total) sales,SUM(i.total_bayar) paid,SUM(i.sisa_piutang) balance',false)->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('c.id')->order_by('sales','DESC')->get()->result_array();if($code==='sales_by_item')return$this->CI->db->select('d.items_id item_id,it.kode_detail AS kode_barang,it.nama_barang,SUM(d.qty) qty,SUM(d.net_amount) net_sales,SUM(d.tax_amount) tax,SUM(d.subtotal) total',false)->from('invoice_details d')->join('invoices i','i.id=d.invoice_id')->join('items it','it.id=d.items_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->group_by('d.items_id')->order_by('net_sales','DESC')->get()->result_array();if($code==='sales_by_barcode')return$this->CI->db->select('i.id record_id,i.no_invoice,i.tanggal,it.kode_detail AS kode_barang,it.nama_barang,b.barcode,b.serial_number,lb.qty,sd.delivery_no')->from('invoice_line_barcodes lb')->join('invoices i','i.id=lb.invoice_id')->join('item_barcodes b','b.id=lb.barcode_id')->join('items it','it.id=b.item_id')->join('sales_deliveries sd','sd.id=lb.sales_delivery_id','left')->where('lb.company_id',$c)->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='running_invoices')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.period_start,i.period_end,i.delivery_status,i.total,i.workflow_status')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.invoice_type'=>'running','i.workflow_status'=>'draft'))->order_by('i.period_end')->get()->result_array();if($code==='sales_returns')return$this->CI->db->select('r.id record_id,r.return_no,r.return_date,c.nama customer,i.no_invoice,d.delivery_no,r.resolution,r.status,SUM(l.net_amount+l.tax_amount) amount',false)->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->join('sales_return_lines l','l.sales_return_id=r.id','left')->where('r.company_id',$c)->where('r.return_date >=',$f['from'])->where('r.return_date <=',$f['to'])->group_by('r.id')->order_by('r.return_date')->get()->result_array();if($code==='revenue_recognition')return$this->CI->db->select('i.id record_id,i.no_invoice,c.nama customer,i.recognition_policy,i.subtotal_before_tax invoice_net,i.recognized_revenue,i.subtotal_before_tax-i.recognized_revenue deferred_revenue,i.recognized_cogs')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.company_id'=>$c,'i.workflow_status'=>'posted'))->where('i.tanggal >=',$f['from'])->where('i.tanggal <=',$f['to'])->order_by('i.tanggal')->get()->result_array();if($code==='unbilled_deliveries')return$this->CI->db->select('d.id record_id,d.delivery_no,d.delivery_date,c.nama customer,d.status')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->where('d.company_id',$c)->where('d.invoice_id IS NULL',null,false)->where('d.delivery_date >=',$f['from'])->where('d.delivery_date <=',$f['to'])->get()->result_array();if($code==='receivable_reconciliation'){$ar=$this->CI->load->library('AccountMappingService')?:null;$account=$this->CI->accountmappingservice->get('accounts_receivable');$sub=(float)$this->CI->db->select('COALESCE(SUM(sisa_piutang),0) v',false)->where(array('company_id'=>$c,'workflow_status'=>'posted'))->get('invoices')->row()->v;$gl=(float)$this->CI->db->select('COALESCE(SUM(d.debit-d.kredit),0) v',false)->from('journal_details d')->join('journals j','j.id=d.journal_id')->where(array('j.company_id'=>$c,'j.status'=>'posted','d.account_id'=>$account))->get()->row()->v;return array(array('subledger_piutang'=>$sub,'buku_besar_piutang'=>$gl,'selisih'=>$sub-$gl,'status'=>abs($sub-$gl)<.01?'Balance':'Perlu direkonsiliasi'));}throw new BusinessException('Jenis laporan penjualan tidak valid.');} + public function run($code,array$f){if($code==='trial_balance')return$this->trialBalance($f);if(in_array($code,array('profit_loss','balance_sheet'),true))return$this->statement($code,$f);if(in_array($code,array('general_ledger','journal_register','account_detail'),true))return$this->ledger($f);if($code==='cash_flow')return$this->cashFlow($f);if($code==='changes_equity')return$this->equity($f);if($code==='actual_budget')return$this->actualBudget($f);if($code==='comparative')return$this->comparative($f);if($code==='purchase_returns')return$this->purchaseReturns($f);if($code==='supplier_refunds')return$this->supplierRefunds($f);if($code==='outstanding_supplier_refunds')return$this->supplierRefunds($f,true);if($code==='supplier_debit_notes')return$this->supplierDebitNotes($f);if(in_array($code,array('sales_summary','sales_by_customer','sales_by_item','sales_by_barcode','running_invoices','sales_returns','revenue_recognition','unbilled_deliveries','receivable_reconciliation'),true))return$this->salesReport($code,$f);throw new BusinessException('Jenis laporan tidak valid.');} +} diff --git a/application/libraries/PurchaseAdjustmentService.php b/application/libraries/PurchaseAdjustmentService.php new file mode 100644 index 0000000..e7133fe --- /dev/null +++ b/application/libraries/PurchaseAdjustmentService.php @@ -0,0 +1,663 @@ +CI =& get_instance(); + $this->CI->load->library(array( + 'TransactionService','NumberingService','ApprovalService','AuditService', + 'PostingService','AccountMappingService','FiscalPeriodService','JournalWorkflowService' + )); + $this->CI->load->model('InventoryModel', 'adjustmentInventoryModel'); + } + + public function returnSources($supplierId = 0) + { + $company = $this->companyId(); + $this->CI->db->select( + "grl.id receipt_line_id,gr.id goods_receipt_id,gr.receipt_no,gr.receipt_date,". + "po.id purchase_order_id,po.po_no,po.supplier_id,s.supplier_code,s.name supplier_name,". + "pol.id po_line_id,pol.description,pol.tracking_type,pol.need_serial_number,". + "grl.item_id,grl.warehouse_id,grl.qty received_qty,w.nama warehouse_name,". + "k.kode_barang,k.nama item_name,COALESCE(i.harga_beli,pol.unit_price) unit_cost,". + "COALESCE((SELECT SUM(x.qty) FROM purchase_return_lines x JOIN purchase_returns rh ON rh.id=x.purchase_return_id WHERE x.receipt_line_id=grl.id AND rh.status NOT IN('draft','rejected','cancelled','reversed')),0) committed_return_qty,". + "GREATEST(0,grl.qty-COALESCE((SELECT SUM(x.qty) FROM purchase_return_lines x JOIN purchase_returns rh ON rh.id=x.purchase_return_id WHERE x.receipt_line_id=grl.id AND rh.status NOT IN('draft','rejected','cancelled','reversed')),0)) returnable_qty", + false + )->from('goods_receipt_lines grl') + ->join('goods_receipts gr','gr.id=grl.goods_receipt_id') + ->join('purchase_order_lines pol','pol.id=grl.po_line_id') + ->join('purchase_orders po','po.id=pol.purchase_order_id') + ->join('suppliers s','s.id=po.supplier_id') + ->join('warehouses w','w.id=grl.warehouse_id','left') + ->join('items i','i.id=grl.item_id','left') + ->join('kode_barang k','k.id=pol.kode_barang_id','left') + ->where('gr.status','posted') + ->having('returnable_qty >', 0); + if ($supplierId) $this->CI->db->where('po.supplier_id', (int)$supplierId); + if ($this->CI->db->field_exists('company_id','purchase_orders')) $this->CI->db->where('po.company_id', $company); + return $this->CI->db->order_by('gr.receipt_date','DESC')->order_by('gr.id','DESC')->get()->result_array(); + } + + public function barcodesForReceiptLine($receiptLineId) + { + return $this->CI->db->select('ib.id,ib.barcode,ib.serial_number,ib.qty_sisa,ib.return_reserved_qty,ib.status,pol.tracking_type') + ->from('item_barcodes ib') + ->join('goods_receipt_lines grl','grl.id=ib.source_receipt_line_id') + ->join('purchase_order_lines pol','pol.id=grl.po_line_id') + ->join('purchase_orders po','po.id=pol.purchase_order_id') + ->where('ib.source_receipt_line_id',(int)$receiptLineId) + ->where('po.company_id',$this->companyId()) + ->where('ib.qty_sisa > ib.return_reserved_qty', null, false) + ->where_in('ib.status', array('available','pending','return_reserved')) + ->order_by('ib.barcode')->get()->result_array(); + } + + public function createReturn(array $data, array $inputLines, $user) + { + return $this->CI->transactionservice->run(function () use ($data, $inputLines, $user) { + $date = $this->validDate($data['return_date'] ?? null, 'Tanggal pengajuan retur'); + $reason = trim((string)($data['reason'] ?? '')); + if ($reason === '') throw new BusinessException('Alasan retur wajib diisi.'); + $category = $this->enum($data['problem_category'] ?? '', array('damaged','wrong_item','specification','over_delivery','po_mismatch','other'), 'other'); + $resolution = $this->enum($data['requested_resolution'] ?? '', array('replacement','debit_note','refund','undecided'), 'undecided'); + $idempotency = trim((string)($data['idempotency_key'] ?? '')); + if ($idempotency === '') throw new BusinessException('Token transaksi retur tidak tersedia. Muat ulang form lalu coba lagi.'); + $existing = $this->CI->db->get_where('purchase_returns', array('idempotency_key'=>$idempotency))->row(); + if ($existing) return (int)$existing->id; + if (!$inputLines) throw new BusinessException('Minimal satu barang wajib dipilih untuk retur.'); + + $normalized = array(); $supplierId = 0; $primaryPo = 0; $primaryReceipt = 0; $amount = 0; + foreach ($inputLines as $input) { + $line = $this->sourceLine((int)($input['receipt_line_id'] ?? 0), false); + if (!$line) throw new BusinessException('Salah satu detail penerimaan tidak ditemukan.'); + if (!$supplierId) $supplierId = (int)$line->supplier_id; + if ($supplierId !== (int)$line->supplier_id) throw new BusinessException('Satu batch retur hanya boleh berisi barang dari satu supplier.'); + $primaryPo = $primaryPo ?: (int)$line->purchase_order_id; + $primaryReceipt = $primaryReceipt ?: (int)$line->goods_receipt_id; + $barcodeId = (int)($input['barcode_id'] ?? 0); + $qty = round((float)($input['qty'] ?? 0), 4); + $barcode = $this->validateBarcode($line, $barcodeId, $qty, false); + $committed = $this->committedReturnQty($line->receipt_line_id); + $returnable = round((float)$line->received_qty - $committed, 4); + if ($qty <= 0 || $qty > $returnable + .0001) throw new BusinessException('Qty retur '.$line->description.' melebihi saldo penerimaan yang dapat diretur.'); + $unitCost = round((float)$line->unit_cost, 4); + $base = round($qty * $unitCost, 2); + $tax = $this->returnTaxForLine($line->receipt_line_id, $qty); + $normalized[] = array('source'=>$line,'barcode'=>$barcode,'qty'=>$qty,'committed'=>$committed,'returnable'=>$returnable,'unit_cost'=>$unitCost,'tax'=>$tax,'base'=>$base); + $amount += $base + $tax; + } + + $number = $this->CI->numberingservice->next('purchase_return', $date); + $this->CI->db->insert('purchase_returns', array( + 'company_id'=>$this->companyId(),'return_no'=>$number,'supplier_id'=>$supplierId, + 'purchase_order_id'=>$primaryPo,'goods_receipt_id'=>$primaryReceipt,'return_date'=>$date, + 'amount'=>round($amount,2),'reason'=>$reason,'problem_category'=>$category, + 'requested_resolution'=>$resolution,'notes'=>trim((string)($data['notes'] ?? '')) ?: null, + 'status'=>'draft','idempotency_key'=>$idempotency,'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s') + )); + $returnId = (int)$this->CI->db->insert_id(); + foreach ($normalized as $row) { + $line = $row['source']; $barcode = $row['barcode']; + $this->CI->db->insert('purchase_return_lines', array( + 'company_id'=>$this->companyId(),'purchase_return_id'=>$returnId, + 'purchase_order_id'=>$line->purchase_order_id,'goods_receipt_id'=>$line->goods_receipt_id, + 'receipt_line_id'=>$line->receipt_line_id,'item_id'=>$line->item_id, + 'barcode_id'=>$barcode ? $barcode->id : null,'barcode_snapshot'=>$barcode ? $barcode->barcode : null, + 'serial_snapshot'=>$barcode ? $barcode->serial_number : null,'warehouse_id'=>$line->warehouse_id, + 'description'=>$line->description,'tracking_type'=>$line->tracking_type, + 'received_qty'=>$line->received_qty,'previous_return_qty'=>$row['committed'], + 'returnable_qty'=>$row['returnable'],'qty'=>$row['qty'],'unit_cost'=>$row['unit_cost'], + 'tax_amount'=>$row['tax'],'total_amount'=>$row['base']+$row['tax'], + 'requested_resolution'=>$resolution,'status'=>'draft' + )); + } + $this->history($returnId, null, 'draft', $user, 'Draft pengajuan retur dibuat.'); + $this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'create',null,array('return_no'=>$number,'supplier_id'=>$supplierId,'line_count'=>count($normalized),'amount'=>$amount),$user); + return $returnId; + }); + } + + public function submitReturn($returnId, $user) + { + return $this->CI->transactionservice->run(function () use ($returnId, $user) { + $header = $this->lockReturn($returnId, array('draft')); + if ((int)$header->created_by !== (int)$user && !$this->isMasterAdmin()) throw new BusinessException('Hanya pembuat draft yang dapat mengajukan retur.'); + if (!$this->hasAttachment('purchase_return', $returnId)) throw new BusinessException('Lampiran bukti pengajuan retur wajib tersedia.'); + $lines = $this->CI->db->where('purchase_return_id',$returnId)->get('purchase_return_lines')->result(); + if (!$lines) throw new BusinessException('Pengajuan retur belum memiliki barang.'); + foreach ($lines as $line) $this->assertReturnLineAvailable($line, false); + $this->CI->approvalservice->create('purchase_return','purchase_return',$returnId,$header->amount,$user,array('return'=>$header,'lines'=>$lines)); + $this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'submitted','submitted_by'=>(int)$user,'submitted_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->history($returnId, 'draft', 'submitted', $user, 'Pengajuan retur dikirim untuk persetujuan.'); + return $returnId; + }); + } + + public function approveReturn($returnId, $user, $roleId) + { + return $this->CI->transactionservice->run(function () use ($returnId, $user, $roleId) { + $header = $this->lockReturn($returnId, array('submitted')); + $request = $this->CI->approvalservice->pendingForEntity('purchase_return',$returnId); + if (!$request) throw new BusinessException('Permintaan persetujuan retur tidak ditemukan.'); + $approval = $this->CI->approvalservice->act($request->id,'approved',$user,$roleId); + if ($approval->status !== 'approved') return $returnId; + $this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'approved','approved_by'=>(int)$user,'approved_at'=>date('Y-m-d H:i:s'))); + $this->history($returnId, 'submitted', 'approved', $user, 'Retur disetujui; sistem melanjutkan reservasi barang.'); + foreach ($this->CI->db->where('purchase_return_id',$returnId)->get('purchase_return_lines')->result() as $line) { + $this->assertReturnLineAvailable($line, true); + if ($line->barcode_id) { + $barcode = $this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$line->barcode_id))->row(); + $newReserved = round((float)$barcode->return_reserved_qty + (float)$line->qty, 4); + $update = array('return_reserved_qty'=>$newReserved); + if ($line->tracking_type === 'UNIT' || $newReserved >= (float)$barcode->qty_sisa-.0001) $update['status']='return_reserved'; + $this->CI->db->where('id',$barcode->id)->update('item_barcodes',$update); + } + $this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('reserved_qty'=>$line->qty,'status'=>'reserved')); + } + $this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'reserved','reserved_by'=>(int)$user,'reserved_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->history($returnId, 'approved', 'reserved', $user, 'Barcode/batch telah direservasi khusus untuk retur; stok belum berkurang.'); + $this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'approve_and_reserve',$header,array('status'=>'reserved'),$user); + return $returnId; + }); + } + + public function rejectReturn($returnId, $reason, $user, $roleId) + { + return $this->CI->transactionservice->run(function () use ($returnId, $reason, $user, $roleId) { + $header = $this->lockReturn($returnId, array('submitted')); + $reason = trim((string)$reason); + if ($reason === '') throw new BusinessException('Alasan penolakan wajib diisi.'); + $request = $this->CI->approvalservice->pendingForEntity('purchase_return',$returnId); + if (!$request) throw new BusinessException('Permintaan persetujuan retur tidak ditemukan.'); + $this->CI->approvalservice->act($request->id,'rejected',$user,$roleId,$reason); + $this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'rejected','rejected_by'=>(int)$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason)); + $this->history($returnId, 'submitted', 'rejected', $user, $reason); + $this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'reject',$header,array('status'=>'rejected','reason'=>$reason),$user); + return $returnId; + }); + } + + public function postReturnShipment($returnId, array $shipment, $user) + { + return $this->CI->transactionservice->run(function () use ($returnId, $shipment, $user) { + $header = $this->lockReturn($returnId, array('reserved')); + $date = $this->validDate($shipment['shipment_date'] ?? null, 'Tanggal pengiriman retur'); + $this->CI->fiscalperiodservice->assertOpen($date); + $key = trim((string)($shipment['idempotency_key'] ?? '')); + if ($key === '') throw new BusinessException('Token posting pengiriman tidak tersedia.'); + $existing = $this->CI->db->get_where('purchase_return_shipments',array('idempotency_key'=>$key))->row(); + if ($existing) return (int)$existing->id; + if (!$this->hasAttachment('purchase_return_shipment', $returnId) && empty($shipment['attachment_pending'])) throw new BusinessException('Bukti pengiriman retur wajib tersedia.'); + + $supplier = $this->CI->db->get_where('suppliers',array('id'=>$header->supplier_id))->row(); + $number = $this->CI->numberingservice->next('purchase_return_shipment',$date); + $this->CI->db->insert('purchase_return_shipments',array('company_id'=>$this->companyId(),'shipment_no'=>$number,'purchase_return_id'=>$returnId,'shipment_date'=>$date,'courier'=>trim((string)($shipment['courier']??''))?:null,'tracking_no'=>trim((string)($shipment['tracking_no']??''))?:null,'notes'=>trim((string)($shipment['notes']??''))?:null,'status'=>'posted','idempotency_key'=>$key,'posted_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s'))); + $shipmentId = (int)$this->CI->db->insert_id(); + $accounting = array('inventory'=>0.0,'tax'=>0.0,'payable'=>0.0,'refund'=>0.0,'posted_inventory'=>false,'invoice_ids'=>array()); + $lineNames = array(); + foreach ($this->CI->db->where('purchase_return_id',$returnId)->order_by('id')->get('purchase_return_lines')->result() as $line) { + if ($line->status !== 'reserved' || (float)$line->reserved_qty < (float)$line->qty-.0001) throw new BusinessException('Semua barang retur harus berstatus reserved sebelum dikirim.'); + $source = $this->sourceLine($line->receipt_line_id, true); + $barcode = $line->barcode_id ? $this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$line->barcode_id))->row() : null; + if (!$barcode || (float)$barcode->qty_sisa < (float)$line->qty-.0001 || (float)$barcode->return_reserved_qty < (float)$line->qty-.0001) throw new BusinessException('Barcode/batch '.$line->barcode_snapshot.' tidak lagi tersedia untuk retur.'); + $remaining = round((float)$barcode->qty_sisa-(float)$line->qty,4); + $reserved = max(0,round((float)$barcode->return_reserved_qty-(float)$line->qty,4)); + $barcodeStatus = $remaining <= .0001 ? 'returned' : 'available'; + $this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>$remaining,'return_reserved_qty'=>$reserved,'status'=>$barcodeStatus,'version'=>(int)$barcode->version+1)); + + $detail = '[AUTO] Pengeluaran retur '.$header->return_no.' / '.$number.' ke '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'Supplier #'.$header->supplier_id).'; PO '.$source->po_no.'; penerimaan '.$source->receipt_no.'; barang '.$source->item_code.' - '.$source->item_name.'; barcode '.$barcode->barcode.($barcode->serial_number?'; SN '.$barcode->serial_number:'').'; qty '.number_format($line->qty,4,'.','').'; alasan '.$header->reason.'; diproses user #'.$user; + $stockKey = 'PURCHASE-RETURN-STOCK-'.$line->id; + $this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'barcode_id'=>$barcode->id,'warehouse_id'=>$line->warehouse_id,'qty'=>$line->qty,'tipe'=>'keluar','activity'=>'supplier_return','keterangan'=>substr($detail,0,255),'ref_type'=>'purchase_return','ref_id'=>$returnId,'idempotency_key'=>$stockKey,'unit_cost'=>$line->unit_cost)); + $stockLogId = (int)$this->CI->db->insert_id(); + $this->CI->db->insert('item_movements',array('item_id'=>$line->item_id,'barcode_id'=>$barcode->id,'qty'=>$line->qty,'from_type'=>'warehouse','from_id'=>$line->warehouse_id,'to_type'=>'supplier','to_id'=>$header->supplier_id,'movement_type'=>'purchase_return','reference_type'=>'purchase_return_line','reference_id'=>$line->id,'notes'=>substr($detail,0,255),'created_at'=>date('Y-m-d H:i:s'))); + $movementId = (int)$this->CI->db->insert_id(); + $ledgerId = $this->writeLedger('purchase_return',$returnId,$line->id,$line->item_id,$line->warehouse_id,$barcode->id,'out',$line->qty,$line->unit_cost,$date,'PURCHASE-RETURN-LEDGER-'.$line->id); + $this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('shipped_qty'=>$line->qty,'stock_log_id'=>$stockLogId,'movement_id'=>$movementId,'ledger_id'=>$ledgerId,'status'=>'shipped')); + $this->CI->db->insert('purchase_return_shipment_lines',array('purchase_return_shipment_id'=>$shipmentId,'purchase_return_line_id'=>$line->id,'qty'=>$line->qty,'created_at'=>date('Y-m-d H:i:s'))); + $this->CI->adjustmentInventoryModel->synchronizeStock($line->item_id); + $lineNames[] = $source->item_code.' '.$source->item_name.' x '.$line->qty; + $this->collectReturnAccounting($line, $source, $accounting, $user); + } + $journalId = $this->postReturnAccounting($header,$supplier,$date,$lineNames,$accounting,$user); + $this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'waiting_supplier','journal_id'=>$journalId,'shipped_by'=>(int)$user,'shipped_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->history($returnId, 'reserved', 'shipped', $user, 'Barang dikeluarkan dari gudang melalui '.$number.'.'); + $this->history($returnId, 'shipped', 'waiting_supplier', $user, 'Menunggu penggantian barang, debit note, atau refund dari supplier.'); + $this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'post_shipment',array('status'=>'reserved'),array('status'=>'waiting_supplier','shipment_no'=>$number,'journal_id'=>$journalId),$user); + return $shipmentId; + }); + } + + /** Membatalkan dokumen yang belum posted tanpa menghapus histori. */ + public function cancelReturn($returnId, $reason, $user) + { + return $this->CI->transactionservice->run(function () use ($returnId, $reason, $user) { + $reason = trim((string) $reason); + if ($reason === '') throw new BusinessException('Alasan pembatalan retur wajib diisi.'); + $header = $this->lockReturn($returnId, array('draft','submitted','rejected','reserved')); + if ($header->status === 'draft' && (int)$header->created_by !== (int)$user && !$this->isMasterAdmin()) { + throw new BusinessException('Draft retur hanya dapat dibatalkan oleh pembuatnya.'); + } + + if ($header->status === 'submitted') { + $request = $this->CI->approvalservice->pendingForEntity('purchase_return', $returnId); + if ($request) $this->CI->db->where('id', $request->id)->update('approval_requests', array('status'=>'cancelled','completed_at'=>date('Y-m-d H:i:s'))); + } + if ($header->status === 'reserved') { + foreach ($this->CI->db->where('purchase_return_id',$returnId)->get('purchase_return_lines')->result() as $line) { + if (!$line->barcode_id) continue; + $barcode = $this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE', array((int)$line->barcode_id))->row(); + if (!$barcode) continue; + $reserved = max(0, round((float)$barcode->return_reserved_qty-(float)$line->reserved_qty, 4)); + $status = $barcode->status; + if ($status === 'return_reserved') $status = empty($barcode->activated_at) ? 'pending' : 'available'; + $this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('return_reserved_qty'=>$reserved,'status'=>$status,'version'=>(int)$barcode->version+1)); + } + } + + $this->CI->db->where('purchase_return_id',$returnId)->where_in('status',array('draft','reserved'))->update('purchase_return_lines',array('status'=>'draft','reserved_qty'=>0)); + $this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'cancelled','cancelled_by'=>(int)$user,'cancelled_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->history($returnId,$header->status,'cancelled',$user,$reason); + $this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'cancel',$header,array('status'=>'cancelled','reason'=>$reason),$user); + return $returnId; + }); + } + + /** + * Reversal formal untuk retur yang sudah keluar. Dokumen turunan yang telah + * menerima penyelesaian harus direversal lebih dahulu agar jejak subledger + * tidak terputus. + */ + public function reverseReturn($returnId, $date, $reason, $user) + { + return $this->CI->transactionservice->run(function () use ($returnId, $date, $reason, $user) { + $reason = trim((string) $reason); + if ($reason === '') throw new BusinessException('Alasan reversal retur wajib diisi.'); + $date = $this->validDate($date, 'Tanggal reversal retur'); + $this->CI->fiscalperiodservice->assertOpen($date); + $header = $this->lockReturn($returnId, array('shipped','waiting_supplier','partially_resolved')); + $replacementCount = $this->CI->db->where('purchase_return_id',$returnId)->where_not_in('status',array('cancelled','reversed'))->count_all_results('purchase_replacements'); + $receivedRefund = $this->CI->db->select('COUNT(rr.id) total',false)->from('supplier_refund_receipts rr')->join('supplier_refund_claims rc','rc.id=rr.supplier_refund_claim_id')->where('rc.purchase_return_id',$returnId)->where('rr.status !=','reversed')->get()->row(); + if ($replacementCount || ($receivedRefund && (int)$receivedRefund->total > 0)) { + throw new BusinessException('Retur sudah memiliki barang pengganti atau dana refund. Reverse dokumen penyelesaian tersebut lebih dahulu.'); + } + + $supplier = $this->CI->db->get_where('suppliers',array('id'=>$header->supplier_id))->row(); + foreach ($this->CI->db->where('purchase_return_id',$returnId)->get('purchase_return_lines')->result() as $line) { + if ((float)$line->shipped_qty <= 0) continue; + $barcode = $this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$line->barcode_id))->row(); + if (!$barcode) throw new BusinessException('Barcode sumber reversal retur tidak ditemukan.'); + $newQty = round((float)$barcode->qty_sisa+(float)$line->shipped_qty,4); + $newStatus = empty($barcode->activated_at) ? 'pending' : 'available'; + $this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>$newQty,'return_reserved_qty'=>0,'status'=>$newStatus,'version'=>(int)$barcode->version+1)); + $detail='[AUTO] Reversal retur '.$header->return_no.'; barang '.$line->description.'; barcode '.$line->barcode_snapshot.($line->serial_snapshot?'; SN '.$line->serial_snapshot:'').'; qty '.number_format($line->shipped_qty,4,'.','').'; alasan '.$reason.'; diproses user #'.$user; + $this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'barcode_id'=>$barcode->id,'warehouse_id'=>$line->warehouse_id,'qty'=>$line->shipped_qty,'tipe'=>'masuk','activity'=>'return_reversal','keterangan'=>substr($detail,0,255),'ref_type'=>'purchase_return_reversal','ref_id'=>$returnId,'idempotency_key'=>'PURCHASE-RETURN-REVERSAL-STOCK-'.$line->id,'unit_cost'=>$line->unit_cost)); + $this->CI->db->insert('item_movements',array('item_id'=>$line->item_id,'barcode_id'=>$barcode->id,'qty'=>$line->shipped_qty,'from_type'=>'supplier','from_id'=>$header->supplier_id,'to_type'=>'warehouse','to_id'=>$line->warehouse_id,'movement_type'=>'purchase_return_reversal','reference_type'=>'purchase_return_line','reference_id'=>$line->id,'notes'=>substr($detail,0,255),'created_at'=>date('Y-m-d H:i:s'))); + $this->writeLedger('purchase_return_reversal',$returnId,$line->id,$line->item_id,$line->warehouse_id,$barcode->id,'in',$line->shipped_qty,$line->unit_cost,$date,'PURCHASE-RETURN-REVERSAL-LEDGER-'.$line->id); + $this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('status'=>'reversed')); + $this->CI->adjustmentInventoryModel->synchronizeStock($line->item_id); + } + + $reversalJournalId = null; + if (!empty($header->journal_id)) { + $reversal = $this->CI->journalworkflowservice->reverse((int)$header->journal_id,$date,$user,'Reversal retur '.$header->return_no.': '.$reason); + $reversalJournalId = (int)$reversal['reversal_id']; + } + + foreach ($this->CI->db->where('purchase_return_id',$returnId)->where_in('status',array('posted','applied'))->get('supplier_debit_notes')->result() as $note) { + $refundClaim = $this->CI->db->where('source_type','debit_note')->where('purchase_return_id',$returnId)->where_not_in('status',array('rejected','cancelled','reversed'))->get('supplier_refund_claims')->row(); + $refundPart = $refundClaim ? (float)$refundClaim->claim_amount : 0; + $applied = max(0, round((float)$note->amount-$refundPart,2)); + if ($note->supplier_invoice_id && $applied > 0) { + $invoice = $this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$note->supplier_invoice_id))->row(); + if ($invoice) { + $balance = min((float)$invoice->total,round((float)$invoice->balance+$applied,2)); + $this->CI->db->where('id',$invoice->id)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance>.001?'partial':'paid')); + } + } + $this->CI->db->where('id',$note->id)->update('supplier_debit_notes',array('status'=>'reversed','reversal_journal_id'=>$reversalJournalId,'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->debitNoteHistory($note->id,$note->status,'reversed',$user,$reason); + } + foreach ($this->CI->db->where('purchase_return_id',$returnId)->where_not_in('status',array('rejected','cancelled','reversed'))->get('supplier_refund_claims')->result() as $claim) { + if ((float)$claim->received_amount > .001) throw new BusinessException('Dana refund sudah diterima dan wajib direversal lebih dahulu.'); + $this->CI->db->where('id',$claim->id)->update('supplier_refund_claims',array('status'=>'reversed','reversal_journal_id'=>$reversalJournalId,'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->refundHistory($claim->id,$claim->status,'reversed',$user,$reason); + } + $this->CI->db->where('purchase_return_id',$returnId)->where('status !=','reversed')->update('purchase_return_shipments',array('status'=>'reversed')); + $this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>'reversed','reversal_journal_id'=>$reversalJournalId,'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->history($returnId,$header->status,'reversed',$user,$reason); + $this->CI->auditservice->record('purchase_return','purchase_return',$returnId,'reverse',$header,array('status'=>'reversed','reversal_journal_id'=>$reversalJournalId,'reason'=>$reason),$user); + return $returnId; + }); + } + + public function receiveReplacement($returnId, array $data, array $quantities, array $serials, $user) + { + return $this->CI->transactionservice->run(function () use ($returnId, $data, $quantities, $serials, $user) { + $header = $this->lockReturn($returnId, array('shipped','waiting_supplier','partially_resolved')); + $date = $this->validDate($data['receipt_date'] ?? null, 'Tanggal penerimaan pengganti'); + $key = trim((string)($data['idempotency_key'] ?? '')); + if ($key === '') throw new BusinessException('Token penerimaan barang pengganti tidak tersedia.'); + $existing = $this->CI->db->get_where('purchase_replacements',array('idempotency_key'=>$key))->row(); + if ($existing) return (int)$existing->id; + if (!$this->hasAttachment('purchase_replacement', $returnId) && empty($data['attachment_pending'])) throw new BusinessException('Bukti penerimaan barang pengganti wajib tersedia.'); + $number = $this->CI->numberingservice->next('purchase_replacement',$date); + $warehouseId = (int)($data['warehouse_id'] ?? 0); + $this->CI->db->where('id',$warehouseId);if($this->CI->db->field_exists('company_id','warehouses'))$this->CI->db->where('company_id',$this->companyId()); + if (!$this->CI->db->get('warehouses')->row()) throw new BusinessException('Gudang penerimaan pengganti tidak valid atau berasal dari perusahaan lain.'); + $this->CI->db->insert('purchase_replacements',array('company_id'=>$this->companyId(),'replacement_no'=>$number,'purchase_return_id'=>$returnId,'supplier_id'=>$header->supplier_id,'receipt_date'=>$date,'supplier_delivery_no'=>trim((string)($data['supplier_delivery_no']??''))?:null,'warehouse_id'=>$warehouseId,'status'=>'posted','idempotency_key'=>$key,'received_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s'))); + $replacementId = (int)$this->CI->db->insert_id(); $receivedAny = false; + foreach ($this->CI->db->where('purchase_return_id',$returnId)->order_by('id')->get('purchase_return_lines')->result() as $line) { + $qty = round((float)($quantities[$line->id] ?? 0),4); + if ($qty <= 0) continue; + $remaining = round((float)$line->shipped_qty-(float)$line->replacement_received_qty,4); + if ($qty > $remaining+.0001) throw new BusinessException('Qty pengganti '.$line->description.' melebihi saldo retur.'); + $source = $this->sourceLine($line->receipt_line_id, true); + $stockKey='PURCHASE-REPLACEMENT-STOCK-'.$replacementId.'-'.$line->id; + $detail='[AUTO] Penerimaan barang pengganti '.$number.' untuk retur '.$header->return_no.'; PO '.$source->po_no.'; barang '.$source->item_code.' - '.$source->item_name.'; qty '.number_format($qty,4,'.','').'; tanpa pembentukan hutang baru; diterima user #'.$user; + $this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'warehouse_id'=>$warehouseId,'qty'=>$qty,'tipe'=>'masuk','activity'=>'purchase','keterangan'=>substr($detail,0,255),'ref_type'=>'purchase_replacement','ref_id'=>$replacementId,'idempotency_key'=>$stockKey,'unit_cost'=>$line->unit_cost)); + $stockLogId=(int)$this->CI->db->insert_id(); + $ledgerId=$this->writeLedger('purchase_replacement',$replacementId,$line->id,$line->item_id,$warehouseId,null,'in',$qty,$line->unit_cost,$date,'PURCHASE-REPLACEMENT-LEDGER-'.$replacementId.'-'.$line->id); + $list = preg_split('/[\r\n,;]+/', trim((string)($serials[$line->id] ?? '')), -1, PREG_SPLIT_NO_EMPTY); + if ($source->tracking_type==='UNIT' && $source->need_serial_number && count($list) > $qty+.0001) throw new BusinessException('Jumlah serial number pengganti melebihi qty yang diterima.'); + $barcodeIds=array(); + if ($source->tracking_type==='UNIT') { + for ($i=0;$i<(int)$qty;$i++) $barcodeIds[]=$this->createReplacementBarcode($number,$line,$warehouseId,1,$source->need_serial_number?($list[$i]??null):null,$source->need_serial_number,$user,$i+1); + } else { + $barcodeIds[]=$this->createReplacementBarcode($number,$line,$warehouseId,$qty,null,false,$user,1); + } + foreach($barcodeIds as $barcodeId)$this->CI->db->insert('item_movements',array('item_id'=>$line->item_id,'barcode_id'=>$barcodeId,'qty'=>$source->tracking_type==='UNIT'?1:$qty,'from_type'=>'supplier','from_id'=>$header->supplier_id,'to_type'=>'warehouse','to_id'=>$warehouseId,'movement_type'=>'purchase_replacement','reference_type'=>'purchase_replacement_line','reference_id'=>$line->id,'notes'=>substr($detail,0,255),'created_at'=>date('Y-m-d H:i:s'))); + $movementId=(int)$this->CI->db->insert_id(); + $this->CI->db->insert('purchase_replacement_lines',array('purchase_replacement_id'=>$replacementId,'purchase_return_line_id'=>$line->id,'item_id'=>$line->item_id,'warehouse_id'=>$warehouseId,'barcode_id'=>count($barcodeIds)===1?$barcodeIds[0]:null,'qty'=>$qty,'unit_cost'=>$line->unit_cost,'stock_log_id'=>$stockLogId,'movement_id'=>$movementId,'ledger_id'=>$ledgerId,'created_at'=>date('Y-m-d H:i:s'))); + $newReceived=round((float)$line->replacement_received_qty+$qty,4);$lineStatus=$newReceived>=(float)$line->shipped_qty-.0001?'resolved':'partially_resolved'; + $this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('replacement_received_qty'=>$newReceived,'status'=>$lineStatus)); + if ($line->barcode_id && $lineStatus==='resolved') $this->CI->db->where('id',$line->barcode_id)->where('status','returned')->update('item_barcodes',array('status'=>'replaced')); + $this->CI->adjustmentInventoryModel->synchronizeStock($line->item_id);$receivedAny=true; + } + if (!$receivedAny) throw new BusinessException('Minimal satu qty barang pengganti wajib diisi.'); + $open=(int)$this->CI->db->where('purchase_return_id',$returnId)->where('status !=','resolved')->count_all_results('purchase_return_lines'); + $newStatus=$open?'partially_resolved':'resolved'; + $this->CI->db->where('id',$replacementId)->update('purchase_replacements',array('status'=>$open?'partially_received':'received')); + $this->CI->db->where('id',$returnId)->update('purchase_returns',array('status'=>$newStatus,'resolved_by'=>$open?null:(int)$user,'resolved_at'=>$open?null:date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->history($returnId, $header->status, $newStatus, $user, 'Penerimaan barang pengganti '.$number.' diposting tanpa hutang baru.'); + $this->CI->auditservice->record('purchase_return','purchase_replacement',$replacementId,'post',null,array('return_no'=>$header->return_no,'status'=>$newStatus),$user); + return $replacementId; + }); + } + + public function createRefund(array $data, array $allocations, $user) + { + return $this->CI->transactionservice->run(function () use ($data,$allocations,$user) { + $date=$this->validDate($data['claim_date']??null,'Tanggal pengajuan refund'); + $reason=trim((string)($data['reason']??''));if($reason==='')throw new BusinessException('Alasan refund wajib diisi.'); + $key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Token pengajuan refund tidak tersedia.'); + $existing=$this->CI->db->get_where('supplier_refund_claims',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id; + $valid=array();$supplierId=0;$eligibleTotal=0;$claimTotal=0;$poRequested=array();$primary=array('purchase_order_id'=>null,'supplier_invoice_id'=>null,'purchase_return_id'=>null); + foreach($allocations as$a){$sourceType=$this->enum($a['source_type']??'',array('purchase_return','supplier_payment','purchase_order','supplier_invoice','supplier_debit_note'),'');$sourceId=(int)($a['source_id']??0);$amount=round((float)($a['amount']??0),2);if(!$sourceType||!$sourceId||$amount<=0)continue;$source=$this->refundSource($sourceType,$sourceId);if(!$source)throw new BusinessException('Dokumen sumber refund tidak valid.');if(!$supplierId)$supplierId=(int)$source['supplier_id'];if($supplierId!==(int)$source['supplier_id'])throw new BusinessException('Satu batch refund hanya boleh untuk satu supplier.');$used=$this->allocatedRefundAmount($sourceType,$sourceId);$eligible=max(0,round((float)$source['eligible_amount']-$used,2));if($amount>$eligible+.001)throw new BusinessException('Nilai refund melebihi hak refund dokumen '.$source['number'].'.');$valid[]=array('source_type'=>$sourceType,'source_id'=>$sourceId,'amount'=>$amount,'eligible'=>$eligible,'source'=>$source);$eligibleTotal+=$eligible;$claimTotal+=$amount;if(!empty($source['purchase_order_id']))$poRequested[(int)$source['purchase_order_id']]=round(($poRequested[(int)$source['purchase_order_id']]??0)+$amount,2);if(isset($primary[$sourceType.'_id']))$primary[$sourceType.'_id']=$sourceId;if($sourceType==='purchase_return')$primary['purchase_return_id']=$sourceId;if(!empty($source['purchase_order_id']))$primary['purchase_order_id']=$source['purchase_order_id'];if(!empty($source['supplier_invoice_id']))$primary['supplier_invoice_id']=$source['supplier_invoice_id'];} + if(!$valid)throw new BusinessException('Pilih minimal satu dokumen sumber dan nilai refund.'); + foreach($poRequested as$poId=>$requested){$po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array($poId))->row();if(!$po||(int)$po->company_id!==$this->companyId())throw new BusinessException('PO sumber refund tidak valid.');$committed=(float)$this->CI->db->select('COALESCE(SUM(claim_amount),0) total',false)->where('purchase_order_id',$poId)->where_not_in('status',array('rejected','cancelled','reversed'))->get('supplier_refund_claims')->row()->total;$poAvailable=max(0,round((float)$po->paid_amount-(float)$po->transit_cleared_amount-$committed,2));if($requested>$poAvailable+.001)throw new BusinessException('Total klaim refund untuk PO '.$po->po_no.' melebihi hak refund yang belum digunakan sebesar Rp '.number_format($poAvailable,2,',','.').'.');} + $number=$this->CI->numberingservice->next('supplier_refund',$date);$sourceCategory=$this->enum($data['source_category']??'',array('prepayment','short_delivery','paid_return','cancelled_order','overpayment','cashback','debit_note','other'),'other'); + $this->CI->db->insert('supplier_refund_claims',array_merge(array('company_id'=>$this->companyId(),'refund_no'=>$number,'supplier_id'=>$supplierId,'claim_date'=>$date,'expected_date'=>!empty($data['expected_date'])?$data['expected_date']:null,'reason'=>$reason,'source_type'=>$sourceCategory,'eligible_amount'=>$eligibleTotal,'claim_amount'=>$claimTotal,'received_amount'=>0,'balance'=>$claimTotal,'target_account_id'=>!empty($data['target_account_id'])?(int)$data['target_account_id']:null,'status'=>'draft','idempotency_key'=>$key,'created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')),$primary)); + $id=(int)$this->CI->db->insert_id();foreach($valid as$v)$this->CI->db->insert('supplier_refund_allocations',array('supplier_refund_claim_id'=>$id,'source_type'=>$v['source_type'],'source_id'=>$v['source_id'],'eligible_amount'=>$v['eligible'],'allocated_amount'=>$v['amount'],'created_at'=>date('Y-m-d H:i:s'))); + $this->refundHistory($id,null,'draft',$user,'Draft klaim refund supplier dibuat.'); + $this->CI->auditservice->record('purchase_refund','supplier_refund',$id,'create',null,array('refund_no'=>$number,'supplier_id'=>$supplierId,'amount'=>$claimTotal),$user);return$id; + }); + } + + public function submitRefund($id,$user) + { + return $this->CI->transactionservice->run(function()use($id,$user){$claim=$this->lockClaim($id,array('draft'));if((int)$claim->created_by!==(int)$user&&!$this->isMasterAdmin())throw new BusinessException('Hanya pembuat draft yang dapat mengajukan refund.');if(!$this->hasAttachment('supplier_refund',$id))throw new BusinessException('Lampiran pengajuan refund wajib tersedia.');$this->CI->approvalservice->create('purchase_refund','supplier_refund',$id,$claim->claim_amount,$user,$claim);$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'submitted','submitted_by'=>(int)$user,'submitted_at'=>date('Y-m-d H:i:s')));$this->refundHistory($id,'draft','submitted',$user,'Klaim refund diajukan untuk persetujuan Finance.');return$id;}); + } + + public function approveRefund($id,$user,$roleId) + { + return $this->CI->transactionservice->run(function()use($id,$user,$roleId){$claim=$this->lockClaim($id,array('submitted'));$request=$this->CI->approvalservice->pendingForEntity('supplier_refund',$id);if(!$request)throw new BusinessException('Permintaan persetujuan refund tidak ditemukan.');$approval=$this->CI->approvalservice->act($request->id,'approved',$user,$roleId);if($approval->status!=='approved')return$id;$journal=null;if(!$claim->entitlement_journal_id){$this->CI->fiscalperiodservice->assertOpen($claim->claim_date);$supplier=$this->CI->db->get_where('suppliers',array('id'=>$claim->supplier_id))->row();$credit=$this->refundCreditAccount($claim);$desc='[AUTO] Pembentukan piutang refund '.$claim->refund_no.' kepada '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$claim->supplier_id).'; sumber '.$claim->source_type.'; nilai Rp '.number_format($claim->claim_amount,2,',','.').'; alasan '.$claim->reason.'; diproses user #'.$user;$journal=$this->CI->postingservice->post(array('tanggal'=>$claim->claim_date,'no_ref'=>$claim->refund_no,'keterangan'=>$desc,'ref_type'=>'supplier_refund_claim','ref_id'=>$id,'created_by'=>$user),array(array('account_id'=>$this->CI->accountmappingservice->get('supplier_refund_receivable'),'debit'=>$claim->claim_amount,'kredit'=>0),array('account_id'=>$credit,'debit'=>0,'kredit'=>$claim->claim_amount)),false,true);}$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'waiting_funds','approved_by'=>(int)$user,'approved_at'=>date('Y-m-d H:i:s'),'entitlement_journal_id'=>$journal?:$claim->entitlement_journal_id));$this->refundHistory($id,'submitted','approved',$user,'Klaim refund disetujui.');$this->refundHistory($id,'approved','waiting_funds',$user,'Menunggu dana dari supplier.');$this->CI->auditservice->record('purchase_refund','supplier_refund',$id,'approve',$claim,array('status'=>'waiting_funds','journal_id'=>$journal),$user);return$id;}); + } + + public function rejectRefund($id,$reason,$user,$roleId) + { + return $this->CI->transactionservice->run(function()use($id,$reason,$user,$roleId){$claim=$this->lockClaim($id,array('submitted'));$reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');$request=$this->CI->approvalservice->pendingForEntity('supplier_refund',$id);if(!$request)throw new BusinessException('Permintaan persetujuan refund tidak ditemukan.');$this->CI->approvalservice->act($request->id,'rejected',$user,$roleId,$reason);$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'rejected','rejected_by'=>(int)$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason));$this->refundHistory($id,'submitted','rejected',$user,$reason);return$id;}); + } + + public function receiveRefund($claimId,array$data,$user) + { + return $this->CI->transactionservice->run(function()use($claimId,$data,$user){$claim=$this->lockClaim($claimId,array('waiting_funds','partial'));$date=$this->validDate($data['receipt_date']??null,'Tanggal penerimaan refund');$amount=round((float)($data['amount']??0),2);if($amount<=0||$amount>(float)$claim->balance+.001)throw new BusinessException('Nilai penerimaan refund tidak valid atau melebihi sisa klaim.');$account=(int)($data['bank_account_id']??0);if(!$this->cashAccount($account))throw new BusinessException('Akun Kas/Bank penerima refund tidak valid.');$reference=trim((string)($data['reference_no']??''));if($reference==='')throw new BusinessException('Referensi bank penerimaan refund wajib diisi.');$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Token penerimaan refund tidak tersedia.');$existing=$this->CI->db->get_where('supplier_refund_receipts',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;if(!$this->hasAttachment('supplier_refund_receipt',$claimId)&&empty($data['attachment_pending']))throw new BusinessException('Bukti penerimaan dana refund wajib tersedia.');$this->CI->fiscalperiodservice->assertOpen($date);$supplier=$this->CI->db->get_where('suppliers',array('id'=>$claim->supplier_id))->row();$number=$this->CI->numberingservice->next('supplier_refund_receipt',$date);$desc='[AUTO] Penerimaan dana refund '.$number.' untuk klaim '.$claim->refund_no.' dari '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$claim->supplier_id).'; referensi bank '.$reference.'; nilai Rp '.number_format($amount,2,',','.').'; diproses user #'.$user;$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$number,'keterangan'=>$desc,'ref_type'=>'supplier_refund_receipt','ref_id'=>$claimId,'created_by'=>$user),array(array('account_id'=>$account,'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('supplier_refund_receivable'),'debit'=>0,'kredit'=>$amount)),false,false);$this->CI->db->insert('supplier_refund_receipts',array('company_id'=>$this->companyId(),'receipt_no'=>$number,'supplier_refund_claim_id'=>$claimId,'receipt_date'=>$date,'bank_account_id'=>$account,'amount'=>$amount,'reference_no'=>$reference,'notes'=>trim((string)($data['notes']??''))?:null,'status'=>'posted','idempotency_key'=>$key,'journal_id'=>$journal,'received_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));$receiptId=(int)$this->CI->db->insert_id();$received=round((float)$claim->received_amount+$amount,2);$balance=max(0,round((float)$claim->claim_amount-$received,2));$status=$balance<=.001?'received':'partial';$this->CI->db->where('id',$claimId)->update('supplier_refund_claims',array('received_amount'=>$received,'balance'=>$balance,'status'=>$status,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->refundHistory($claimId,$claim->status,$status,$user,'Penerimaan dana '.$number.' sebesar Rp '.number_format($amount,2,',','.').'; referensi '.$reference.'.');$this->CI->auditservice->record('purchase_refund','supplier_refund_receipt',$receiptId,'post',null,array('refund_no'=>$claim->refund_no,'amount'=>$amount,'balance'=>$balance,'journal_id'=>$journal),$user);return$receiptId;}); + } + + public function reconcileRefund($id,$user) + { + return $this->CI->transactionservice->run(function()use($id,$user){$claim=$this->lockClaim($id,array('received'));if((float)$claim->balance>.001)throw new BusinessException('Refund belum diterima seluruhnya.');$this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'reconciled','reconciled_by'=>(int)$user,'reconciled_at'=>date('Y-m-d H:i:s')));$this->refundHistory($id,'received','reconciled',$user,'Refund telah dicocokkan dengan mutasi Kas/Bank dan diselesaikan.');$this->CI->auditservice->record('purchase_refund','supplier_refund',$id,'reconcile',$claim,array('status'=>'reconciled'),$user);return$id;}); + } + + public function cancelRefund($id,$reason,$user) + { + return $this->CI->transactionservice->run(function()use($id,$reason,$user){ + $reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan pembatalan refund wajib diisi.'); + $claim=$this->lockClaim($id,array('draft','submitted','rejected')); + if($claim->status==='draft'&&(int)$claim->created_by!==(int)$user&&!$this->isMasterAdmin())throw new BusinessException('Draft refund hanya dapat dibatalkan oleh pembuatnya.'); + if($claim->status==='submitted'){$request=$this->CI->approvalservice->pendingForEntity('supplier_refund',$id);if($request)$this->CI->db->where('id',$request->id)->update('approval_requests',array('status'=>'cancelled','completed_at'=>date('Y-m-d H:i:s')));} + $this->CI->db->where('id',$id)->update('supplier_refund_claims',array('status'=>'cancelled','cancelled_by'=>(int)$user,'cancelled_at'=>date('Y-m-d H:i:s'),'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->refundHistory($id,$claim->status,'cancelled',$user,$reason); + $this->CI->auditservice->record('purchase_refund','supplier_refund',$id,'cancel',$claim,array('status'=>'cancelled','reason'=>$reason),$user);return$id; + }); + } + + public function reverseRefundReceipt($receiptId,$date,$reason,$user) + { + return $this->CI->transactionservice->run(function()use($receiptId,$date,$reason,$user){ + $reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan reversal penerimaan refund wajib diisi.'); + $date=$this->validDate($date,'Tanggal reversal refund');$this->CI->fiscalperiodservice->assertOpen($date); + $receipt=$this->CI->db->query('SELECT * FROM supplier_refund_receipts WHERE id=? FOR UPDATE',array((int)$receiptId))->row(); + if(!$receipt||!in_array($receipt->status,array('posted','reconciled'),true))throw new BusinessException('Bukti penerimaan refund tidak dapat direversal.'); + if((int)$receipt->company_id!==$this->companyId())throw new BusinessException('Dokumen refund berasal dari perusahaan lain.',array(),403); + $claim=$this->lockClaim($receipt->supplier_refund_claim_id,array('partial','received','reconciled')); + $reversal=$this->CI->journalworkflowservice->reverse((int)$receipt->journal_id,$date,$user,'Reversal penerimaan refund '.$receipt->receipt_no.': '.$reason); + $received=max(0,round((float)$claim->received_amount-(float)$receipt->amount,2));$balance=max(0,round((float)$claim->claim_amount-$received,2));$status=$received>.001?'partial':'waiting_funds'; + $this->CI->db->where('id',$receipt->id)->update('supplier_refund_receipts',array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'])); + $this->CI->db->where('id',$claim->id)->update('supplier_refund_claims',array('received_amount'=>$received,'balance'=>$balance,'status'=>$status,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->refundHistory($claim->id,$claim->status,$status,$user,'Penerimaan '.$receipt->receipt_no.' direversal: '.$reason); + $this->CI->auditservice->record('purchase_refund','supplier_refund_receipt',$receipt->id,'reverse',$receipt,array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'],'reason'=>$reason),$user);return$receipt->id; + }); + } + + public function createDebitNote(array$data,$user) + { + return $this->CI->transactionservice->run(function()use($data,$user){ + $invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)($data['supplier_invoice_id']??0)))->row(); + if(!$invoice||!in_array($invoice->status,array('partial','paid'),true))throw new BusinessException('Invoice Supplier tidak dapat dikoreksi dengan Debit Note.'); + if($this->CI->db->field_exists('company_id','supplier_invoices')&&(int)$invoice->company_id!==$this->companyId())throw new BusinessException('Invoice Supplier berasal dari perusahaan lain.',array(),403); + $date=$this->validDate($data['note_date']??null,'Tanggal Debit Note');$base=round((float)($data['base_amount']??0),2);$tax=round((float)($data['tax_amount']??0),2);$total=$base+$tax; + if($base<=0||$tax<0||$total<=0)throw new BusinessException('Nilai Debit Note tidak valid.'); + $reason=trim((string)($data['reason']??''));if($reason==='')throw new BusinessException('Alasan Debit Note wajib diisi.'); + $key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Token Debit Note tidak tersedia.'); + $existing=$this->CI->db->get_where('supplier_debit_notes',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id; + $used=$this->CI->db->select('COALESCE(SUM(amount),0) total',false)->where('supplier_invoice_id',$invoice->id)->where_not_in('status',array('cancelled','reversed'))->get('supplier_debit_notes')->row(); + $available=max(0,round((float)$invoice->total-(float)($used?$used->total:0),2)); + if($total>$available+.001)throw new BusinessException('Total Debit Note melebihi nilai invoice yang masih dapat dikoreksi, yaitu Rp '.number_format($available,2,',','.').'.'); + $po=$this->CI->db->select('pol.purchase_order_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$invoice->id)->limit(1)->get()->row(); + $returnId=!empty($data['purchase_return_id'])?(int)$data['purchase_return_id']:null; + if($returnId){$return=$this->CI->db->get_where('purchase_returns',array('id'=>$returnId,'company_id'=>$this->companyId(),'supplier_id'=>$invoice->supplier_id))->row();if(!$return||!in_array($return->status,array('shipped','waiting_supplier','partially_resolved','resolved'),true)||($po&&(int)$return->purchase_order_id!==(int)$po->purchase_order_id))throw new BusinessException('Retur sumber Debit Note tidak valid atau tidak terkait dengan invoice yang dipilih.');} + $number=$this->CI->numberingservice->next('supplier_debit_note',$date); + $this->CI->db->insert('supplier_debit_notes',array('company_id'=>$this->companyId(),'debit_note_no'=>$number,'supplier_id'=>$invoice->supplier_id,'supplier_invoice_id'=>$invoice->id,'purchase_order_id'=>$po?$po->purchase_order_id:null,'purchase_return_id'=>$returnId,'note_date'=>$date,'correction_type'=>$this->enum($data['correction_type']??'',array('return','quantity','price','discount','tax','chargeback','billing_error','other'),'other'),'base_amount'=>$base,'tax_amount'=>$tax,'amount'=>$total,'reason'=>$reason,'idempotency_key'=>$key,'status'=>'draft','created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'))); + $id=(int)$this->CI->db->insert_id(); + $this->CI->db->insert('supplier_debit_note_lines',array('supplier_debit_note_id'=>$id,'description'=>$reason,'qty'=>0,'base_amount'=>$base,'tax_amount'=>$tax,'total_amount'=>$total,'created_at'=>date('Y-m-d H:i:s'))); + $this->debitNoteHistory($id,null,'draft',$user,'Draft Debit Note dibuat untuk invoice '.$invoice->internal_no.'.'); + $this->CI->auditservice->record('supplier_debit_note','supplier_debit_note',$id,'create',null,array('debit_note_no'=>$number,'invoice'=>$invoice->internal_no,'amount'=>$total),$user);return$id; + }); + } + + public function postDebitNote($id,$user) + { + return $this->CI->transactionservice->run(function()use($id,$user){ + $note=$this->CI->db->query('SELECT * FROM supplier_debit_notes WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$note||$note->status!=='draft')throw new BusinessException('Debit Note harus berstatus draft.'); + if((int)$note->company_id!==$this->companyId())throw new BusinessException('Debit Note berasal dari perusahaan lain.',array(),403); + if(!$this->hasAttachment('supplier_debit_note',$id))throw new BusinessException('Lampiran Debit Note wajib tersedia.'); + $invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$note->supplier_invoice_id))->row();if(!$invoice||!in_array($invoice->status,array('partial','paid'),true))throw new BusinessException('Invoice Supplier tidak dapat dikoreksi.'); + $used=$this->CI->db->select('COALESCE(SUM(amount),0) total',false)->where('supplier_invoice_id',$invoice->id)->where('id !=',$note->id)->where_in('status',array('posted','applied'))->get('supplier_debit_notes')->row(); + if((float)$note->amount>(float)$invoice->total-(float)($used?$used->total:0)+.001)throw new BusinessException('Nilai invoice yang dapat dikoreksi telah digunakan Debit Note lain. Muat ulang data.'); + $this->CI->fiscalperiodservice->assertOpen($note->note_date);$applied=min((float)$note->amount,max(0,(float)$invoice->balance));$refund=round((float)$note->amount-$applied,2);$supplier=$this->CI->db->get_where('suppliers',array('id'=>$note->supplier_id))->row(); + $desc='[AUTO] Debit Note '.$note->debit_note_no.' untuk invoice '.$invoice->internal_no.' / '.$invoice->supplier_invoice_no.'; supplier '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'#'.$note->supplier_id).'; pokok Rp '.number_format($note->base_amount,2,',','.').'; pajak Rp '.number_format($note->tax_amount,2,',','.').'; pengurang hutang Rp '.number_format($applied,2,',','.').'; piutang refund Rp '.number_format($refund,2,',','.').'; alasan '.$note->reason.'; diproses user #'.$user; + $entries=array();if($applied>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>$applied,'kredit'=>0);if($refund>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('supplier_refund_receivable'),'debit'=>$refund,'kredit'=>0);if((float)$note->base_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_return_adjustment'),'debit'=>0,'kredit'=>$note->base_amount);if((float)$note->tax_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>0,'kredit'=>$note->tax_amount); + $journal=$this->CI->postingservice->post(array('tanggal'=>$note->note_date,'no_ref'=>$note->debit_note_no,'keterangan'=>$desc,'ref_type'=>'supplier_debit_note','ref_id'=>$id,'created_by'=>$user),$entries,false,true); + $balance=max(0,round((float)$invoice->balance-$applied,2));$this->CI->db->where('id',$invoice->id)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance<=.001?'paid':'partial')); + $this->CI->db->where('id',$id)->update('supplier_debit_notes',array('status'=>'applied','journal_id'=>$journal,'posted_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s'))); + $this->debitNoteHistory($id,'draft','posted',$user,'Debit Note diposting ke jurnal #'.$journal.'.');$this->debitNoteHistory($id,'posted','applied',$user,'Saldo invoice berkurang Rp '.number_format($applied,2,',','.').($refund>0?'; Rp '.number_format($refund,2,',','.').' menjadi Piutang Refund Supplier.':'.')); + if($refund>0)$this->createAutomaticRefundClaim($note->supplier_id,$note->purchase_order_id,$invoice->id,$note->purchase_return_id,'debit_note',$id,$refund,$note->note_date,'Penyelesaian Debit Note '.$note->debit_note_no.' pada invoice yang telah dibayar.',$journal,$user); + $this->CI->auditservice->record('supplier_debit_note','supplier_debit_note',$id,'post',$note,array('status'=>'applied','journal_id'=>$journal,'invoice_balance'=>$balance,'refund_receivable'=>$refund),$user);return$id; + }); + } + + public function reverseDebitNote($id,$date,$reason,$user) + { + return $this->CI->transactionservice->run(function()use($id,$date,$reason,$user){ + $reason=trim((string)$reason);if($reason==='')throw new BusinessException('Alasan reversal Debit Note wajib diisi.');$date=$this->validDate($date,'Tanggal reversal Debit Note'); + $note=$this->CI->db->query('SELECT * FROM supplier_debit_notes WHERE id=? FOR UPDATE',array((int)$id))->row(); + if(!$note||!in_array($note->status,array('posted','applied'),true))throw new BusinessException('Debit Note tidak dapat direversal.'); + if((int)$note->company_id!==$this->companyId())throw new BusinessException('Debit Note berasal dari perusahaan lain.',array(),403); + $claim=$this->CI->db->where('source_type','debit_note')->where('supplier_invoice_id',$note->supplier_invoice_id)->where_not_in('status',array('rejected','cancelled','reversed'))->get('supplier_refund_claims')->row(); + if($claim&&(float)$claim->received_amount>.001)throw new BusinessException('Dana penyelesaian Debit Note sudah diterima. Reverse penerimaan refund lebih dahulu.'); + $reversal=$this->CI->journalworkflowservice->reverse((int)$note->journal_id,$date,$user,'Reversal Debit Note '.$note->debit_note_no.': '.$reason); + $refund=$claim?(float)$claim->claim_amount:0;$applied=max(0,round((float)$note->amount-$refund,2)); + if($note->supplier_invoice_id&&$applied>0){$invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$note->supplier_invoice_id))->row();if($invoice){$balance=min((float)$invoice->total,round((float)$invoice->balance+$applied,2));$this->CI->db->where('id',$invoice->id)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance>.001?'partial':'paid'));}} + if($claim){$this->CI->db->where('id',$claim->id)->update('supplier_refund_claims',array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'],'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s')));$this->refundHistory($claim->id,$claim->status,'reversed',$user,'Debit Note sumber direversal: '.$reason);} + $this->CI->db->where('id',$id)->update('supplier_debit_notes',array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'],'reversal_reason'=>$reason,'updated_by'=>(int)$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->debitNoteHistory($id,$note->status,'reversed',$user,$reason);$this->CI->auditservice->record('supplier_debit_note','supplier_debit_note',$id,'reverse',$note,array('status'=>'reversed','reversal_journal_id'=>(int)$reversal['reversal_id'],'reason'=>$reason),$user);return$id; + }); + } + + public function document($type,$id) + { + $company=$this->companyId();$h=null;$lines=array();$history=array(); + if($type==='purchase_return'||$type==='return_resolution'){ + $type='purchase_return'; + $h=$this->CI->db->select('pr.*,s.supplier_code,s.name supplier_name,po.po_no,u.nama created_name,su.nama submitted_name,au.nama approved_name,ru.nama resolved_name')->from('purchase_returns pr')->join('suppliers s','s.id=pr.supplier_id')->join('purchase_orders po','po.id=pr.purchase_order_id','left')->join('users u','u.id=pr.created_by','left')->join('users su','su.id=pr.submitted_by','left')->join('users au','au.id=pr.approved_by','left')->join('users ru','ru.id=pr.resolved_by','left')->where(array('pr.id'=>(int)$id,'pr.company_id'=>$company))->get()->row_array(); + $lines=$this->CI->db->select('l.*,gr.receipt_no,k.kode_barang,k.nama item_name,w.nama warehouse_name')->from('purchase_return_lines l')->join('goods_receipts gr','gr.id=l.goods_receipt_id','left')->join('items i','i.id=l.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('warehouses w','w.id=l.warehouse_id','left')->where(array('l.purchase_return_id'=>(int)$id,'l.company_id'=>$company))->order_by('l.id')->get()->result_array(); + $history=$this->CI->db->select('h.*,u.nama user_name')->from('purchase_return_status_history h')->join('users u','u.id=h.user_id','left')->where('h.purchase_return_id',(int)$id)->order_by('h.id')->get()->result_array(); + } elseif($type==='purchase_return_shipment'){ + $h=$this->CI->db->select('sh.*,pr.return_no,pr.reason,pr.problem_category,s.supplier_code,s.name supplier_name,po.po_no,u.nama posted_name')->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')->join('users u','u.id=sh.posted_by','left')->where(array('sh.id'=>(int)$id,'sh.company_id'=>$company))->get()->row_array(); + $lines=$this->CI->db->select('sl.qty,rl.description,rl.unit_cost,rl.total_amount,rl.barcode_snapshot,rl.serial_snapshot,k.kode_barang,k.nama item_name,gr.receipt_no')->from('purchase_return_shipment_lines sl')->join('purchase_return_lines rl','rl.id=sl.purchase_return_line_id')->join('items i','i.id=rl.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('goods_receipts gr','gr.id=rl.goods_receipt_id','left')->where('sl.purchase_return_shipment_id',(int)$id)->get()->result_array(); + if($h)$history=$this->CI->db->select('x.*,u.nama user_name')->from('purchase_return_status_history x')->join('users u','u.id=x.user_id','left')->where('x.purchase_return_id',(int)$h['purchase_return_id'])->order_by('x.id')->get()->result_array(); + } elseif($type==='supplier_refund'){ + $h=$this->CI->db->select('r.*,s.supplier_code,s.name supplier_name,po.po_no,si.internal_no,u.nama created_name,su.nama submitted_name,a.nama approved_name,ru.nama rejected_name')->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')->join('users u','u.id=r.created_by','left')->join('users su','su.id=r.submitted_by','left')->join('users a','a.id=r.approved_by','left')->join('users ru','ru.id=r.rejected_by','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$company))->get()->row_array(); + $lines=$this->CI->db->where('supplier_refund_claim_id',(int)$id)->get('supplier_refund_allocations')->result_array(); + $history=$this->CI->db->select('h.*,u.nama user_name')->from('supplier_refund_status_history h')->join('users u','u.id=h.user_id','left')->where('h.supplier_refund_claim_id',(int)$id)->order_by('h.id')->get()->result_array(); + } elseif($type==='supplier_refund_receipt'){ + $h=$this->CI->db->select('rr.*,rc.refund_no,rc.reason,s.supplier_code,s.name supplier_name,a.kode_akun,a.nama_akun,u.nama received_name')->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')->join('users u','u.id=rr.received_by','left')->where(array('rr.id'=>(int)$id,'rr.company_id'=>$company))->get()->row_array(); + if($h)$history=$this->CI->db->select('x.*,u.nama user_name')->from('supplier_refund_status_history x')->join('users u','u.id=x.user_id','left')->where('x.supplier_refund_claim_id',(int)$h['supplier_refund_claim_id'])->order_by('x.id')->get()->result_array(); + } elseif($type==='supplier_debit_note'){ + $h=$this->CI->db->select('d.*,s.supplier_code,s.name supplier_name,si.internal_no,si.supplier_invoice_no,po.po_no,u.nama created_name,p.nama posted_name')->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')->join('users u','u.id=d.created_by','left')->join('users p','p.id=d.posted_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>$company))->get()->row_array(); + $lines=$this->CI->db->where('supplier_debit_note_id',(int)$id)->get('supplier_debit_note_lines')->result_array(); + $history=$this->CI->db->select('h.*,u.nama user_name')->from('supplier_debit_note_status_history h')->join('users u','u.id=h.user_id','left')->where('h.supplier_debit_note_id',(int)$id)->order_by('h.id')->get()->result_array(); + } elseif($type==='purchase_replacement'){ + $h=$this->CI->db->select('r.*,pr.return_no,s.supplier_code,s.name supplier_name,w.nama warehouse_name,u.nama received_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')->join('users u','u.id=r.received_by','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$company))->get()->row_array(); + $lines=$this->CI->db->select('l.*,prl.description,k.kode_barang,k.nama item_name,ib.barcode,ib.serial_number')->from('purchase_replacement_lines l')->join('purchase_return_lines prl','prl.id=l.purchase_return_line_id')->join('items i','i.id=l.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('item_barcodes ib','ib.id=l.barcode_id','left')->where('l.purchase_replacement_id',(int)$id)->get()->result_array(); + if($h)$history=$this->CI->db->select('x.*,u.nama user_name')->from('purchase_return_status_history x')->join('users u','u.id=x.user_id','left')->where('x.purchase_return_id',(int)$h['purchase_return_id'])->order_by('x.id')->get()->result_array(); + } else return null; + if(!$h)return null; + $attachments=$this->CI->db->where(array('entity_type'=>$type,'entity_id'=>(int)$id))->order_by('id','DESC')->get('transaction_attachments')->result_array(); + return array('type'=>$type,'header'=>$h,'lines'=>$lines,'history'=>$history,'attachments'=>$attachments); + } + + private function collectReturnAccounting($line,$source,array &$totals,$user) + { + $invoiceLine=$this->CI->db->select('sil.*,si.id supplier_invoice_id,si.internal_no,si.status invoice_status,si.balance invoice_balance,si.paid_amount') + ->from('supplier_invoice_lines sil')->join('supplier_invoices si','si.id=sil.supplier_invoice_id') + ->where('sil.receipt_line_id',$line->receipt_line_id)->where_in('si.status',array('partial','paid'))->order_by('si.id')->limit(1)->get()->row(); + $base=round((float)$line->qty*(float)$line->unit_cost,2);$tax=round((float)$line->tax_amount,2);$total=$base+$tax; + if($invoiceLine){$invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array($invoiceLine->supplier_invoice_id))->row();$ap=min($total,max(0,(float)$invoice->balance));$refund=round($total-$ap,2);$totals['inventory']+=$base;$totals['tax']+=$tax;$totals['payable']+=$ap;$totals['refund']+=$refund;$totals['posted_inventory']=true;$totals['invoice_ids'][$invoice->id]=($totals['invoice_ids'][$invoice->id]??0)+$ap;$this->createAutomaticDebitNote($line,$source,$invoice,$base,$tax,$ap,$user);if($refund>0)$line->_automatic_refund=$refund;} + elseif(!empty($source->advance_journal_id)||(float)$source->po_paid_amount>0){$totals['inventory']+=$base;$totals['refund']+=$total;$totals['posted_inventory']=true;$line->_automatic_refund=$total;} + } + + private function postReturnAccounting($header,$supplier,$date,array$lineNames,array$totals,$user) + { + if(!$totals['posted_inventory'])return null; + foreach($totals['invoice_ids']as$invoiceId=>$applied){$invoice=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();$balance=max(0,round((float)$invoice->balance-$applied,2));$this->CI->db->where('id',$invoiceId)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance<=.001?'paid':'partial'));} + $desc='[AUTO] Retur pembelian '.$header->return_no.' kepada '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$header->supplier_id).'; PO #'.$header->purchase_order_id.'; barang '.implode(', ',$lineNames).'; pokok Rp '.number_format($totals['inventory'],2,',','.').'; pajak Rp '.number_format($totals['tax'],2,',','.').'; pengurang hutang Rp '.number_format($totals['payable'],2,',','.').'; piutang refund Rp '.number_format($totals['refund'],2,',','.').'; alasan '.$header->reason.'; diproses user #'.$user; + $entries=array();if($totals['payable']>.001)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>round($totals['payable'],2),'kredit'=>0);if($totals['refund']>.001)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('supplier_refund_receivable'),'debit'=>round($totals['refund'],2),'kredit'=>0);if($totals['inventory']>.001)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>round($totals['inventory'],2));if($totals['tax']>.001)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>0,'kredit'=>round($totals['tax'],2)); + $debit=array_sum(array_column($entries,'debit'));$credit=array_sum(array_column($entries,'kredit'));$diff=round($debit-$credit,2);if(abs($diff)>.001){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_return_adjustment'),'debit'=>$diff<0?abs($diff):0,'kredit'=>$diff>0?$diff:0);} + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$header->return_no,'keterangan'=>$desc,'ref_type'=>'purchase_return','ref_id'=>$header->id,'created_by'=>$user),$entries,false,true); + $this->CI->db->where('purchase_return_id',$header->id)->where('status','applied')->update('supplier_debit_notes',array('journal_id'=>$journal,'posted_by'=>(int)$user,'posted_at'=>date('Y-m-d H:i:s'))); + if($totals['refund']>.001)$this->createAutomaticRefundClaim($header->supplier_id,$header->purchase_order_id,null,$header->id,'purchase_return',$header->id,$totals['refund'],$date,'Refund atas retur pembelian '.$header->return_no,$journal,$user); + return$journal; + } + + private function createAutomaticDebitNote($line,$source,$invoice,$base,$tax,$applied,$user) + { + if($applied<=.001)return null;$existing=$this->CI->db->where(array('purchase_return_id'=>$line->purchase_return_id,'supplier_invoice_id'=>$invoice->id))->get('supplier_debit_notes')->row();if($existing){$this->CI->db->where('id',$existing->id)->set('base_amount','base_amount+'.$this->CI->db->escape(min($base,$applied)),false)->set('tax_amount','tax_amount+'.$this->CI->db->escape(max(0,$applied-$base)),false)->set('amount','amount+'.$this->CI->db->escape($applied),false)->update('supplier_debit_notes');$id=$existing->id;}else{$number=$this->CI->numberingservice->next('supplier_debit_note',date('Y-m-d'));$this->CI->db->insert('supplier_debit_notes',array('company_id'=>$this->companyId(),'debit_note_no'=>$number,'supplier_id'=>$invoice->supplier_id,'supplier_invoice_id'=>$invoice->id,'purchase_order_id'=>$source->purchase_order_id,'purchase_return_id'=>$line->purchase_return_id,'note_date'=>date('Y-m-d'),'correction_type'=>'return','base_amount'=>min($base,$applied),'tax_amount'=>max(0,$applied-$base),'amount'=>$applied,'reason'=>'Otomatis dari retur pembelian','status'=>'applied','created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$this->debitNoteHistory($id,null,'applied',$user,'Debit Note otomatis dari retur pembelian setelah invoice.');}$this->CI->db->insert('supplier_debit_note_lines',array('supplier_debit_note_id'=>$id,'purchase_return_line_id'=>$line->id,'supplier_invoice_line_id'=>null,'item_id'=>$line->item_id,'description'=>$line->description,'qty'=>$line->qty,'base_amount'=>min($base,$applied),'tax_amount'=>max(0,$applied-$base),'total_amount'=>$applied,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$line->id)->update('purchase_return_lines',array('debit_note_amount'=>$applied));return$id; + } + + private function createAutomaticRefundClaim($supplierId,$poId,$invoiceId,$returnId,$sourceType,$sourceId,$amount,$date,$reason,$journal,$user) + { + $existing=$this->CI->db->where(array('purchase_return_id'=>$returnId,'status !='=>'reversed'))->get('supplier_refund_claims')->row();if($existing)return$existing->id;$number=$this->CI->numberingservice->next('supplier_refund',$date);$this->CI->db->insert('supplier_refund_claims',array('company_id'=>$this->companyId(),'refund_no'=>$number,'supplier_id'=>$supplierId,'purchase_order_id'=>$poId,'supplier_invoice_id'=>$invoiceId,'purchase_return_id'=>$returnId,'claim_date'=>$date,'reason'=>$reason,'source_type'=>$sourceType==='purchase_return'?'paid_return':'debit_note','eligible_amount'=>$amount,'claim_amount'=>$amount,'received_amount'=>0,'balance'=>$amount,'status'=>'waiting_funds','entitlement_journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'),'approved_by'=>$user,'approved_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$this->CI->db->insert('supplier_refund_allocations',array('supplier_refund_claim_id'=>$id,'source_type'=>$sourceType,'source_id'=>$sourceId,'eligible_amount'=>$amount,'allocated_amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')));$this->refundHistory($id,null,'waiting_funds',$user,'Piutang refund dibentuk otomatis dari dokumen '.$sourceType.' #'.$sourceId.'.');return$id; + } + + private function sourceLine($id,$lock) + { + $sql="SELECT grl.id receipt_line_id,grl.goods_receipt_id,grl.item_id,grl.warehouse_id,grl.qty received_qty,grl.advance_journal_id,gr.receipt_no,gr.supplier_id,po.id purchase_order_id,po.po_no,po.paid_amount po_paid_amount,pol.description,COALESCE(pol.tracking_type,'QTY') tracking_type,COALESCE(pol.need_serial_number,0) need_serial_number,COALESCE(i.harga_beli,pol.unit_price) unit_cost,COALESCE(k.kode_barang,'-') item_code,COALESCE(k.nama,i.nama_barang,pol.description) item_name FROM goods_receipt_lines grl JOIN goods_receipts gr ON gr.id=grl.goods_receipt_id JOIN purchase_order_lines pol ON pol.id=grl.po_line_id JOIN purchase_orders po ON po.id=pol.purchase_order_id LEFT JOIN items i ON i.id=grl.item_id LEFT JOIN kode_barang k ON k.id=pol.kode_barang_id WHERE grl.id=? AND gr.status='posted' AND po.company_id=?".($lock?' FOR UPDATE':''); + return$this->CI->db->query($sql,array((int)$id,$this->companyId()))->row(); + } + + private function validateBarcode($line,$barcodeId,$qty,$lock) + { + if(!$barcodeId)throw new BusinessException('Barcode/batch barang '.$line->description.' wajib dipilih.');$sql='SELECT * FROM item_barcodes WHERE id=?'.($lock?' FOR UPDATE':'');$barcode=$this->CI->db->query($sql,array($barcodeId))->row();if(!$barcode||(int)$barcode->item_id!==(int)$line->item_id||(int)$barcode->source_receipt_line_id!==(int)$line->receipt_line_id)throw new BusinessException('Barcode/serial tidak berasal dari penerimaan yang dipilih.');if(!in_array($barcode->status,array('available','pending','return_reserved'),true))throw new BusinessException('Barcode '.$barcode->barcode.' tidak berada di gudang atau tidak dapat diretur.');$available=(float)$barcode->qty_sisa-(float)$barcode->return_reserved_qty;if($qty<=0||$qty>$available+.0001)throw new BusinessException('Qty retur melebihi saldo barcode '.$barcode->barcode.'.');if($line->tracking_type==='UNIT'&&abs($qty-1)>.0001)throw new BusinessException('Barang UNIT wajib diretur satu barcode/serial per baris.');return$barcode; + } + + private function assertReturnLineAvailable($line,$lock) + { + $source=$this->sourceLine($line->receipt_line_id,$lock);if(!$source)throw new BusinessException('Dokumen penerimaan sumber tidak ditemukan.');$committed=$this->committedReturnQty($line->receipt_line_id,$line->purchase_return_id);if((float)$line->qty>(float)$source->received_qty-$committed+.0001)throw new BusinessException('Saldo retur '.$line->description.' telah digunakan dokumen lain.');return$this->validateBarcode($source,$line->barcode_id,$line->qty,$lock); + } + + private function committedReturnQty($receiptLineId,$excludeReturnId=0) + { + $this->CI->db->select('COALESCE(SUM(l.qty),0) total',false)->from('purchase_return_lines l')->join('purchase_returns h','h.id=l.purchase_return_id')->where('l.receipt_line_id',(int)$receiptLineId)->where_not_in('h.status',array('draft','rejected','cancelled','reversed'));if($excludeReturnId)$this->CI->db->where('h.id !=',(int)$excludeReturnId);$row=$this->CI->db->get()->row();return$row?(float)$row->total:0; + } + + private function returnTaxForLine($receiptLineId,$qty) + { + $row=$this->CI->db->select('sil.qty,sil.tax_amount')->from('supplier_invoice_lines sil')->join('supplier_invoices si','si.id=sil.supplier_invoice_id')->where('sil.receipt_line_id',(int)$receiptLineId)->where_in('si.status',array('partial','paid'))->order_by('si.id')->limit(1)->get()->row();return$row&&(float)$row->qty>0?round(((float)$row->tax_amount/(float)$row->qty)*$qty,2):0; + } + + private function writeLedger($type,$documentId,$lineId,$itemId,$warehouseId,$barcodeId,$direction,$qty,$cost,$date,$key) + { + $existing=$this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;$this->CI->db->insert('inventory_ledger',array('item_id'=>$itemId,'warehouse_id'=>$warehouseId,'barcode_id'=>$barcodeId,'movement_date'=>$date,'direction'=>$direction,'qty'=>$qty,'unit_cost'=>$cost,'value'=>$qty*$cost,'document_type'=>$type,'document_id'=>$documentId,'document_line_id'=>$lineId,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));return(int)$this->CI->db->insert_id(); + } + + private function createReplacementBarcode($number,$line,$warehouse,$qty,$serial,$pending,$user,$sequence) + { + $barcode=preg_replace('/[^A-Z0-9\-]/','',strtoupper($number.'-'.$line->id.'-'.str_pad($sequence,4,'0',STR_PAD_LEFT)));$this->CI->db->insert('item_barcodes',array('item_id'=>$line->item_id,'barcode'=>$barcode,'serial_number'=>$serial?:null,'warehouse_id'=>$warehouse,'qty_awal'=>$qty,'qty_sisa'=>$qty,'sale_price'=>0,'status'=>$pending?'pending':'available','activated_at'=>$pending?null:date('Y-m-d H:i:s'),'activated_by'=>$pending?null:(int)$user,'source_receipt_line_id'=>$line->receipt_line_id,'created_at'=>date('Y-m-d H:i:s')));return(int)$this->CI->db->insert_id(); + } + + private function refundSource($type,$id) + { + $company=$this->companyId(); + if($type==='purchase_return'){$r=$this->CI->db->get_where('purchase_returns',array('id'=>$id,'company_id'=>$company))->row();if(!$r||!in_array($r->status,array('shipped','waiting_supplier','partially_resolved','resolved'),true))return null;return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>$r->amount,'number'=>$r->return_no,'purchase_order_id'=>$r->purchase_order_id);} + if($type==='supplier_payment'){$r=$this->CI->db->select('p.*,po.transit_cleared_amount,po.paid_amount po_paid_amount,COALESCE((SELECT SUM(a.amount) FROM supplier_payment_allocations a WHERE a.supplier_payment_id=p.id),0) allocated_amount',false)->from('supplier_payments p')->join('purchase_orders po','po.id=p.purchase_order_id','left')->where(array('p.id'=>$id,'p.status'=>'posted','p.company_id'=>$company))->get()->row();if(!$r)return null;if($r->purchase_order_id){$later=(float)$this->CI->db->select('COALESCE(SUM(amount),0) total',false)->where(array('purchase_order_id'=>$r->purchase_order_id,'status'=>'posted'))->where('id >',$r->id)->get('supplier_payments')->row()->total;$eligible=min((float)$r->amount,max(0,(float)$r->po_paid_amount-(float)$r->transit_cleared_amount-$later));}else{$eligible=max(0,(float)$r->amount-(float)$r->allocated_amount);}return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>$eligible,'number'=>$r->payment_no,'purchase_order_id'=>$r->purchase_order_id);} + if($type==='purchase_order'){$r=$this->CI->db->get_where('purchase_orders',array('id'=>$id,'company_id'=>$company))->row();if(!$r||in_array($r->status,array('cancelled','closed'),true))return null;return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>max(0,$r->paid_amount-$r->transit_cleared_amount),'number'=>$r->po_no,'purchase_order_id'=>$r->id);} + if($type==='supplier_invoice'){$r=$this->CI->db->get_where('supplier_invoices',array('id'=>$id,'company_id'=>$company))->row();if(!$r||!in_array($r->status,array('partial','paid'),true))return null;return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>max(0,(float)$r->paid_amount-(float)$r->total),'number'=>$r->internal_no,'supplier_invoice_id'=>$r->id);} + if($type==='supplier_debit_note'){$r=$this->CI->db->get_where('supplier_debit_notes',array('id'=>$id,'company_id'=>$company))->row();if(!$r||!in_array($r->status,array('posted','applied'),true))return null;return array('supplier_id'=>$r->supplier_id,'eligible_amount'=>$r->amount,'number'=>$r->debit_note_no,'supplier_invoice_id'=>$r->supplier_invoice_id,'purchase_order_id'=>$r->purchase_order_id);} + return null; + } + + private function allocatedRefundAmount($type,$id){$row=$this->CI->db->select('COALESCE(SUM(a.allocated_amount),0) total',false)->from('supplier_refund_allocations a')->join('supplier_refund_claims c','c.id=a.supplier_refund_claim_id')->where(array('a.source_type'=>$type,'a.source_id'=>$id))->where_not_in('c.status',array('rejected','cancelled','reversed'))->get()->row();return$row?(float)$row->total:0;} + private function refundCreditAccount($claim){if(in_array($claim->source_type,array('prepayment','cancelled_order','overpayment'),true))return$this->CI->accountmappingservice->get('goods_in_transit');return$this->CI->accountmappingservice->get('purchase_return_adjustment');} + private function cashAccount($id){return$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->companyId(),'is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->get('accounts')->row();} + private function hasAttachment($type,$id){return(bool)$this->CI->db->where(array('entity_type'=>$type,'entity_id'=>(int)$id))->count_all_results('transaction_attachments');} + private function companyId(){return(int)$this->CI->companycontext->id();} + private function isMasterAdmin(){return$this->CI->session->userdata('role')==='Admin';} + private function validDate($date,$label){$parsed=DateTime::createFromFormat('Y-m-d',(string)$date);if(!$parsed||$parsed->format('Y-m-d')!==$date)throw new BusinessException($label.' tidak valid.');return$date;} + private function enum($value,array$allowed,$fallback){return in_array($value,$allowed,true)?$value:$fallback;} + private function lockReturn($id,array$status){$row=$this->CI->db->query('SELECT * FROM purchase_returns WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$row||!in_array($row->status,$status,true))throw new BusinessException('Dokumen retur tidak ditemukan atau statusnya tidak mengizinkan tindakan ini.');if($this->CI->db->field_exists('company_id','purchase_returns')&&(int)$row->company_id!==$this->companyId())throw new BusinessException('Dokumen retur berasal dari perusahaan lain.',array(),403);return$row;} + private function lockClaim($id,array$status){$row=$this->CI->db->query('SELECT * FROM supplier_refund_claims WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$row||!in_array($row->status,$status,true))throw new BusinessException('Dokumen refund tidak ditemukan atau statusnya tidak mengizinkan tindakan ini.');if((int)$row->company_id!==$this->companyId())throw new BusinessException('Dokumen refund berasal dari perusahaan lain.',array(),403);return$row;} + private function history($id,$from,$to,$user,$notes){$this->CI->db->insert('purchase_return_status_history',array('purchase_return_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>(int)$user,'ip_address'=>$this->CI->input->ip_address(),'session_id'=>session_id()?:null,'created_at'=>date('Y-m-d H:i:s')));} + private function refundHistory($id,$from,$to,$user,$notes){$this->CI->db->insert('supplier_refund_status_history',array('supplier_refund_claim_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>(int)$user,'ip_address'=>$this->CI->input->ip_address(),'session_id'=>session_id()?:null,'created_at'=>date('Y-m-d H:i:s')));} + private function debitNoteHistory($id,$from,$to,$user,$notes){$this->CI->db->insert('supplier_debit_note_status_history',array('supplier_debit_note_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>(int)$user,'ip_address'=>$this->CI->input->ip_address(),'session_id'=>session_id()?:null,'created_at'=>date('Y-m-d H:i:s')));} +} diff --git a/application/libraries/PurchaseService.php b/application/libraries/PurchaseService.php new file mode 100644 index 0000000..9b53df3 --- /dev/null +++ b/application/libraries/PurchaseService.php @@ -0,0 +1,234 @@ +$value){ + $value=round((float)$value,2);if($value<=0)continue; + $row=$this->CI->db->where(array('id'=>(int)$account,'is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->get('accounts')->row(); + if(!$row)throw new BusinessException('Sumber Kas/Bank tidak valid.'); + $valid[(int)$account]=($valid[(int)$account]??0)+$value;$gross+=$value; + } + if(!$valid||$gross<=0)throw new BusinessException('Minimal satu sumber Kas/Bank dan nominal wajib diisi.'); + return array($valid,round($gross,2)); + } + + private function purchaseBankCharge($amount,$accountId) + { + $amount=round((float)$amount,2);if($amount<0)throw new BusinessException('Biaya admin bank tidak boleh negatif.'); + if($amount<=.001)return array(0,null); + $accountId=(int)$accountId; + if(!$accountId)$accountId=(int)$this->CI->accountmappingservice->get('bank_charge_expense'); + $account=$this->CI->db->get_where('accounts',array('id'=>$accountId,'tipe'=>'expense','is_active'=>1,'allow_posting'=>1))->row(); + if(!$account)throw new BusinessException('Akun Beban Administrasi Bank tidak valid.'); + return array($amount,$accountId); + } + + public function prepayOrderWithBankCharge($poId,$date,array$sources,$reference,$notes,$user,$bankCharge=0,$bankChargeAccount=0) + { + return$this->CI->transactionservice->run(function()use($poId,$date,$sources,$reference,$notes,$user,$bankCharge,$bankChargeAccount){ + $po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array((int)$poId))->row(); + if(!$po||!in_array($po->status,array('approved','partially_received'),true))throw new BusinessException('PO belum disetujui atau sudah selesai.'); + if($po->payment_timing!=='prepaid'&&!($po->payment_timing==='installment'&&(int)$po->down_payment_required===1))throw new BusinessException('Menu Pembayaran hanya untuk pelunasan sebelum barang diterima atau DP yang diwajibkan PO. Cicilan reguler dibayar melalui Hutang & Pembayaran.'); + list($valid,$gross)=$this->purchasePaymentSources($sources);list($charge,$chargeAccount)=$this->purchaseBankCharge($bankCharge,$bankChargeAccount); + $principal=round($gross-$charge,2);if($principal<=0)throw new BusinessException('Total sumber Kas/Bank harus lebih besar daripada biaya admin bank.'); + $required=round(($po->payment_timing==='installment'?$po->down_payment_amount:$po->total)-$po->paid_amount,2); + if($required<=.001)throw new BusinessException('Pembayaran awal/DP untuk PO ini sudah lunas.'); + if(abs($principal-$required)>.001)throw new BusinessException('Pokok pembayaran harus sama dengan '.($po->payment_timing==='installment'?'sisa DP':'total PO').' sebesar Rp '.number_format($required,2,',','.').'. Total sumber Kas/Bank termasuk admin harus Rp '.number_format($required+$charge,2,',','.').'.'); + $this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_payment',$date); + $desc='[AUTO] '.($po->payment_timing==='installment'?'Pembayaran DP':'Pelunasan sebelum penerimaan').' PO '.$po->po_no.'; pokok Rp '.number_format($principal,2,',','.').'; admin bank Rp '.number_format($charge,2,',','.').'; total keluar Rp '.number_format($gross,2,',','.').'; referensi '.($reference?:'-').'; pokok dicatat sebagai Barang Dalam Perjalanan'; + $entries=array(array('account_id'=>$this->CI->accountmappingservice->get('goods_in_transit'),'debit'=>$principal,'kredit'=>0,'keterangan'=>$desc)); + if($charge>0)$entries[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0,'keterangan'=>$desc); + foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'purchase_prepayment','ref_id'=>$po->id,'created_by'=>$user),$entries,false,true); + $payment=array('payment_no'=>$no,'supplier_id'=>$po->supplier_id,'purchase_order_id'=>$po->id,'payment_kind'=>'prepayment','payment_date'=>$date,'amount'=>$principal,'bank_charge_amount'=>$charge,'bank_charge_account_id'=>$chargeAccount,'total_cash_out'=>$gross,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')); + if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id(); + $this->CI->db->insert('supplier_payments',$payment);$paymentId=$this->CI->db->insert_id(); + foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$paymentId,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s'))); + $paid=round($po->paid_amount+$principal,2);$this->CI->db->where('id',$po->id)->update('purchase_orders',array('paid_amount'=>$paid,'payment_status'=>$paid>=$po->total-.001?'paid':'partial')); + return$paymentId; + }); + } + + public function settleSupplierInvoiceMultiWithBankCharge($id,array$sources,$paymentDate,$reference,$user,$bankCharge=0,$bankChargeAccount=0) + { + $bill=$this->CI->db->get_where('supplier_invoices',array('id'=>(int)$id))->row();if(!$bill)throw new BusinessException('Invoice supplier tidak ditemukan.'); + $po=$this->CI->db->select('po.*')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->join('purchase_orders po','po.id=pol.purchase_order_id')->where('sil.supplier_invoice_id',$id)->limit(1)->get()->row(); + $alreadyPaid=$po&&in_array($po->payment_timing,array('prepaid','on_receipt'),true)&&$po->paid_amount>=$bill->total-.001; + if($alreadyPaid||$bill->payment_plan==='credit'||$bill->payment_plan==='installment'){ + if(round((float)$bankCharge,2)>0)throw new BusinessException('Biaya admin bank hanya diisi ketika terjadi pembayaran baru.'); + return$this->settleSupplierInvoiceMulti($id,$sources,$paymentDate,$reference,$user); + } + list($valid,$gross)=$this->purchasePaymentSources($sources);list($charge,$chargeAccount)=$this->purchaseBankCharge($bankCharge,$bankChargeAccount); + $principal=round($gross-$charge,2);$required=round((float)$bill->total,2); + if($principal<=0||abs($principal-$required)>.001)throw new BusinessException('Pokok pembayaran invoice harus Rp '.number_format($required,2,',','.').'. Total sumber Kas/Bank termasuk admin harus Rp '.number_format($required+$charge,2,',','.').'.'); + return$this->CI->transactionservice->run(function()use($id,$po,$valid,$gross,$charge,$chargeAccount,$principal,$paymentDate,$reference,$user){ + $locked=$this->lock('supplier_invoices',$id,'verified');$date=$paymentDate?:$locked->invoice_date;$this->CI->fiscalperiodservice->assertOpen($date); + $supplier=$this->CI->db->get_where('suppliers',array('id'=>$locked->supplier_id))->row();$no=$this->CI->numberingservice->next('supplier_payment',$date); + $desc='[AUTO] Pembayaran langsung invoice supplier '.$locked->supplier_invoice_no.' ('.$locked->internal_no.') kepada '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$locked->supplier_id).'; pokok Rp '.number_format($principal,2,',','.').'; admin bank Rp '.number_format($charge,2,',','.').'; total keluar Rp '.number_format($gross,2,',','.').'; referensi '.($reference?:'-'); + $entries=array();foreach($this->purchaseDebitGroups($id)as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>$value,'kredit'=>0,'keterangan'=>$desc); + if($locked->tax_total>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>$locked->tax_total,'kredit'=>0,'keterangan'=>$desc); + if($charge>0)$entries[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0,'keterangan'=>$desc); + foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'supplier_invoice_cash','ref_id'=>$id,'created_by'=>$user),$entries,false,true); + $payment=array('payment_no'=>$no,'supplier_id'=>$locked->supplier_id,'purchase_order_id'=>$po?$po->id:null,'payment_date'=>$date,'amount'=>$principal,'bank_charge_amount'=>$charge,'bank_charge_account_id'=>$chargeAccount,'total_cash_out'=>$gross,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')); + if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id(); + $this->CI->db->insert('supplier_payments',$payment);$pid=$this->CI->db->insert_id(); + foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$pid,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s'))); + $this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$pid,'supplier_invoice_id'=>$id,'amount'=>$principal,'created_at'=>date('Y-m-d H:i:s'))); + $this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'paid','paid_amount'=>$principal,'balance'=>0,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'))); + return array('invoice_id'=>$id,'payment_id'=>$pid,'payment_no'=>$no,'mode'=>'cash'); + }); + } + + public function paymentMultiWithBankCharge($supplierId,$date,array$sources,array$allocations,$allowAdvance,$user,$reference,$bankCharge=0,$bankChargeAccount=0) + { + return$this->CI->transactionservice->run(function()use($supplierId,$date,$sources,$allocations,$allowAdvance,$user,$reference,$bankCharge,$bankChargeAccount){ + $supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$supplierId))->row();if(!$supplier)throw new BusinessException('Supplier tidak ditemukan.'); + list($valid,$gross)=$this->purchasePaymentSources($sources);list($charge,$chargeAccount)=$this->purchaseBankCharge($bankCharge,$bankChargeAccount); + $principal=round($gross-$charge,2);if($principal<=0)throw new BusinessException('Total sumber Kas/Bank harus lebih besar daripada biaya admin bank.'); + $allocated=0;$bills=array(); + foreach($allocations as$id=>$value){$value=round((float)$value,2);if($value<=0)continue;$bill=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$bill||(int)$bill->supplier_id!==(int)$supplierId||$bill->status!=='partial'||$value>$bill->balance+.001)throw new BusinessException('Alokasi tagihan supplier tidak valid atau melebihi saldo.');$allocated+=$value;$bills[]=array($bill,$value);} + if(!$bills&&!$allowAdvance)throw new BusinessException('Pilih minimal satu tagihan yang akan dibayar.'); + if($allocated>$principal+.001)throw new BusinessException('Total alokasi tagihan melebihi pokok pembayaran setelah biaya admin bank.'); + $advance=round($principal-$allocated,2);if($advance>0&&!$allowAdvance)throw new BusinessException('Pokok pembayaran harus sama dengan alokasi tagihan, atau centang uang muka supplier.'); + $this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_payment',$date); + $docs=array_map(function($row){return$row[0]->internal_no.' Rp '.number_format($row[1],2,',','.');},$bills); + $desc='[AUTO] Pembayaran supplier '.$supplier->supplier_code.' - '.$supplier->name.'; tagihan '.($docs?implode(', ',$docs):'uang muka').'; pokok Rp '.number_format($principal,2,',','.').'; admin bank Rp '.number_format($charge,2,',','.').'; total keluar Rp '.number_format($gross,2,',','.').'; referensi '.($reference?:'-'); + $entries=array();if($allocated>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>$allocated,'kredit'=>0,'keterangan'=>$desc);if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('supplier_advances'),'debit'=>$advance,'kredit'=>0,'keterangan'=>$desc);if($charge>0)$entries[]=array('account_id'=>$chargeAccount,'debit'=>$charge,'kredit'=>0,'keterangan'=>$desc);foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'supplier_payment','created_by'=>$user),$entries,false,true); + $payment=array('payment_no'=>$no,'supplier_id'=>$supplierId,'payment_date'=>$date,'amount'=>$principal,'bank_charge_amount'=>$charge,'bank_charge_account_id'=>$chargeAccount,'total_cash_out'=>$gross,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')); + if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id(); + $this->CI->db->insert('supplier_payments',$payment);$pid=$this->CI->db->insert_id();foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$pid,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s'))); + foreach($bills as$row){list($bill,$value)=$row;$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$pid,'supplier_invoice_id'=>$bill->id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$paid=$bill->paid_amount+$value;$balance=max(0,$bill->total-$paid);$this->CI->db->where('id',$bill->id)->update('supplier_invoices',array('paid_amount'=>$paid,'balance'=>$balance,'status'=>$balance<=.001?'paid':'partial'));$poLine=$this->CI->db->select('pol.purchase_order_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$bill->id)->limit(1)->get()->row();if($poLine){$scheduleAllocation=$value;foreach($this->CI->db->where('purchase_order_id',$poLine->purchase_order_id)->where_in('status',array('unpaid','partial'))->order_by('due_date')->order_by('installment_no')->get('purchase_installment_schedules')->result()as$schedule){if($scheduleAllocation<=.001)break;$applied=min($scheduleAllocation,max(0,$schedule->amount-$schedule->paid_amount));if($applied<=0)continue;$schedulePaid=$schedule->paid_amount+$applied;$this->CI->db->where('id',$schedule->id)->update('purchase_installment_schedules',array('paid_amount'=>$schedulePaid,'status'=>$schedulePaid>=$schedule->amount-.001?'paid':'partial'));$scheduleAllocation-=$applied;}}} + if($advance>0)$this->CI->db->insert('supplier_advances',array('advance_no'=>'SADV-'.$no,'supplier_id'=>$supplierId,'supplier_payment_id'=>$pid,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s'))); + return array('payment_id'=>$pid,'payment_no'=>$no,'amount'=>$principal,'bank_charge'=>$charge,'total_cash_out'=>$gross,'allocated'=>$allocated,'advance'=>$advance); + }); + } + public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('TransactionService','NumberingService','ApprovalService','AuditService','PostingService','AccountMappingService','FiscalPeriodService'));$this->CI->load->model('InventoryModel','purchaseInventoryModel');} + public function createCatalogRequest(array$d,$user){if(empty($d['department_id'])||trim($d['department'])==='')throw new BusinessException('Departemen wajib dipilih dari master.');$source=isset($d['lines'])&&is_array($d['lines'])?$d['lines']:array($d);if(!$source)throw new BusinessException('Minimal satu barang wajib ditambahkan.');$lines=array();foreach($source as$index=>$input){$catalog=$this->CI->db->get_where('kode_barang',array('id'=>(int)($input['kode_barang_id']??0)))->row();if(!$catalog)throw new BusinessException('Master Barang pada baris '.($index+1).' tidak ditemukan.');$tracking=strtoupper($catalog->tracking_type?:'UNIT');$qty=(float)($input['qty']??0);$per=$tracking==='UNIT'?1:(float)($input['qty_per_barcode']??0);$description=trim((string)($input['description']??''));$price=round((float)($input['estimated_price']??0),2);if($description===''||$qty<=0||$price<0)throw new BusinessException('Deskripsi, qty, dan estimasi harga baris '.($index+1).' tidak valid.');if($tracking==='QTY'&&$per<=0)throw new BusinessException('Isi per barcode wajib diisi pada baris '.($index+1).'.');if($tracking==='UNIT'&&abs($qty-round($qty))>.0001)throw new BusinessException('Qty barang UNIT pada baris '.($index+1).' harus bilangan bulat.');$lines[]=array('item_id'=>null,'kode_barang_id'=>$catalog->id,'description'=>$description,'qty'=>$qty,'estimated_price'=>$price,'tracking_type'=>$tracking,'qty_per_barcode'=>$per,'need_serial_number'=>strtolower($catalog->need_serial_number)==='true'?1:0);} $d['lines']=$lines;$id=$this->createRequest($d,$user);$this->CI->db->where('id',$id)->update('purchase_requests',array('department_id'=>(int)$d['department_id']));return$id;} + public function createCatalogOrder($requestId,$supplierId,$date,$expected,$warehouseId,$description,$qty,$price,$taxRate,$user,array$options=array()){$line=$this->CI->db->where('purchase_request_id',(int)$requestId)->order_by('id')->get('purchase_request_lines')->row();if(!$line||!$line->kode_barang_id)throw new BusinessException('PR belum memiliki Master Kode Barang.');$request=$this->CI->db->get_where('purchase_requests',array('id'=>(int)$requestId))->row();$itemId=null;if($request&&$request->destination_type==='warehouse'){$item=$this->CI->db->where('company_id',$this->CI->companycontext->id())->where('kode_id',$line->kode_barang_id)->where('nama_barang',trim($description))->get('items')->row();if(!$item){$catalog=$this->CI->db->get_where('kode_barang',array('id'=>$line->kode_barang_id))->row();$this->CI->db->insert('items',array('company_id'=>$this->CI->companycontext->id(),'stok'=>0,'planned_qty'=>$qty,'planned_warehouse_id'=>$warehouseId,'planned_tracking_qty'=>$line->qty_per_barcode,'kode_detail'=>$catalog->kode_barang.'-'.date('ymdHis'),'kode_id'=>$catalog->id,'nama_barang'=>trim($description),'harga_beli'=>$price,'harga_jual'=>0,'tanggal_pembelian'=>$date,'status'=>'draft'));$itemId=$this->CI->db->insert_id();}else{$itemId=$item->id;}}$id=$this->createOrder($requestId,$supplierId,$date,$expected,$itemId,$warehouseId,$description,$qty,$price,$taxRate,$user,$options);$plan=in_array($options['payment_plan']??'',array('cash','installment','credit'),true)?$options['payment_plan']:'credit';$this->CI->db->where('id',$id)->update('purchase_orders',array('payment_plan'=>$plan));$this->CI->db->where('purchase_order_id',$id)->update('purchase_order_lines',array('kode_barang_id'=>$line->kode_barang_id,'tracking_type'=>$line->tracking_type,'qty_per_barcode'=>$line->qty_per_barcode,'need_serial_number'=>$line->need_serial_number));return$id;} + public function createProfessionalOrder(array$d,$user){return$this->CI->transactionservice->run(function()use($d,$user){$request=$this->lock('purchase_requests',(int)$d['request_id'],'approved');$supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$d['supplier_id'],'is_active'=>1))->row();if(!$supplier)throw new BusinessException('Supplier tidak aktif atau tidak ditemukan.');$warehouse=(int)$d['warehouse_id'];if(!$warehouse||!$this->CI->db->get_where('warehouses',array('id'=>$warehouse))->row())throw new BusinessException('Gudang tujuan wajib dipilih.');$method=in_array($d['procurement_method'],array('online','direct','supplier'),true)?$d['procurement_method']:'supplier';$timing=in_array($d['payment_timing'],array('prepaid','on_receipt','installment','credit'),true)?$d['payment_timing']:'credit';if($method==='online'&&(trim($d['marketplace'])===''||trim($d['external_order_no'])===''))throw new BusinessException('Marketplace dan nomor pesanan wajib untuk pembelian online.');$requestLines=$this->CI->db->where('purchase_request_id',$request->id)->order_by('id')->get('purchase_request_lines')->result();if(!$requestLines)throw new BusinessException('PR tidak memiliki barang.');$subtotal=0;$taxTotal=0;$prepared=array();foreach($requestLines as$line){$price=round((float)($d['unit_prices'][$line->id]??$line->estimated_price),2);$taxRate=round((float)($d['tax_rates'][$line->id]??0),4);if($price<0||$taxRate<0)throw new BusinessException('Harga atau pajak barang tidak valid.');$base=(float)$line->qty*$price;$tax=$base*$taxRate/100;$subtotal+=$base;$taxTotal+=$tax;$prepared[]=array($line,$price,$taxRate,$tax,$base+$tax);}foreach(array('shipping_cost','insurance_cost','service_fee','discount_amount','cashback_amount')as$key)$d[$key]=max(0,round((float)$d[$key],2));$total=$subtotal+$taxTotal+$d['shipping_cost']+$d['insurance_cost']+$d['service_fee']-$d['discount_amount']-$d['cashback_amount'];if($total<0)throw new BusinessException('Total PO tidak boleh negatif.');$downPaymentRequired=$timing==='installment'&&!empty($d['down_payment_required']);$downPaymentAmount=$downPaymentRequired?round((float)$d['down_payment_amount'],2):0;if($downPaymentRequired&&($downPaymentAmount<=0||$downPaymentAmount>=$total))throw new BusinessException('Nilai DP harus lebih dari nol dan lebih kecil dari total PO.');$no=$this->CI->numberingservice->next('purchase_order',$d['date']);$this->CI->db->insert('purchase_orders',array('po_no'=>$no,'supplier_id'=>$supplier->id,'purchase_request_id'=>$request->id,'order_date'=>$d['date'],'expected_date'=>$d['expected_date']?:null,'procurement_method'=>$method,'payment_timing'=>$timing,'marketplace'=>$method==='online'?$d['marketplace']:null,'external_order_no'=>$d['external_order_no']?:null,'courier'=>$d['courier']?:null,'tracking_no'=>$d['tracking_no']?:null,'status'=>'draft','subtotal'=>$subtotal,'tax_total'=>$taxTotal,'total'=>$total,'shipping_cost'=>$d['shipping_cost'],'insurance_cost'=>$d['insurance_cost'],'service_fee'=>$d['service_fee'],'discount_amount'=>$d['discount_amount'],'cashback_amount'=>$d['cashback_amount'],'notes'=>$d['notes'],'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'),'payment_plan'=>$timing==='installment'?'installment':($timing==='credit'?'credit':'cash'),'down_payment_required'=>$downPaymentRequired?1:0,'down_payment_amount'=>$downPaymentAmount));$poId=$this->CI->db->insert_id();foreach($prepared as$row){list($line,$price,$taxRate,$tax,$lineTotal)=$row;$item=$this->CI->db->where('kode_id',$line->kode_barang_id)->where('nama_barang',$line->description)->get('items')->row();if(!$item){$catalog=$this->CI->db->get_where('kode_barang',array('id'=>$line->kode_barang_id))->row();$this->CI->db->insert('items',array('stok'=>0,'planned_qty'=>$line->qty,'planned_warehouse_id'=>$warehouse,'planned_tracking_qty'=>$line->qty_per_barcode,'kode_detail'=>$catalog->kode_barang.'-'.date('ymdHis').'-'.$line->id,'kode_id'=>$catalog->id,'nama_barang'=>$line->description,'harga_beli'=>$price,'harga_jual'=>0,'tanggal_pembelian'=>$d['date'],'status'=>'draft'));$itemId=$this->CI->db->insert_id();}else$itemId=$item->id;$this->CI->db->insert('purchase_order_lines',array('purchase_order_id'=>$poId,'request_line_id'=>$line->id,'item_id'=>$itemId,'warehouse_id'=>$warehouse,'description'=>$line->description,'qty'=>$line->qty,'unit_price'=>$price,'tax_rate'=>$taxRate,'tax_amount'=>$tax,'line_total'=>$lineTotal,'destination_type'=>'warehouse','kode_barang_id'=>$line->kode_barang_id,'tracking_type'=>$line->tracking_type,'qty_per_barcode'=>$line->qty_per_barcode,'need_serial_number'=>$line->need_serial_number));$this->CI->db->where('id',$line->id)->set('ordered_qty','ordered_qty+'.$this->CI->db->escape($line->qty),false)->update('purchase_request_lines');}$this->CI->db->where('id',$request->id)->update('purchase_requests',array('status'=>'converted'));if($timing==='installment')$this->createInstallmentSchedule($poId,$total-$downPaymentAmount,(int)$d['installment_count'],$d['first_due_date']);$this->CI->auditservice->record('purchase','purchase_order',$poId,'create',null,array('po_no'=>$no,'method'=>$method,'payment_timing'=>$timing,'line_count'=>count($prepared),'total'=>$total),$user);return$poId;});} + private function createInstallmentSchedule($poId,$total,$count,$firstDue){if($count<2||!$firstDue)throw new BusinessException('Jumlah cicilan minimal 2 dan tanggal cicilan pertama wajib diisi.');$base=floor(($total/$count)*100)/100;$allocated=0;for($i=1;$i<=$count;$i++){$amount=$i===$count?round($total-$allocated,2):$base;$this->CI->db->insert('purchase_installment_schedules',array('purchase_order_id'=>$poId,'installment_no'=>$i,'due_date'=>date('Y-m-d',strtotime($firstDue.' +'.($i-1).' month')),'amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')));$allocated+=$amount;}} + public function prepayOrder($poId,$date,array$sources,$reference,$notes,$user){return$this->CI->transactionservice->run(function()use($poId,$date,$sources,$reference,$notes,$user){$po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array((int)$poId))->row();if(!$po||!in_array($po->status,array('approved','partially_received'),true))throw new BusinessException('PO belum disetujui atau sudah selesai.');if($po->payment_timing!=='prepaid'&&!($po->payment_timing==='installment'&&(int)$po->down_payment_required===1))throw new BusinessException('Menu Pembayaran hanya untuk pelunasan sebelum barang diterima atau DP yang diwajibkan PO. Cicilan reguler dibayar melalui Hutang & Pembayaran.');$total=0;$valid=array();foreach($sources as$account=>$amount){$amount=round((float)$amount,2);if($amount<=0)continue;$a=$this->CI->db->get_where('accounts',array('id'=>(int)$account,'is_active'=>1,'allow_posting'=>1))->row();if(!$a)throw new BusinessException('Akun sumber pembayaran tidak valid.');$total+=$amount;$valid[]=array((int)$account,$amount);}if(!$valid||$total<=0)throw new BusinessException('Minimal satu sumber pembayaran wajib diisi.');$required=round(($po->payment_timing==='installment'?$po->down_payment_amount:$po->total)-$po->paid_amount,2);if($required<=.001)throw new BusinessException('Pembayaran awal/DP untuk PO ini sudah lunas.');if(abs($total-$required)>.001)throw new BusinessException('Total pembayaran harus sama dengan '.($po->payment_timing==='installment'?'sisa DP':'total PO').' sebesar Rp '.number_format($required,2,',','.').'.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_payment',$date);$entries=array(array('account_id'=>$this->CI->accountmappingservice->get('goods_in_transit'),'debit'=>$total,'kredit'=>0));foreach($valid as$source)$entries[]=array('account_id'=>$source[0],'debit'=>0,'kredit'=>$source[1]);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'[AUTO] '.($po->payment_timing==='installment'?'Pembayaran DP':'Pelunasan sebelum penerimaan').' untuk PO '.$po->po_no.'; referensi '.($reference?:'-').'; dicatat sebagai Barang Dalam Perjalanan','ref_type'=>'purchase_prepayment','ref_id'=>$po->id,'created_by'=>$user),$entries,false,true);$this->CI->db->insert('supplier_payments',array('payment_no'=>$no,'supplier_id'=>$po->supplier_id,'purchase_order_id'=>$po->id,'payment_kind'=>'prepayment','payment_date'=>$date,'amount'=>$total,'bank_account_id'=>$valid[0][0],'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$paymentId=$this->CI->db->insert_id();foreach($valid as$source)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$paymentId,'account_id'=>$source[0],'amount'=>$source[1],'created_at'=>date('Y-m-d H:i:s')));$paid=round($po->paid_amount+$total,2);$this->CI->db->where('id',$po->id)->update('purchase_orders',array('paid_amount'=>$paid,'payment_status'=>$paid>=$po->total-.001?'paid':'partial'));return$paymentId;});} + public function refundOrder($poId,$date,$account,$amount,$reason,$user){return$this->CI->transactionservice->run(function()use($poId,$date,$account,$amount,$reason,$user){$po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array((int)$poId))->row();$amount=round((float)$amount,2);if(!$po||$amount<=0||$amount>$po->paid_amount-$po->transit_cleared_amount+.001)throw new BusinessException('Nilai refund melebihi pembayaran yang masih berada dalam perjalanan.');if(trim($reason)==='')throw new BusinessException('Alasan refund wajib diisi.');$no='RFP-'.date('YmdHis').'-'.$po->id;$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Refund pembelian PO '.$po->po_no.': '.$reason,'ref_type'=>'purchase_refund','ref_id'=>$po->id,'created_by'=>$user),array(array('account_id'=>$account,'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('goods_in_transit'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('purchase_refunds',array('refund_no'=>$no,'purchase_order_id'=>$po->id,'refund_date'=>$date,'account_id'=>$account,'amount'=>$amount,'reason'=>$reason,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$paid=max(0,$po->paid_amount-$amount);$this->CI->db->where('id',$po->id)->update('purchase_orders',array('paid_amount'=>$paid,'payment_status'=>$paid<=.001?'refunded':'partial'));return$this->CI->db->insert_id();});} + public function finalizeReceiptTracking($receiptId){return$this->CI->transactionservice->run(function()use($receiptId){ + $lines=$this->CI->db->select('grl.*,gr.receipt_no,gr.supplier_id,pol.tracking_type,pol.qty_per_barcode,pol.need_serial_number,i.kode_detail,i.harga_beli,i.harga_jual,i.status item_status')->from('goods_receipt_lines grl')->join('goods_receipts gr','gr.id=grl.goods_receipt_id')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->join('items i','i.id=grl.item_id','left')->where('grl.goods_receipt_id',$receiptId)->get()->result(); + foreach($lines as$l){ + if(!$l->item_id)continue;$tracking=$l->tracking_type?:'UNIT';$per=$tracking==='UNIT'?1:(float)$l->qty_per_barcode;if($per<=0)throw new BusinessException('Qty per barcode PO belum valid.'); + $existing=$this->CI->db->where('source_receipt_line_id',$l->id)->count_all_results('item_barcodes'); + $needsSerial=(int)$l->need_serial_number===1;$hasSalePrice=(float)$l->harga_jual>0&&(float)$l->harga_jual>=(float)$l->harga_beli;$barcodeStatus=(!$needsSerial&&$hasSalePrice)?'available':'pending'; + if(!$existing){$remaining=(float)$l->qty;$index=1;while($remaining>.0001){$barcodeQty=min($per,$remaining);$code=strtoupper(preg_replace('/[^A-Z0-9_-]/i','',(string)$l->kode_detail)).'-GR'.$receiptId.'-'.$l->id.'-'.str_pad($index,3,'0',STR_PAD_LEFT);$this->CI->db->insert('item_barcodes',array('item_id'=>$l->item_id,'barcode'=>$code,'warehouse_id'=>$l->warehouse_id,'qty_awal'=>$barcodeQty,'qty_sisa'=>$barcodeQty,'status'=>$barcodeStatus,'source_receipt_line_id'=>$l->id,'created_at'=>date('Y-m-d H:i:s')));$remaining-=$barcodeQty;$index++;}} + foreach($this->CI->db->where('source_receipt_line_id',$l->id)->order_by('id')->get('item_barcodes')->result()as$barcode){$movementExists=$this->CI->db->where(array('barcode_id'=>$barcode->id,'reference_type'=>'goods_receipt_line','reference_id'=>$l->id,'movement_type'=>'purchase'))->count_all_results('item_movements');if(!$movementExists)$this->CI->db->insert('item_movements',array('item_id'=>$l->item_id,'barcode_id'=>$barcode->id,'qty'=>$barcode->qty_awal,'from_type'=>'supplier','from_id'=>$l->supplier_id,'to_type'=>'warehouse','to_id'=>$l->warehouse_id,'movement_type'=>'purchase','reference_type'=>'goods_receipt_line','reference_id'=>$l->id,'notes'=>'Penerimaan barang '.$l->receipt_no.' dari supplier ke gudang','created_at'=>date('Y-m-d H:i:s')));} + $itemStatus=($l->item_status==='active'||(!$needsSerial&&$hasSalePrice))?'active':'draft';$this->CI->db->where('id',$l->item_id)->update('items',array('status'=>$itemStatus)); + }return$receiptId; + });} + public function syncInvoicePaymentPlan($invoiceId,$poId){$po=$this->CI->db->get_where('purchase_orders',array('id'=>$poId))->row();if($po)$this->CI->db->where('id',$invoiceId)->update('supplier_invoices',array('payment_plan'=>$po->payment_plan));return$invoiceId;} + public function settleSupplierInvoiceMulti($id,array$sources,$paymentDate,$reference,$user){$bill=$this->CI->db->get_where('supplier_invoices',array('id'=>(int)$id))->row();if(!$bill)throw new BusinessException('Invoice supplier tidak ditemukan.');$po=$this->CI->db->select('po.*')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->join('purchase_orders po','po.id=pol.purchase_order_id')->where('sil.supplier_invoice_id',$id)->limit(1)->get()->row();if($po&&in_array($po->payment_timing,array('prepaid','on_receipt'),true)&&$po->paid_amount>=$bill->total-.001)return array('invoice_id'=>$this->settleSupplierInvoice($id,null,0,$paymentDate,$reference,$user),'payment_id'=>null,'mode'=>'reconcile');if($bill->payment_plan==='credit')return array('invoice_id'=>$this->postSupplierInvoice($id,$user),'payment_id'=>null,'mode'=>'payable');if($bill->payment_plan==='installment'){$invoiceId=$po&&$po->paid_amount>0?$this->settleSupplierInvoice($id,null,0,$paymentDate,$reference,$user):$this->postSupplierInvoice($id,$user);return array('invoice_id'=>$invoiceId,'payment_id'=>null,'mode'=>'installment_payable');}$valid=array();$amount=0;foreach($sources as$account=>$value){$value=round((float)$value,2);if($value<=0)continue;$a=$this->CI->db->where(array('id'=>(int)$account,'is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->get('accounts')->row();if(!$a)throw new BusinessException('Sumber Kas/Bank tidak valid.');$valid[(int)$account]=($valid[(int)$account]??0)+$value;$amount+=$value;}if(!$valid)throw new BusinessException('Minimal satu sumber Kas/Bank wajib diisi.');$required=$bill->payment_plan==='installment'?$amount:(float)$bill->total;if($amount<=0||$amount>$bill->total+.001||($bill->payment_plan!=='installment'&&abs($amount-$bill->total)>.001))throw new BusinessException($bill->payment_plan==='installment'?'Total cicilan harus lebih dari nol dan tidak melebihi invoice.':'Total seluruh sumber Kas/Bank harus sama dengan total invoice.');if($bill->payment_plan==='installment'){$this->postSupplierInvoice($id,$user);$r=$this->paymentMulti($bill->supplier_id,$paymentDate,$valid,array($id=>$required),false,$user,$reference);return array('invoice_id'=>$id,'payment_id'=>$r['payment_id'],'payment_no'=>$r['payment_no'],'mode'=>'installment');}return$this->CI->transactionservice->run(function()use($id,$bill,$valid,$amount,$paymentDate,$reference,$user){$locked=$this->lock('supplier_invoices',$id,'verified');$this->CI->fiscalperiodservice->assertOpen($paymentDate?:$locked->invoice_date);$supplier=$this->CI->db->get_where('suppliers',array('id'=>$locked->supplier_id))->row();$no=$this->CI->numberingservice->next('supplier_payment',$paymentDate?:$locked->invoice_date);$desc='[AUTO] Pembayaran langsung invoice supplier '.$locked->supplier_invoice_no.' ('.$locked->internal_no.') kepada '.($supplier?$supplier->supplier_code.' - '.$supplier->name:'supplier #'.$locked->supplier_id).'; referensi '.($reference?:'-');$entries=array();foreach($this->purchaseDebitGroups($id)as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>$value,'kredit'=>0,'keterangan'=>$desc);if($locked->tax_total>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>$locked->tax_total,'kredit'=>0,'keterangan'=>$desc);foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc);$journal=$this->CI->postingservice->post(array('tanggal'=>$paymentDate?:$locked->invoice_date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'supplier_invoice_cash','ref_id'=>$id,'created_by'=>$user),$entries,false,true);$payment=array('payment_no'=>$no,'supplier_id'=>$locked->supplier_id,'payment_date'=>$paymentDate?:$locked->invoice_date,'amount'=>$amount,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'));if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id();$this->CI->db->insert('supplier_payments',$payment);$pid=$this->CI->db->insert_id();foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$pid,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$pid,'supplier_invoice_id'=>$id,'amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'paid','paid_amount'=>$amount,'balance'=>0,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));return array('invoice_id'=>$id,'payment_id'=>$pid,'payment_no'=>$no,'mode'=>'cash');});} + public function settleSupplierInvoice($id,$bankAccount,$initialAmount,$paymentDate,$reference,$user){$bill=$this->CI->db->get_where('supplier_invoices',array('id'=>(int)$id))->row();if(!$bill)throw new BusinessException('Tagihan supplier tidak ditemukan.');$po=$this->CI->db->select('po.*')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->join('purchase_orders po','po.id=pol.purchase_order_id')->where('sil.supplier_invoice_id',$id)->limit(1)->get()->row();if($po&&in_array($po->payment_timing,array('prepaid','on_receipt'),true)){if($po->paid_amount<$bill->total-.001)throw new BusinessException('Pembayaran awal belum mencukupi nilai invoice. Selesaikan selisih melalui rekonsiliasi/refund, bukan membayar ulang invoice ini.');return$this->CI->transactionservice->run(function()use($id,$bill,$po,$user){$this->lock('supplier_invoices',$id,'verified');$payment=$this->CI->db->where(array('purchase_order_id'=>$po->id,'payment_kind'=>'prepayment','status'=>'posted'))->order_by('id')->get('supplier_payments')->row();$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'paid','paid_amount'=>$bill->total,'balance'=>0,'journal_id'=>$payment?$payment->journal_id:null,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));if($payment)$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$payment->id,'supplier_invoice_id'=>$id,'amount'=>$bill->total,'created_at'=>date('Y-m-d H:i:s')));return$id;});}if($po&&$po->payment_timing==='installment'&&$po->paid_amount>0)return$this->postInstallmentBalance($id,$bill,$po,$user);if($bill->payment_plan==='credit')return$this->postSupplierInvoice($id,$user);if(!$bankAccount)throw new BusinessException('Akun Kas/Bank wajib dipilih.');if($bill->payment_plan==='installment'){$journal=$this->postSupplierInvoice($id,$user);$amount=round((float)$initialAmount,2);if($amount<=0||$amount>$bill->total)throw new BusinessException('Nominal cicilan pertama harus lebih dari nol dan tidak melebihi tagihan.');$this->payment($bill->supplier_id,$paymentDate,$bankAccount,$amount,array($id=>$amount),false,$user,$reference);return$journal;}return$this->CI->transactionservice->run(function()use($id,$bankAccount,$paymentDate,$reference,$user){$bill=$this->lock('supplier_invoices',$id,'verified');$this->CI->fiscalperiodservice->assertOpen($bill->invoice_date);$entries=array();foreach($this->purchaseDebitGroups($id)as$account=>$amount)$entries[]=array('account_id'=>$account,'debit'=>$amount,'kredit'=>0);if($bill->tax_total>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>$bill->tax_total,'kredit'=>0);$entries[]=array('account_id'=>$bankAccount,'debit'=>0,'kredit'=>$bill->total);$journal=$this->CI->postingservice->post(array('tanggal'=>$paymentDate?:$bill->invoice_date,'no_ref'=>$bill->internal_no,'keterangan'=>'Pembelian tunai supplier '.$bill->supplier_invoice_no.'; pembayaran langsung tanpa membentuk saldo hutang','ref_type'=>'supplier_invoice_cash','ref_id'=>$id,'created_by'=>$user),$entries,false,true);$no=$this->CI->numberingservice->next('supplier_payment',$paymentDate?:$bill->invoice_date);$this->CI->db->insert('supplier_payments',array('payment_no'=>$no,'supplier_id'=>$bill->supplier_id,'payment_date'=>$paymentDate?:$bill->invoice_date,'amount'=>$bill->total,'bank_account_id'=>$bankAccount,'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$paymentId=$this->CI->db->insert_id();$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$paymentId,'supplier_invoice_id'=>$id,'amount'=>$bill->total,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'paid','paid_amount'=>$bill->total,'balance'=>0,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));return$journal;});} + private function postInstallmentBalance($id,$bill,$po,$user){return$this->CI->transactionservice->run(function()use($id,$bill,$po,$user){$this->lock('supplier_invoices',$id,'verified');$remaining=round(max(0,$bill->total-$po->paid_amount),2);$journal=null;if($remaining>0){$journal=$this->CI->postingservice->post(array('tanggal'=>$bill->invoice_date,'no_ref'=>$bill->internal_no,'keterangan'=>'Pengakuan sisa tagihan cicilan '.$bill->supplier_invoice_no.' setelah pembayaran awal PO '.$po->po_no,'ref_type'=>'supplier_invoice','ref_id'=>$id,'created_by'=>$user),array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$remaining,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>0,'kredit'=>$remaining)),false,true);}$payments=$this->CI->db->where(array('purchase_order_id'=>$po->id,'payment_kind'=>'prepayment','status'=>'posted'))->get('supplier_payments')->result();$allocated=0;foreach($payments as$p){$value=min($p->amount,$bill->total-$allocated);if($value<=0)break;if(!$this->CI->db->get_where('supplier_payment_allocations',array('supplier_payment_id'=>$p->id,'supplier_invoice_id'=>$id))->row())$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$p->id,'supplier_invoice_id'=>$id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$allocated+=$value;}$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>$remaining<=.001?'paid':'partial','paid_amount'=>$bill->total-$remaining,'balance'=>$remaining,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));return$id;});} + private function purchaseDebitGroups($invoiceId) + { + $group=array(); + foreach($this->CI->db->select('sil.qty,sil.unit_price,pol.destination_type,pol.asset_account_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$invoiceId)->get()->result()as$l){ + $account=$l->destination_type==='asset'?(int)$l->asset_account_id:$this->CI->accountmappingservice->get('inventory'); + if(!$account)throw new BusinessException('Akun tujuan pembelian belum dikonfigurasi.'); + $group[$account]=($group[$account]??0)+(float)$l->qty*(float)$l->unit_price; + } + $bill=$this->CI->db->get_where('supplier_invoices',array('id'=>(int)$invoiceId))->row(); + if($bill&&$group){ + $expectedDebit=round((float)$bill->total-(float)$bill->tax_total,2); + $adjustment=round($expectedDebit-array_sum($group),2); + if(abs($adjustment)>.001){$first=array_key_first($group);$group[$first]+=$adjustment;} + } + return$group; + } + public function syncReceiptLedger($receiptId){$lines=$this->CI->db->select('grl.*,gr.receipt_date,pol.unit_price,i.harga_beli landed_unit_cost')->from('goods_receipt_lines grl')->join('goods_receipts gr','gr.id=grl.goods_receipt_id')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->join('items i','i.id=grl.item_id','left')->where('grl.goods_receipt_id',$receiptId)->where('grl.item_id IS NOT NULL',null,false)->get()->result();$items=array();foreach($lines as$l){$this->writePurchaseLedger('goods_receipt',$receiptId,$l->id,$l->item_id,$l->warehouse_id,'in',$l->qty,($l->landed_unit_cost?:$l->unit_price),$l->receipt_date);$items[(int)$l->item_id]=true;}foreach(array_keys($items)as$itemId)$this->CI->purchaseInventoryModel->synchronizeStock($itemId);return$receiptId;} + public function clearTransitForReceipt($receiptId,$user){return$this->CI->transactionservice->run(function()use($receiptId,$user){$receipt=$this->CI->db->select('gr.*,po.po_no,po.total,po.subtotal,po.paid_amount,po.transit_cleared_amount')->from('goods_receipts gr')->join('purchase_orders po','po.id=gr.purchase_order_id')->where('gr.id',(int)$receiptId)->get()->row();if(!$receipt||$receipt->paid_amount<=0)return null;$base=(float)$this->CI->db->select('COALESCE(SUM(grl.qty*pol.unit_price),0) total',false)->from('goods_receipt_lines grl')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->where('grl.goods_receipt_id',$receiptId)->get()->row()->total;$share=$receipt->subtotal>0?$base/$receipt->subtotal:0;$value=round(min($receipt->paid_amount-$receipt->transit_cleared_amount,$receipt->total*$share),2);if($value<=0)return null;$this->CI->fiscalperiodservice->assertOpen($receipt->receipt_date);$journal=$this->CI->postingservice->post(array('tanggal'=>$receipt->receipt_date,'no_ref'=>$receipt->receipt_no,'keterangan'=>'Penerimaan barang '.$receipt->receipt_no.' dari PO '.$receipt->po_no.'; pemindahan nilai Barang Dalam Perjalanan ke Persediaan','ref_type'=>'goods_receipt_transit','ref_id'=>$receiptId,'created_by'=>$user),array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$value,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('goods_in_transit'),'debit'=>0,'kredit'=>$value)),false,true);$this->CI->db->where('goods_receipt_id',$receiptId)->update('goods_receipt_lines',array('advance_journal_id'=>$journal));$this->CI->db->where('id',$receipt->purchase_order_id)->set('transit_cleared_amount','transit_cleared_amount+'.$this->CI->db->escape($value),false)->update('purchase_orders');return$journal;});} + private function writePurchaseLedger($type,$documentId,$lineId,$itemId,$warehouseId,$direction,$qty,$cost,$date){$key='PURCHASE-'.strtoupper($type).'-'.$lineId;if($this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row())return;$this->CI->db->insert('inventory_ledger',array('item_id'=>$itemId,'warehouse_id'=>$warehouseId,'movement_date'=>$date,'direction'=>$direction,'qty'=>$qty,'unit_cost'=>$cost,'value'=>$qty*$cost,'document_type'=>$type,'document_id'=>$documentId,'document_line_id'=>$lineId,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$ledgerId=$this->CI->db->insert_id();if($direction==='in')$this->CI->db->insert('inventory_cost_layers',array('item_id'=>$itemId,'warehouse_id'=>$warehouseId,'source_ledger_id'=>$ledgerId,'received_date'=>$date,'original_qty'=>$qty,'remaining_qty'=>$qty,'unit_cost'=>$cost,'status'=>'open'));} + public function purchaseReturnTracked($receiptLineId,$barcodeId,$date,$qty,$reason,$user){return$this->CI->transactionservice->run(function()use($receiptLineId,$barcodeId,$date,$qty,$reason,$user){$line=$this->CI->db->select('grl.*,pol.tracking_type,pol.unit_price')->from('goods_receipt_lines grl')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->where('grl.id',(int)$receiptLineId)->get()->row();if(!$line||!$line->item_id)throw new BusinessException('Detail penerimaan tidak ditemukan.');$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$barcode||(int)$barcode->item_id!==(int)$line->item_id||(int)$barcode->source_receipt_line_id!==(int)$line->id)throw new BusinessException('Barcode/serial tidak berasal dari penerimaan yang dipilih.');$qty=(float)$qty;if($line->tracking_type==='UNIT'&&abs($qty-1)>.0001)throw new BusinessException('Retur barang UNIT dilakukan satu barcode/serial per transaksi.');if($qty<=0||$qty>(float)$barcode->qty_sisa+.0001)throw new BusinessException('Qty retur melebihi saldo barcode.');$id=$this->purchaseReturn($receiptLineId,$date,$qty,$reason,$user);$remaining=max(0,(float)$barcode->qty_sisa-$qty);$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>$remaining,'status'=>$remaining<=.0001?'sold_out':'available'));$this->CI->db->where('purchase_return_id',$id)->where('receipt_line_id',$receiptLineId)->update('purchase_return_lines',array('barcode_id'=>$barcode->id));$returnLine=$this->CI->db->where('purchase_return_id',$id)->where('receipt_line_id',$receiptLineId)->get('purchase_return_lines')->row();$this->CI->db->where('ref_type','purchase_return')->where('ref_id',$id)->update('stock_logs',array('barcode_id'=>$barcode->id));$this->writePurchaseLedger('purchase_return',$id,$returnLine->id,$line->item_id,$line->warehouse_id,'out',$qty,$line->unit_price,$date);return$id;});} + public function createRequest(array$d,$user){return$this->CI->transactionservice->run(function()use($d,$user){$lines=isset($d['lines'])&&is_array($d['lines'])?$d['lines']:array($d);if(trim($d['purpose'])===''||!$lines)throw new BusinessException('Tujuan dan minimal satu barang Purchase Request wajib diisi.');$self=!empty($d['applicant_is_creator']);$applicant=trim((string)($d['applicant_name']??''));if(!$self&&$applicant==='')throw new BusinessException('Nama pihak yang mengajukan wajib diisi.');if(mb_strlen($applicant)>150)throw new BusinessException('Nama pihak yang mengajukan maksimal 150 karakter.');$destination=in_array($d['destination_type'],array('warehouse','asset'),true)?$d['destination_type']:'warehouse';$no=$this->CI->numberingservice->next('purchase_request',$d['date']);$this->CI->db->insert('purchase_requests',array('request_no'=>$no,'request_date'=>$d['date'],'needed_date'=>$d['needed_date']?:null,'department'=>$d['department'],'purpose'=>$d['purpose'],'destination_type'=>$destination,'status'=>'draft','requested_by'=>$user,'applicant_is_creator'=>$self?1:0,'applicant_name'=>$self?null:$applicant,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();foreach($lines as$line){$this->CI->db->insert('purchase_request_lines',array('purchase_request_id'=>$id,'item_id'=>$line['item_id']?:null,'kode_barang_id'=>$line['kode_barang_id']??null,'description'=>$line['description'],'qty'=>$line['qty'],'estimated_price'=>$line['estimated_price'],'tracking_type'=>$line['tracking_type']??null,'qty_per_barcode'=>$line['qty_per_barcode']??null,'need_serial_number'=>$line['need_serial_number']??0));}$this->CI->auditservice->record('purchase','purchase_request',$id,'create',null,array('request_no'=>$no,'destination_type'=>$destination,'line_count'=>count($lines),'applicant_is_creator'=>$self,'applicant_name'=>$self?null:$applicant),$user);return$id;});} + public function submitRequest($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$r=$this->lock('purchase_requests',$id,'draft');if((int)$r->requested_by!==(int)$user)throw new BusinessException('Hanya pembuat Purchase Request yang dapat mengajukan dokumen ini.',403);$lines=$this->CI->db->where('purchase_request_id',$id)->get('purchase_request_lines')->result();$amount=0;foreach($lines as$l)$amount+=(float)$l->qty*(float)$l->estimated_price;$this->CI->approvalservice->create('purchase','purchase_request',$id,$amount,$user,array('request'=>$r,'lines'=>$lines));$this->CI->db->where('id',$id)->update('purchase_requests',array('status'=>'submitted'));return$id;});} + public function approveRequest($id,$user,$role){return$this->approveEntity('purchase_requests','purchase_request',$id,$user,$role);} + public function deleteRequest($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$r=$this->CI->db->query('SELECT * FROM purchase_requests WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$r)throw new BusinessException('Purchase Request tidak ditemukan.',404);if($r->status!=='draft')throw new BusinessException('Hanya Purchase Request berstatus draft yang dapat dihapus.');if((int)$r->requested_by!==(int)$user)throw new BusinessException('Hanya pembuat Purchase Request yang dapat menghapus draft ini.',403);$this->CI->auditservice->record('purchase','purchase_request',$r->id,'delete',array('request_no'=>$r->request_no,'status'=>$r->status),null,$user);$this->CI->db->where('purchase_request_id',$r->id)->delete('purchase_request_lines');$this->CI->db->where('id',$r->id)->delete('purchase_requests');return$r->id;});} + public function createOrder($requestId,$supplierId,$date,$expected,$itemId,$warehouseId,$description,$qty,$price,$taxRate,$user,array$options=array()){return$this->CI->transactionservice->run(function()use($requestId,$supplierId,$date,$expected,$itemId,$warehouseId,$description,$qty,$price,$taxRate,$user,$options){$r=$this->lock('purchase_requests',$requestId,'approved');$destination=$r->destination_type?:'warehouse';if(!empty($options['destination_type'])&&$options['destination_type']!==$destination)throw new BusinessException('Tujuan PO harus sama dengan Purchase Request.');if($destination==='warehouse'&&(!$itemId||!$warehouseId))throw new BusinessException('Barang pending dan gudang wajib dipilih.');if($destination==='asset'&&(empty($options['asset_account_id'])||empty($options['asset_location_id'])||(int)$options['useful_life_months']<=0))throw new BusinessException('Akun, lokasi, dan masa manfaat aset wajib diisi.');$supplier=$this->CI->db->get_where('suppliers',array('id'=>$supplierId,'is_active'=>1))->row();if(!$supplier)throw new BusinessException('Supplier tidak aktif atau tidak ditemukan.');$base=$qty*$price;$tax=$base*$taxRate/100;$no=$this->CI->numberingservice->next('purchase_order',$date);$this->CI->db->insert('purchase_orders',array('po_no'=>$no,'supplier_id'=>$supplierId,'purchase_request_id'=>$requestId,'order_date'=>$date,'expected_date'=>$expected?:null,'status'=>'draft','subtotal'=>$base,'tax_total'=>$tax,'total'=>$base+$tax,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$requestLine=$this->CI->db->where('purchase_request_id',$requestId)->order_by('id')->get('purchase_request_lines')->row();$this->CI->db->insert('purchase_order_lines',array('purchase_order_id'=>$id,'request_line_id'=>$requestLine?$requestLine->id:null,'item_id'=>$itemId?:($requestLine?$requestLine->item_id:null),'warehouse_id'=>$destination==='warehouse'?$warehouseId:null,'description'=>$description,'qty'=>$qty,'unit_price'=>$price,'tax_rate'=>$taxRate,'tax_amount'=>$tax,'line_total'=>$base+$tax,'destination_type'=>$destination,'asset_account_id'=>$destination==='asset'?$options['asset_account_id']:null,'asset_location_id'=>$destination==='asset'?$options['asset_location_id']:null,'useful_life_months'=>$destination==='asset'?$options['useful_life_months']:null,'residual_value'=>$destination==='asset'?$options['residual_value']:0));$this->CI->db->where('id',$requestId)->update('purchase_requests',array('status'=>'converted'));return$id;});} + public function submitOrder($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$po=$this->lock('purchase_orders',$id,'draft');$lines=$this->CI->db->where('purchase_order_id',$id)->get('purchase_order_lines')->result();if(!$lines)throw new BusinessException('PO tidak memiliki barang.');$this->CI->approvalservice->create('purchase','purchase_order',$id,$po->total,$user,array('order'=>$po,'lines'=>$lines));$this->CI->db->where('id',$id)->update('purchase_orders',array('status'=>'submitted'));return$id;});} + public function approveOrder($id,$user,$role){return$this->approveEntity('purchase_orders','purchase_order',$id,$user,$role);} + public function rejectRequest($id,$user,$role,$reason){return$this->rejectEntity('purchase_requests','purchase_request',$id,$user,$role,$reason);} + public function rejectOrder($id,$user,$role,$reason){return$this->rejectEntity('purchase_orders','purchase_order',$id,$user,$role,$reason);} + public function updateDraftOrder($id,array$d,$user){return$this->CI->transactionservice->run(function()use($id,$d,$user){$po=$this->lock('purchase_orders',$id,'draft');$supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$d['supplier_id'],'is_active'=>1))->row();if(!$supplier)throw new BusinessException('Supplier tidak aktif atau tidak ditemukan.');$warehouse=(int)$d['warehouse_id'];if(!$this->CI->db->get_where('warehouses',array('id'=>$warehouse))->row())throw new BusinessException('Gudang tujuan tidak valid.');$method=in_array($d['procurement_method'],array('online','direct','supplier'),true)?$d['procurement_method']:'supplier';$timing=in_array($d['payment_timing'],array('prepaid','on_receipt','installment','credit'),true)?$d['payment_timing']:'credit';if($method==='online'&&(trim($d['marketplace'])===''||trim($d['external_order_no'])===''))throw new BusinessException('Marketplace dan nomor pesanan wajib untuk PO online.');$subtotal=0;$taxTotal=0;$lines=$this->CI->db->where('purchase_order_id',$id)->get('purchase_order_lines')->result();foreach($lines as$l){$price=round((float)($d['unit_prices'][$l->id]??$l->unit_price),2);$rate=round((float)($d['tax_rates'][$l->id]??$l->tax_rate),4);if($price<0||$rate<0)throw new BusinessException('Harga atau pajak tidak valid.');$base=$l->qty*$price;$tax=$base*$rate/100;$subtotal+=$base;$taxTotal+=$tax;$this->CI->db->where('id',$l->id)->update('purchase_order_lines',array('warehouse_id'=>$warehouse,'unit_price'=>$price,'tax_rate'=>$rate,'tax_amount'=>$tax,'line_total'=>$base+$tax));}foreach(array('shipping_cost','insurance_cost','service_fee','discount_amount','cashback_amount')as$key)$d[$key]=max(0,round((float)$d[$key],2));$total=$subtotal+$taxTotal+$d['shipping_cost']+$d['insurance_cost']+$d['service_fee']-$d['discount_amount']-$d['cashback_amount'];if($total<0)throw new BusinessException('Total PO tidak boleh negatif.');$downPaymentRequired=$timing==='installment'&&!empty($d['down_payment_required']);$downPaymentAmount=$downPaymentRequired?round((float)$d['down_payment_amount'],2):0;if($downPaymentRequired&&($downPaymentAmount<=0||$downPaymentAmount>=$total))throw new BusinessException('Nilai DP harus lebih dari nol dan lebih kecil dari total PO.');$this->CI->db->where('id',$id)->update('purchase_orders',array('supplier_id'=>$supplier->id,'order_date'=>$d['date'],'expected_date'=>$d['expected_date']?:null,'procurement_method'=>$method,'payment_timing'=>$timing,'payment_plan'=>$timing==='installment'?'installment':($timing==='credit'?'credit':'cash'),'down_payment_required'=>$downPaymentRequired?1:0,'down_payment_amount'=>$downPaymentAmount,'marketplace'=>$method==='online'?$d['marketplace']:null,'external_order_no'=>$d['external_order_no']?:null,'courier'=>$d['courier']?:null,'tracking_no'=>$d['tracking_no']?:null,'subtotal'=>$subtotal,'tax_total'=>$taxTotal,'total'=>$total,'shipping_cost'=>$d['shipping_cost'],'insurance_cost'=>$d['insurance_cost'],'service_fee'=>$d['service_fee'],'discount_amount'=>$d['discount_amount'],'cashback_amount'=>$d['cashback_amount'],'notes'=>$d['notes']));if($this->CI->db->table_exists('purchase_installment_schedules')){$this->CI->db->where('purchase_order_id',$id)->delete('purchase_installment_schedules');if($timing==='installment')$this->createInstallmentSchedule($id,$total-$downPaymentAmount,(int)$d['installment_count'],$d['first_due_date']);}$this->CI->auditservice->record('purchase','purchase_order',$id,'update',array('total'=>$po->total),array('total'=>$total,'method'=>$method,'payment_timing'=>$timing),$user);return$id;});} + public function receive($poId,$date,$deliveryNo,array$quantities,$user){return$this->CI->transactionservice->run(function()use($poId,$date,$deliveryNo,$quantities,$user){$po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array($poId))->row();if(!$po||!in_array($po->status,array('approved','partially_received'),true))throw new BusinessException('PO belum disetujui atau sudah selesai.');$no=$this->CI->numberingservice->next('goods_receipt',$date);$this->CI->db->insert('goods_receipts',array('receipt_no'=>$no,'purchase_order_id'=>$poId,'supplier_id'=>$po->supplier_id,'receipt_date'=>$date,'supplier_delivery_no'=>$deliveryNo,'status'=>'posted','received_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));$rid=$this->CI->db->insert_id();$receivedAny=false;$poLines=$this->CI->db->select('pol.*,COALESCE(k.allocate_purchase_cost,0) allocate_purchase_cost',false)->from('purchase_order_lines pol')->join('kode_barang k','k.id=pol.kode_barang_id','left')->where('pol.purchase_order_id',$poId)->get()->result();$allocatableQty=0;foreach($poLines as$costLine)if((int)$costLine->allocate_purchase_cost===1)$allocatableQty+=(float)$costLine->qty;$netAdditional=(float)$po->shipping_cost+(float)$po->insurance_cost+(float)$po->service_fee-(float)$po->discount_amount-(float)$po->cashback_amount;$additionalPerUnit=$allocatableQty>0?$netAdditional/$allocatableQty:0;foreach($poLines as$l){$landedUnitCost=round(max(0,(float)$l->unit_price+((int)$l->allocate_purchase_cost===1?$additionalPerUnit:0)),2);if(!$l->item_id||!$this->CI->db->get_where('items',array('id'=>(int)$l->item_id))->row()){$catalog=$this->CI->db->get_where('kode_barang',array('id'=>(int)$l->kode_barang_id))->row();if(!$catalog)throw new BusinessException('Master Barang PO untuk '.$l->description.' tidak ditemukan.');$this->CI->db->insert('items',array('company_id'=>$this->CI->companycontext->id(),'stok'=>0,'planned_qty'=>$l->qty,'planned_warehouse_id'=>$l->warehouse_id,'planned_tracking_qty'=>$l->qty_per_barcode,'kode_detail'=>$catalog->kode_barang.'-GR-'.date('ymdHis').'-'.$l->id,'kode_id'=>$catalog->id,'nama_barang'=>$l->description,'harga_beli'=>$landedUnitCost,'harga_jual'=>0,'tanggal_pembelian'=>$date,'status'=>'draft'));$l->item_id=$this->CI->db->insert_id();$this->CI->db->where('id',$l->id)->update('purchase_order_lines',array('item_id'=>$l->item_id));}$qty=isset($quantities[$l->id])?(float)$quantities[$l->id]:0;$remaining=(float)$l->qty-(float)$l->received_qty;if($qty<=0)continue;if($qty>$remaining+0.0001)throw new BusinessException('Penerimaan melebihi sisa PO untuk '.$l->description);$stockLog=null;$assetId=null;if($l->destination_type==='asset'){$cost=$qty*(float)$l->unit_price;$life=max(1,(int)$l->useful_life_months);$residual=min((float)$l->residual_value,$cost);$this->CI->db->insert('assets',array('kode_asset'=>'AST-PO-'.$po->id.'-'.$l->id,'nama_asset'=>$l->description,'harga_per_unit'=>$l->unit_price,'nilai_perolehan'=>$cost,'tanggal_perolehan'=>$date,'capitalization_date'=>$date,'masa_manfaat'=>$life,'nilai_residu'=>$residual,'penyusutan_per_bulan'=>($cost-$residual)/$life,'nilai_buku'=>$cost,'sumber'=>'pembelian','source_type'=>'purchase','source_id'=>$l->id,'account_debit_id'=>$l->asset_account_id,'asset_account_id'=>$l->asset_account_id,'lokasi_asset_id'=>$l->asset_location_id,'keterangan'=>'Penerimaan aset dari '.$po->po_no,'qty'=>$qty,'status'=>'aktif','lifecycle_status'=>'active','qr_code'=>'ASSET:PO:'.$po->id.':'.$l->id));$assetId=$this->CI->db->insert_id();}else{$this->CI->db->insert('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$l->warehouse_id,'qty'=>$qty,'tipe'=>'masuk','unit_cost'=>$landedUnitCost,'keterangan'=>'[AUTO] Penerimaan '.$no.' dari PO '.$po->po_no,'ref_type'=>'goods_receipt','ref_id'=>$rid));$stockLog=$this->CI->db->insert_id();$this->CI->db->where('id',$l->item_id)->update('items',array('status'=>'draft','tanggal_pembelian'=>$date,'harga_beli'=>$landedUnitCost));}$this->CI->db->insert('goods_receipt_lines',array('goods_receipt_id'=>$rid,'po_line_id'=>$l->id,'item_id'=>$l->item_id,'warehouse_id'=>$l->warehouse_id,'qty'=>$qty,'stock_log_id'=>$stockLog,'asset_id'=>$assetId));$this->CI->db->where('id',$l->id)->set('received_qty','received_qty+'.$this->CI->db->escape($qty),false)->update('purchase_order_lines');$receivedAny=true;}if(!$receivedAny)throw new BusinessException('Minimal satu qty penerimaan harus diisi.');$remaining=$this->CI->db->where('purchase_order_id',$poId)->where('received_qty < qty',null,false)->count_all_results('purchase_order_lines');$this->CI->db->where('id',$poId)->update('purchase_orders',array('status'=>$remaining?'partially_received':'received'));$this->CI->auditservice->record('purchase','goods_receipt',$rid,'post',null,array('receipt_no'=>$no,'po_no'=>$po->po_no),$user);return$rid;});} + public function createSupplierInvoice($poId,$receiptId,$supplierNo,$date,$user) + { + return $this->CI->transactionservice->run(function()use($poId,$receiptId,$supplierNo,$date,$user){ + $po=$this->CI->db->query('SELECT * FROM purchase_orders WHERE id=? FOR UPDATE',array((int)$poId))->row(); + $receipt=$this->CI->db->get_where('goods_receipts',array('id'=>(int)$receiptId,'purchase_order_id'=>(int)$poId,'status'=>'posted'))->row(); + if(!$po||!$receipt||!in_array($po->status,array('partially_received','received'),true))throw new BusinessException('Invoice supplier hanya dapat dibuat dari penerimaan barang yang sudah diposting.'); + $supplier=$this->CI->db->get_where('suppliers',array('id'=>$po->supplier_id))->row(); + $supplierNo=trim((string)$supplierNo); + if($supplierNo==='')throw new BusinessException('Nomor invoice/nota supplier wajib diisi.'); + $this->CI->db->where('supplier_id',(int)$po->supplier_id)->where('supplier_invoice_no',$supplierNo); + if($this->CI->db->field_exists('company_id','supplier_invoices'))$this->CI->db->where('company_id',(int)($po->company_id?:$this->CI->companycontext->id())); + $duplicate=$this->CI->db->get('supplier_invoices')->row(); + if($duplicate)throw new BusinessException('Nomor invoice supplier '.$supplierNo.' sudah tercatat dengan nomor internal '.$duplicate->internal_no.'. Buka detail invoice tersebut; jangan membuat invoice kedua.'); + $receivedLines=$this->CI->db + ->select("grl.*,pol.unit_price,pol.tax_rate,pol.qty ordered_qty,pol.received_qty total_received_qty,COALESCE((SELECT SUM(sil.qty) FROM supplier_invoice_lines sil WHERE sil.receipt_line_id=grl.id),0) receipt_invoiced_qty,COALESCE((SELECT SUM(prl.shipped_qty) FROM purchase_return_lines prl JOIN purchase_returns pr ON pr.id=prl.purchase_return_id WHERE prl.receipt_line_id=grl.id AND pr.status IN('shipped','waiting_supplier','partially_resolved','resolved')),0) receipt_returned_qty",false) + ->from('goods_receipt_lines grl') + ->join('goods_receipts gr','gr.id=grl.goods_receipt_id') + ->join('purchase_order_lines pol','pol.id=grl.po_line_id') + ->where('gr.purchase_order_id',(int)$poId) + ->where('gr.status','posted') + ->order_by('gr.receipt_date')->order_by('gr.id')->order_by('grl.id') + ->get()->result(); + $availableLines=array(); + foreach($receivedLines as$l){$available=round((float)$l->qty-(float)$l->receipt_returned_qty-(float)$l->receipt_invoiced_qty,4);if($available>0)$availableLines[]=array($l,$available);} + if(!$availableLines)throw new BusinessException('Tidak ada akumulasi penerimaan pada PO ini yang belum ditagihkan.'); + // Retur yang resmi dikirim sebelum invoice pertama pada baris penerimaan + // mengurangi nilai yang boleh ditagihkan. Retur setelah invoice ditangani + // oleh Debit Note/Refund dan tidak boleh mengubah histori invoice tersebut. + $preInvoiceReturn=$this->CI->db + ->select('COALESCE(SUM(prl.shipped_qty*pol.unit_price*(1+(pol.tax_rate/100))),0) total',false) + ->from('purchase_return_lines prl') + ->join('purchase_returns pr','pr.id=prl.purchase_return_id') + ->join('goods_receipt_lines grl0','grl0.id=prl.receipt_line_id') + ->join('purchase_order_lines pol','pol.id=grl0.po_line_id') + ->where('pol.purchase_order_id',(int)$poId) + ->where_in('pr.status',array('shipped','waiting_supplier','partially_resolved','resolved')) + ->where("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)",null,false) + ->get()->row(); + $preInvoiceReturnDeduction=round((float)($preInvoiceReturn?$preInvoiceReturn->total:0),2); + $internal=$this->CI->numberingservice->next('supplier_invoice',$date); + $due=date('Y-m-d',strtotime($date.' +'.(int)$supplier->payment_term_days.' days')); + $invoice=array('supplier_invoice_no'=>$supplierNo,'internal_no'=>$internal,'supplier_id'=>$po->supplier_id,'invoice_date'=>$date,'due_date'=>$due,'status'=>'verified','created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')); + if($this->CI->db->field_exists('company_id','supplier_invoices'))$invoice['company_id']=(int)($po->company_id?:$this->CI->companycontext->id()); + $this->CI->db->insert('supplier_invoices',$invoice); + $id=$this->CI->db->insert_id();$subtotal=0;$tax=0; + foreach($availableLines as$row){list($l,$qty)=$row;$base=$qty*(float)$l->unit_price;$lineTax=$base*(float)$l->tax_rate/100;$this->CI->db->insert('supplier_invoice_lines',array('supplier_invoice_id'=>$id,'po_line_id'=>$l->po_line_id,'receipt_line_id'=>$l->id,'item_id'=>$l->item_id,'qty'=>$qty,'unit_price'=>$l->unit_price,'tax_amount'=>$lineTax,'line_total'=>$base+$lineTax,'match_status'=>'matched'));$this->CI->db->where('id',$l->po_line_id)->set('invoiced_qty','invoiced_qty+'.$this->CI->db->escape($qty),false)->update('purchase_order_lines');$subtotal+=$base;$tax+=$lineTax;} + // Invoice Supplier mewakili seluruh kewajiban PO, bukan hanya subtotal barang. + // Dengan demikian ongkir, asuransi, layanan, diskon, dan cashback ikut ke saldo hutang. + $total=max(0,round((float)$po->total-$preInvoiceReturnDeduction,2)); + if($total+0.001<$subtotal+$tax)throw new BusinessException('Nilai invoice neto lebih kecil dari detail barang. Periksa retur, biaya tambahan, diskon, dan pajak PO.'); + $this->CI->db->where('id',$id)->update('supplier_invoices',array('subtotal'=>$subtotal,'tax_total'=>$tax,'total'=>$total,'balance'=>$total)); + $this->CI->auditservice->record('purchase','supplier_invoice',$id,'create_from_accumulated_receipts',null,array('po_no'=>$po->po_no,'anchor_receipt'=>$receipt->receipt_no,'receipt_line_count'=>count($availableLines),'pre_invoice_return_deduction'=>$preInvoiceReturnDeduction,'total'=>$total),$user); + return$id; + }); + } + public function verifySupplierInvoice($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$bill=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$bill||$bill->status!=='verified')throw new BusinessException('Invoice Supplier tidak berada pada tahap finalisasi.');$unmatched=$this->CI->db->where('supplier_invoice_id',$id)->where('match_status !=','matched')->count_all_results('supplier_invoice_lines');if($unmatched)throw new BusinessException('Matching PO, penerimaan, dan invoice belum sesuai.');if(!$this->CI->db->where(array('entity_type'=>'supplier_invoice','entity_id'=>$id))->count_all_results('transaction_attachments'))throw new BusinessException('Lampiran invoice/nota supplier wajib tersedia.');$this->CI->auditservice->record('purchase','supplier_invoice',$id,'verify_matching',array('status'=>$bill->status),array('status'=>'verified'),$user);return$id;});} + public function submitSupplierInvoice($id,$user){return$this->verifySupplierInvoice($id,$user);} + public function assertPaymentEvidence($poId){$payments=$this->CI->db->where(array('purchase_order_id'=>(int)$poId,'payment_kind'=>'prepayment','status'=>'posted'))->get('supplier_payments')->result();foreach($payments as$p)if(!$this->CI->db->where(array('entity_type'=>'supplier_payment','entity_id'=>$p->id))->count_all_results('transaction_attachments'))throw new BusinessException('Bukti pembayaran '.$p->payment_no.' wajib diunggah sebelum penerimaan barang.');return true;} + public function approveSupplierInvoice($id,$user,$role){return$this->approveEntity('supplier_invoices','supplier_invoice',$id,$user,$role);} + public function postSupplierInvoice($id,$user){return$this->CI->transactionservice->run(function()use($id,$user){$bill=$this->lock('supplier_invoices',$id,'verified');$this->CI->fiscalperiodservice->assertOpen($bill->invoice_date);$group=array();foreach($this->CI->db->select('sil.qty,sil.unit_price,pol.destination_type,pol.asset_account_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$id)->get()->result()as$l){$account=$l->destination_type==='asset'?(int)$l->asset_account_id:$this->CI->accountmappingservice->get('inventory');if(!$account)throw new BusinessException('Akun tujuan pembelian belum dikonfigurasi.');if(!isset($group[$account]))$group[$account]=0;$group[$account]+=(float)$l->qty*(float)$l->unit_price;}$expectedDebit=round((float)$bill->total-(float)$bill->tax_total,2);$costAdjustment=round($expectedDebit-array_sum($group),2);if(abs($costAdjustment)>.001&&$group){$firstAccount=array_key_first($group);$group[$firstAccount]+=$costAdjustment;}$entries=array();foreach($group as$account=>$amount)$entries[]=array('account_id'=>$account,'debit'=>$amount,'kredit'=>0);if($bill->tax_total>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('purchase_input_tax'),'debit'=>$bill->tax_total,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>0,'kredit'=>$bill->total);$journal=$this->CI->postingservice->post(array('tanggal'=>$bill->invoice_date,'no_ref'=>$bill->internal_no,'keterangan'=>'Tagihan supplier '.$bill->supplier_invoice_no.'; matching PO/penerimaan; tujuan '.(count($group)>1?'campuran':(isset($l)&&$l->destination_type==='asset'?'aset tetap':'persediaan')),'ref_type'=>'supplier_invoice','ref_id'=>$id,'created_by'=>$user),$entries,false,true);$this->CI->db->where('id',$id)->update('supplier_invoices',array('status'=>'partial','journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));$this->CI->load->library('TaxService');$this->CI->taxservice->supplierInvoice($id,$journal);return$id;});} + public function paymentMulti($supplierId,$date,array$sources,array$allocations,$allowAdvance,$user,$reference){return$this->CI->transactionservice->run(function()use($supplierId,$date,$sources,$allocations,$allowAdvance,$user,$reference){$supplier=$this->CI->db->get_where('suppliers',array('id'=>(int)$supplierId))->row();if(!$supplier)throw new BusinessException('Supplier tidak ditemukan.');$valid=array();$amount=0;foreach($sources as$account=>$value){$value=round((float)$value,2);if($value<=0)continue;$a=$this->CI->db->where(array('id'=>(int)$account,'is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->get('accounts')->row();if(!$a)throw new BusinessException('Sumber Kas/Bank tidak valid.');$valid[(int)$account]=($valid[(int)$account]??0)+$value;$amount+=$value;}if(!$valid||$amount<=0)throw new BusinessException('Minimal satu sumber Kas/Bank dan nominal wajib diisi.');$allocated=0;$bills=array();foreach($allocations as$id=>$value){$value=round((float)$value,2);if($value<=0)continue;$b=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$b||(int)$b->supplier_id!==(int)$supplierId||$b->status!=='partial'||$value>$b->balance+.001)throw new BusinessException('Alokasi tagihan supplier tidak valid atau melebihi saldo.');$allocated+=$value;$bills[]=array($b,$value);}if(!$bills&&!$allowAdvance)throw new BusinessException('Pilih minimal satu tagihan yang akan dibayar.');if($allocated>$amount+.001)throw new BusinessException('Total alokasi tagihan melebihi total sumber pembayaran.');$advance=round($amount-$allocated,2);if($advance>0&&!$allowAdvance)throw new BusinessException('Total sumber dana harus sama dengan alokasi tagihan, atau centang uang muka supplier.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_payment',$date);$docs=array_map(function($r){return$r[0]->internal_no.' Rp '.number_format($r[1],2,',','.');},$bills);$desc='[AUTO] Pembayaran supplier '.$supplier->supplier_code.' - '.$supplier->name.'; tagihan '.($docs?implode(', ',$docs):'uang muka').'; referensi '.($reference?:'-');$entries=array();if($allocated>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>$allocated,'kredit'=>0,'keterangan'=>$desc);if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('supplier_advances'),'debit'=>$advance,'kredit'=>0,'keterangan'=>$desc);foreach($valid as$account=>$value)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$value,'keterangan'=>$desc);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>$desc,'ref_type'=>'supplier_payment','created_by'=>$user),$entries,false,true);$payment=array('payment_no'=>$no,'supplier_id'=>$supplierId,'payment_date'=>$date,'amount'=>$amount,'bank_account_id'=>array_key_first($valid),'status'=>'posted','reference_no'=>$reference,'journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'));if($this->CI->db->field_exists('company_id','supplier_payments'))$payment['company_id']=$this->CI->companycontext->id();$this->CI->db->insert('supplier_payments',$payment);$pid=$this->CI->db->insert_id();foreach($valid as$account=>$value)$this->CI->db->insert('purchase_payment_sources',array('supplier_payment_id'=>$pid,'account_id'=>$account,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));foreach($bills as$row){list($b,$value)=$row;$this->CI->db->insert('supplier_payment_allocations',array('supplier_payment_id'=>$pid,'supplier_invoice_id'=>$b->id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$paid=$b->paid_amount+$value;$balance=max(0,$b->total-$paid);$this->CI->db->where('id',$b->id)->update('supplier_invoices',array('paid_amount'=>$paid,'balance'=>$balance,'status'=>$balance<=.001?'paid':'partial'));$poLine=$this->CI->db->select('pol.purchase_order_id')->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->where('sil.supplier_invoice_id',$b->id)->limit(1)->get()->row();if($poLine){$scheduleAllocation=$value;foreach($this->CI->db->where('purchase_order_id',$poLine->purchase_order_id)->where_in('status',array('unpaid','partial'))->order_by('due_date')->order_by('installment_no')->get('purchase_installment_schedules')->result()as$schedule){if($scheduleAllocation<=.001)break;$applied=min($scheduleAllocation,max(0,$schedule->amount-$schedule->paid_amount));if($applied<=0)continue;$schedulePaid=$schedule->paid_amount+$applied;$this->CI->db->where('id',$schedule->id)->update('purchase_installment_schedules',array('paid_amount'=>$schedulePaid,'status'=>$schedulePaid>=$schedule->amount-.001?'paid':'partial'));$scheduleAllocation-=$applied;}}}if($advance>0)$this->CI->db->insert('supplier_advances',array('advance_no'=>'SADV-'.$no,'supplier_id'=>$supplierId,'supplier_payment_id'=>$pid,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')));return array('payment_id'=>$pid,'payment_no'=>$no,'amount'=>$amount,'allocated'=>$allocated,'advance'=>$advance);});} + public function payment($supplierId,$date,$bank,$amount,array$allocations,$allowAdvance,$user,$reference){return$this->paymentMulti($supplierId,$date,array((int)$bank=>(float)$amount),$allocations,$allowAdvance,$user,$reference);} + public function purchaseReturn($receiptLineId,$date,$qty,$reason,$user){return$this->CI->transactionservice->run(function()use($receiptLineId,$date,$qty,$reason,$user){$line=$this->CI->db->query('SELECT grl.*,gr.supplier_id,gr.id receipt_id,pol.unit_price FROM goods_receipt_lines grl JOIN goods_receipts gr ON gr.id=grl.goods_receipt_id JOIN purchase_order_lines pol ON pol.id=grl.po_line_id WHERE grl.id=? FOR UPDATE',array((int)$receiptLineId))->row();$qty=(float)$qty;if(!$line||$qty<=0||$qty>(float)$line->qty)throw new BusinessException('Qty retur tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan retur wajib diisi.');$already=(float)$this->CI->db->select('COALESCE(SUM(prl.qty),0) total',false)->from('purchase_return_lines prl')->join('purchase_returns pr','pr.id=prl.purchase_return_id')->where('prl.receipt_line_id',$line->id)->where('pr.status','posted')->get()->row()->total;if($already+$qty>(float)$line->qty)throw new BusinessException('Total retur melebihi penerimaan.');$no=$this->CI->numberingservice->next('purchase_return',$date);$amount=$qty*$line->unit_price;$this->CI->db->insert('purchase_returns',array('return_no'=>$no,'supplier_id'=>$line->supplier_id,'goods_receipt_id'=>$line->receipt_id,'return_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$this->CI->db->insert('stock_logs',array('item_id'=>$line->item_id,'warehouse_id'=>$line->warehouse_id,'qty'=>$qty,'tipe'=>'keluar','keterangan'=>'[AUTO] Retur pembelian '.$no.' | '.$reason,'ref_type'=>'purchase_return','ref_id'=>$id));$log=$this->CI->db->insert_id();$this->CI->db->insert('purchase_return_lines',array('purchase_return_id'=>$id,'receipt_line_id'=>$line->id,'item_id'=>$line->item_id,'warehouse_id'=>$line->warehouse_id,'qty'=>$qty,'unit_cost'=>$line->unit_price,'stock_log_id'=>$log));$this->CI->purchaseInventoryModel->synchronizeStock($line->item_id);$this->CI->auditservice->record('purchase','purchase_return',$id,'post',null,array('return_no'=>$no,'qty'=>$qty,'amount'=>$amount),$user);return$id;});} + public function debitNote($billId,$date,$amount,$reason,$user){return$this->CI->transactionservice->run(function()use($billId,$date,$amount,$reason,$user){$bill=$this->CI->db->query('SELECT * FROM supplier_invoices WHERE id=? FOR UPDATE',array((int)$billId))->row();$amount=round((float)$amount,2);if(!$bill||$bill->status!=='partial'||$amount<=0||$amount>$bill->balance)throw new BusinessException('Nilai debit note tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan debit note wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('supplier_debit_note',$date);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Debit note tagihan '.$bill->supplier_invoice_no.': '.$reason,'ref_type'=>'supplier_debit_note','ref_id'=>$billId,'created_by'=>$user),array(array('account_id'=>$this->CI->accountmappingservice->get('accounts_payable'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('supplier_debit_notes',array('debit_note_no'=>$no,'supplier_id'=>$bill->supplier_id,'supplier_invoice_id'=>$billId,'note_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$balance=$bill->balance-$amount;$this->CI->db->where('id',$billId)->update('supplier_invoices',array('balance'=>$balance,'status'=>$balance<=0?'paid':'partial'));return$id;});} + public function aging($date=null){$date=$date?:date('Y-m-d');$sql="SELECT s.id supplier_id,s.name,SUM(CASE WHEN i.due_date>=? THEN i.balance ELSE 0 END) current_amount,SUM(CASE WHEN DATEDIFF(?,i.due_date) BETWEEN 1 AND 30 THEN i.balance ELSE 0 END) days_1_30,SUM(CASE WHEN DATEDIFF(?,i.due_date) BETWEEN 31 AND 60 THEN i.balance ELSE 0 END) days_31_60,SUM(CASE WHEN DATEDIFF(?,i.due_date) BETWEEN 61 AND 90 THEN i.balance ELSE 0 END) days_61_90,SUM(CASE WHEN DATEDIFF(?,i.due_date)>90 THEN i.balance ELSE 0 END) over_90,SUM(i.balance) total FROM supplier_invoices i JOIN suppliers s ON s.id=i.supplier_id WHERE i.status='partial' AND i.balance>0";$params=array($date,$date,$date,$date,$date);if($this->CI->db->field_exists('company_id','supplier_invoices')){$sql.=' AND i.company_id=?';$params[]=$this->CI->companycontext->id();}$sql.=' GROUP BY s.id,s.name';return$this->CI->db->query($sql,$params)->result();} + private function approveEntity($table,$type,$id,$user,$role){return$this->CI->transactionservice->run(function()use($table,$type,$id,$user,$role){$row=$this->lock($table,$id,'submitted');$request=$this->CI->approvalservice->pendingForEntity($type,$id);if(!$request)throw new BusinessException('Permintaan approval tidak ditemukan.');$a=$this->CI->approvalservice->act($request->id,'approved',$user,$role);if($a->status==='approved')$this->CI->db->where('id',$id)->update($table,array('status'=>'approved','approved_by'=>$user,'approved_at'=>date('Y-m-d H:i:s')));return$id;});} + private function rejectEntity($table,$type,$id,$user,$role,$reason){return$this->CI->transactionservice->run(function()use($table,$type,$id,$user,$role,$reason){$this->lock($table,$id,'submitted');$request=$this->CI->approvalservice->pendingForEntity($type,$id);if(!$request)throw new BusinessException('Permintaan approval tidak ditemukan.');$this->CI->approvalservice->act($request->id,'rejected',$user,$role,$reason);$this->CI->db->where('id',$id)->update($table,array('status'=>'rejected'));return$id;});} + private function lock($table,$id,$status){$row=$this->CI->db->query('SELECT * FROM '.$table.' WHERE id=? FOR UPDATE',array((int)$id))->row();if(!$row)throw new BusinessException('Dokumen tidak ditemukan.');if($row->status!==$status)throw new BusinessException('Status dokumen harus '.$status.'.');return$row;} +} diff --git a/application/libraries/ReceivableAdjustmentsService.php b/application/libraries/ReceivableAdjustmentsService.php new file mode 100644 index 0000000..84debb0 --- /dev/null +++ b/application/libraries/ReceivableAdjustmentsService.php @@ -0,0 +1,39 @@ +CI=&get_instance(); + $this->CI->load->library(array('TransactionService','PostingService','NumberingService','AccountMappingService','FiscalPeriodService','AuditService')); + } + public function creditNote($invoiceId,$date,$amount,$reason,$userId){ + return $this->CI->transactionservice->run(function()use($invoiceId,$date,$amount,$reason,$userId){ + $invoice=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row(); + $amount=round((float)$amount,2); + if(!$invoice||$invoice->workflow_status!=='posted'||$amount<=0||$amount>(float)$invoice->sisa_piutang)throw new BusinessException('Nilai credit note tidak valid.'); + if(trim($reason)==='')throw new BusinessException('Alasan credit note wajib diisi.'); + $this->CI->fiscalperiodservice->assertOpen($date); + $details=$this->CI->db->where('invoice_id',$invoice->id)->get('invoice_details')->result(); + $entries=array();$debit=0;$base=max(.01,(float)$invoice->total); + foreach($details as $line){$part=$amount*((float)$line->subtotal/$base);$tax=$part*((float)$line->tax_amount/max(.01,(float)$line->subtotal));$net=$part-$tax;$entries[]=array('account_id'=>$line->account_id,'debit'=>$net,'kredit'=>0);$debit+=$net;if($tax>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>$tax,'kredit'=>0);$debit+=$tax;}} + $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$debit); + $no=$this->CI->numberingservice->next('credit_note',$date); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Credit Note '.$invoice->no_invoice,'ref_type'=>'credit_note','ref_id'=>$invoice->id,'created_by'=>$userId),$entries,false,true); + $this->CI->db->insert('credit_notes',array('credit_note_no'=>$no,'invoice_id'=>$invoice->id,'customer_id'=>$invoice->customer_id,'credit_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s'))); + $id=$this->CI->db->insert_id();$balance=max(0,(float)$invoice->sisa_piutang-$amount); + $this->CI->db->where('id',$invoice->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial')); + $this->CI->auditservice->record('receivable','credit_note',$id,'post',null,array('invoice_id'=>$invoice->id,'amount'=>$amount),$userId);return$id; + }); + } + public function refund($advanceId,$date,$amount,$bankAccountId,$reason,$userId){ + return $this->CI->transactionservice->run(function()use($advanceId,$date,$amount,$bankAccountId,$reason,$userId){ + $advance=$this->CI->db->query('SELECT * FROM customer_advances WHERE id=? FOR UPDATE',array((int)$advanceId))->row();$amount=round((float)$amount,2); + if(!$advance||$amount<=0||$amount>(float)$advance->available_amount)throw new BusinessException('Nilai refund tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan refund wajib diisi.'); + $this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('refund',$date); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Refund '.$advance->advance_no,'ref_type'=>'customer_refund','ref_id'=>$advance->id,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>$amount,'kredit'=>0),array('account_id'=>(int)$bankAccountId,'debit'=>0,'kredit'=>$amount)),false,true); + $this->CI->db->insert('customer_refunds',array('refund_no'=>$no,'customer_id'=>$advance->customer_id,'advance_id'=>$advance->id,'refund_date'=>$date,'amount'=>$amount,'bank_account_id'=>$bankAccountId,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$id=$this->CI->db->insert_id();$available=(float)$advance->available_amount-$amount;$this->CI->db->where('id',$advance->id)->update('customer_advances',array('available_amount'=>$available,'status'=>$available<=0?'refunded':'partially_used'));$this->CI->auditservice->record('receivable','refund',$id,'post',null,array('advance_id'=>$advance->id,'amount'=>$amount),$userId);return$id; + }); + } +} diff --git a/application/libraries/ReceivableService.php b/application/libraries/ReceivableService.php new file mode 100644 index 0000000..c53309e --- /dev/null +++ b/application/libraries/ReceivableService.php @@ -0,0 +1,60 @@ +CI=&get_instance();$this->CI->load->library(array('TransactionService','ApprovalService','AuditService','PostingService','NumberingService','AccountMappingService','FiscalPeriodService','CompanyContext'));$this->CI->load->model('InvoiceModel','receivableInvoiceModel');$this->CI->load->model('InventoryModel','receivableInventoryModel'); + } + public function submitInvoice($id,$userId){return $this->CI->transactionservice->run(function()use($id,$userId){$i=$this->invoiceLock($id,'draft');$this->validateInvoice($i);$this->assertCreditLimit($i);$amount=$this->invoiceTotal($id);$r=$this->CI->approvalservice->create('invoice','invoice',$id,$amount,$userId,array('invoice'=>$i,'lines'=>$this->lines($id)));$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'submitted','total'=>$amount,'sisa_piutang'=>$amount));$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'submit',$i,$after,$userId);return $r;});} + public function approveInvoice($id,$userId,$roleId){return $this->CI->transactionservice->run(function()use($id,$userId,$roleId){$i=$this->invoiceLock($id,'submitted');$r=$this->CI->approvalservice->pendingForEntity('invoice',$id);if(!$r)throw new BusinessException('Approval invoice tidak ditemukan.');$a=$this->CI->approvalservice->act($r->id,'approved',$userId,$roleId);if($a->status==='approved')$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'approved'));$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'approve',$i,$after,$userId);return $after;});} + public function postInvoice($id,$userId){return $this->CI->transactionservice->run(function()use($id,$userId){$i=$this->invoiceLock($id,'approved');$this->CI->fiscalperiodservice->assertOpen($i->tanggal);if($i->journal_id)throw new BusinessException('Invoice sudah mempunyai jurnal.');$lines=$this->lines($id);$ar=$this->CI->accountmappingservice->get('accounts_receivable');$taxAccount=$this->CI->accountmappingservice->get('sales_tax_payable');$entries=array(array('account_id'=>$ar,'debit'=>$i->total,'kredit'=>0));$credits=array();$hpp=0;foreach($lines as $l){$net=(float)$l->subtotal-(float)$l->tax_amount;$credits[$l->account_id]=($credits[$l->account_id]??0)+$net;if((float)$l->tax_amount>0)$credits[$taxAccount]=($credits[$taxAccount]??0)+(float)$l->tax_amount;$hpp+=(float)$l->total_hpp_barang;}foreach($credits as $account=>$amount)$entries[]=array('account_id'=>$account,'debit'=>0,'kredit'=>$amount);if($hpp>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$hpp,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$hpp);}$journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>'Posting Invoice '.$i->no_invoice,'ref_type'=>'invoice','ref_id'=>$id,'created_by'=>$userId),$entries,false,true); + foreach($lines as $line)$this->postStock($i,$line); + $this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_at'=>date('Y-m-d H:i:s'),'posted_by'=>$userId,'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total));$this->CI->load->library('TaxService');$this->CI->taxservice->invoice($id,$journal);$after=$this->CI->receivableInvoiceModel->find($id);$this->CI->auditservice->record('invoice','invoice',$id,'post',$i,$after,$userId);return $after;});} + public function recordPayment($customerId,$date,$cashAccountId,$amount,array $allocations,$allowAdvance,$userId,$reference='',$notes=''){return $this->CI->transactionservice->run(function()use($customerId,$date,$cashAccountId,$amount,$allocations,$allowAdvance,$userId,$reference,$notes){$amount=round((float)$amount,2);if($amount<=0)throw new BusinessException('Nilai pembayaran harus lebih dari nol.');$this->CI->fiscalperiodservice->assertOpen($date);$allocated=0;$locked=array();foreach($allocations as $invoiceId=>$value){$value=round((float)$value,2);if($value<=0)continue;$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();if(!$i||$i->workflow_status!=='posted'||(int)$i->customer_id!==(int)$customerId)throw new BusinessException('Invoice alokasi tidak valid.');if($value>(float)$i->sisa_piutang+0.001)throw new BusinessException('Alokasi melebihi saldo invoice '.$i->no_invoice);$allocated+=$value;$locked[]=array($i,$value);} + if($allocated>$amount+0.001)throw new BusinessException('Total alokasi melebihi pembayaran.');$advance=round($amount-$allocated,2);if($advance>0&&!$allowAdvance)throw new BusinessException('Pembayaran melebihi alokasi. Aktifkan mekanisme uang muka pelanggan.');$paymentNo=$this->CI->numberingservice->next('payment',$date);$entries=array(array('account_id'=>(int)$cashAccountId,'debit'=>$amount,'kredit'=>0));if($allocated>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$allocated);if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>'Penerimaan pelanggan #'.$customerId,'ref_type'=>'customer_payment','created_by'=>$userId),$entries,false,true);$this->CI->db->insert('payments',array('invoice_id'=>count($locked)===1?$locked[0][0]->id:null,'tanggal'=>$date,'jumlah'=>$amount,'journal_id'=>$journal,'metode'=>'manual','payment_no'=>$paymentNo,'customer_id'=>$customerId,'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'created_by'=>$userId));$pid=$this->CI->db->insert_id();foreach($locked as $row){list($i,$value)=$row;$this->CI->db->insert('payment_allocations',array('payment_id'=>$pid,'invoice_id'=>$i->id,'amount'=>$value,'created_at'=>date('Y-m-d H:i:s')));$paid=(float)$i->total_bayar+$value;$balance=max(0,(float)$i->total-$paid);$this->CI->db->where('id',$i->id)->update('invoices',array('total_bayar'=>$paid,'sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));}if($advance>0)$this->CI->db->insert('customer_advances',array('advance_no'=>'ADV-'.$paymentNo,'customer_id'=>$customerId,'payment_id'=>$pid,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('receivable','payment',$pid,'post',null,array('payment_no'=>$paymentNo,'amount'=>$amount,'allocated'=>$allocated,'advance'=>$advance),$userId);return array('payment_id'=>$pid,'payment_no'=>$paymentNo,'advance'=>$advance);});} + public function reverseInvoice($id,$date,$reason,$userId){return $this->CI->transactionservice->run(function()use($id,$date,$reason,$userId){$i=$this->invoiceLock($id,'posted');if(trim($reason)==='')throw new BusinessException('Alasan reversal wajib diisi.');if((float)$i->total_bayar>0)throw new BusinessException('Invoice yang telah dibayar harus diselesaikan dengan credit note/refund.');$this->CI->fiscalperiodservice->assertOpen($date);$entries=array();foreach($this->CI->db->where('journal_id',$i->journal_id)->get('journal_details')->result() as $l)$entries[]=array('account_id'=>$l->account_id,'debit'=>$l->kredit,'kredit'=>$l->debit);if(!$entries)throw new BusinessException('Jurnal invoice tidak ditemukan.');$rid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('reversal',$date),'keterangan'=>'Reversal invoice '.$i->no_invoice.': '.$reason,'ref_type'=>'invoice_reversal','ref_id'=>$id,'reversal_of'=>$i->journal_id,'reversal_reason'=>$reason,'created_by'=>$userId),$entries,false,true);foreach($this->lines($id)as$line)$this->reverseStock($i,$line,$date,$reason);$this->CI->db->where('id',$id)->update('invoices',array('workflow_status'=>'reversed','status'=>'cancelled','reversal_journal_id'=>$rid,'sisa_piutang'=>0));$this->CI->auditservice->record('invoice','invoice',$id,'reverse',$i,$this->CI->receivableInvoiceModel->find($id),$userId);return $rid;});} + public function postWriteoff($invoiceId,$date,$amount,$reason,$userId){return $this->CI->transactionservice->run(function()use($invoiceId,$date,$amount,$reason,$userId){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? FOR UPDATE',array((int)$invoiceId))->row();$amount=round((float)$amount,2);if(!$i||$i->workflow_status!=='posted'||$amount<=0||$amount>(float)$i->sisa_piutang)throw new BusinessException('Nilai write-off tidak valid.');if(trim($reason)==='')throw new BusinessException('Alasan write-off wajib diisi.');$this->CI->fiscalperiodservice->assertOpen($date);$no=$this->CI->numberingservice->next('writeoff',$date);$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Write-off '.$i->no_invoice,'ref_type'=>'receivable_writeoff','ref_id'=>$invoiceId,'created_by'=>$userId),array(array('account_id'=>$this->CI->accountmappingservice->get('bad_debt_expense'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$amount)),false,true);$this->CI->db->insert('receivable_writeoffs',array('writeoff_no'=>$no,'invoice_id'=>$invoiceId,'writeoff_date'=>$date,'amount'=>$amount,'reason'=>$reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s')));$wid=$this->CI->db->insert_id();$balance=max(0,(float)$i->sisa_piutang-$amount);$this->CI->db->where('id',$i->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=0?'paid':'partial'));$this->CI->auditservice->record('receivable','writeoff',$wid,'post',null,array('invoice_id'=>$invoiceId,'amount'=>$amount),$userId);return $wid;});} + public function aging($asOf=null){$asOf=$asOf?:date('Y-m-d');$company=$this->CI->companycontext->id();return $this->CI->db->query("SELECT i.customer_id,c.nama,SUM(CASE WHEN i.jatuh_tempo>=? THEN i.sisa_piutang ELSE 0 END) current_amount,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 1 AND 30 THEN i.sisa_piutang ELSE 0 END) days_1_30,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 31 AND 60 THEN i.sisa_piutang ELSE 0 END) days_31_60,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo) BETWEEN 61 AND 90 THEN i.sisa_piutang ELSE 0 END) days_61_90,SUM(CASE WHEN DATEDIFF(?,i.jatuh_tempo)>90 THEN i.sisa_piutang ELSE 0 END) days_over_90,SUM(i.sisa_piutang) total FROM invoices i JOIN customers c ON c.id=i.customer_id WHERE i.company_id=? AND i.workflow_status='posted' AND i.sisa_piutang>0 AND i.tanggal<=? GROUP BY i.customer_id,c.nama ORDER BY c.nama",array($asOf,$asOf,$asOf,$asOf,$asOf,$company,$asOf))->result();} + public function statement($customerId,$from,$to){$company=$this->CI->companycontext->id();$customer=$this->CI->db->get_where('customers',array('id'=>$customerId,'company_id'=>$company))->row();if(!$customer)throw new BusinessException('Customer tidak ditemukan pada perusahaan aktif.');$invoices=$this->CI->db->where(array('company_id'=>$company,'customer_id'=>$customerId))->where('tanggal >=',$from)->where('tanggal <=',$to)->where('workflow_status','posted')->order_by('tanggal')->get('invoices')->result();$payments=$this->CI->db->where(array('company_id'=>$company,'customer_id'=>$customerId))->where('tanggal >=',$from)->where('tanggal <=',$to)->where('status','posted')->order_by('tanggal')->get('payments')->result();return array('customer'=>$customer,'invoices'=>$invoices,'payments'=>$payments);} + private function postStock($invoice,$line) + { + if(!$line->items_id||$line->stock_posted_at)return; + $item=$this->CI->db->select('i.company_id,k.tracking_type')->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where('i.id',(int)$line->items_id)->get()->row();if(!$item)throw new BusinessException('Master barang invoice tidak ditemukan.');if(isset($invoice->company_id)&&(int)$invoice->company_id>0&&(int)$item->company_id!==(int)$invoice->company_id)throw new BusinessException('Barang invoice berasal dari perusahaan lain.'); + $key='SALE-INVOICE-LINE-'.(int)$line->id; + $ledger=$this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row(); + if($ledger){$this->CI->receivableInventoryModel->synchronizeStock($line->items_id);$this->CI->db->where('id',$line->id)->update('invoice_details',array('stock_posted_at'=>$ledger->created_at));return;} + $available=(float)$this->CI->db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty FROM inventory_ledger WHERE item_id=? AND warehouse_id=? FOR UPDATE",array((int)$line->items_id,(int)$line->warehouse_id))->row()->qty;$reserved=(float)$this->CI->db->select('COALESCE(SUM(qty-released_qty),0) qty',false)->where(array('item_id'=>(int)$line->items_id,'warehouse_id'=>(int)$line->warehouse_id))->where_in('status',array('active','partially_released'))->get('stock_reservations')->row()->qty;$available-=$reserved; + if($available<(float)$line->qty-.0001)throw new BusinessException('Stok gudang tidak cukup untuk '.$line->nama_item.'. Tersedia '.number_format($available,0,',','.').'.'); + $hasBarcode=(int)$this->CI->db->where(array('item_id'=>(int)$line->items_id,'warehouse_id'=>(int)$line->warehouse_id))->where('qty_sisa >',0)->count_all_results('item_barcodes');if($hasBarcode&&!$line->barcode_id)throw new BusinessException('Barcode/SN wajib dipilih untuk '.$line->nama_item.' agar stok fisik dapat ditelusuri.'); + if($line->barcode_id){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? FOR UPDATE',array((int)$line->barcode_id,(int)$line->items_id,(int)$line->warehouse_id))->row();$barcodeAvailable=$barcode?(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)($barcode->return_reserved_qty??0):0;if(!$barcode||$barcode->status!=='available'||$barcodeAvailable<(float)$line->qty-.0001)throw new BusinessException('Barcode '.$line->nama_item.' tidak tersedia, berada di lokasi lain, atau sedang direservasi.');$remaining=(float)$barcode->qty_sisa-(float)$line->qty;$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('qty_sisa'=>$remaining,'status'=>$remaining<=.0001?'sold_out':'available','version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses transaksi lain. Silakan ulangi.');} + $cost=(float)$line->qty>0?(float)$line->total_hpp_barang/(float)$line->qty:0;$description='[AUTO] Penjualan '.$invoice->no_invoice.' | '.$line->nama_item.' | Qty '.number_format((float)$line->qty,0,',','.'); + $this->CI->db->insert('inventory_ledger',array('item_id'=>$line->items_id,'warehouse_id'=>$line->warehouse_id,'barcode_id'=>$line->barcode_id,'movement_date'=>$invoice->tanggal,'direction'=>'out','qty'=>$line->qty,'unit_cost'=>$cost,'value'=>$cost*(float)$line->qty,'document_type'=>'invoice_details','document_id'=>$invoice->id,'document_line_id'=>$line->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s'))); + $this->consumeCostLayers($line->items_id,$line->warehouse_id,$line->qty); + $this->CI->db->insert('stock_logs',array('item_id'=>$line->items_id,'barcode_id'=>$line->barcode_id,'qty'=>$line->qty,'tipe'=>'keluar','unit_cost'=>$cost,'keterangan'=>$description,'warehouse_id'=>$line->warehouse_id,'ref_type'=>'invoice_details','ref_id'=>$line->id,'idempotency_key'=>$key));$stockLog=$this->CI->db->insert_id(); + $this->CI->db->insert('item_movements',array('item_id'=>$line->items_id,'barcode_id'=>$line->barcode_id,'qty'=>$line->qty,'from_type'=>'warehouse','from_id'=>$line->warehouse_id,'to_type'=>'invoice','to_id'=>$invoice->id,'movement_type'=>'sold_out','reference_type'=>'invoice_details','reference_id'=>$line->id,'notes'=>$description,'created_at'=>date('Y-m-d H:i:s'))); + $this->CI->receivableInventoryModel->synchronizeStock($line->items_id); + $this->CI->db->where('id',$line->id)->update('invoice_details',array('stock_posted_at'=>date('Y-m-d H:i:s'),'stock_log_id'=>$stockLog)); + } + private function consumeCostLayers($itemId,$warehouseId,$qty) + { + $remaining=(float)$qty;$layers=$this->CI->db->query("SELECT * FROM inventory_cost_layers WHERE item_id=? AND warehouse_id=? AND status='open' AND remaining_qty>0 ORDER BY received_date,id FOR UPDATE",array((int)$itemId,(int)$warehouseId))->result(); + foreach($layers as$layer){if($remaining<=.0001)break;$used=min($remaining,(float)$layer->remaining_qty);$left=(float)$layer->remaining_qty-$used;$this->CI->db->where('id',$layer->id)->update('inventory_cost_layers',array('remaining_qty'=>$left,'status'=>$left<=.0001?'closed':'open'));$remaining-=$used;} + } + private function reverseStock($invoice,$line,$date,$reason) + { + if(!$line->items_id||!$line->stock_posted_at)return;$key='SALE-INVOICE-REVERSAL-'.(int)$line->id;if($this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>$key))->row())return; + $original=$this->CI->db->get_where('inventory_ledger',array('idempotency_key'=>'SALE-INVOICE-LINE-'.(int)$line->id))->row();if(!$original)throw new BusinessException('Ledger stok penjualan untuk '.$line->nama_item.' tidak ditemukan; reversal dihentikan agar histori tidak rusak.'); + $description='[AUTO] Reversal stok invoice '.$invoice->no_invoice.' | '.$line->nama_item.' | Alasan '.$reason;$this->CI->db->insert('inventory_ledger',array('item_id'=>$line->items_id,'warehouse_id'=>$line->warehouse_id,'barcode_id'=>$line->barcode_id,'movement_date'=>$date,'direction'=>'in','qty'=>$line->qty,'unit_cost'=>$original->unit_cost,'value'=>$original->value,'document_type'=>'invoice_reversal','document_id'=>$invoice->id,'document_line_id'=>$line->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$ledgerId=$this->CI->db->insert_id(); + $this->CI->db->insert('inventory_cost_layers',array('item_id'=>$line->items_id,'warehouse_id'=>$line->warehouse_id,'source_ledger_id'=>$ledgerId,'received_date'=>$date,'original_qty'=>$line->qty,'remaining_qty'=>$line->qty,'unit_cost'=>$original->unit_cost,'status'=>'open')); + $this->CI->db->insert('stock_logs',array('item_id'=>$line->items_id,'barcode_id'=>$line->barcode_id,'qty'=>$line->qty,'tipe'=>'masuk','unit_cost'=>$original->unit_cost,'keterangan'=>$description,'warehouse_id'=>$line->warehouse_id,'ref_type'=>'invoice_reversal','ref_id'=>$invoice->id,'idempotency_key'=>$key)); + $this->CI->db->insert('item_movements',array('item_id'=>$line->items_id,'barcode_id'=>$line->barcode_id,'qty'=>$line->qty,'from_type'=>'invoice','from_id'=>$invoice->id,'to_type'=>'warehouse','to_id'=>$line->warehouse_id,'movement_type'=>'customer_return','reference_type'=>'invoice_reversal','reference_id'=>$invoice->id,'notes'=>$description,'created_at'=>date('Y-m-d H:i:s'))); + if($line->barcode_id){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($line->barcode_id))->row();if($barcode)$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>(float)$barcode->qty_sisa+(float)$line->qty,'status'=>'available'));}$this->CI->receivableInventoryModel->synchronizeStock($line->items_id); + } + private function invoiceLock($id,$status){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->CI->companycontext->id()))->row();if(!$i)throw new BusinessException('Invoice tidak ditemukan.',array(),404);if($i->workflow_status!==$status)throw new BusinessException('Invoice harus berstatus '.$status.'.');return $i;} + private function lines($id){return $this->CI->db->where('invoice_id',$id)->order_by('id')->get('invoice_details')->result();} + private function invoiceTotal($id){$r=$this->CI->db->select('COALESCE(SUM(subtotal),0) total',false)->get_where('invoice_details',array('invoice_id'=>$id))->row();return round((float)$r->total,2);} + private function validateInvoice($i){if($i->jatuh_tempo<$i->tanggal)throw new BusinessException('Jatuh tempo tidak boleh sebelum tanggal invoice.');if(!$this->lines($i->id))throw new BusinessException('Invoice belum memiliki item.');} + private function assertCreditLimit($i){$c=$this->CI->db->get_where('customers',array('id'=>$i->customer_id))->row();if(!$c)throw new BusinessException('Customer tidak ditemukan.');if($c->is_credit_blocked)throw new BusinessException('Kredit customer sedang diblokir.');if((float)$c->credit_limit>0){$out=(float)$this->CI->db->select('COALESCE(SUM(sisa_piutang),0) total',false)->where('customer_id',$c->id)->where('workflow_status','posted')->get('invoices')->row()->total;if($out+$this->invoiceTotal($i->id)>(float)$c->credit_limit)throw new BusinessException('Credit limit customer terlampaui.');}} +} diff --git a/application/libraries/SalesService.php b/application/libraries/SalesService.php new file mode 100644 index 0000000..2abf8f7 --- /dev/null +++ b/application/libraries/SalesService.php @@ -0,0 +1,550 @@ +CI=&get_instance(); + $this->CI->load->library(array('CompanyContext','TransactionService','NumberingService','PostingService','AccountMappingService','FiscalPeriodService','AuditService','ApprovalService')); + $this->CI->load->model('InventoryModel','salesInventoryModel'); + } + private function company(){return(int)$this->CI->companycontext->id();} + public function money($v) + { + $v=trim(str_ireplace(array('Rp',' '),'',(string)$v));if($v==='')return 0.0; + $comma=strrpos($v,',');$dot=strrpos($v,'.'); + if($comma!==false&&$dot!==false)$v=$comma>$dot?str_replace(',','.',str_replace('.','',$v)):str_replace(',','',$v); + elseif($comma!==false)$v=str_replace(',','.',str_replace('.','',$v)); + elseif($dot!==false&&preg_match('/^\d{1,3}(\.\d{3})+(\.\d+)?$/',$v))$v=str_replace('.','',$v); + if(!is_numeric($v))throw new BusinessException('Nominal tidak valid.');return round((float)$v,2); + } + private function qty($v){$v=(float)str_replace(',','.',(string)$v);if($v<=0)throw new BusinessException('Qty harus lebih dari nol.');return round($v,4);} + private function tenant($table,$id,$label) + { + $row=$this->CI->db->get_where($table,array('id'=>(int)$id,'company_id'=>$this->company()))->row(); + if(!$row)throw new BusinessException($label.' tidak ditemukan pada perusahaan aktif.');return$row; + } + private function addCompany($table,array$data){if($this->CI->db->field_exists('company_id',$table))$data['company_id']=$this->company();return$data;} + + public function dashboard() + { + $c=$this->company();$today=date('Y-m-d');$month=date('Y-m-01'); + return array( + 'month_sales'=>(float)$this->CI->db->select('COALESCE(SUM(total),0) total',false)->where('company_id',$c)->where('workflow_status','posted')->where('tanggal >=',$month)->get('invoices')->row()->total, + 'open_invoice'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('unpaid','partial'))->count_all_results('invoices'), + 'overdue'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('unpaid','partial'))->where('jatuh_tempo <',$today)->where('sisa_piutang >',0)->count_all_results('invoices'), + 'draft'=>(int)$this->CI->db->where('company_id',$c)->where('deleted_at', null)->where_in('workflow_status',array('draft','submitted'))->count_all_results('invoices'), + 'delivery'=>(int)$this->CI->db->where('company_id',$c)->where('status','draft')->count_all_results('sales_deliveries'), + 'return'=>(int)$this->CI->db->where('company_id',$c)->where_in('status',array('draft','submitted','approved','received'))->count_all_results('sales_returns') + ); + } + public function invoices($limit=100) + { + return$this->CI->db->select('i.*,c.nama customer_name,c.alamat customer_address,u.nama creator_name') + ->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left') + ->where('i.company_id',$this->company())->where('i.deleted_at IS NULL',null,false)->order_by('i.id','DESC')->limit((int)$limit)->get()->result(); + } + public function deliveries($limit=100) + { + return$this->CI->db->select('d.*,c.nama customer_name,i.no_invoice,w.nama warehouse_name,u.nama creator_name') + ->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->join('invoices i','i.id=d.invoice_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left') + ->where('d.company_id',$this->company())->order_by('d.id','DESC')->limit((int)$limit)->get()->result(); + } + public function returns($limit=100) + { + return$this->CI->db->select('r.*,c.nama customer_name,i.no_invoice,d.delivery_no,u.nama creator_name') + ->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->join('users u','u.id=r.created_by','left') + ->where('r.company_id',$this->company())->order_by('r.id','DESC')->limit((int)$limit)->get()->result(); + } + + public function createInvoiceHeader(array$header,$user) + { + return $this->CI->transactionservice->run(function()use($header,$user){return $this->insertInvoiceHeader($header,$user);}); + } + private function insertInvoiceHeader(array$header,$user) + { + $customer=$this->tenant('customers',$header['customer_id']??0,'Customer'); + if(isset($customer->is_active)&&(int)$customer->is_active!==1)throw new BusinessException('Customer sudah tidak aktif.'); + $date=$header['date']??date('Y-m-d');$due=$header['due_date']??$date; + $start=!empty($header['period_start'])?$header['period_start']:$date;$end=!empty($header['period_end'])?$header['period_end']:$date; + foreach(array($date,$due,$start,$end)as$value)if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$value))throw new BusinessException('Tanggal atau periode invoice tidak valid.'); + if($due<$date)throw new BusinessException('Jatuh tempo tidak boleh sebelum tanggal invoice.'); + if($end<$start)throw new BusinessException('Periode akhir tidak boleh sebelum periode awal.'); + $type=in_array($header['invoice_type']??'',array('one_time','running'),true)?$header['invoice_type']:'one_time'; + $policy=in_array($header['recognition_policy']??'',array('accrual','on_payment'),true)?$header['recognition_policy']:'accrual'; + $key=trim((string)($header['idempotency_key']??'')); + if($key!==''){$existing=$this->CI->db->get_where('invoices',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row();if($existing)return(int)$existing->id;} + $sourceOrder=null;if(!empty($header['source_sales_order_id'])){$sourceOrder=$this->CI->db->get_where('sales_documents',array('id'=>(int)$header['source_sales_order_id'],'company_id'=>$this->company(),'customer_id'=>$customer->id,'document_type'=>'sales_order','status'=>'accepted'))->row();if(!$sourceOrder)throw new BusinessException('Sales Order sumber tidak valid atau belum diterima.');} + $data=array('company_id'=>$this->company(),'no_invoice'=>$this->CI->numberingservice->next('invoice',$date),'customer_id'=>$customer->id,'tanggal'=>$date,'jatuh_tempo'=>$due,'total'=>0,'total_bayar'=>0,'sisa_piutang'=>0,'status'=>'draft','workflow_status'=>'draft','posted'=>0,'keterangan'=>trim((string)($header['notes']??'')),'invoice_type'=>$type,'period_start'=>$start,'period_end'=>$end,'recognition_policy'=>$policy,'source_sales_order_id'=>$sourceOrder?$sourceOrder->id:null,'delivery_status'=>'not_delivered','created_by'=>(int)$user,'created_at'=>date('Y-m-d H:i:s'),'idempotency_key'=>$key!==''?$key:null,'version'=>1); + $this->CI->db->insert('invoices',$this->columns('invoices',$data));$id=(int)$this->CI->db->insert_id(); + $this->CI->auditservice->record('sales','invoice',$id,'create_header',null,array('invoice_type'=>$type,'recognition_policy'=>$policy,'period_start'=>$start,'period_end'=>$end),$user);return$id; + } + public function createInvoice(array$header,array$rows,$user) + { + return$this->CI->transactionservice->run(function()use($header,$rows,$user){$id=$this->insertInvoiceHeader($header,$user);if($rows)$this->appendInvoiceLinesInternal($id,$rows,$user);return$id;}); + } + public function appendInvoiceLines($invoiceId,array$rows,$user) + { + return$this->CI->transactionservice->run(function()use($invoiceId,$rows,$user){$this->appendInvoiceLinesInternal($invoiceId,$rows,$user);return$invoiceId;}); + } + public function appendInvoiceLine($invoiceId,array$row,$user) + { + return$this->CI->transactionservice->run(function()use($invoiceId,$row,$user){$ids=$this->appendInvoiceLinesInternal($invoiceId,array($row),$user);return reset($ids);}); + } + public function appendInvoiceLinesBatch($invoiceId,array$rows,$batchDiscount,$user) + { + return $this->CI->transactionservice->run(function()use($invoiceId,$rows,$batchDiscount,$user){ + if(!$rows)throw new BusinessException('Tambahkan minimal satu barang, jasa, atau tabungan.'); + if(count($rows)>100)throw new BusinessException('Maksimal 100 item dalam satu kali penambahan.'); + + $grossValues=array();$totalGross=0.0; + foreach($rows as $index=>&$row){ + $qty=$this->qty($row['qty']??0); + $priceValue=$row['unit_price']??''; + if(($priceValue===''||$priceValue===null)&&(($row['line_type']??'inventory')==='inventory')){ + $item=$this->tenant('items',$row['item_id']??0,'Barang baris '.($index+1)); + $priceValue=$item->harga_jual; + } + $price=$this->money($priceValue); + if($price<0)throw new BusinessException('Harga item ke-'.($index+1).' tidak boleh negatif.'); + $row['qty']=$qty;$row['unit_price']=$price;$row['discount_amount']=0;$row['specification']=''; + $grossValues[$index]=round($qty*$price,2);$totalGross+=$grossValues[$index]; + } + unset($row); + + $discount=$this->money($batchDiscount); + if($discount<0)throw new BusinessException('Diskon penambahan item tidak boleh negatif.'); + if($discount>$totalGross+.001)throw new BusinessException('Diskon tidak boleh melebihi total bruto item yang ditambahkan.'); + + $positiveIndexes=array_keys(array_filter($grossValues,function($gross){return$gross>0;})); + $lastPositive=$positiveIndexes?end($positiveIndexes):null;$allocated=0.0; + foreach($rows as $index=>&$row){ + if($discount<=0||$grossValues[$index]<=0){$lineDiscount=0.0;} + elseif($index===$lastPositive){$lineDiscount=round($discount-$allocated,2);} + else{$lineDiscount=round($discount*$grossValues[$index]/$totalGross,2);} + $lineDiscount=max(0,min($grossValues[$index],$lineDiscount)); + $row['discount_amount']=$lineDiscount;$allocated+=$lineDiscount; + } + unset($row); + + $ids=$this->appendInvoiceLinesInternal($invoiceId,$rows,$user); + $this->CI->auditservice->record('sales','invoice',(int)$invoiceId,'append_line_batch',null,array('line_count'=>count($rows),'batch_discount'=>$discount,'line_ids'=>$ids),$user); + return array('line_ids'=>$ids,'line_count'=>count($ids),'batch_discount'=>$discount); + }); + } + public function deleteDraftInvoice($id,$user,$privileged=false) + { + return$this->CI->transactionservice->run(function()use($id,$user,$privileged){ + $i=$this->lockInvoice($id,'draft');if(!$privileged&&(int)$i->created_by!==(int)$user)throw new BusinessException('Hanya pembuat Draft atau pengguna berizin hapus yang boleh membatalkan invoice.'); + $postedDelivery=(int)$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->where_in('status',array('posted','partially_returned','returned'))->count_all_results('sales_deliveries');if($postedDelivery)throw new BusinessException('Draft tidak dapat dibatalkan karena Surat Jalan sudah diposting. Gunakan alur retur/koreksi agar stok dan jurnal tetap sinkron.'); + foreach($this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->get('invoice_details')->result()as$line){$this->reverseInvoiceLineInitialJournal($line,$user,'Draft invoice '.$i->no_invoice.' dibatalkan');$this->releaseInvoiceLineBarcodes($line->id);} + $this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company(),'status'=>'draft'))->update('sales_deliveries',array('status'=>'cancelled')); + $this->CI->db->where(array('id'=>$i->id,'company_id'=>$this->company()))->update('invoices',array('deleted_at'=>date('Y-m-d H:i:s'),'status'=>'cancelled','updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'))); + $this->CI->auditservice->record('sales','invoice',$i->id,'cancel_draft',$i,array('status'=>'cancelled'),$user);return$i->id; + }); + } + private function appendInvoiceLinesInternal($invoiceId,array$rows,$user) + { + $invoice=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$invoiceId,$this->company()))->row(); + if(!$invoice||$invoice->workflow_status!=='draft')throw new BusinessException('Item hanya dapat ditambahkan pada invoice Draft/Berjalan.');$this->assertDraftOwner($invoice,$user); + if(!$rows)throw new BusinessException('Tambahkan minimal satu barang, jasa, atau tabungan.');$inserted=array(); + $customer=$this->tenant('customers',$invoice->customer_id,'Customer'); + $existingLines=$this->CI->db->select('id,line_type,subtotal,total_hpp_barang')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result();$lineJournalMode=true;foreach($existingLines as$existingLine)if($existingLine->line_type!=='savings'&&((float)$existingLine->subtotal>0||(float)$existingLine->total_hpp_barang>0)){$existingJournal=$this->initialLineJournal($existingLine->id);if(!$existingJournal||$existingJournal->is_reversed||$existingJournal->status!=='posted'){$lineJournalMode=false;break;}} + $existingTypes=$this->CI->db->select('DISTINCT line_type',false)->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result_array(); + $families=array();foreach($existingTypes as$t)$families[$this->lineFamily($t['line_type'])]=true;foreach($rows as$r)$families[$this->lineFamily($r['line_type']??'inventory')]=true; + if(count($families)>1)throw new BusinessException('Invoice penjualan barang/jasa tidak dapat dicampur dengan invoice tabungan. Buat invoice terpisah agar jurnal dan pelunasannya jelas.'); + $financialTypes=array();foreach($existingTypes as$t)if($this->lineFamily($t['line_type'])==='financial')$financialTypes[$t['line_type']]=true;foreach($rows as$r)if($this->lineFamily($r['line_type']??'inventory')==='financial')$financialTypes[$r['line_type']]=true; + if(count($financialTypes)>1)throw new BusinessException('Data pinjaman lama dan tabungan tidak dapat dicampur pada invoice yang sama.'); + foreach($rows as$n=>$row){ + $requestedType=$row['line_type']??'inventory';if($requestedType==='loan')throw new BusinessException('Piutang/Pinjaman sudah tidak tersedia pada Invoice. Gunakan jenis Tabungan sesuai alur terbaru.'); + $lineType=in_array($requestedType,array('inventory','service','savings'),true)?$requestedType:'inventory';$item=null;$warehouse=null;$kb=null;$key=trim((string)($row['idempotency_key']??'')); + if($key!==''){$existing=$this->CI->db->get_where('invoice_details',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row();if($existing){if((int)$existing->invoice_id!==(int)$invoice->id)throw new BusinessException('Kunci permintaan item sudah digunakan dokumen lain.');$inserted[]=(int)$existing->id;continue;}} + $accountLabel=$lineType==='loan'?'Akun piutang':($lineType==='savings'?'Akun simpanan customer':'Akun pendapatan'); + $account=$this->tenant('accounts',$row['account_id']??0,$accountLabel);$counterAccount=null; + $mayUseHidden=function_exists('is_master_admin_user')&&is_master_admin_user(); + if((isset($account->is_active)&&(int)$account->is_active!==1)||(isset($account->allow_posting)&&(int)$account->allow_posting!==1)||(!$mayUseHidden&&isset($account->is_hidden)&&(int)$account->is_hidden===1))throw new BusinessException($accountLabel.' tidak aktif atau tidak dapat diposting.'); + if(in_array($lineType,array('inventory','service'),true)&&(string)$account->tipe!=='revenue')throw new BusinessException('Barang dan jasa harus dialokasikan ke akun pendapatan.'); + if($lineType==='loan'){ + if((string)$account->tipe!=='asset')throw new BusinessException('Akun piutang pinjaman harus merupakan akun Aktiva.'); + $counterAccount=$this->tenant('accounts',$row['counter_account_id']??0,'Akun sumber dana pinjaman'); + if((string)$counterAccount->tipe!=='asset'||(isset($counterAccount->is_active)&&(int)$counterAccount->is_active!==1)||(isset($counterAccount->allow_posting)&&(int)$counterAccount->allow_posting!==1))throw new BusinessException('Akun sumber dana pinjaman harus merupakan akun Aktiva aktif yang dapat diposting.'); + if((int)$counterAccount->id===(int)$account->id)throw new BusinessException('Akun piutang dan akun sumber dana pinjaman harus berbeda.'); + } + if($lineType==='savings'&&(string)$account->tipe!=='liability')throw new BusinessException('Akun tabungan customer harus merupakan akun Passiva/Kewajiban.'); + if($lineType==='inventory'){$item=$this->tenant('items',$row['item_id']??0,'Barang baris '.($n+1));$warehouse=$this->tenant('warehouses',$row['warehouse_id']??0,'Gudang');if($item->status!=='active')throw new BusinessException('Barang '.$item->nama_barang.' belum aktif.');$kb=$this->CI->db->get_where('kode_barang',array('id'=>$item->kode_id))->row();} + $q=$lineType==='savings'?1:$this->qty($row['qty']??0);$price=$this->money($row['unit_price']??($item?$item->harga_jual:0));$discount=$lineType==='savings'?0:max(0,$this->money($row['discount_amount']??0));$tax=$lineType==='savings'?0:max(0,(float)str_replace(',','.',(string)($row['tax_rate']??0))); + if($discount>$q*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto baris.'); + $base=max(0,$q*$price-$discount);$taxAmount=round($base*$tax/100,2);$total=$base+$taxAmount;$service=$row['service_date']??$invoice->tanggal; + if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.'); + $barcodes=(array)($row['barcodes']??array());$barcodeQty=0;$cleanBarcodes=array(); + if($lineType==='inventory'){ + $seenBarcodes=array(); + foreach($barcodes as$b){ + $bid=(int)($b['id']??$b['barcode_id']??0); + if(!$bid||isset($seenBarcodes[$bid]))throw new BusinessException('Barcode tidak boleh dipilih dua kali pada satu baris.'); + $seenBarcodes[$bid]=true; + if($this->CI->db->where(array('invoice_id'=>$invoice->id,'barcode_id'=>$bid))->count_all_results('invoice_line_barcodes'))throw new BusinessException('Barcode sudah dipakai pada item lain di invoice ini.'); + $bq=$this->qty($b['qty']??1);$barcode=$this->CI->db->query("SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? AND status='available' FOR UPDATE",array($bid,$item->id,$warehouse->id))->row();if(!$barcode)throw new BusinessException('Barcode barang '.$item->nama_barang.' tidak tersedia di gudang terpilih.');$available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)($barcode->return_reserved_qty??0);if($bq>$available+.0001)throw new BusinessException('Qty barcode '.$barcode->barcode.' melebihi saldo tersedia.');$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('reserved_qty'=>(float)$barcode->reserved_qty+$bq,'version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang dipilih pengguna lain. Silakan ulangi.');$barcode->reserved_qty=(float)$barcode->reserved_qty+$bq;$barcode->version=(int)$barcode->version+1;$barcodeQty+=$bq;$cleanBarcodes[]=array('row'=>$barcode,'qty'=>$bq); + } + if(abs($barcodeQty-$q)>.0001)throw new BusinessException('Total qty barcode harus sama dengan qty barang '.$item->nama_barang.'.');if($kb&&$kb->tracking_type==='UNIT')foreach($cleanBarcodes as$b)if(abs($b['qty']-1)>.0001)throw new BusinessException('Barcode UNIT harus memiliki qty 1.'); + } + elseif(trim((string)($row['description']??''))==='')throw new BusinessException('Nama jasa atau rincian tagihan wajib diisi.'); + $name=$lineType==='inventory'?$item->nama_barang:trim((string)$row['description']); + $detail=array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'line_type'=>$lineType,'tanggal'=>$service,'service_date'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counterAccount?$counterAccount->id:null,'items_id'=>$item?$item->id:null,'nama_item'=>$name,'specification'=>trim((string)($row['specification']??'')),'unit'=>$kb?$kb->unit:trim((string)($row['unit']??($lineType==='loan'?'Pinjaman':($lineType==='savings'?'Tabungan':'Jasa')))),'qty'=>$q,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$base,'subtotal'=>$total,'subtotal_asli'=>$total,'warehouse_id'=>$warehouse?$warehouse->id:null,'keterangan'=>trim((string)($row['notes']??'')),'total_hpp_barang'=>$item?round($q*(float)$item->harga_beli,2):0,'is_cicilan'=>0,'idempotency_key'=>$key!==''?$key:null); + $this->CI->db->insert('invoice_details',$this->columns('invoice_details',$detail));$detailId=(int)$this->CI->db->insert_id();$inserted[]=$detailId; + if($lineType==='inventory')foreach($cleanBarcodes as$b)$this->CI->db->insert('invoice_line_barcodes',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'invoice_detail_id'=>$detailId,'sales_delivery_id'=>null,'sales_delivery_line_id'=>null,'barcode_id'=>$b['row']->id,'qty'=>$b['qty'],'created_at'=>date('Y-m-d H:i:s'))); + $storedDetail=$this->CI->db->get_where('invoice_details',array('id'=>$detailId,'company_id'=>$this->company()))->row();if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$detailId,$storedDetail,$item,$cleanBarcodes,$customer,$user); + } + $this->refreshInvoiceTotals($invoice->id);$this->updateInvoiceDeliveryStatus($invoice->id);return$inserted; + } + + /** + * Jurnal awal dibuat per baris agar setiap perubahan Draft mempunyai jejak + * sumber yang dapat dibalik tanpa menghapus histori jurnal. + */ + private function postInvoiceLineInitialJournal($invoice,$detailId,$line,$item,array$barcodes,$customer,$user) + { + if($line->line_type==='savings')return null; + $entries=array(); + if($line->line_type==='inventory'&&(float)$line->total_hpp_barang>0){ + $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>(float)$line->total_hpp_barang,'kredit'=>0); + $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>(float)$line->total_hpp_barang); + } + if((float)$line->subtotal>0){ + $entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>(float)$line->subtotal,'kredit'=>0); + if((float)$line->net_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>0,'kredit'=>(float)$line->net_amount); + if((float)$line->tax_amount>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>0,'kredit'=>(float)$line->tax_amount); + } + if(!$entries)return null; + $qty=number_format((float)$line->qty,abs((float)$line->qty-round((float)$line->qty))<.0001?0:4,',','.'); + if($line->line_type==='inventory'){ + $codes=array();foreach($barcodes as$b)if(isset($b['row']->barcode))$codes[]=$b['row']->barcode; + $description="Penjualan '".$item->nama_barang."' Sebanyak '".$qty."' '".($line->unit?:'-')."' - Kode: '".($codes?implode(', ',$codes):'-')."' - Kepada: '".$customer->nama."'"; + }else{ + $description="Penjualan '".$line->nama_item."' Sebanyak '".$qty."' - Kepada: '".$customer->nama."' - '".($line->keterangan?:'-')."'"; + } + return$this->CI->postingservice->post(array('tanggal'=>$line->service_date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_journal',$line->service_date),'keterangan'=>$description,'ref_type'=>'invoice_line','ref_id'=>(int)$detailId,'created_by'=>$user),$entries,false,false); + } + + private function initialLineJournal($lineId,$lock=false) + { + $sql="SELECT * FROM journals WHERE company_id=? AND ref_type='invoice_line' AND ref_id=? ORDER BY id DESC LIMIT 1".($lock?' FOR UPDATE':''); + return$this->CI->db->query($sql,array($this->company(),(int)$lineId))->row(); + } + + private function reverseInvoiceLineInitialJournal($line,$user,$reason) + { + $journal=$this->initialLineJournal($line->id,true);if(!$journal||$journal->is_reversed||$journal->status==='reversed')return null; + $entries=array();foreach($this->CI->db->where('journal_id',$journal->id)->order_by('id')->get('journal_details')->result()as$d)$entries[]=array('account_id'=>$d->account_id,'debit'=>(float)$d->kredit,'kredit'=>(float)$d->debit); + if(!$entries)return null; + $date=$line->service_date?:($line->tanggal?:date('Y-m-d')); + $reversal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('invoice_line_reversal',$date),'keterangan'=>'Pembalikan item invoice: '.$reason,'ref_type'=>'invoice_line_reversal','ref_id'=>(int)$journal->id,'reversal_of'=>(int)$journal->id,'reversal_reason'=>$reason,'created_by'=>$user),$entries,false,true); + $this->CI->db->where('id',$journal->id)->update('journals',array('status'=>'reversed','is_reversed'=>1,'updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'),'reversal_reason'=>$reason)); + return$reversal; + } + public function generateDelivery($invoiceId,$user) + { + return $this->CI->transactionservice->run(function()use($invoiceId,$user){ + $invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user); + $lines=$this->CI->db->select('d.*,it.nama_barang,k.unit')->from('invoice_details d')->join('items it','it.id=d.items_id')->join('kode_barang k','k.id=it.kode_id','left')->where(array('d.invoice_id'=>$invoice->id,'d.company_id'=>$this->company(),'d.line_type'=>'inventory'))->where('d.delivery_line_id IS NULL',null,false)->order_by('d.service_date,d.warehouse_id,d.id')->get()->result(); + if(!$lines)throw new BusinessException('Tidak ada barang invoice yang belum dibuatkan Surat Jalan.'); + $groups=array(); + foreach($lines as$l){ + $warehouse=$this->tenant('warehouses',$l->warehouse_id,'Gudang');$key=$l->service_date.'|'.$warehouse->id; + if(!isset($groups[$key]))$groups[$key]=array('date'=>$l->service_date,'warehouse'=>$warehouse,'lines'=>array()); + $bars=$this->CI->db->select('ilb.*,ib.barcode,ib.serial_number')->from('invoice_line_barcodes ilb')->join('item_barcodes ib','ib.id=ilb.barcode_id')->where(array('ilb.invoice_detail_id'=>$l->id,'ilb.company_id'=>$this->company()))->where('ilb.sales_delivery_line_id IS NULL',null,false)->order_by('ilb.id')->get()->result(); + $allocated=0;$clean=array();foreach($bars as$b){$allocated+=(float)$b->qty;$clean[]=array('allocation'=>$b,'row'=>(object)array('id'=>(int)$b->barcode_id),'qty'=>(float)$b->qty);} + if(abs($allocated-(float)$l->qty)>.0001)throw new BusinessException('Alokasi barcode item '.$l->nama_barang.' tidak sama dengan qty invoice.'); + $groups[$key]['lines'][]=array('detail_id'=>(int)$l->id,'item'=>(object)array('id'=>$l->items_id,'nama_barang'=>$l->nama_barang,'harga_beli'=>$l->total_hpp_barang/max(.0001,(float)$l->qty)),'kb'=>(object)array('unit'=>$l->unit),'qty'=>(float)$l->qty,'barcodes'=>$clean,'specification'=>$l->specification,'notes'=>$l->keterangan); + } + $deliveryIds=array();foreach($groups as$group)$deliveryIds[]=$this->createDeliveryForLines($invoice,$group,$user); + $this->updateInvoiceDeliveryStatus($invoice->id);$this->CI->auditservice->record('sales','invoice',$invoice->id,'generate_delivery',null,array('delivery_ids'=>$deliveryIds),$user); + return array('delivery_ids'=>$deliveryIds,'delivery_count'=>count($deliveryIds)); + }); + } + + private function createDeliveryForLines($invoice,array$group,$user) + { + $delivery=$this->CI->db->query("SELECT * FROM sales_deliveries WHERE company_id=? AND invoice_id=? AND delivery_date=? AND warehouse_id=? AND status='draft' ORDER BY id LIMIT 1 FOR UPDATE",array($this->company(),$invoice->id,$group['date'],$group['warehouse']->id))->row(); + if(!$delivery){$this->CI->db->insert('sales_deliveries',array('company_id'=>$this->company(),'delivery_no'=>$this->CI->numberingservice->next('sales_delivery',$group['date']),'customer_id'=>$invoice->customer_id,'invoice_id'=>$invoice->id,'warehouse_id'=>$group['warehouse']->id,'delivery_date'=>$group['date'],'address'=>'','status'=>'draft','created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$deliveryId=(int)$this->CI->db->insert_id();$lineNo=0;}else{$deliveryId=(int)$delivery->id;$max=$this->CI->db->select_max('line_no','max_line')->get_where('sales_delivery_lines',array('sales_delivery_id'=>$deliveryId))->row();$lineNo=(int)($max->max_line??0);} + foreach($group['lines']as$line){$lineNo++;$this->CI->db->insert('sales_delivery_lines',array('company_id'=>$this->company(),'sales_delivery_id'=>$deliveryId,'line_no'=>$lineNo,'item_id'=>$line['item']->id,'invoice_detail_id'=>$line['detail_id'],'description'=>$line['item']->nama_barang,'specification'=>$line['specification'],'unit'=>$line['kb']?$line['kb']->unit:null,'qty'=>$line['qty'],'unit_cost'=>$line['item']->harga_beli,'total_cost'=>$line['qty']*$line['item']->harga_beli,'condition_notes'=>$line['notes'],'created_at'=>date('Y-m-d H:i:s')));$deliveryLine=(int)$this->CI->db->insert_id();$this->CI->db->where('id',$line['detail_id'])->update('invoice_details',array('delivery_line_id'=>$deliveryLine));foreach($line['barcodes']as$b){$this->CI->db->insert('sales_delivery_barcodes',array('company_id'=>$this->company(),'sales_delivery_id'=>$deliveryId,'sales_delivery_line_id'=>$deliveryLine,'item_id'=>$line['item']->id,'barcode_id'=>$b['row']->id,'qty'=>$b['qty'],'status'=>'allocated','created_at'=>date('Y-m-d H:i:s')));$this->CI->db->where(array('id'=>$b['allocation']->id,'invoice_detail_id'=>$line['detail_id']))->update('invoice_line_barcodes',array('sales_delivery_id'=>$deliveryId,'sales_delivery_line_id'=>$deliveryLine));}} + return$deliveryId; + } + private function refreshInvoiceTotals($id) + { + $r=$this->CI->db->select('COALESCE(SUM(subtotal),0) total,COALESCE(SUM(net_amount),0) net,COALESCE(SUM(tax_amount),0) tax,COALESCE(SUM(discount_amount),0) discount,MIN(service_date) period_start,MAX(service_date) period_end',false)->get_where('invoice_details',array('invoice_id'=>(int)$id,'company_id'=>$this->company()))->row(); + $invoice=$this->CI->db->select('tanggal')->get_where('invoices',array('id'=>(int)$id,'company_id'=>$this->company()))->row(); + $start=$r->period_start?:$invoice->tanggal;$end=$r->period_end?:$invoice->tanggal; + $this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->update('invoices',array('total'=>$r->total,'subtotal_before_tax'=>$r->net,'tax_total'=>$r->tax,'discount_total'=>$r->discount,'sisa_piutang'=>$r->total,'period_start'=>$start,'period_end'=>$end,'invoice_type'=>$start===$end?'one_time':'running')); + } + public function invoiceForEditor($id) + { + $invoice=$this->CI->db->select("i.*,c.nama customer_name,c.alamat customer_address,c.telp customer_phone,c.email customer_email,u.nama creator_name,ua.nama approver_name,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id) line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory') inventory_line_count,(SELECT COUNT(*) FROM invoice_details dx WHERE dx.invoice_id=i.id AND dx.company_id=i.company_id AND dx.line_type='inventory' AND dx.delivery_line_id IS NULL) pending_delivery_count",false) + ->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left')->join('users ua','ua.id=i.approved_by','left') + ->where(array('i.id'=>(int)$id,'i.company_id'=>$this->company()))->where('i.deleted_at IS NULL',null,false)->get()->row(); + if(!$invoice)throw new BusinessException('Invoice tidak ditemukan pada perusahaan aktif.');return$invoice; + } + public function invoiceLinesPage($invoiceId,$start=0,$length=25,$search='') + { + $invoice=$this->invoiceForEditor($invoiceId);$start=max(0,(int)$start);$length=max(1,min(100,(int)$length));$search=trim((string)$search); + $base=function()use($invoiceId,$search){$this->CI->db->from('invoice_details d')->join('items it','it.id=d.items_id','left')->join('accounts a','a.id=d.account_id','left')->join('accounts ca','ca.id=d.counter_account_id','left')->where(array('d.invoice_id'=>(int)$invoiceId,'d.company_id'=>$this->company()));if($search!=='')$this->CI->db->group_start()->like('d.nama_item',$search)->or_like('d.specification',$search)->or_like('d.keterangan',$search)->or_like('it.kode_detail',$search)->or_like('a.nama_akun',$search)->or_like('ca.nama_akun',$search)->group_end();}; + $base();$filtered=(int)$this->CI->db->count_all_results();$total=(int)$this->CI->db->where(array('invoice_id'=>(int)$invoiceId,'company_id'=>$this->company()))->count_all_results('invoice_details'); + $base();$rows=$this->CI->db->select("d.*,it.kode_detail item_code,a.kode_akun,a.nama_akun,ca.kode_akun counter_kode_akun,ca.nama_akun counter_nama_akun,(SELECT GROUP_CONCAT(CONCAT(ib.barcode,IF(NULLIF(ib.serial_number,'') IS NULL,'',CONCAT(' / ',ib.serial_number))) ORDER BY ilb.id SEPARATOR '||') FROM invoice_line_barcodes ilb JOIN item_barcodes ib ON ib.id=ilb.barcode_id WHERE ilb.invoice_detail_id=d.id) barcode_labels",false)->order_by('d.service_date','ASC')->order_by('d.id','ASC')->limit($length,$start)->get()->result(); + return array('invoice'=>$invoice,'rows'=>$rows,'recordsTotal'=>$total,'recordsFiltered'=>$filtered,'summary'=>array('line_count'=>$total,'subtotal'=>(float)$invoice->subtotal_before_tax,'discount'=>(float)$invoice->discount_total,'tax'=>(float)$invoice->tax_total,'total'=>(float)$invoice->total)); + } + public function updateInvoiceHeader($id,array$data,$user) + { + return$this->CI->transactionservice->run(function()use($id,$data,$user){$invoice=$this->lockInvoice($id,'draft');$this->assertDraftOwner($invoice,$user);$lineCount=(int)$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->count_all_results('invoice_details');if($lineCount)throw new BusinessException('Aturan invoice hanya dapat diubah sebelum item pertama ditambahkan.');$date=$data['date']??$invoice->tanggal;$due=$data['due_date']??$invoice->jatuh_tempo;$start=array_key_exists('period_start',$data)?$data['period_start']:$date;$end=array_key_exists('period_end',$data)?$data['period_end']:$date;if($due<$date||$end<$start)throw new BusinessException('Tanggal invoice atau jatuh tempo tidak valid.');$customer=$this->tenant('customers',$data['customer_id']??$invoice->customer_id,'Customer');$update=array('customer_id'=>$customer->id,'tanggal'=>$date,'jatuh_tempo'=>$due,'period_start'=>$start,'period_end'=>$end,'invoice_type'=>in_array($data['invoice_type']??'',array('one_time','running'),true)?$data['invoice_type']:'one_time','recognition_policy'=>in_array($data['recognition_policy']??'',array('accrual','on_payment'),true)?$data['recognition_policy']:$invoice->recognition_policy,'keterangan'=>trim((string)($data['notes']??$invoice->keterangan)),'updated_by'=>$user,'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$invoice->version+1);$this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->update('invoices',$update);$this->CI->auditservice->record('sales','invoice',$invoice->id,'update_header',$invoice,$update,$user);return$invoice->id;}); + } + public function removeInvoiceLine($invoiceId,$lineId,$user) + { + return$this->CI->transactionservice->run(function()use($invoiceId,$lineId,$user){ + $invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user);$line=$this->CI->db->query('SELECT * FROM invoice_details WHERE id=? AND invoice_id=? AND company_id=? FOR UPDATE',array((int)$lineId,$invoice->id,$this->company()))->row();if(!$line)throw new BusinessException('Baris invoice tidak ditemukan.'); + $deliveryId=0;if($line->delivery_line_id){$deliveryLine=$this->CI->db->select('l.*,d.status delivery_status')->from('sales_delivery_lines l')->join('sales_deliveries d','d.id=l.sales_delivery_id')->where('l.id',$line->delivery_line_id)->get()->row();if(!$deliveryLine||$deliveryLine->delivery_status!=='draft'||$deliveryLine->stock_posted_at)throw new BusinessException('Item yang Surat Jalannya sudah diposting tidak dapat dihapus.');$deliveryId=(int)$deliveryLine->sales_delivery_id;} + $this->reverseInvoiceLineInitialJournal($line,$user,'Item dihapus dari Draft invoice '.$invoice->no_invoice); + $this->releaseInvoiceLineBarcodes($line->id); + if($line->delivery_line_id){$this->CI->db->where('sales_delivery_line_id',$line->delivery_line_id)->delete('sales_delivery_barcodes');$this->CI->db->where('id',$line->delivery_line_id)->delete('sales_delivery_lines');if(!$this->CI->db->where('sales_delivery_id',$deliveryId)->count_all_results('sales_delivery_lines'))$this->CI->db->where('id',$deliveryId)->update('sales_deliveries',array('status'=>'cancelled'));} + $this->CI->db->where('id',$line->id)->delete('invoice_details');$this->refreshInvoiceTotals($invoice->id);$this->updateInvoiceDeliveryStatus($invoice->id);$this->CI->auditservice->record('sales','invoice',$invoice->id,'remove_line',$line,null,$user);return$line->id; + }); + } + + private function releaseInvoiceLineBarcodes($lineId) + { + $bars=$this->CI->db->where(array('invoice_detail_id'=>(int)$lineId,'company_id'=>$this->company()))->get('invoice_line_barcodes')->result(); + foreach($bars as$b){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($b->barcode_id))->row();if($barcode)$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('reserved_qty'=>max(0,(float)$barcode->reserved_qty-(float)$b->qty),'status'=>'available','version'=>(int)$barcode->version+1));} + $this->CI->db->where(array('invoice_detail_id'=>(int)$lineId,'company_id'=>$this->company()))->delete('invoice_line_barcodes'); + } + + public function updateNonInventoryLine($invoiceId,$lineId,array$data,$user) + { + return $this->CI->transactionservice->run(function()use($invoiceId,$lineId,$data,$user){ + $invoice=$this->lockInvoice($invoiceId,'draft');$this->assertDraftOwner($invoice,$user); + $line=$this->CI->db->query('SELECT * FROM invoice_details WHERE id=? AND invoice_id=? AND company_id=? FOR UPDATE',array((int)$lineId,$invoice->id,$this->company()))->row(); + if(!$line||!in_array($line->line_type,array('service','savings'),true))throw new BusinessException('Hanya item Jasa/Nonbarang atau Tabungan pada Draft yang dapat diedit.'); + $type=$line->line_type;$description=trim((string)($data['description']??''));if($description==='')throw new BusinessException('Deskripsi item wajib diisi.'); + $account=$this->tenant('accounts',$data['account_id']??0,$type==='savings'?'Akun simpanan customer':'Akun pendapatan'); + if(isset($account->is_active)&&!(int)$account->is_active||isset($account->allow_posting)&&!(int)$account->allow_posting)throw new BusinessException('Akun item tidak aktif atau tidak dapat diposting.'); + $counter=null;if($type==='service'&&(string)$account->tipe!=='revenue')throw new BusinessException('Jasa harus menggunakan akun pendapatan.'); + if($type==='savings'&&(string)$account->tipe!=='liability')throw new BusinessException('Tabungan harus menggunakan akun Passiva/Kewajiban.'); + $qty=$type==='savings'?1:$this->qty($data['qty']??0);$price=$this->money($data['unit_price']??0);$discount=$type==='savings'?0:max(0,$this->money($data['discount_amount']??0));if($discount>$qty*$price)throw new BusinessException('Diskon tidak boleh melebihi nilai bruto item.'); + $tax=$type==='service'?max(0,(float)str_replace(',','.',(string)($data['tax_rate']??0))):0;$net=max(0,$qty*$price-$discount);$taxAmount=round($net*$tax/100,2);$service=$data['service_date']??$line->service_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',(string)$service))throw new BusinessException('Tanggal transaksi item tidak valid.'); + $update=$this->columns('invoice_details',array('service_date'=>$service,'tanggal'=>$service,'account_id'=>$account->id,'counter_account_id'=>$counter?$counter->id:null,'nama_item'=>$description,'unit'=>trim((string)($data['unit']??$line->unit)),'qty'=>$qty,'harga'=>$price,'discount_amount'=>$discount,'tax_rate'=>$tax,'tax_amount'=>$taxAmount,'net_amount'=>$net,'subtotal'=>$net+$taxAmount,'subtotal_asli'=>$net+$taxAmount,'keterangan'=>trim((string)($data['notes']??'')))); + $lineJournalMode=true;$otherLines=$this->CI->db->select('id,line_type,subtotal,total_hpp_barang')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->where('id !=',$line->id)->get('invoice_details')->result();foreach($otherLines as$other)if($other->line_type!=='savings'&&((float)$other->subtotal>0||(float)$other->total_hpp_barang>0)){$otherJournal=$this->initialLineJournal($other->id);if(!$otherJournal||$otherJournal->is_reversed||$otherJournal->status!=='posted'){$lineJournalMode=false;break;}} + $this->reverseInvoiceLineInitialJournal($line,$user,'Item Draft invoice '.$invoice->no_invoice.' diedit'); + $this->CI->db->where(array('id'=>$line->id,'invoice_id'=>$invoice->id,'company_id'=>$this->company()))->update('invoice_details',$update);$updated=$this->CI->db->get_where('invoice_details',array('id'=>$line->id,'company_id'=>$this->company()))->row();$customer=$this->tenant('customers',$invoice->customer_id,'Customer');if($lineJournalMode)$this->postInvoiceLineInitialJournal($invoice,$line->id,$updated,null,array(),$customer,$user);$this->refreshInvoiceTotals($invoice->id);$this->CI->auditservice->record('sales','invoice_detail',$line->id,'update',$line,$update,$user);return$line->id; + }); + } + + public function updateDelivery($id,array$data,array$attachments,$user) + { + return$this->CI->transactionservice->run(function()use($id,$data,$attachments,$user){$d=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$d||$d->status!=='draft')throw new BusinessException('Hanya Surat Jalan Draft yang dapat dilengkapi.');$date=$data['delivery_date']??$d->delivery_date;if(!preg_match('/^\d{4}-\d{2}-\d{2}$/',$date))throw new BusinessException('Tanggal pengiriman tidak valid.');$update=array('delivery_date'=>$date,'address'=>trim((string)($data['address']??'')),'driver_name'=>trim((string)($data['driver_name']??'')),'vehicle_no'=>trim((string)($data['vehicle_no']??'')),'courier'=>trim((string)($data['courier']??'')),'tracking_no'=>trim((string)($data['tracking_no']??'')),'recipient_name'=>trim((string)($data['recipient_name']??'')),'notes'=>trim((string)($data['notes']??'')),'version'=>(int)$d->version+1);$this->CI->db->where('id',$d->id)->update('sales_deliveries',$update);foreach($attachments as$a)$this->insertAttachment('sales_delivery',$d->id,$a,$user);$this->CI->auditservice->record('sales','sales_delivery',$d->id,'update',$d,$update,$user);return$d->id;}); + } + + public function postDelivery($id,$user) + { + return$this->CI->transactionservice->run(function()use($id,$user){ + $d=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row(); + if(!$d)throw new BusinessException('Surat Jalan tidak ditemukan.');if($d->status==='posted')return$d->id;if($d->status!=='draft')throw new BusinessException('Hanya Surat Jalan Draft yang dapat diposting.'); + $this->CI->fiscalperiodservice->assertOpen($d->delivery_date);$lines=$this->CI->db->where(array('sales_delivery_id'=>$d->id,'company_id'=>$this->company()))->order_by('line_no')->get('sales_delivery_lines')->result();if(!$lines)throw new BusinessException('Surat Jalan belum mempunyai barang.'); + $totalCost=0;$inventoryReclass=0; + foreach($lines as$l){ + if($l->stock_posted_at)continue;$bars=$this->CI->db->where('sales_delivery_line_id',$l->id)->get('sales_delivery_barcodes')->result();$allocated=0; + foreach($bars as$b){$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? AND item_id=? AND warehouse_id=? FOR UPDATE',array($b->barcode_id,$l->item_id,$d->warehouse_id))->row();if(!$barcode||$barcode->status!=='available'||(float)$barcode->qty_sisa<(float)$b->qty-.0001||(float)$barcode->reserved_qty<(float)$b->qty-.0001)throw new BusinessException('Barcode pada Surat Jalan sudah tidak tersedia atau reservasinya tidak valid.');$left=(float)$barcode->qty_sisa-(float)$b->qty;$reserved=max(0,(float)$barcode->reserved_qty-(float)$b->qty);$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('qty_sisa'=>$left,'reserved_qty'=>$reserved,'status'=>$left<=.0001?'sold_out':'available','version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses pengguna lain. Silakan ulangi.');$this->CI->db->where('id',$b->id)->update('sales_delivery_barcodes',array('status'=>'delivered'));$allocated+=(float)$b->qty;} + if(abs($allocated-(float)$l->qty)>.0001)throw new BusinessException('Alokasi barcode Surat Jalan tidak sama dengan qty barang.'); + $detailBefore=$this->CI->db->get_where('invoice_details',array('id'=>$l->invoice_detail_id,'company_id'=>$this->company()))->row();$initialJournal=$detailBefore?$this->initialLineJournal($detailBefore->id):null;$hadInitialJournal=$initialJournal&&!$initialJournal->is_reversed&&$initialJournal->status==='posted';$previousCost=$detailBefore?(float)$detailBefore->total_hpp_barang:0; + $cost=$this->consumeCost($l->item_id,$d->warehouse_id,$l->qty);$totalCost+=$cost;$inventoryReclass+=$hadInitialJournal?($cost-$previousCost):$cost;$key='SALES-DELIVERY-LINE-'.$l->id;$desc='[AUTO] Surat Jalan '.$d->delivery_no.' | '.$l->description.' | Qty '.number_format($l->qty,0,',','.'); + $ledger=array('item_id'=>$l->item_id,'warehouse_id'=>$d->warehouse_id,'movement_date'=>$d->delivery_date,'direction'=>'out','qty'=>$l->qty,'unit_cost'=>$l->qty>0?$cost/$l->qty:0,'value'=>$cost,'document_type'=>'sales_delivery','document_id'=>$d->id,'document_line_id'=>$l->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s'));$ledger=$this->addCompany('inventory_ledger',$ledger);$this->CI->db->insert('inventory_ledger',$ledger); + foreach($bars as$b){$log=$this->addCompany('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$d->warehouse_id,'barcode_id'=>$b->barcode_id,'qty'=>$b->qty,'tipe'=>'keluar','unit_cost'=>$l->qty>0?$cost/$l->qty:0,'keterangan'=>$desc,'ref_type'=>'sales_delivery','ref_id'=>$l->id,'idempotency_key'=>$key.'-B'.$b->id));$this->CI->db->insert('stock_logs',$log);$move=$this->addCompany('item_movements',array('item_id'=>$l->item_id,'barcode_id'=>$b->barcode_id,'qty'=>$b->qty,'from_type'=>'warehouse','from_id'=>$d->warehouse_id,'to_type'=>'customer','to_id'=>$d->customer_id,'movement_type'=>'sold_out','reference_type'=>'sales_delivery','reference_id'=>$l->id,'notes'=>$desc,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('item_movements',$move);} + $this->CI->salesInventoryModel->synchronizeStock($l->item_id);$this->CI->db->where('id',$l->id)->update('sales_delivery_lines',array('unit_cost'=>$l->qty>0?$cost/$l->qty:0,'total_cost'=>$cost,'stock_posted_at'=>date('Y-m-d H:i:s')));$this->CI->db->where('id',$l->invoice_detail_id)->update('invoice_details',array('total_hpp_barang'=>$cost,'stock_posted_at'=>date('Y-m-d H:i:s'))); + } + $journal=null;if(abs($inventoryReclass)>.001){$amount=abs(round($inventoryReclass,2));$entries=$inventoryReclass>0?array(array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>0,'kredit'=>$amount)):array(array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$amount,'kredit'=>0),array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$amount));$journal=$this->CI->postingservice->post(array('tanggal'=>$d->delivery_date,'no_ref'=>$this->CI->numberingservice->next('sales_delivery_journal',$d->delivery_date),'keterangan'=>'[AUTO] Penyesuaian biaya aktual pengiriman '.$d->delivery_no.' | Customer #'.$d->customer_id.' | Selisih Rp '.number_format($inventoryReclass,2,',','.'),'ref_type'=>'sales_delivery','ref_id'=>$d->id,'created_by'=>$user),$entries,false,true);} + $this->CI->db->where('id',$d->id)->update('sales_deliveries',array('status'=>'posted','journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'version'=>(int)$d->version+1));$this->updateInvoiceDeliveryStatus($d->invoice_id);$this->CI->auditservice->record('sales','sales_delivery',$d->id,'post',$d,array('journal_id'=>$journal,'cost'=>$totalCost),$user);return$d->id; + }); + } + private function consumeCost($item,$warehouse,$qty) + { + $remaining=(float)$qty;$cost=0;$layers=$this->CI->db->query("SELECT * FROM inventory_cost_layers WHERE item_id=? AND warehouse_id=? AND status='open' AND remaining_qty>0 ORDER BY received_date,id FOR UPDATE",array((int)$item,(int)$warehouse))->result(); + foreach($layers as$l){if($remaining<=.0001)break;$take=min($remaining,(float)$l->remaining_qty);$left=(float)$l->remaining_qty-$take;$cost+=$take*(float)$l->unit_cost;$this->CI->db->where('id',$l->id)->update('inventory_cost_layers',array('remaining_qty'=>$left,'status'=>$left<=.0001?'consumed':'open'));$remaining-=$take;} + if($remaining>.0001)throw new BusinessException('Lapisan biaya persediaan tidak cukup untuk pengiriman.');return round($cost,2); + } + private function updateInvoiceDeliveryStatus($invoiceId) + { + if(!$invoiceId)return;$stockLines=(int)$this->CI->db->where(array('invoice_id'=>$invoiceId,'company_id'=>$this->company(),'line_type'=>'inventory'))->count_all_results('invoice_details');if(!$stockLines){$status='delivered';}else{$counts=$this->CI->db->select("SUM(status='posted') posted_count,COUNT(*) total_count",false)->where(array('invoice_id'=>$invoiceId,'company_id'=>$this->company()))->where_in('status',array('draft','posted','partially_returned','returned'))->get('sales_deliveries')->row();$status=!$counts||!$counts->posted_count?'not_delivered':($counts->posted_count==$counts->total_count?'delivered':'partial');}$this->CI->db->where(array('id'=>$invoiceId,'company_id'=>$this->company()))->update('invoices',array('delivery_status'=>$status)); + } + + public function submitInvoice($id,$user) + { + return$this->CI->transactionservice->run(function()use($id,$user){$i=$this->lockInvoice($id,'draft');$this->assertDraftOwner($i,$user);$open=(int)$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company(),'status'=>'draft'))->count_all_results('sales_deliveries');if($open)throw new BusinessException('Seluruh Surat Jalan Draft harus diposting sebelum invoice diajukan.');$this->refreshInvoiceTotals($i->id);$this->updateInvoiceDeliveryStatus($i->id);$i=$this->lockInvoice($id,'draft');if((float)$i->total<=0)throw new BusinessException('Invoice belum mempunyai nilai.');if($i->delivery_status!=='delivered')throw new BusinessException('Pengiriman barang pada invoice belum selesai.');$this->CI->approvalservice->create('invoice','invoice',$i->id,$i->total,$user,array('invoice_no'=>$i->no_invoice,'customer_id'=>$i->customer_id));$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'submitted','finalized_at'=>date('Y-m-d H:i:s'),'finalized_by'=>$user,'submitted_at'=>date('Y-m-d H:i:s'),'submitted_by'=>$user));$this->CI->auditservice->record('sales','invoice',$i->id,'submit',$i,array('status'=>'submitted'),$user);return$i->id;}); + } + public function rejectInvoice($id,$reason,$user,$roleId) + { + return$this->CI->transactionservice->run(function()use($id,$reason,$user,$roleId){$i=$this->lockInvoice($id,'submitted');if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');$request=$this->CI->approvalservice->pendingForEntity('invoice',$i->id);if($request)$this->CI->approvalservice->act($request->id,'rejected',$user,$roleId,trim($reason));$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'draft','rejected_by'=>$user,'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>trim($reason),'finalized_at'=>null,'finalized_by'=>null));$this->CI->auditservice->record('sales','invoice',$i->id,'reject',$i,array('status'=>'draft','reason'=>$reason),$user);return$i->id;}); + } + public function approveAndPostInvoice($id,$user,$roleId) + { + return$this->CI->transactionservice->run(function()use($id,$user,$roleId){$i=$this->lockInvoice($id,'submitted');$request=$this->CI->approvalservice->pendingForEntity('invoice',$i->id);if(!$request)throw new BusinessException('Permintaan approval invoice tidak ditemukan.');$approval=$this->CI->approvalservice->act($request->id,'approved',$user,$roleId);if($approval->status!=='approved')throw new BusinessException('Tahap approval invoice belum lengkap.');$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'approved','approved_by'=>$user,'approved_at'=>date('Y-m-d H:i:s')));return$this->postInvoiceInternal($i->id,$user);}); + } + private function postInvoiceInternal($id,$user) + { + $i=$this->lockInvoice($id,'approved');$this->CI->fiscalperiodservice->assertOpen($i->tanggal);if($i->journal_id)throw new BusinessException('Invoice sudah pernah diposting.');if($i->delivery_status!=='delivered')throw new BusinessException('Invoice hanya dapat diposting setelah seluruh Surat Jalan diposting.');$lines=$this->CI->db->where(array('invoice_id'=>$i->id,'company_id'=>$this->company()))->get('invoice_details')->result();if(!$lines)throw new BusinessException('Invoice belum mempunyai item.'); + $family=$this->lineFamily($lines[0]->line_type);$entries=array();$cogs=0;$revenueAmount=0; + if($family==='financial'&&$lines[0]->line_type==='savings'){ + $this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>null,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment')); + $this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>null,'purpose'=>'Tabungan; jurnal dibentuk saat pembayaran'),$user);return$i->id; + } + if($family==='sales'){ + $initialJournals=array();$journalizedLines=0;foreach($lines as$l){if((float)$l->subtotal<=0&&(float)$l->total_hpp_barang<=0){$journalizedLines++;continue;}$lineJournal=$this->initialLineJournal($l->id);if($lineJournal&&!$lineJournal->is_reversed&&$lineJournal->status==='posted'){$initialJournals[]=$lineJournal;$journalizedLines++;}} + if($journalizedLines===count($lines)&&$initialJournals){ + foreach($lines as$l){$revenueAmount+=(float)$l->net_amount;$cogs+=(float)$l->total_hpp_barang;} + $journal=(int)$initialJournals[0]->id;$this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>0,'recognized_cogs'=>0,'recognition_policy'=>'on_payment')); + $this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>'Pendapatan diakui proporsional saat pembayaran','line_journal_count'=>count($initialJournals)),$user);return$i->id; + } + } + if($family==='financial'){ + $debits=array();$credits=array();foreach($lines as$l){$value=(float)$l->subtotal;if($l->line_type==='loan'){$debits[(int)$l->account_id]=($debits[(int)$l->account_id]??0)+$value;$credits[(int)$l->counter_account_id]=($credits[(int)$l->counter_account_id]??0)+$value;}else{$ar=$this->CI->accountmappingservice->get('accounts_receivable');$debits[$ar]=($debits[$ar]??0)+$value;$credits[(int)$l->account_id]=($credits[(int)$l->account_id]??0)+$value;}} + foreach($debits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>$v,'kredit'=>0);foreach($credits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>0,'kredit'=>$v); + }else{ + $receivable=$this->CI->accountmappingservice->get('accounts_receivable');$deferred=$this->CI->accountmappingservice->get('deferred_revenue');$entries[]=array('account_id'=>$receivable,'debit'=>$i->total,'kredit'=>0);$revenueCredits=array(); + foreach($lines as$l){$revenueCredits[(int)$l->account_id]=($revenueCredits[(int)$l->account_id]??0)+(float)$l->net_amount;$revenueAmount+=(float)$l->net_amount;if((float)$l->tax_amount>0){$tax=$this->CI->accountmappingservice->get('sales_tax_payable');$entries[]=array('account_id'=>$tax,'debit'=>0,'kredit'=>(float)$l->tax_amount);}$cogs+=(float)$l->total_hpp_barang;} + if($revenueAmount>0)$entries[]=array('account_id'=>$deferred,'debit'=>0,'kredit'=>$revenueAmount); + if($i->recognition_policy==='accrual'&&$revenueAmount>0){$entries[]=array('account_id'=>$deferred,'debit'=>$revenueAmount,'kredit'=>0);foreach($revenueCredits as$a=>$v)if($v>0)$entries[]=array('account_id'=>$a,'debit'=>0,'kredit'=>$v);} + if($i->recognition_policy==='accrual'&&$cogs>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$cogs,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$cogs);} + } + $customer=$this->CI->db->select('nama')->get_where('customers',array('id'=>$i->customer_id,'company_id'=>$this->company()))->row();$purpose=$family==='financial'?($lines[0]->line_type==='loan'?'Tagihan Pinjaman':'Tagihan Tabungan'):($i->recognition_policy==='accrual'?'Pendapatan diakui saat posting':'Pendapatan diakui proporsional setelah pembayaran');$journalDescription='[AUTO] Posting Invoice '.$i->no_invoice.' | Customer '.($customer?$customer->nama:'#'.$i->customer_id).' | Tujuan '.$purpose.' | Total Rp '.number_format($i->total,2,',','.').' | Referensi invoice #'.$i->id; + $journal=$this->CI->postingservice->post(array('tanggal'=>$i->tanggal,'no_ref'=>$this->CI->numberingservice->next('invoice_journal',$i->tanggal),'keterangan'=>$journalDescription,'ref_type'=>'invoice','ref_id'=>$i->id,'created_by'=>$user),$entries,false,true); + if($family==='financial'&&$lines[0]->line_type==='loan')foreach($credits as$accountId=>$value)$this->recordCashSubledger($accountId,$i->tanggal,'out',$value,'customer',$i->customer_id,'invoice_loan',$i->id,$journal,$journalDescription,$user); + if($family==='sales'&&$i->recognition_policy==='accrual'){$this->CI->db->insert('revenue_recognition_ledger',array('company_id'=>$this->company(),'invoice_id'=>$i->id,'recognition_date'=>$i->tanggal,'revenue_amount'=>$revenueAmount,'cogs_amount'=>$cogs,'journal_id'=>$journal,'idempotency_key'=>'INVOICE-ACCRUAL-'.$i->id,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));} + $this->CI->db->where('id',$i->id)->update('invoices',array('workflow_status'=>'posted','posted'=>1,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'journal_id'=>$journal,'status'=>'unpaid','sisa_piutang'=>$i->total,'recognized_revenue'=>$family==='sales'&&$i->recognition_policy==='accrual'?$revenueAmount:0,'recognized_cogs'=>$family==='sales'&&$i->recognition_policy==='accrual'?$cogs:0));if($family==='sales'){$this->CI->load->library('TaxService');$this->CI->taxservice->invoice($i->id,$journal);}$this->CI->auditservice->record('sales','invoice',$i->id,'post',$i,array('journal_id'=>$journal,'purpose'=>$purpose),$user);return$i->id; + } + public function recordPayment($customerId,$date,array$sources,array$allocations,$allowAdvance,$user,$reference='',$notes='',array$attachments=array()) + { + return $this->CI->transactionservice->run(function()use($customerId,$date,$sources,$allocations,$allowAdvance,$user,$reference,$notes,$attachments){ + $customer=$this->tenant('customers',$customerId,'Customer'); + $this->CI->fiscalperiodservice->assertOpen($date); + $sourceTotal=0;$entries=array();$sourceRows=array();$seenSources=array(); + foreach($sources as$n=>$source){ + $amount=$this->money($source['amount']??0);if($amount<=0)continue; + $account=$this->tenant('accounts',$source['account_id']??0,'Akun penerimaan/pelunasan ke-'.($n+1)); + if(isset($seenSources[$account->id]))throw new BusinessException('Akun penerimaan/pelunasan yang sama cukup dipilih satu kali.');$seenSources[$account->id]=true; + if(!in_array((string)$account->tipe,array('asset','liability'),true))throw new BusinessException('Sumber pelunasan harus menggunakan akun Aktiva atau Passiva.');if(isset($account->allow_posting)&&!(int)$account->allow_posting)throw new BusinessException('Akun '.$account->nama_akun.' tidak dapat diposting.');if(isset($account->is_hidden)&&(int)$account->is_hidden===1&&!(function_exists('is_master_admin_user')&&is_master_admin_user()))throw new BusinessException('Akun '.$account->nama_akun.' tidak dapat digunakan oleh pengguna ini.'); + $sourceTotal+=$amount;$sourceRows[]=array('account_id'=>$account->id,'amount'=>$amount);$entries[]=array('account_id'=>$account->id,'debit'=>$amount,'kredit'=>0); + } + if($sourceTotal<=0)throw new BusinessException('Tambahkan minimal satu akun penerimaan/pelunasan Aktiva atau Passiva.'); + $allocated=0;$locked=array(); + foreach($allocations as$invoiceId=>$raw){ + $amount=$this->money($raw);if($amount<=0)continue; + $invoice=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$invoiceId,$this->company()))->row(); + if(!$invoice||$invoice->workflow_status!=='posted'||(int)$invoice->customer_id!==(int)$customer->id)throw new BusinessException('Invoice alokasi tidak valid.'); + if($amount>(float)$invoice->sisa_piutang+.001)throw new BusinessException('Alokasi melebihi sisa invoice '.$invoice->no_invoice.'.'); + $allocated+=$amount;$locked[]=array($invoice,$amount); + } + if($allocated>$sourceTotal+.001)throw new BusinessException('Total alokasi invoice melebihi sumber pembayaran.'); + $advance=round($sourceTotal-$allocated,2);if($advance>.001&&!$allowAdvance)throw new BusinessException('Terdapat kelebihan pembayaran. Aktifkan pencatatan uang muka pelanggan.'); + $settlementCredits=array();foreach($locked as$row)foreach($this->paymentReceivableDistribution($row[0],$row[1])as$accountId=>$value)$settlementCredits[$accountId]=($settlementCredits[$accountId]??0)+$value;foreach($sourceRows as$s)if(isset($settlementCredits[$s['account_id']]))throw new BusinessException('Akun penerimaan/pelunasan tidak boleh sama dengan akun piutang yang sedang dilunasi.');foreach($settlementCredits as$accountId=>$value)if($value>0)$entries[]=array('account_id'=>$accountId,'debit'=>0,'kredit'=>$value); + if($advance>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$advance); + $paymentNo=$this->CI->numberingservice->next('payment',$date); + $invoiceNames=array_map(function($x){return$x[0]->no_invoice.' Rp '.number_format($x[1],2,',','.');},$locked); + $savingsPayment=!empty($locked);foreach($locked as$row){$firstLine=$this->CI->db->select('line_type')->where(array('invoice_id'=>$row[0]->id,'company_id'=>$this->company()))->order_by('id')->limit(1)->get('invoice_details')->row();if(!$firstLine||$firstLine->line_type!=='savings'){$savingsPayment=false;break;}} + $paymentDescription=$savingsPayment?"Tabungan '".$customer->nama."' - '".(trim((string)$notes)?:'-')."'":'[AUTO] Penerimaan '.$paymentNo.' | Customer '.$customer->nama.' | Alokasi '.implode(', ',$invoiceNames).' | Referensi '.($reference?:'-').' | Total Rp '.number_format($sourceTotal,2,',','.'); + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$paymentNo,'keterangan'=>$paymentDescription,'ref_type'=>'customer_payment','ref_id'=>null,'created_by'=>$user),$entries,false,false); + $payment=$this->columns('payments',array('company_id'=>$this->company(),'invoice_id'=>count($locked)===1?$locked[0][0]->id:null,'tanggal'=>$date,'jumlah'=>$sourceTotal,'journal_id'=>$journal,'metode'=>count($sourceRows)>1?'multi_account':'manual','payment_no'=>$paymentNo,'customer_id'=>$customer->id,'status'=>'posted','reference_no'=>$reference,'notes'=>$notes,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))); + $this->CI->db->insert('payments',$payment);$paymentId=(int)$this->CI->db->insert_id(); + foreach($sourceRows as$s){$this->CI->db->insert('payment_sources',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'account_id'=>$s['account_id'],'amount'=>$s['amount'],'created_at'=>date('Y-m-d H:i:s')));$this->recordCashSubledger($s['account_id'],$date,'in',$s['amount'],'customer',$customer->id,'customer_payment',$paymentId,$journal,'Penerimaan '.$paymentNo.' dari '.$customer->nama,$user);} + foreach($locked as$row){ + list($invoice,$amount)=$row;$this->CI->db->insert('payment_allocations',$this->columns('payment_allocations',array('company_id'=>$this->company(),'payment_id'=>$paymentId,'invoice_id'=>$invoice->id,'amount'=>$amount,'created_at'=>date('Y-m-d H:i:s')))); + $paid=round((float)$invoice->total_bayar+$amount,2);$balance=max(0,round((float)$invoice->sisa_piutang-$amount,2)); + $this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->update('invoices',array('total_bayar'=>$paid,'sisa_piutang'=>$balance,'status'=>$balance<=.001?'paid':'partial')); + if($this->invoiceFamily($invoice->id)==='sales')$this->recognizeOnPayment($invoice,$paymentId,$amount,$date,$user); + } + if($advance>0)$this->CI->db->insert('customer_advances',$this->columns('customer_advances',array('company_id'=>$this->company(),'advance_no'=>'ADV-'.$paymentNo,'customer_id'=>$customer->id,'payment_id'=>$paymentId,'original_amount'=>$advance,'available_amount'=>$advance,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s')))); + foreach($attachments as$a)$this->insertAttachment('customer_payment',$paymentId,$a,$user); + $this->CI->db->where('id',$journal)->update('journals',array('ref_id'=>$paymentId)); + $this->CI->auditservice->record('sales','payment',$paymentId,'post',null,array('payment_no'=>$paymentNo,'amount'=>$sourceTotal,'allocated'=>$allocated,'advance'=>$advance),$user); + return array('payment_id'=>$paymentId,'payment_no'=>$paymentNo,'advance'=>$advance); + }); + } + private function paymentReceivableDistribution($invoice,$amount) + { + $lines=$this->CI->db->select('line_type,account_id,subtotal')->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result();$weights=array(); + foreach($lines as$l){$account=in_array($l->line_type,array('loan','savings'),true)?(int)$l->account_id:$this->CI->accountmappingservice->get('accounts_receivable');$weights[$account]=($weights[$account]??0)+(float)$l->subtotal;} + $total=array_sum($weights);if($total<=0)throw new BusinessException('Komposisi akun piutang invoice tidak valid.');$result=array();$used=0;$accounts=array_keys($weights);$last=end($accounts); + foreach($weights as$account=>$weight){$part=(int)$account===(int)$last?round($amount-$used,2):round($amount*$weight/$total,2);$used+=$part;if($part>0)$result[(int)$account]=$part;} + return$result; + } + + private function recordCashSubledger($glAccountId,$date,$direction,$amount,$partyType,$partyId,$sourceType,$sourceId,$journalId,$description,$user) + { + if(!$this->CI->db->table_exists('cash_transactions'))return;$cash=$this->CI->db->get_where('cash_accounts',array('company_id'=>$this->company(),'gl_account_id'=>(int)$glAccountId,'is_active'=>1))->row();if(!$cash)return; + $key='C'.$this->company().'-'.strtoupper($sourceType).'-'.(int)$sourceId.'-'.(int)$cash->id.'-'.$direction;if($this->CI->db->where('idempotency_key',$key)->count_all_results('cash_transactions'))return; + $row=$this->columns('cash_transactions',array('company_id'=>$this->company(),'transaction_no'=>$this->CI->numberingservice->next('cash_transaction',$date),'transaction_date'=>$date,'cash_account_id'=>$cash->id,'transaction_type'=>$direction==='in'?'receipt':'payment','direction'=>$direction,'amount'=>$amount,'counter_account_id'=>null,'party_type'=>$partyType,'party_id'=>$partyId,'reference_no'=>null,'description'=>'[AUTO] '.$description,'status'=>'posted','requires_approval'=>0,'source_type'=>$sourceType,'source_id'=>$sourceId,'journal_id'=>$journalId,'idempotency_key'=>$key,'created_by'=>$user,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s'),'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('cash_transactions',$row); + } + + private function invoiceFamily($invoiceId) + { + $line=$this->CI->db->select('line_type')->where(array('invoice_id'=>(int)$invoiceId,'company_id'=>$this->company()))->order_by('id')->limit(1)->get('invoice_details')->row();return$this->lineFamily($line?$line->line_type:'inventory'); + } + + private function lineFamily($type){return in_array($type,array('loan','savings'),true)?'financial':'sales';} + + private function recognizeOnPayment($invoice,$paymentId,$allocated,$date,$user) + { + $netTotal=max(.01,(float)$invoice->subtotal_before_tax);$gross=max(.01,(float)$invoice->total); + $remainingRevenue=max(0,$netTotal-(float)$invoice->recognized_revenue);$revenue=min($remainingRevenue,round($allocated*($netTotal/$gross),2)); + $lines=$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('invoice_details')->result(); + $allCost=0;foreach($lines as$l)$allCost+=(float)$l->total_hpp_barang;$remainingCost=max(0,$allCost-(float)$invoice->recognized_cogs);$cost=min($remainingCost,round($allocated*($allCost/$gross),2)); + if($revenue<=0&&$cost<=0)return; + $entries=array();if($revenue>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$revenue,'kredit'=>0);$left=$revenue;foreach($lines as$idx=>$l){$part=$idx===count($lines)-1?$left:round($revenue*((float)$l->net_amount/$netTotal),2);$left-=$part;if($part>0)$entries[]=array('account_id'=>$l->account_id,'debit'=>0,'kredit'=>$part);}} + if($cost>0){$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>$cost,'kredit'=>0);$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$cost);} + $journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$this->CI->numberingservice->next('revenue_recognition',$date),'keterangan'=>'[AUTO] Pengakuan pendapatan setelah pembayaran | Invoice '.$invoice->no_invoice.' | Pembayaran #'.$paymentId.' | Pendapatan Rp '.number_format($revenue,2,',','.').' | HPP Rp '.number_format($cost,2,',','.'),'ref_type'=>'revenue_recognition','ref_id'=>$paymentId,'created_by'=>$user),$entries,false,false); + $this->CI->db->insert('revenue_recognition_ledger',array('company_id'=>$this->company(),'invoice_id'=>$invoice->id,'payment_id'=>$paymentId,'recognition_date'=>$date,'revenue_amount'=>$revenue,'cogs_amount'=>$cost,'journal_id'=>$journal,'idempotency_key'=>'PAYMENT-RECOGNITION-'.$paymentId.'-'.$invoice->id,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))); + $this->CI->db->where(array('id'=>$invoice->id,'company_id'=>$this->company()))->set('recognized_revenue','recognized_revenue+'.(float)$revenue,false)->set('recognized_cogs','recognized_cogs+'.(float)$cost,false)->update('invoices'); + } + public function invoiceDetail($id) + { + $invoice=$this->CI->db->select('i.*,c.nama customer_name,c.alamat customer_address,u.nama creator_name,ua.nama approver_name')->from('invoices i')->join('customers c','c.id=i.customer_id')->join('users u','u.id=i.created_by','left')->join('users ua','ua.id=i.approved_by','left')->where(array('i.id'=>(int)$id,'i.company_id'=>$this->company()))->get()->row();if(!$invoice)throw new BusinessException('Invoice tidak ditemukan.'); + $lines=$this->CI->db->select('d.*,it.kode_detail item_code,a.kode_akun,a.nama_akun')->from('invoice_details d')->join('items it','it.id=d.items_id','left')->join('accounts a','a.id=d.account_id','left')->where(array('d.invoice_id'=>$invoice->id,'d.company_id'=>$this->company()))->order_by('d.service_date,d.id')->get()->result(); + $barcodes=$this->CI->db->select('b.*,ib.barcode,ib.serial_number,sd.delivery_no,w.nama warehouse_name,it.kode_detail item_code,it.nama_barang,d.service_date')->from('invoice_line_barcodes b')->join('invoice_details d','d.id=b.invoice_detail_id')->join('item_barcodes ib','ib.id=b.barcode_id')->join('sales_deliveries sd','sd.id=b.sales_delivery_id','left')->join('warehouses w','w.id=ib.warehouse_id','left')->join('items it','it.id=ib.item_id','left')->where(array('b.invoice_id'=>$invoice->id,'b.company_id'=>$this->company()))->order_by('b.id')->get()->result(); + $payments=$this->CI->db->select('p.*,pa.amount allocated')->from('payment_allocations pa')->join('payments p','p.id=pa.payment_id')->where('pa.invoice_id',$invoice->id)->order_by('p.tanggal,p.id')->get()->result();foreach($payments as$p)$p->attachments=$this->attachments(array('customer_payment'),$p->id); + return array('invoice'=>$invoice,'lines'=>$lines,'barcodes'=>$barcodes,'deliveries'=>$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('sales_deliveries')->result(),'payments'=>$payments,'returns'=>$this->CI->db->where(array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->get('sales_returns')->result(),'attachments'=>$this->attachments(array('invoice'),$invoice->id)); + } + public function deliveryDetail($id) + { + $delivery=$this->CI->db->select('d.*,c.nama customer_name,c.alamat customer_address,i.no_invoice,w.nama warehouse_name,u.nama creator_name')->from('sales_deliveries d')->join('customers c','c.id=d.customer_id')->join('invoices i','i.id=d.invoice_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>$this->company()))->get()->row();if(!$delivery)throw new BusinessException('Surat Jalan tidak ditemukan.'); + return array('delivery'=>$delivery,'lines'=>$this->CI->db->where(array('sales_delivery_id'=>$delivery->id,'company_id'=>$this->company()))->order_by('line_no')->get('sales_delivery_lines')->result(),'barcodes'=>$this->CI->db->select('b.*,ib.barcode,ib.serial_number')->from('sales_delivery_barcodes b')->join('item_barcodes ib','ib.id=b.barcode_id')->where(array('b.sales_delivery_id'=>$delivery->id,'b.company_id'=>$this->company()))->order_by('b.id')->get()->result(),'attachments'=>$this->attachments(array('sales_delivery'),$delivery->id)); + } + public function createReturn(array$header,array$rows,$user,array$attachments=array()) + { + return$this->CI->transactionservice->run(function()use($header,$rows,$user,$attachments){ + $delivery=$this->CI->db->query('SELECT * FROM sales_deliveries WHERE id=? AND company_id=? FOR UPDATE',array((int)($header['sales_delivery_id']??0),$this->company()))->row();if(!$delivery||!in_array($delivery->status,array('posted','partially_returned'),true))throw new BusinessException('Surat Jalan tidak valid untuk retur.'); + if(trim((string)($header['reason']??''))==='')throw new BusinessException('Alasan retur wajib diisi.');if(!$rows)throw new BusinessException('Pilih minimal satu barcode yang diretur.'); + $resolution=in_array($header['resolution']??'',array('restock','quarantine','damaged','replacement','credit_note','refund'),true)?$header['resolution']:'credit_note';$date=$header['return_date']??date('Y-m-d'); + $this->CI->db->insert('sales_returns',array('company_id'=>$this->company(),'return_no'=>$this->CI->numberingservice->next('sales_return',$date),'customer_id'=>$delivery->customer_id,'invoice_id'=>$delivery->invoice_id,'sales_delivery_id'=>$delivery->id,'return_date'=>$date,'reason'=>trim($header['reason']),'problem_category'=>trim((string)($header['problem_category']??'')),'resolution'=>$resolution,'status'=>'draft','notes'=>trim((string)($header['notes']??'')),'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s')));$returnId=(int)$this->CI->db->insert_id(); + foreach($rows as$row){$deliveryBarcode=$this->CI->db->query('SELECT b.*,l.invoice_detail_id,l.unit_cost FROM sales_delivery_barcodes b JOIN sales_delivery_lines l ON l.id=b.sales_delivery_line_id WHERE b.id=? AND b.sales_delivery_id=? AND b.company_id=? FOR UPDATE',array((int)($row['sales_delivery_barcode_id']??0),$delivery->id,$this->company()))->row();$qty=$this->qty($row['qty']??0);if(!$deliveryBarcode||$qty>(float)$deliveryBarcode->qty-(float)$deliveryBarcode->returned_qty+.0001)throw new BusinessException('Qty retur melebihi barang yang pernah dikirim.');$detail=$this->CI->db->get_where('invoice_details',array('id'=>$deliveryBarcode->invoice_detail_id,'company_id'=>$this->company()))->row();$ratio=$qty/max(.0001,(float)$detail->qty);$condition=in_array($row['physical_condition']??'',array('saleable','quarantine','damaged','lost'),true)?$row['physical_condition']:'saleable';$this->CI->db->insert('sales_return_lines',array('company_id'=>$this->company(),'sales_return_id'=>$returnId,'sales_delivery_line_id'=>$deliveryBarcode->sales_delivery_line_id,'invoice_detail_id'=>$detail->id,'item_id'=>$deliveryBarcode->item_id,'qty'=>$qty,'unit_price'=>$detail->harga,'net_amount'=>round($detail->net_amount*$ratio,2),'tax_amount'=>round($detail->tax_amount*$ratio,2),'unit_cost'=>$deliveryBarcode->unit_cost,'physical_condition'=>$condition,'notes'=>trim((string)($row['notes']??'')),'created_at'=>date('Y-m-d H:i:s')));$lineId=(int)$this->CI->db->insert_id();$this->CI->db->insert('sales_return_barcodes',array('company_id'=>$this->company(),'sales_return_id'=>$returnId,'sales_return_line_id'=>$lineId,'sales_delivery_barcode_id'=>$deliveryBarcode->id,'barcode_id'=>$deliveryBarcode->barcode_id,'qty'=>$qty,'status'=>'requested','created_at'=>date('Y-m-d H:i:s')));} + foreach($attachments as$a)$this->insertAttachment('sales_return',$returnId,$a,$user);$this->returnHistory($returnId,null,'draft','Retur dibuat',$user);return$returnId; + }); + } + public function submitReturn($id,$user){return$this->returnState($id,'draft','submitted',$user,'Retur diajukan.');} + public function approveReturn($id,$user){return$this->returnState($id,'submitted','approved',$user,'Retur disetujui.');} + public function rejectReturn($id,$reason,$user){if(trim($reason)==='')throw new BusinessException('Alasan penolakan wajib diisi.');return$this->returnState($id,'submitted','rejected',$user,$reason);} + public function receiveReturn($id,$user){return$this->returnState($id,'approved','received',$user,'Barang retur diterima dan diperiksa.');} + private function returnState($id,$from,$to,$user,$notes){return$this->CI->transactionservice->run(function()use($id,$from,$to,$user,$notes){$r=$this->lockReturn($id,$from);$data=array('status'=>$to);$data[$to.'_by']=$user;$data[$to.'_at']=date('Y-m-d H:i:s');if($to==='rejected')$data['rejection_reason']=$notes;$this->CI->db->where('id',$r->id)->update('sales_returns',$data);$this->returnHistory($r->id,$from,$to,$notes,$user);return$r->id;});} + public function postReturn($id,$user) + { + return$this->CI->transactionservice->run(function()use($id,$user){$r=$this->lockReturn($id,'received');$this->CI->fiscalperiodservice->assertOpen($r->return_date);$lines=$this->CI->db->where(array('sales_return_id'=>$r->id,'company_id'=>$this->company()))->get('sales_return_lines')->result();$net=0;$tax=0;$cost=0; + foreach($lines as$l){$bars=$this->CI->db->where('sales_return_line_id',$l->id)->get('sales_return_barcodes')->result();foreach($bars as$b){$source=$this->CI->db->query('SELECT * FROM sales_delivery_barcodes WHERE id=? FOR UPDATE',array($b->sales_delivery_barcode_id))->row();if(!$source||(float)$source->returned_qty+(float)$b->qty>(float)$source->qty+.0001)throw new BusinessException('Barcode retur sudah diproses atau qty tidak valid.');$barcode=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array($b->barcode_id))->row();$status=$l->physical_condition==='saleable'?'available':($l->physical_condition==='damaged'?'damaged':'quarantine');$this->CI->db->where('id',$barcode->id)->update('item_barcodes',array('qty_sisa'=>(float)$barcode->qty_sisa+(float)$b->qty,'status'=>$status,'version'=>(int)$barcode->version+1));$this->CI->db->where('id',$source->id)->update('sales_delivery_barcodes',array('returned_qty'=>(float)$source->returned_qty+(float)$b->qty,'status'=>(float)$source->returned_qty+(float)$b->qty>=(float)$source->qty?'returned':'partially_returned'));$this->CI->db->where('id',$b->id)->update('sales_return_barcodes',array('status'=>'posted'));} + $value=round((float)$l->unit_cost*(float)$l->qty,2);$key='SALES-RETURN-LINE-'.$l->id;$warehouse=$this->CI->db->select('warehouse_id')->get_where('sales_deliveries',array('id'=>$r->sales_delivery_id))->row()->warehouse_id;$ledger=$this->addCompany('inventory_ledger',array('item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'movement_date'=>$r->return_date,'direction'=>'in','qty'=>$l->qty,'unit_cost'=>$l->unit_cost,'value'=>$value,'document_type'=>'sales_return','document_id'=>$r->id,'document_line_id'=>$l->id,'idempotency_key'=>$key,'created_at'=>date('Y-m-d H:i:s')));$this->CI->db->insert('inventory_ledger',$ledger);if($l->physical_condition==='saleable')$this->CI->db->insert('inventory_cost_layers',$this->columns('inventory_cost_layers',array('company_id'=>$this->company(),'item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'source_ledger_id'=>$this->CI->db->insert_id(),'received_date'=>$r->return_date,'original_qty'=>$l->qty,'remaining_qty'=>$l->qty,'unit_cost'=>$l->unit_cost,'status'=>'open')));$this->CI->db->insert('stock_logs',$this->addCompany('stock_logs',array('item_id'=>$l->item_id,'warehouse_id'=>$warehouse,'qty'=>$l->qty,'tipe'=>'masuk','unit_cost'=>$l->unit_cost,'keterangan'=>'[AUTO] Retur Penjualan '.$r->return_no.' | Kondisi '.$l->physical_condition,'ref_type'=>'sales_return','ref_id'=>$l->id,'idempotency_key'=>$key)));$this->CI->db->insert('item_movements',$this->addCompany('item_movements',array('item_id'=>$l->item_id,'qty'=>$l->qty,'from_type'=>'customer','from_id'=>$r->customer_id,'to_type'=>$l->physical_condition==='saleable'?'warehouse':'quarantine','to_id'=>$warehouse,'movement_type'=>'customer_return','reference_type'=>'sales_return','reference_id'=>$l->id,'notes'=>'[AUTO] Retur '.$r->return_no,'created_at'=>date('Y-m-d H:i:s'))));$this->CI->salesInventoryModel->synchronizeStock($l->item_id);$net+=(float)$l->net_amount;$tax+=(float)$l->tax_amount;$cost+=$value;$this->CI->db->where('id',$l->id)->update('sales_return_lines',array('posted_at'=>date('Y-m-d H:i:s')));} + $invoice=$this->lockInvoice($r->invoice_id);$credit=min((float)$invoice->sisa_piutang,$net+$tax);$excess=max(0,$net+$tax-$credit);$entries=array();$revenueRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_revenue/max(.01,(float)$invoice->subtotal_before_tax));$recognizedNet=round($net*$revenueRatio,2);$deferredNet=$net-$recognizedNet;if($recognizedNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('goods_revenue'),'debit'=>$recognizedNet,'kredit'=>0);if($deferredNet>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('deferred_revenue'),'debit'=>$deferredNet,'kredit'=>0);if($tax>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('sales_tax_payable'),'debit'=>$tax,'kredit'=>0);if($credit>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('accounts_receivable'),'debit'=>0,'kredit'=>$credit);if($excess>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('customer_advances'),'debit'=>0,'kredit'=>$excess);if($cost>0){$allInvoiceCost=(float)$this->CI->db->select('COALESCE(SUM(total_hpp_barang),0) v',false)->get_where('invoice_details',array('invoice_id'=>$invoice->id,'company_id'=>$this->company()))->row()->v;$costRatio=$invoice->recognition_policy==='accrual'?1:min(1,(float)$invoice->recognized_cogs/max(.01,$allInvoiceCost));$recognizedCost=round($cost*$costRatio,2);$deferredCost=$cost-$recognizedCost;$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory'),'debit'=>$cost,'kredit'=>0);if($recognizedCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('cost_of_goods_sold'),'debit'=>0,'kredit'=>$recognizedCost);if($deferredCost>0)$entries[]=array('account_id'=>$this->CI->accountmappingservice->get('inventory_in_sales'),'debit'=>0,'kredit'=>$deferredCost);}$journal=$this->CI->postingservice->post(array('tanggal'=>$r->return_date,'no_ref'=>$r->return_no,'keterangan'=>'[AUTO] Retur Penjualan '.$r->return_no.' | Invoice '.$invoice->no_invoice.' | Customer #'.$r->customer_id.' | Nilai Rp '.number_format($net+$tax,2,',','.'),'ref_type'=>'sales_return','ref_id'=>$r->id,'created_by'=>$user),$entries,false,true); + $balance=max(0,(float)$invoice->sisa_piutang-$credit);$this->CI->db->where('id',$invoice->id)->update('invoices',array('sisa_piutang'=>$balance,'status'=>$balance<=.001?'paid':'partial'));$this->CI->db->insert('credit_notes',$this->columns('credit_notes',array('company_id'=>$this->company(),'credit_note_no'=>'CN-'.$r->return_no,'invoice_id'=>$invoice->id,'customer_id'=>$r->customer_id,'credit_date'=>$r->return_date,'amount'=>$net+$tax,'reason'=>$r->reason,'status'=>'posted','journal_id'=>$journal,'created_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))));$creditId=(int)$this->CI->db->insert_id();if($excess>0)$this->CI->db->insert('customer_advances',$this->columns('customer_advances',array('company_id'=>$this->company(),'advance_no'=>'ADV-'.$r->return_no,'customer_id'=>$r->customer_id,'payment_id'=>null,'original_amount'=>$excess,'available_amount'=>$excess,'status'=>'available','journal_id'=>$journal,'created_at'=>date('Y-m-d H:i:s'))));$this->CI->db->where('id',$r->id)->update('sales_returns',array('status'=>'completed','credit_note_id'=>$creditId,'journal_id'=>$journal,'posted_by'=>$user,'posted_at'=>date('Y-m-d H:i:s')));$this->returnHistory($r->id,'received','completed','Stok dan credit note diposting.',$user);return$r->id;}); + } + public function returnDetail($id){$r=$this->CI->db->select('r.*,c.nama customer_name,i.no_invoice,d.delivery_no')->from('sales_returns r')->join('customers c','c.id=r.customer_id')->join('invoices i','i.id=r.invoice_id','left')->join('sales_deliveries d','d.id=r.sales_delivery_id','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$this->company()))->get()->row();if(!$r)throw new BusinessException('Retur tidak ditemukan.');return array('return'=>$r,'lines'=>$this->CI->db->select('l.*,it.kode_detail AS kode_barang,it.nama_barang')->from('sales_return_lines l')->join('items it','it.id=l.item_id')->where('l.sales_return_id',$r->id)->get()->result(),'barcodes'=>$this->CI->db->select('b.*,ib.barcode,ib.serial_number')->from('sales_return_barcodes b')->join('item_barcodes ib','ib.id=b.barcode_id')->where('b.sales_return_id',$r->id)->get()->result(),'history'=>$this->CI->db->select('h.*,u.nama user_name')->from('sales_return_history h')->join('users u','u.id=h.user_id','left')->where('h.sales_return_id',$r->id)->order_by('h.id')->get()->result(),'attachments'=>$this->attachments(array('sales_return'),$r->id));} + private function lockReturn($id,$status=null){$r=$this->CI->db->query('SELECT * FROM sales_returns WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$r)throw new BusinessException('Retur tidak ditemukan.');if($status!==null&&$r->status!==$status)throw new BusinessException('Retur harus berstatus '.$status.'.');return$r;} + private function returnHistory($id,$from,$to,$notes,$user){$this->CI->db->insert('sales_return_history',array('company_id'=>$this->company(),'sales_return_id'=>$id,'from_status'=>$from,'to_status'=>$to,'notes'=>$notes,'user_id'=>$user,'created_at'=>date('Y-m-d H:i:s')));} + private function attachments(array$types,$entityId){return$this->CI->db->where('company_id',$this->company())->where_in('entity_type',$types)->where('entity_id',(int)$entityId)->order_by('id')->get('transaction_attachments')->result();} + private function insertAttachment($type,$id,array$a,$user){$this->CI->db->insert('transaction_attachments',$this->columns('transaction_attachments',array('company_id'=>$this->company(),'module'=>'sales','entity_type'=>$type,'entity_id'=>$id,'original_name'=>$a['original_name'],'stored_name'=>$a['stored_name'],'mime_type'=>$a['mime_type'],'file_size'=>$a['file_size'],'sha256'=>$a['sha256'],'uploaded_by'=>$user,'created_at'=>date('Y-m-d H:i:s'))));} + private function columns($table,array$data){$fields=array_flip($this->CI->db->list_fields($table));return array_intersect_key($data,$fields);} + private function assertDraftOwner($invoice,$user){if(function_exists('is_master_admin_user')&&is_master_admin_user())return;if((int)$invoice->created_by!==(int)$user)throw new BusinessException('Draft invoice hanya dapat dikelola dan diajukan oleh pembuatnya.');} + private function lockInvoice($id,$status=null){$i=$this->CI->db->query('SELECT * FROM invoices WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$i)throw new BusinessException('Invoice tidak ditemukan.');if($status!==null&&$i->workflow_status!==$status)throw new BusinessException('Invoice harus berstatus '.$status.'.');return$i;} +} diff --git a/application/libraries/TaxService.php b/application/libraries/TaxService.php new file mode 100644 index 0000000..dc3d0b3 --- /dev/null +++ b/application/libraries/TaxService.php @@ -0,0 +1,9 @@ +CI=&get_instance();} + private function code($type,$date){return$this->CI->db->where('tax_type',$type)->where('is_active',1)->where('effective_from <=',$date)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$date)->group_end()->order_by('effective_from','DESC')->get('tax_codes')->row();} + public function invoice($id,$journal){$i=$this->CI->db->get_where('invoices',array('id'=>$id))->row();$tax=$this->code('sales',$i->tanggal);if(!$tax)return;foreach($this->CI->db->get_where('invoice_details',array('invoice_id'=>$id))->result()as$l)if((float)$l->tax_amount>0&&!$this->CI->db->get_where('tax_transactions',array('idempotency_key'=>'INV-TAX-'.$l->id))->row())$this->CI->db->insert('tax_transactions',array('tax_date'=>$i->tanggal,'tax_code_id'=>$tax->id,'direction'=>'output','source_type'=>'invoice','source_id'=>$id,'source_line_id'=>$l->id,'counterparty_type'=>'customer','counterparty_id'=>$i->customer_id,'tax_base'=>(float)$l->subtotal-(float)$l->tax_amount,'tax_amount'=>$l->tax_amount,'status'=>'posted','journal_id'=>$journal,'idempotency_key'=>'INV-TAX-'.$l->id,'created_at'=>date('Y-m-d H:i:s')));} + public function supplierInvoice($id,$journal){$i=$this->CI->db->get_where('supplier_invoices',array('id'=>$id))->row();$tax=$this->code('purchase',$i->invoice_date);if(!$tax)$tax=$this->code('sales',$i->invoice_date);if(!$tax)return;foreach($this->CI->db->get_where('supplier_invoice_lines',array('supplier_invoice_id'=>$id))->result()as$l)if((float)$l->tax_amount>0&&!$this->CI->db->get_where('tax_transactions',array('idempotency_key'=>'BILL-TAX-'.$l->id))->row())$this->CI->db->insert('tax_transactions',array('tax_date'=>$i->invoice_date,'tax_code_id'=>$tax->id,'direction'=>'input','source_type'=>'supplier_invoice','source_id'=>$id,'source_line_id'=>$l->id,'counterparty_type'=>'supplier','counterparty_id'=>$i->supplier_id,'tax_invoice_no'=>$i->supplier_invoice_no,'tax_base'=>(float)$l->line_total-(float)$l->tax_amount,'tax_amount'=>$l->tax_amount,'status'=>'posted','journal_id'=>$journal,'idempotency_key'=>'BILL-TAX-'.$l->id,'created_at'=>date('Y-m-d H:i:s')));} +} diff --git a/application/libraries/TechnicianEquipmentService.php b/application/libraries/TechnicianEquipmentService.php new file mode 100644 index 0000000..3ecf8f5 --- /dev/null +++ b/application/libraries/TechnicianEquipmentService.php @@ -0,0 +1,250 @@ +CI=&get_instance();$this->CI->load->library(array('CompanyContext','NumberingService')); } + private function company(){return(int)$this->CI->companycontext->id();} + private function now(){return date('Y-m-d H:i:s');} + private function qty($value){$qty=round((float)$value,4);if($qty<=0)throw new BusinessException('Jumlah wajib lebih dari 0.');return$qty;} + private function date($value){$date=trim((string)$value);$d=DateTime::createFromFormat('Y-m-d',$date);if(!$d||$d->format('Y-m-d')!==$date)throw new BusinessException('Tanggal transaksi tidak valid.');return$date;} + private function key(array$row){return hash('sha256',implode('|',array($this->company(),$row['subject_type'],$row['subject_id'],$row['custodian_type'],(int)($row['custodian_id']??0),(int)($row['customer_id']??0),$row['usage_status'],$row['condition_status'])));} + private function user($id){$r=$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company(),'is_active'=>1))->get('k_employees')->row();if(!$r)throw new BusinessException('Karyawan/teknisi tidak ditemukan pada perusahaan aktif.');return$r;} + private function customer($id){$r=$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->get('customers')->row();if(!$r)throw new BusinessException('Customer tidak ditemukan pada perusahaan aktif.');return$r;} + private function warehouse($id){$r=$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company(),'is_active'=>1))->get('warehouses')->row();if(!$r)throw new BusinessException('Gudang tidak ditemukan pada perusahaan aktif.');return$r;} + + public function dashboard() + { + $company=$this->company();$summary=array('carried'=>0,'installed'=>0,'inspection'=>0,'repair'=>0,'damaged'=>0,'lost'=>0); + $rows=$this->CI->db->select("SUM(CASE WHEN custodian_type='technician' THEN qty ELSE 0 END) carried,SUM(CASE WHEN usage_status='installed' THEN qty ELSE 0 END) installed,SUM(CASE WHEN condition_status='inspection_required' THEN qty ELSE 0 END) inspection,SUM(CASE WHEN condition_status IN('repair_required','under_repair') THEN qty ELSE 0 END) repair,SUM(CASE WHEN condition_status='damaged' THEN qty ELSE 0 END) damaged,SUM(CASE WHEN condition_status='lost' THEN qty ELSE 0 END) lost",false)->where('company_id',$company)->get('technician_custody_allocations')->row(); + if($rows)foreach($summary as$key=>$value)$summary[$key]=(float)($rows->$key??0);$inspectionStats=$this->inspectionQueueStats();$summary['inspection']=(float)$inspectionStats['qty']; + $technicians=$this->CI->db->select("e.id,e.employee_code,e.full_name,d.department_name,COALESCE(SUM(CASE WHEN a.custodian_type='technician' THEN a.qty ELSE 0 END),0) carried_qty,COALESCE(SUM(CASE WHEN a.usage_status='installed' THEN a.qty ELSE 0 END),0) installed_qty,COUNT(DISTINCT a.subject_type,a.subject_id) subject_count",false)->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('technician_custody_allocations a',"a.custodian_type='technician' AND a.custodian_id=e.id AND a.company_id=e.company_id",'left')->where(array('e.company_id'=>$company,'e.is_active'=>1))->group_start()->where_in('LOWER(d.department_name)',array('teknisi','it','admin','gudang'))->or_where('a.id IS NOT NULL',null,false)->group_end()->group_by('e.id')->order_by('e.full_name')->get()->result(); + return array('summary'=>$summary,'technicians'=>$technicians,'inspection_count'=>(int)$inspectionStats['row_count']); + } + + public function search($query,$mode='all',$technicianId=0,$limit=20) + { + $query=trim((string)$query);if($query==='')return array();$mode=in_array($mode,array('all','handover','transfer','return','customer'),true)?$mode:'all';$limit=max(1,min(30,(int)$limit));$like='%'.$this->CI->db->escape_like_str($query).'%';$company=$this->company();$rows=array(); + if(in_array($mode,array('all','handover'),true)){ + $sql="SELECT b.id subject_id,NULL allocation_id,'company_barcode' subject_type,b.barcode lookup_code,b.barcode,b.serial_number,i.kode_detail,i.nama_barang,COALESCE(k.tracking_type,'QTY') tracking_type,(b.qty_sisa-COALESCE(b.reserved_qty,0)-COALESCE(b.return_reserved_qty,0)) available_qty,'company' ownership_type,b.warehouse_id,w.nama warehouse_name,NULL technician_id,NULL technician_name,NULL customer_name FROM item_barcodes b JOIN items i ON i.id=b.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id LEFT JOIN warehouses w ON w.id=b.warehouse_id WHERE i.company_id=? AND (b.barcode LIKE ? OR b.serial_number LIKE ? OR i.kode_detail LIKE ? OR i.nama_barang LIKE ?)"; + $params=array($company,$like,$like,$like,$like);if($mode==='handover')$sql.=" AND b.status='available' AND b.qty_sisa-COALESCE(b.reserved_qty,0)-COALESCE(b.return_reserved_qty,0)>.0001";if($mode==='all'&&(int)$technicianId>0){$employee=(int)$technicianId;$sql.=" AND (EXISTS(SELECT 1 FROM technician_custody_allocations sa WHERE sa.company_id=i.company_id AND sa.subject_type='company_barcode' AND sa.subject_id=b.id AND sa.custodian_type='technician' AND sa.custodian_id={$employee}) OR EXISTS(SELECT 1 FROM technician_equipment_document_lines sl JOIN technician_equipment_documents sd ON sd.id=sl.document_id WHERE sl.company_id=i.company_id AND sl.subject_type='company_barcode' AND sl.subject_id=b.id AND (sd.technician_id={$employee} OR sd.target_technician_id={$employee})))";}$sql.=" ORDER BY CASE WHEN b.barcode=? OR b.serial_number=? THEN 0 ELSE 1 END,i.nama_barang,b.id LIMIT ".$limit;$params[]=$query;$params[]=$query;$rows=$this->CI->db->query($sql,$params)->result_array(); + } + if(in_array($mode,array('transfer','return'),true)){ + $sql="SELECT a.subject_id,a.id allocation_id,a.subject_type,a.barcode_snapshot lookup_code,a.barcode_snapshot barcode,COALESCE(b.serial_number,r.serial_number) serial_number,i.kode_detail,CASE WHEN a.subject_type='company_barcode' THEN i.nama_barang ELSE r.equipment_name END nama_barang,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,a.qty available_qty,a.ownership_type,NULL warehouse_id,NULL warehouse_name,a.custodian_id technician_id,e.full_name technician_name,c.nama customer_name FROM technician_custody_allocations a LEFT JOIN items i ON i.id=a.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id LEFT JOIN item_barcodes b ON b.id=a.barcode_id LEFT JOIN customer_equipment_registry r ON r.id=a.customer_equipment_id LEFT JOIN k_employees e ON e.id=a.custodian_id LEFT JOIN customers c ON c.id=a.customer_id WHERE a.company_id=? AND a.custodian_type='technician' AND a.qty>.0001";$params=array($company);if((int)$technicianId>0){$sql.=' AND a.custodian_id=?';$params[]=(int)$technicianId;}$sql.=" AND (a.barcode_snapshot LIKE ? OR b.serial_number LIKE ? OR r.serial_number LIKE ? OR i.kode_detail LIKE ? OR i.nama_barang LIKE ? OR r.equipment_name LIKE ?) ORDER BY CASE WHEN a.barcode_snapshot=? OR b.serial_number=? OR r.serial_number=? THEN 0 ELSE 1 END,nama_barang,a.id LIMIT ".$limit;array_push($params,$like,$like,$like,$like,$like,$like,$query,$query,$query);$rows=$this->CI->db->query($sql,$params)->result_array(); + } + if(in_array($mode,array('all','customer'),true)){ + $left=max(1,$limit-count($rows));$sql="SELECT r.id subject_id,NULL allocation_id,'customer_equipment' subject_type,r.external_barcode lookup_code,r.external_barcode barcode,r.serial_number,NULL kode_detail,r.equipment_name nama_barang,r.tracking_type,r.total_qty available_qty,r.ownership_type,NULL warehouse_id,NULL warehouse_name,NULL technician_id,NULL technician_name,c.nama customer_name FROM customer_equipment_registry r LEFT JOIN customers c ON c.id=r.owner_customer_id WHERE r.company_id=? AND (r.external_barcode LIKE ? OR r.serial_number LIKE ? OR r.equipment_name LIKE ? OR r.brand LIKE ? OR r.model LIKE ?)";if((int)$technicianId>0){$employee=(int)$technicianId;$sql.=" AND (EXISTS(SELECT 1 FROM technician_custody_allocations sa WHERE sa.company_id=r.company_id AND sa.subject_type='customer_equipment' AND sa.subject_id=r.id AND sa.custodian_type='technician' AND sa.custodian_id={$employee}) OR EXISTS(SELECT 1 FROM technician_equipment_document_lines sl JOIN technician_equipment_documents sd ON sd.id=sl.document_id WHERE sl.company_id=r.company_id AND sl.subject_type='customer_equipment' AND sl.subject_id=r.id AND (sd.technician_id={$employee} OR sd.target_technician_id={$employee})))";}$sql.=" ORDER BY CASE WHEN r.external_barcode=? OR r.serial_number=? THEN 0 ELSE 1 END,r.equipment_name,r.id LIMIT ".$left;$external=$this->CI->db->query($sql,array($company,$like,$like,$like,$like,$like,$query,$query))->result_array();$rows=array_merge($rows,$external); + } + foreach($rows as&$row){$row['tracking_type']=strtoupper((string)$row['tracking_type'])==='UNIT'?'UNIT':'QTY';$row['display_name']=trim((string)($row['kode_detail']??'')).(($row['kode_detail']??'')!==null&&($row['kode_detail']??'')!==''?' - ':'').($row['nama_barang']??'-');$row['available_qty']=(float)$row['available_qty'];}unset($row);usort($rows,function($a,$b)use($query){$aExact=strcasecmp((string)$a['barcode'],$query)===0||strcasecmp((string)($a['serial_number']??''),$query)===0;$bExact=strcasecmp((string)$b['barcode'],$query)===0||strcasecmp((string)($b['serial_number']??''),$query)===0;return$aExact===$bExact?strcasecmp((string)$a['display_name'],(string)$b['display_name']):($aExact?-1:1);});return array_slice($rows,0,$limit); + } + + public function technicianHoldings($technicianId) + { + $technician=$this->user($technicianId); + $rows=$this->CI->db->query("SELECT a.subject_id,a.id allocation_id,a.subject_type,a.barcode_snapshot lookup_code,a.barcode_snapshot barcode,COALESCE(b.serial_number,r.serial_number) serial_number,i.kode_detail,CASE WHEN a.subject_type='company_barcode' THEN i.nama_barang ELSE r.equipment_name END nama_barang,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,a.qty available_qty,a.ownership_type,a.usage_status,a.condition_status,a.customer_id,c.nama customer_name,a.custodian_id technician_id,e.full_name technician_name FROM technician_custody_allocations a LEFT JOIN items i ON i.id=a.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id LEFT JOIN item_barcodes b ON b.id=a.barcode_id LEFT JOIN customer_equipment_registry r ON r.id=a.customer_equipment_id LEFT JOIN k_employees e ON e.id=a.custodian_id LEFT JOIN customers c ON c.id=a.customer_id WHERE a.company_id=? AND a.custodian_type='technician' AND a.custodian_id=? AND a.qty>.0001 ORDER BY CASE a.usage_status WHEN 'carried' THEN 0 WHEN 'removed' THEN 1 ELSE 2 END,nama_barang,a.barcode_snapshot,a.id",array($this->company(),(int)$technician->id))->result_array(); + foreach($rows as&$row){$row['tracking_type']=strtoupper((string)$row['tracking_type'])==='UNIT'?'UNIT':'QTY';$row['display_name']=trim((string)($row['kode_detail']??'')).(($row['kode_detail']??'')!==null&&($row['kode_detail']??'')!==''?' - ':'').($row['nama_barang']??'-');$row['available_qty']=(float)$row['available_qty'];}unset($row); + return$rows; + } + + public function scan($code) + { + $code=trim((string)$code);if($code==='')throw new BusinessException('Barcode atau serial wajib diisi.');$company=$this->company(); + $internal=$this->CI->db->select("b.id subject_id,b.id barcode_id,b.item_id,b.barcode,b.serial_number,b.warehouse_id,b.qty_sisa,b.reserved_qty,b.return_reserved_qty,b.status barcode_status,b.version,i.kode_detail,i.nama_barang,k.tracking_type,k.unit,w.nama warehouse_name,'company_barcode' subject_type,'company' ownership_type",false)->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')->where('i.company_id',$company)->group_start()->where('b.barcode',$code)->or_where('b.serial_number',$code)->group_end()->limit(1)->get()->row_array(); + if($internal){$internal['tracking_type']=strtoupper((string)$internal['tracking_type'])==='UNIT'?'UNIT':'QTY';$internal['warehouse_available']=max(0,(float)$internal['qty_sisa']-(float)$internal['reserved_qty']-(float)$internal['return_reserved_qty']);$internal['display_name']=$internal['kode_detail'].' - '.$internal['nama_barang'];$internal['allocations']=$this->allocations('company_barcode',(int)$internal['subject_id']);$internal['actions']=$this->actions($internal);return$internal;} + $external=$this->CI->db->select("r.*,r.id subject_id,r.external_barcode barcode,'customer_equipment' subject_type")->from('customer_equipment_registry r')->where('r.company_id',$company)->group_start()->where('r.external_barcode',$code)->or_where('r.serial_number',$code)->group_end()->limit(1)->get()->row_array(); + if(!$external)throw new BusinessException('Barcode atau serial tidak ditemukan.');$external['display_name']=$external['equipment_name'].($external['brand']?' - '.$external['brand']:'');$external['warehouse_available']=0;$external['allocations']=$this->allocations('customer_equipment',(int)$external['subject_id']);$external['actions']=$this->actions($external);return$external; + } + + public function allocations($subjectType=null,$subjectId=null,$technicianId=null) + { + $this->CI->db->select("a.*,i.kode_detail,i.nama_barang,b.serial_number,r.equipment_name,r.brand,r.model,r.serial_number external_serial,c.nama customer_name,e.full_name technician_name,w.nama warehouse_name,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,CASE WHEN a.subject_type='company_barcode' THEN CONCAT(COALESCE(i.kode_detail,'-'),' - ',COALESCE(i.nama_barang,'-')) ELSE CONCAT(COALESCE(r.equipment_name,'-'),IF(r.brand IS NULL OR r.brand='','',CONCAT(' - ',r.brand))) END display_name",false)->from('technician_custody_allocations a')->join('items i','i.id=a.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('item_barcodes b','b.id=a.barcode_id','left')->join('customer_equipment_registry r','r.id=a.customer_equipment_id','left')->join('customers c','c.id=a.customer_id','left')->join('k_employees e',"e.id=a.custodian_id AND a.custodian_type='technician'",'left')->join('warehouses w',"w.id=a.custodian_id AND a.custodian_type IN('warehouse','quarantine','service_center')",'left')->where('a.company_id',$this->company())->where('a.qty >',0); + if($subjectType)$this->CI->db->where(array('a.subject_type'=>$subjectType,'a.subject_id'=>(int)$subjectId));if($technicianId)$this->CI->db->group_start()->where(array('a.custodian_type'=>'technician','a.custodian_id'=>(int)$technicianId))->or_where(array('a.usage_status'=>'installed','a.custodian_type'=>'customer'))->where("EXISTS(SELECT 1 FROM technician_equipment_document_lines dl JOIN technician_equipment_documents dh ON dh.id=dl.document_id WHERE dl.subject_type=a.subject_type AND dl.subject_id=a.subject_id AND dh.technician_id=".(int)$technicianId.")",null,false)->group_end(); + return$this->CI->db->order_by('a.updated_at','DESC')->get()->result_array(); + } + + private function actions(array$subject) + { + $actions=array();if($subject['subject_type']==='company_barcode'&&(float)$subject['warehouse_available']>0)$actions[]='handover'; + foreach($subject['allocations']as$a){if(in_array($a['custodian_type'],array('quarantine','service_center'),true)&&in_array($a['condition_status'],array('inspection_required','repair_required','under_repair','damaged'),true)){$actions[]='inspect';continue;}if($a['custodian_type']==='technician'){if(in_array($a['usage_status'],array('carried','removed','returned'),true)){$actions[]='install';$actions[]='transfer';$actions[]='return';if($subject['subject_type']==='customer_equipment')$actions[]='return_customer';}}elseif($a['custodian_type']==='customer'&&$a['usage_status']==='installed'){$actions[]='remove';$actions[]='replacement';}} + return array_values(array_unique($actions)); + } + + public function registerExternal(array$data,$userId) + { + $this->CI->db->trans_begin();try{$row=$this->createExternal($data,$userId);if($this->CI->db->trans_status()===false)throw new RuntimeException('Register barang customer gagal disimpan.');$this->CI->db->trans_commit();return$row;}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;} + } + private function createExternal(array$data,$userId) + { + $customer=$this->customer($data['owner_customer_id']??0);$barcode=strtoupper(trim((string)($data['external_barcode']??'')));$name=trim((string)($data['equipment_name']??''));if($barcode===''||$name==='')throw new BusinessException('Nama barang dan barcode eksternal wajib diisi.');$tracking=strtoupper((string)($data['tracking_type']??'UNIT'))==='QTY'?'QTY':'UNIT';$qty=$tracking==='UNIT'?1:$this->qty($data['qty']??0);$ownership=in_array(($data['ownership_type']??''),array('customer','transferred_non_inventory','unknown'),true)?$data['ownership_type']:'customer'; + $duplicate=$this->CI->db->where(array('company_id'=>$this->company(),'external_barcode'=>$barcode))->get('customer_equipment_registry')->row();if($duplicate)throw new BusinessException('Barcode barang customer sudah terdaftar.');$sourceDate=trim((string)($data['source_date']??date('Y-m-d')));$sourceDate=$this->date($sourceDate);$row=array('company_id'=>$this->company(),'owner_customer_id'=>$customer->id,'current_customer_id'=>$customer->id,'equipment_name'=>$name,'category'=>trim((string)($data['category']??''))?:null,'brand'=>trim((string)($data['brand']??''))?:null,'model'=>trim((string)($data['model']??''))?:null,'external_barcode'=>$barcode,'serial_number'=>trim((string)($data['serial_number']??''))?:null,'description'=>trim((string)($data['description']??''))?:null,'source_date'=>$sourceDate,'source_document_no'=>trim((string)($data['source_document_no']??''))?:null,'source_reason'=>trim((string)($data['source_reason']??''))?:null,'ownership_type'=>$ownership,'deployment_allowed'=>!empty($data['deployment_allowed'])?1:0,'authorization_reference'=>trim((string)($data['authorization_reference']??''))?:null,'tracking_type'=>$tracking,'total_qty'=>$qty,'custody_type'=>'customer','custody_id'=>$customer->id,'usage_status'=>'installed','condition_status'=>'good','lifecycle_status'=>'active','version'=>1,'registered_by'=>(int)$userId,'registered_at'=>$this->now());if($ownership==='customer'&&!empty($data['deployment_allowed'])&&empty($row['authorization_reference']))throw new BusinessException('Referensi otorisasi wajib diisi jika barang customer boleh digunakan kembali.');if($ownership==='transferred_non_inventory'&&(!$row['source_document_no']||!$row['source_reason']))throw new BusinessException('Dokumen sumber dan alasan penyerahan wajib diisi.');$this->CI->db->insert('customer_equipment_registry',$row);$id=(int)$this->CI->db->insert_id();$subject=array('subject_type'=>'customer_equipment','subject_id'=>$id,'item_id'=>null,'barcode_id'=>null,'customer_equipment_id'=>$id,'barcode_snapshot'=>$barcode,'ownership_type'=>$ownership);$this->addBalance($subject,'customer',$customer->id,$customer->id,'installed','good',$qty,null);return$this->CI->db->get_where('customer_equipment_registry',array('id'=>$id))->row_array(); + } + + public function process(array$data,$userId) + { + $action=(string)($data['action']??'');if(!in_array($action,array('handover','install','remove','transfer','return','return_customer'),true))throw new BusinessException('Tindakan peralatan tidak valid.');$date=$this->date($data['date']??'');$notes=trim((string)($data['notes']??''));if($notes==='')throw new BusinessException('Keterangan transaksi wajib diisi.');$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Kunci transaksi tidak tersedia. Muat ulang halaman.'); + $this->CI->db->trans_begin(); + try{ + if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>(int)$existing->id,'document_no'=>$existing->document_no,'duplicate'=>true);} + $doc=$this->createDocument($action,$date,$data,$userId,$key); + if($action==='handover')$this->handover($doc,$data,$userId); + else{ + $source=$this->lockAllocation($data['allocation_id']??0);$qty=$this->validatedQty($source,$data['qty']??0); + if($action==='install'){if($source['custodian_type']!=='technician'||!in_array($source['usage_status'],array('carried','removed','returned'),true))throw new BusinessException('Hanya barang yang sedang dibawa teknisi yang dapat dipasang.');$customer=$this->customer($data['customer_id']??0);$this->assertReusableExternal($source,$customer->id);$this->move($doc,$source,'customer',$customer->id,$customer->id,'installed','good',$qty,'installation',$notes);} + elseif($action==='remove'){if($source['custodian_type']!=='customer'||$source['usage_status']!=='installed')throw new BusinessException('Hanya barang terpasang yang dapat dilepas.');$technician=$this->user($data['technician_id']??$doc['technician_id']);$condition=in_array(($data['condition_status']??''),array('good','inspection_required','repair_required','damaged'),true)?$data['condition_status']:'inspection_required';$this->move($doc,$source,'technician',$technician->id,$source['customer_id'],'removed',$condition,$qty,'uninstallation',$notes);} + elseif($action==='transfer'){$target=$this->user($data['target_technician_id']??0);if($source['custodian_type']!=='technician'||(int)$source['custodian_id']===(int)$target->id)throw new BusinessException('Teknisi tujuan transfer tidak valid.');$this->move($doc,$source,'technician',$target->id,$source['customer_id'],'carried',$source['condition_status'],$qty,'transfer',$notes);} + elseif($action==='return')$this->returnFromTechnician($doc,$source,$qty,$data,$notes); + elseif($action==='return_customer'){if($source['subject_type']!=='customer_equipment'||$source['custodian_type']!=='technician')throw new BusinessException('Tindakan ini hanya untuk barang customer yang sedang dibawa teknisi.');$customer=$this->customer($data['customer_id']??$source['customer_id']);$this->move($doc,$source,'customer',$customer->id,$customer->id,'returned',$source['condition_status'],$qty,'transfer',$notes);} + } + if($this->CI->db->trans_status()===false)throw new RuntimeException('Transaksi peralatan gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no']); + }catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;} + } + + public function processBatch(array$data,$userId) + { + $action=(string)($data['action']??'');if(!in_array($action,array('handover','transfer','return'),true))throw new BusinessException('Jenis perpindahan batch tidak valid.');$date=$this->date($data['date']??'');$notes=trim((string)($data['notes']??''));if($notes==='')$notes=$action==='handover'?'Serah terima barang batch':($action==='transfer'?'Transfer barang antar teknisi':'Pengembalian barang batch ke gudang');$key=trim((string)($data['idempotency_key']??''));$items=$data['items']??array();if($key===''||!is_array($items)||count($items)<1)throw new BusinessException('Daftar barang dan kunci transaksi wajib tersedia.');if(count($items)>200)throw new BusinessException('Maksimal 200 baris dalam satu transaksi batch.'); + $seen=array();foreach($items as$row){$identity=$action==='handover'?'S'.(int)($row['subject_id']??0):'A'.(int)($row['allocation_id']??0);if(isset($seen[$identity]))throw new BusinessException('Barang yang sama tercantum lebih dari satu kali. Gabungkan jumlahnya pada satu baris.');$seen[$identity]=true;} + $technician=null;$target=null;$warehouse=null;if($action==='handover')$technician=$this->user($data['technician_id']??0);elseif($action==='transfer'){$technician=$this->user($data['technician_id']??0);$target=$this->user($data['target_technician_id']??0);if((int)$technician->id===(int)$target->id)throw new BusinessException('Teknisi asal dan tujuan harus berbeda.');}else{$technician=$this->user($data['technician_id']??0);if(!empty($data['warehouse_id']))$warehouse=$this->warehouse($data['warehouse_id']);} + $this->CI->db->trans_begin();try{if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>(int)$existing->id,'document_no'=>$existing->document_no,'line_count'=>$this->CI->db->where('document_id',$existing->id)->count_all_results('technician_equipment_document_lines'),'duplicate'=>true);}$header=$data;$header['technician_id']=$technician->id;if($target)$header['target_technician_id']=$target->id;if($warehouse)$header['warehouse_id']=$warehouse->id;$header['notes']=$notes;$doc=$this->createDocument($action,$date,$header,$userId,$key); + foreach($items as$index=>$row){$rowNotes=trim((string)($row['notes']??''));$lineNotes=$notes.($rowNotes!==''?' | '.$rowNotes:'');if($action==='handover'){$payload=array_merge($header,array('subject_id'=>(int)($row['subject_id']??0),'qty'=>$row['qty']??0,'notes'=>$lineNotes));$this->handover($doc,$payload,$userId);continue;}$source=$this->lockAllocation($row['allocation_id']??0);if($source['custodian_type']!=='technician'||(int)$source['custodian_id']!==(int)$technician->id)throw new BusinessException('Baris '.($index+1).' tidak lagi berada pada teknisi asal.');$qty=$this->validatedQty($source,$row['qty']??0);if($action==='transfer')$this->move($doc,$source,'technician',$target->id,$source['customer_id'],'carried',$source['condition_status'],$qty,'transfer',$lineNotes);else$this->returnFromTechnician($doc,$source,$qty,$data,$lineNotes,$warehouse);} + if($this->CI->db->trans_status()===false)throw new RuntimeException('Transaksi batch gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no'],'line_count'=>count($items));}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;} + } + + private function returnFromTechnician(array$doc,array$source,$qty,array$data,$notes,$warehouse=null) + { + if($source['custodian_type']!=='technician')throw new BusinessException('Barang harus berada pada teknisi sebelum dikembalikan.'); + if($source['subject_type']==='company_barcode'||$source['ownership_type']==='transferred_non_inventory'){ + if(!$warehouse)$warehouse=$this->warehouse($data['warehouse_id']??0); + if($source['subject_type']==='company_barcode')$this->move($doc,$source,'quarantine',$warehouse->id,$source['customer_id'],'returned','inspection_required',$qty,'transfer',$notes); + else$this->move($doc,$source,'warehouse',$warehouse->id,$source['customer_id'],'returned',$source['condition_status'],$qty,'transfer',$notes,false); + return; + } + $customerId=(int)($source['customer_id']??0);if($customerId<1&&$source['subject_type']==='customer_equipment'){$registry=$this->CI->db->select('owner_customer_id')->get_where('customer_equipment_registry',array('id'=>(int)$source['subject_id'],'company_id'=>$this->company()))->row();$customerId=(int)($registry->owner_customer_id??0);} + $customer=$this->customer($customerId);$this->move($doc,$source,'customer',$customer->id,$customer->id,'returned',$source['condition_status'],$qty,'transfer',$notes); + } + + private function handover(array$doc,array$data,$userId) + { + $barcodeId=(int)($data['subject_id']??0);$barcode=$this->CI->db->query("SELECT b.*,i.company_id,i.nama_barang,COALESCE(k.tracking_type,'QTY') tracking_type 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.id=? AND i.company_id=? FOR UPDATE",array($barcodeId,$this->company()))->row();if(!$barcode)throw new BusinessException('Barcode gudang tidak ditemukan.');if((string)$barcode->status!=='available')throw new BusinessException('Barcode belum tersedia untuk diserahkan dari gudang.');$tracking=strtoupper((string)$barcode->tracking_type)==='UNIT'?'UNIT':'QTY';$qty=$tracking==='UNIT'?1:$this->qty($data['qty']??0);$available=(float)$barcode->qty_sisa-(float)$barcode->reserved_qty-(float)$barcode->return_reserved_qty;if($qty>$available+.0001)throw new BusinessException('Jumlah melebihi saldo barcode yang tersedia di gudang.');$technician=$this->user($data['technician_id']??0);$subject=array('subject_type'=>'company_barcode','subject_id'=>$barcode->id,'item_id'=>$barcode->item_id,'barcode_id'=>$barcode->id,'customer_equipment_id'=>null,'barcode_snapshot'=>$barcode->barcode,'ownership_type'=>'company');$this->addBalance($subject,'technician',$technician->id,null,'carried','good',$qty,$doc['id']);$reserved=round((float)$barcode->reserved_qty+$qty,4);$left=(float)$barcode->qty_sisa-$reserved-(float)$barcode->return_reserved_qty;$status=$left>.0001?'available':'installed';$this->CI->db->where(array('id'=>$barcode->id,'version'=>$barcode->version))->update('item_barcodes',array('reserved_qty'=>$reserved,'status'=>$status,'version'=>(int)$barcode->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode sedang diproses pengguna lain.');$notes=trim((string)$data['notes']);$movement=$this->movement($subject,$qty,'warehouse',$barcode->warehouse_id,'technician',$technician->id,'transfer',$doc['id'],'[AUTO] Serah terima '.$doc['document_no'].' | '.$notes);$this->line($doc,$subject,$tracking,'handover','warehouse',$barcode->warehouse_id,'technician',$technician->id,null,'warehouse','carried','good','good',$qty,$movement,$notes); + } + + public function replacement(array$data,$userId) + { + $date=$this->date($data['date']??'');$notes=trim((string)($data['notes']??''));if($notes===''||empty($data['customer_id'])||empty($data['technician_id']))throw new BusinessException('Customer, teknisi, tanggal, dan keterangan wajib diisi.');$customer=$this->customer($data['customer_id']);$this->user($data['technician_id']);$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Kunci transaksi tidak tersedia.'); + $this->CI->db->trans_begin();try{if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>(int)$existing->id,'document_no'=>$existing->document_no,'duplicate'=>true);}$doc=$this->createDocument('replacement',$date,$data,$userId,$key);$out=$this->lockAllocation($data['outgoing_allocation_id']??0);$outQty=$this->validatedQty($out,$data['outgoing_qty']??0);if($out['custodian_type']!=='technician')throw new BusinessException('Barang pengganti harus berada pada teknisi.');$this->move($doc,$out,'customer',$customer->id,$customer->id,'installed','good',$outQty,'installation','Barang pengganti: '.$notes); + if(!empty($data['incoming_allocation_id'])){$old=$this->lockAllocation($data['incoming_allocation_id']);if($old['custodian_type']!=='customer'||(int)$old['customer_id']!==(int)$customer->id)throw new BusinessException('Barang lama tidak terpasang pada customer yang dipilih.');$inQty=$this->validatedQty($old,$data['incoming_qty']??0);} + else{$external=$this->createExternal(array('owner_customer_id'=>$customer->id,'equipment_name'=>$data['old_equipment_name']??'','category'=>$data['old_category']??'','brand'=>$data['old_brand']??'','model'=>$data['old_model']??'','external_barcode'=>$data['old_barcode']??'','serial_number'=>$data['old_serial_number']??'','description'=>$data['old_description']??'','source_date'=>$date,'source_document_no'=>$data['work_order_no']??$doc['document_no'],'source_reason'=>$data['reason']??$notes,'ownership_type'=>$data['old_ownership_type']??'customer','deployment_allowed'=>!empty($data['old_deployment_allowed']),'authorization_reference'=>$data['old_authorization_reference']??'','tracking_type'=>$data['old_tracking_type']??'UNIT','qty'=>$data['incoming_qty']??1),$userId);$old=$this->lockAllocationBySubject('customer_equipment',$external['id'],'customer',$customer->id);$inQty=$this->validatedQty($old,$data['incoming_qty']??0);} + $this->assertReusableExternal($out,$customer->id);$condition=in_array(($data['old_condition']??''),array('good','inspection_required','repair_required','damaged'),true)?$data['old_condition']:'inspection_required';$this->move($doc,$old,'technician',(int)$data['technician_id'],$customer->id,'removed',$condition,$inQty,'uninstallation','Barang lama dilepas: '.$notes); + if($this->CI->db->trans_status()===false)throw new RuntimeException('Dokumen penggantian gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no']);}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;} + } + + public function inspect(array$data,$userId) + { + $date=$this->date($data['date']??'');$notes=trim((string)($data['notes']??''));if($notes==='')throw new BusinessException('Catatan hasil pemeriksaan wajib diisi.');$result=(string)($data['result_status']??'');if(!in_array($result,array('good','repair_required','under_repair','damaged','lost','return_customer','scrapped'),true))throw new BusinessException('Hasil pemeriksaan tidak valid.');$key=trim((string)($data['idempotency_key']??''));if($key==='')throw new BusinessException('Kunci transaksi tidak tersedia. Muat ulang halaman.'); + $this->CI->db->trans_begin();try{if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>$existing->id,'document_no'=>$existing->document_no,'duplicate'=>true);}$source=$this->lockAllocation($data['allocation_id']??0);$externalStorage=$source['subject_type']==='customer_equipment'&&in_array($source['custodian_type'],array('customer','warehouse'),true)&&$source['usage_status']==='returned';if(!in_array($source['condition_status'],array('inspection_required','repair_required','under_repair','damaged'),true)||(!in_array($source['custodian_type'],array('quarantine','service_center'),true)&&!$externalStorage))throw new BusinessException('Barang tidak berada pada antrean pemeriksaan/perbaikan.');$qty=$this->validatedQty($source,$data['qty']??0);$doc=$this->createDocument('inspection',$date,$data,$userId,$key);$this->CI->db->insert('technician_equipment_inspections',array('company_id'=>$this->company(),'document_id'=>$doc['id'],'source_allocation_id'=>$source['id'],'subject_type'=>$source['subject_type'],'subject_id'=>$source['subject_id'],'result_status'=>$result,'qty'=>$qty,'inspection_date'=>$date,'notes'=>$notes,'inspected_by'=>(int)$userId,'created_at'=>$this->now())); + if($externalStorage){$condition=in_array($result,array('return_customer','good'),true)?'good':$result;$this->move($doc,$source,$source['custodian_type'],$source['custodian_id'],$source['customer_id'],'returned',$condition,$qty,'adjustment',$notes,false);if($result==='scrapped')$this->CI->db->where(array('id'=>$source['subject_id'],'company_id'=>$this->company()))->update('customer_equipment_registry',array('lifecycle_status'=>'disposed','updated_at'=>$this->now()));} + elseif($result==='good'&&$source['subject_type']==='company_barcode'){$this->consume($source,$qty);$this->releaseInternal($source['barcode_id'],$qty,'available');$movement=$this->movement($this->subject($source),$qty,'warehouse',$source['custodian_id'],'warehouse',$source['custodian_id'],'adjustment',$doc['id'],'[AUTO] Lulus pemeriksaan '.$doc['document_no'].' | '.$notes);$this->line($doc,$source,$this->tracking($source),'inspection',$source['custodian_type'],$source['custodian_id'],'warehouse',$source['custodian_id'],$source['customer_id'],$source['usage_status'],'available',$source['condition_status'],'good',$qty,$movement,$notes);} + elseif($result==='return_customer'||($result==='good'&&$source['subject_type']==='customer_equipment')){$customer=$this->customer($source['customer_id']);$this->move($doc,$source,'customer',$customer->id,$customer->id,'returned','good',$qty,'transfer',$notes);} + else{$toType=$result==='under_repair'?'service_center':'quarantine';$condition=$result;$this->move($doc,$source,$toType,$source['custodian_id'],$source['customer_id'],'returned',$condition,$qty,'adjustment',$notes,false);if($source['subject_type']==='company_barcode')$this->refreshBarcodeStatus($source['barcode_id'],$result==='damaged'?'damaged':'quarantine');if($source['subject_type']==='customer_equipment'&&$result==='scrapped')$this->CI->db->where(array('id'=>$source['subject_id'],'company_id'=>$this->company()))->update('customer_equipment_registry',array('lifecycle_status'=>'disposed','updated_at'=>$this->now()));} + if($source['subject_type']==='customer_equipment')$this->syncExternal($source['subject_id']);if($this->CI->db->trans_status()===false)throw new RuntimeException('Pemeriksaan gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no']);}catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;} + } + + public function reverse(array$data,$userId) + { + $sourceId=(int)($data['document_id']??0);$reason=trim((string)($data['reason']??''));$date=$this->date($data['date']??date('Y-m-d'));$key=trim((string)($data['idempotency_key']??''));if($sourceId<1||$reason===''||$key==='')throw new BusinessException('Dokumen, tanggal, alasan koreksi, dan kunci transaksi wajib diisi.'); + $this->CI->db->trans_begin();try{ + if($existing=$this->CI->db->get_where('technician_equipment_documents',array('company_id'=>$this->company(),'idempotency_key'=>$key))->row()){$this->CI->db->trans_commit();return array('document_id'=>(int)$existing->id,'document_no'=>$existing->document_no,'duplicate'=>true);} + $original=$this->CI->db->query('SELECT * FROM technician_equipment_documents WHERE id=? AND company_id=? FOR UPDATE',array($sourceId,$this->company()))->row_array();if(!$original)throw new BusinessException('Dokumen yang akan dikoreksi tidak ditemukan.');if($original['status']!=='posted')throw new BusinessException('Dokumen sudah pernah dikoreksi atau tidak dapat dibalik.');if($original['document_type']==='opening')throw new BusinessException('Saldo awal migrasi tidak dapat dikoreksi dari transaksi operasional.'); + $payload=array('customer_id'=>$original['customer_id'],'technician_id'=>$original['technician_id'],'target_technician_id'=>$original['target_technician_id'],'warehouse_id'=>$original['warehouse_id'],'reason'=>$reason,'notes'=>'[REVERSAL] '.$original['document_no'].' | '.$reason);$doc=$this->createDocument('correction',$date,$payload,$userId,$key);$this->CI->db->where('id',$doc['id'])->update('technician_equipment_documents',array('reversal_of_id'=>$sourceId)); + $lines=$this->CI->db->where(array('document_id'=>$sourceId,'company_id'=>$this->company()))->order_by('id','DESC')->get('technician_equipment_document_lines')->result_array();if(!$lines)throw new BusinessException('Detail dokumen sumber tidak ditemukan.'); + foreach($lines as$line){$subject=array('subject_type'=>$line['subject_type'],'subject_id'=>$line['subject_id'],'item_id'=>$line['item_id'],'barcode_id'=>$line['barcode_id'],'customer_equipment_id'=>$line['customer_equipment_id'],'barcode_snapshot'=>$line['barcode_snapshot'],'ownership_type'=>$line['ownership_type']); + if($line['subject_type']==='company_barcode'&&$line['to_custodian_type']==='warehouse'){$this->reserveInternal($line['barcode_id'],$line['qty']);} + else{$target=$this->lockMatchingAllocation($line['subject_type'],$line['subject_id'],$line['to_custodian_type'],$line['to_custodian_id'],$line['customer_id'],$line['to_usage_status'],$line['to_condition_status']);$this->consume($target,(float)$line['qty']);} + if($line['subject_type']==='company_barcode'&&$line['from_custodian_type']==='warehouse')$this->releaseInternal($line['barcode_id'],(float)$line['qty'],'available'); + else$this->addBalance($subject,$line['from_custodian_type'],$line['from_custodian_id'],$line['customer_id'],$line['from_usage_status'],$line['from_condition_status'],(float)$line['qty'],$doc['id']); + $movement=null;if($line['subject_type']==='company_barcode'){$from=in_array($line['to_custodian_type'],array('quarantine','service_center'),true)?'warehouse':$line['to_custodian_type'];$to=in_array($line['from_custodian_type'],array('quarantine','service_center'),true)?'warehouse':$line['from_custodian_type'];$movement=$this->movement($subject,(float)$line['qty'],$from,$line['to_custodian_id'],$to,$line['from_custodian_id'],'adjustment',$doc['id'],'[AUTO][REVERSAL] '.$original['document_no'].' | '.$reason);} + $this->line($doc,$subject,$line['tracking_type'],'correction',$line['to_custodian_type'],$line['to_custodian_id'],$line['from_custodian_type'],$line['from_custodian_id'],$line['customer_id'],$line['to_usage_status'],$line['from_usage_status'],$line['to_condition_status'],$line['from_condition_status'],(float)$line['qty'],$movement,$reason);if($line['subject_type']==='customer_equipment')$this->syncExternal($line['subject_id']); + } + $this->CI->db->where(array('id'=>$sourceId,'status'=>'posted'))->update('technician_equipment_documents',array('status'=>'reversed','reversed_by'=>(int)$userId,'reversed_at'=>$this->now()));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Status dokumen sumber telah berubah.');if($this->CI->db->trans_status()===false)throw new RuntimeException('Koreksi gagal disimpan.');$this->CI->db->trans_commit();return array('document_id'=>$doc['id'],'document_no'=>$doc['document_no']); + }catch(Throwable$e){$this->CI->db->trans_rollback();throw$e;} + } + + public function inspectionQueueStats($technicianId=0) + { + $this->CI->db->select('COUNT(*) row_count,COALESCE(SUM(a.qty),0) qty',false)->from('technician_custody_allocations a')->join('technician_equipment_documents d','d.id=a.last_document_id','left')->where('a.company_id',$this->company())->group_start()->where_in('a.custodian_type',array('technician','quarantine','service_center'))->or_group_start()->where('a.subject_type','customer_equipment')->where_in('a.custodian_type',array('customer','warehouse'))->where('a.usage_status','returned')->group_end()->group_end()->where("(d.document_type IS NULL OR d.document_type <> 'inspection')",null,false)->where_in('a.condition_status',array('inspection_required','repair_required','under_repair','damaged'))->where('a.qty >',0); + if((int)$technicianId>0){$employee=(int)$technicianId;$this->CI->db->where("COALESCE(CASE WHEN a.custodian_type='technician' THEN a.custodian_id END,d.technician_id)={$employee}",null,false);}$row=$this->CI->db->get()->row_array();return array('row_count'=>(int)($row['row_count']??0),'qty'=>(float)($row['qty']??0)); + } + + public function inspectionQueue(array$filters=array()) + { + $this->CI->db + ->select("a.*,d.document_date,COALESCE(CASE WHEN a.custodian_type='technician' THEN a.custodian_id END,d.technician_id) technician_id,e.full_name technician_name,CASE WHEN a.subject_type='company_barcode' THEN CONCAT(i.kode_detail,' - ',i.nama_barang) ELSE r.equipment_name END display_name,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,COALESCE(b.serial_number,r.serial_number) serial_number,w.nama warehouse_name,c.nama customer_name,(a.custodian_type='technician') requires_return",false) + ->from('technician_custody_allocations a') + ->join('technician_equipment_documents d','d.id=a.last_document_id','left') + ->join('k_employees e',"e.id=COALESCE(CASE WHEN a.custodian_type='technician' THEN a.custodian_id END,d.technician_id)",'left') + ->join('items i','i.id=a.item_id','left')->join('kode_barang k','k.id=i.kode_id','left') + ->join('item_barcodes b','b.id=a.barcode_id','left')->join('customer_equipment_registry r','r.id=a.customer_equipment_id','left') + ->join('warehouses w',"w.id=a.custodian_id AND a.custodian_type IN('warehouse','quarantine','service_center')",'left') + ->join('customers c','c.id=a.customer_id','left')->where('a.company_id',$this->company()) + ->group_start()->where_in('a.custodian_type',array('technician','quarantine','service_center'))->or_group_start()->where('a.subject_type','customer_equipment')->where_in('a.custodian_type',array('customer','warehouse'))->where('a.usage_status','returned')->group_end()->group_end() + ->where("(d.document_type IS NULL OR d.document_type <> 'inspection')",null,false) + ->where_in('a.condition_status',array('inspection_required','repair_required','under_repair','damaged'))->where('a.qty >',0); + if(!empty($filters['date_from']))$this->CI->db->where('d.document_date >=',$filters['date_from']); + if(!empty($filters['date_to']))$this->CI->db->where('d.document_date <=',$filters['date_to']); + if(!empty($filters['technician_id'])){$technicianId=(int)$filters['technician_id'];$this->CI->db->where("COALESCE(CASE WHEN a.custodian_type='technician' THEN a.custodian_id END,d.technician_id)={$technicianId}",null,false);} + if(!empty($filters['customer_id']))$this->CI->db->where('a.customer_id',(int)$filters['customer_id']); + if(!empty($filters['ownership']))$this->CI->db->where('a.ownership_type',$filters['ownership']); + if(!empty($filters['condition']))$this->CI->db->where('a.condition_status',$filters['condition']); + if(!empty($filters['status']))$this->CI->db->where('a.usage_status',$filters['status']); + return$this->CI->db->order_by('requires_return','DESC',false)->order_by('a.updated_at')->get()->result_array(); + } + public function documents(array$filters=array()){$this->CI->db->select('d.*,c.nama customer_name,e.full_name technician_name,t.full_name target_technician_name,w.nama warehouse_name,u.nama creator_name,(SELECT COUNT(*) FROM technician_equipment_document_lines l WHERE l.document_id=d.id) line_count')->from('technician_equipment_documents d')->join('customers c','c.id=d.customer_id','left')->join('k_employees e','e.id=d.technician_id','left')->join('k_employees t','t.id=d.target_technician_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')->where('d.company_id',$this->company());if(!empty($filters['date_from']))$this->CI->db->where('d.document_date >=',$filters['date_from']);if(!empty($filters['date_to']))$this->CI->db->where('d.document_date <=',$filters['date_to']);foreach(array('customer_id','technician_id')as$key)if(!empty($filters[$key]))$this->CI->db->where('d.'.$key,(int)$filters[$key]);if(!empty($filters['document_type']))$this->CI->db->where('d.document_type',$filters['document_type']);if(!empty($filters['status']))$this->CI->db->where('d.status',$filters['status']);return$this->CI->db->order_by('d.document_date','DESC')->order_by('d.id','DESC')->limit(1000)->get()->result_array();} + public function history(array$filters=array()){$this->CI->db->select("d.id document_id,d.document_no,d.document_type,d.document_date,d.status document_status,d.notes document_notes,d.created_at,d.idempotency_key,e.full_name technician_name,t.full_name target_technician_name,c.nama customer_name,u.nama creator_name,l.*,CASE WHEN l.subject_type='company_barcode' THEN CONCAT(COALESCE(i.kode_detail,'-'),' - ',COALESCE(i.nama_barang,'-')) ELSE COALESCE(r.equipment_name,'-') END display_name,COALESCE(b.serial_number,r.serial_number) serial_number",false)->from('technician_equipment_document_lines l')->join('technician_equipment_documents d','d.id=l.document_id')->join('k_employees e','e.id=d.technician_id','left')->join('k_employees t','t.id=d.target_technician_id','left')->join('customers c','c.id=l.customer_id','left')->join('users u','u.id=d.created_by','left')->join('items i','i.id=l.item_id','left')->join('item_barcodes b','b.id=l.barcode_id','left')->join('customer_equipment_registry r','r.id=l.customer_equipment_id','left')->where('d.company_id',$this->company());if(!empty($filters['date_from']))$this->CI->db->where('d.document_date >=',$filters['date_from']);if(!empty($filters['date_to']))$this->CI->db->where('d.document_date <=',$filters['date_to']);if(!empty($filters['technician_id']))$this->CI->db->group_start()->where('d.technician_id',(int)$filters['technician_id'])->or_where('d.target_technician_id',(int)$filters['technician_id'])->group_end();if(!empty($filters['customer_id']))$this->CI->db->where('l.customer_id',(int)$filters['customer_id']);if(!empty($filters['status']))$this->CI->db->where('l.to_usage_status',$filters['status']);if(!empty($filters['condition']))$this->CI->db->where('l.to_condition_status',$filters['condition']);if(!empty($filters['ownership']))$this->CI->db->where('l.ownership_type',$filters['ownership']);if(!empty($filters['query'])){$q=$this->CI->db->escape_like_str($filters['query']);$this->CI->db->where("(l.barcode_snapshot LIKE '%$q%' OR b.serial_number LIKE '%$q%' OR r.serial_number LIKE '%$q%' OR i.nama_barang LIKE '%$q%' OR r.equipment_name LIKE '%$q%')",null,false);}return$this->CI->db->order_by('d.document_date','DESC')->order_by('l.id','DESC')->limit(5000)->get()->result_array();} + public function document($id){$header=$this->CI->db->select('d.*,c.nama customer_name,e.full_name technician_name,t.full_name target_technician_name,w.nama warehouse_name,u.nama creator_name')->from('technician_equipment_documents d')->join('customers c','c.id=d.customer_id','left')->join('k_employees e','e.id=d.technician_id','left')->join('k_employees t','t.id=d.target_technician_id','left')->join('warehouses w','w.id=d.warehouse_id','left')->join('users u','u.id=d.created_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>$this->company()))->get()->row_array();if(!$header)throw new BusinessException('Dokumen peralatan tidak ditemukan.');$header['lines']=$this->CI->db->select("l.*,CASE WHEN l.subject_type='company_barcode' THEN CONCAT(i.kode_detail,' - ',i.nama_barang) ELSE r.equipment_name END display_name,COALESCE(b.serial_number,r.serial_number) serial_number,c.nama customer_name",false)->from('technician_equipment_document_lines l')->join('items i','i.id=l.item_id','left')->join('item_barcodes b','b.id=l.barcode_id','left')->join('customer_equipment_registry r','r.id=l.customer_equipment_id','left')->join('customers c','c.id=l.customer_id','left')->where('l.document_id',(int)$id)->order_by('l.id')->get()->result_array();$header['attachments']=$this->CI->db->where(array('company_id'=>$this->company(),'entity_type'=>'technician_equipment_document','entity_id'=>(int)$id))->get('transaction_attachments')->result_array();return$header;} + public function externalList($technicianId=null){$this->CI->db->select('r.*,c.nama owner_name,cc.nama current_customer_name')->from('customer_equipment_registry r')->join('customers c','c.id=r.owner_customer_id','left')->join('customers cc','cc.id=r.current_customer_id','left')->where('r.company_id',$this->company());if($technicianId){$technicianId=(int)$technicianId;$this->CI->db->where("(EXISTS(SELECT 1 FROM technician_custody_allocations a WHERE a.company_id=r.company_id AND a.subject_type='customer_equipment' AND a.subject_id=r.id AND a.custodian_type='technician' AND a.custodian_id={$technicianId}) OR EXISTS(SELECT 1 FROM technician_equipment_document_lines dl JOIN technician_equipment_documents dh ON dh.id=dl.document_id WHERE dl.company_id=r.company_id AND dl.subject_type='customer_equipment' AND dl.subject_id=r.id AND (dh.technician_id={$technicianId} OR dh.target_technician_id={$technicianId})))",null,false);}return$this->CI->db->order_by('r.id','DESC')->limit(1000)->get()->result_array();} + + public function customerEquipmentSummary($technicianId=0) + { + $this->CI->db->select("c.id customer_id,c.nama customer_name,COUNT(DISTINCT CONCAT(a.subject_type,':',a.subject_id)) item_count,SUM(a.qty) total_qty,SUM(CASE WHEN a.ownership_type IN('company','transferred_non_inventory') THEN a.qty ELSE 0 END) company_qty,SUM(CASE WHEN a.ownership_type NOT IN('company','transferred_non_inventory') THEN a.qty ELSE 0 END) customer_qty,SUM(CASE WHEN a.usage_status='installed' THEN a.qty ELSE 0 END) installed_qty,SUM(CASE WHEN a.condition_status='good' THEN a.qty ELSE 0 END) good_qty",false)->from('technician_custody_allocations a')->join('customers c','c.id=a.customer_id')->where('a.company_id',$this->company())->where('a.customer_id IS NOT NULL',null,false)->where('a.qty >',0); + if((int)$technicianId>0){$employee=(int)$technicianId;$this->CI->db->where("(a.custodian_type='technician' AND a.custodian_id={$employee} OR EXISTS(SELECT 1 FROM technician_equipment_document_lines sl JOIN technician_equipment_documents sd ON sd.id=sl.document_id WHERE sl.company_id=a.company_id AND sl.subject_type=a.subject_type AND sl.subject_id=a.subject_id AND sl.customer_id=a.customer_id AND (sd.technician_id={$employee} OR sd.target_technician_id={$employee})))",null,false);} + return$this->CI->db->group_by(array('c.id','c.nama'))->order_by('c.nama')->get()->result_array(); + } + + public function customerEquipmentRows($customerId,$status='all',$technicianId=0) + { + $status=in_array($status,array('all','installed','stored','technician','inspection','good','damaged'),true)?$status:'all'; + $this->CI->db->select("a.*,CASE WHEN a.subject_type='company_barcode' THEN CONCAT(COALESCE(i.kode_detail,'-'),' - ',COALESCE(i.nama_barang,'-')) ELSE COALESCE(r.equipment_name,'-') END display_name,CASE WHEN a.subject_type='company_barcode' THEN COALESCE(k.tracking_type,'QTY') ELSE COALESCE(r.tracking_type,'UNIT') END tracking_type,COALESCE(b.serial_number,r.serial_number) serial_number,c.nama customer_name,e.full_name technician_name,w.nama warehouse_name",false)->from('technician_custody_allocations a')->join('items i','i.id=a.item_id','left')->join('kode_barang k','k.id=i.kode_id','left')->join('item_barcodes b','b.id=a.barcode_id','left')->join('customer_equipment_registry r','r.id=a.customer_equipment_id','left')->join('customers c','c.id=a.customer_id','left')->join('k_employees e',"e.id=a.custodian_id AND a.custodian_type='technician'",'left')->join('warehouses w',"w.id=a.custodian_id AND a.custodian_type IN('warehouse','quarantine','service_center')",'left')->where(array('a.company_id'=>$this->company(),'a.customer_id'=>(int)$customerId))->where('a.qty >',0); + if($status==='installed')$this->CI->db->where('a.usage_status','installed');elseif($status==='stored')$this->CI->db->where(array('a.custodian_type'=>'customer','a.usage_status'=>'returned'));elseif($status==='technician')$this->CI->db->where('a.custodian_type','technician');elseif($status==='inspection')$this->CI->db->where_in('a.condition_status',array('inspection_required','repair_required','under_repair'));elseif($status==='good')$this->CI->db->where('a.condition_status','good');elseif($status==='damaged')$this->CI->db->where_in('a.condition_status',array('damaged','lost','scrapped')); + if((int)$technicianId>0){$employee=(int)$technicianId;$this->CI->db->where("(a.custodian_type='technician' AND a.custodian_id={$employee} OR EXISTS(SELECT 1 FROM technician_equipment_document_lines sl JOIN technician_equipment_documents sd ON sd.id=sl.document_id WHERE sl.company_id=a.company_id AND sl.subject_type=a.subject_type AND sl.subject_id=a.subject_id AND sl.customer_id=a.customer_id AND (sd.technician_id={$employee} OR sd.target_technician_id={$employee})))",null,false);} + return$this->CI->db->order_by('display_name')->order_by('a.barcode_snapshot')->get()->result_array(); + } + + public function customerEquipmentNotes($customerId,$technicianId=0) + { + $this->CI->db->select("d.id document_id,d.document_no,d.document_type,d.document_date,d.created_at,COALESCE(NULLIF(TRIM(l.notes),''),NULLIF(TRIM(d.notes),''),'-') note_text,CASE WHEN l.subject_type='company_barcode' THEN CONCAT(COALESCE(i.kode_detail,'-'),' - ',COALESCE(i.nama_barang,'-')) ELSE COALESCE(r.equipment_name,'-') END display_name,l.barcode_snapshot,COALESCE(b.serial_number,r.serial_number) serial_number,l.ownership_type,l.qty,u.nama actor_name,e.full_name technician_name",false)->from('technician_equipment_document_lines l')->join('technician_equipment_documents d','d.id=l.document_id')->join('items i','i.id=l.item_id','left')->join('item_barcodes b','b.id=l.barcode_id','left')->join('customer_equipment_registry r','r.id=l.customer_equipment_id','left')->join('users u','u.id=d.created_by','left')->join('k_employees e','e.id=d.technician_id','left')->where(array('l.company_id'=>$this->company(),'l.customer_id'=>(int)$customerId)); + if((int)$technicianId>0){$employee=(int)$technicianId;$this->CI->db->group_start()->where('d.technician_id',$employee)->or_where('d.target_technician_id',$employee)->group_end();} + return$this->CI->db->order_by('d.document_date','DESC')->order_by('l.id','DESC')->limit(5000)->get()->result_array(); + } + public function external($id){$row=$this->CI->db->select('r.*,c.nama owner_name,cc.nama current_customer_name,u.nama registered_name')->from('customer_equipment_registry r')->join('customers c','c.id=r.owner_customer_id','left')->join('customers cc','cc.id=r.current_customer_id','left')->join('users u','u.id=r.registered_by','left')->where(array('r.id'=>(int)$id,'r.company_id'=>$this->company()))->get()->row_array();if(!$row)throw new BusinessException('Barang customer tidak ditemukan.');$row['allocations']=$this->allocations('customer_equipment',(int)$id);$row['attachments']=$this->CI->db->where(array('company_id'=>$this->company(),'entity_type'=>'customer_equipment_registry','entity_id'=>(int)$id))->get('transaction_attachments')->result_array();return$row;} + + private function createDocument($type,$date,array$data,$userId,$key){$number=$this->CI->numberingservice->next('technician_equipment',$date);$row=array('company_id'=>$this->company(),'document_no'=>$number,'document_type'=>$type,'document_date'=>$date,'status'=>'posted','customer_id'=>(int)($data['customer_id']??0)?:null,'technician_id'=>(int)($data['technician_id']??0)?:null,'target_technician_id'=>(int)($data['target_technician_id']??0)?:null,'warehouse_id'=>(int)($data['warehouse_id']??0)?:null,'work_order_no'=>trim((string)($data['work_order_no']??''))?:null,'installation_location'=>trim((string)($data['installation_location']??''))?:null,'reason'=>trim((string)($data['reason']??''))?:null,'notes'=>trim((string)($data['notes']??''))?:null,'idempotency_key'=>$key,'created_by'=>(int)$userId,'created_at'=>$this->now());$this->CI->db->insert('technician_equipment_documents',$row);$row['id']=(int)$this->CI->db->insert_id();return$row;} + private function lockAllocation($id){$row=$this->CI->db->query('SELECT * FROM technician_custody_allocations WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row_array();if(!$row||(float)$row['qty']<=0)throw new BusinessException('Saldo posisi barang tidak ditemukan atau sudah berubah.');return$row;} + private function lockAllocationBySubject($type,$id,$custodian,$custodianId){$row=$this->CI->db->query('SELECT * FROM technician_custody_allocations WHERE company_id=? AND subject_type=? AND subject_id=? AND custodian_type=? AND custodian_id=? AND qty>0 ORDER BY id LIMIT 1 FOR UPDATE',array($this->company(),$type,(int)$id,$custodian,(int)$custodianId))->row_array();if(!$row)throw new BusinessException('Posisi barang tidak ditemukan.');return$row;} + private function lockMatchingAllocation($type,$id,$custodian,$custodianId,$customer,$usage,$condition){$sql='SELECT * FROM technician_custody_allocations WHERE company_id=? AND subject_type=? AND subject_id=? AND custodian_type=? AND COALESCE(custodian_id,0)=? AND COALESCE(customer_id,0)=? AND usage_status=? AND condition_status=? AND qty>0 ORDER BY id LIMIT 1 FOR UPDATE';$row=$this->CI->db->query($sql,array($this->company(),$type,(int)$id,$custodian,(int)$custodianId,(int)$customer,$usage,$condition))->row_array();if(!$row)throw new BusinessException('Koreksi ditolak karena barang hasil transaksi sudah dipindahkan atau statusnya telah berubah.');return$row;} + private function validatedQty(array$allocation,$input){$tracking=$this->tracking($allocation);$qty=$tracking==='UNIT'?1:$this->qty($input);if($qty>(float)$allocation['qty']+.0001)throw new BusinessException('Jumlah melebihi saldo pada posisi asal.');if($tracking==='UNIT'&&abs((float)$allocation['qty']-1)>.0001)throw new BusinessException('Saldo barang UNIT tidak valid.');return$qty;} + private function tracking(array$subject){if($subject['subject_type']==='company_barcode'){$r=$this->CI->db->select("COALESCE(k.tracking_type,'QTY') tracking_type",false)->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('kode_barang k','k.id=i.kode_id','left')->where('b.id',(int)$subject['barcode_id'])->get()->row();return$r&&strtoupper($r->tracking_type)==='UNIT'?'UNIT':'QTY';}$r=$this->CI->db->get_where('customer_equipment_registry',array('id'=>(int)$subject['subject_id'],'company_id'=>$this->company()))->row();return$r&&strtoupper($r->tracking_type)==='QTY'?'QTY':'UNIT';} + private function subject(array$a){return array('subject_type'=>$a['subject_type'],'subject_id'=>(int)$a['subject_id'],'item_id'=>$a['item_id']?:null,'barcode_id'=>$a['barcode_id']?:null,'customer_equipment_id'=>$a['customer_equipment_id']?:null,'barcode_snapshot'=>$a['barcode_snapshot'],'ownership_type'=>$a['ownership_type']);} + private function assertReusableExternal(array$source,$customerId){if($source['subject_type']!=='customer_equipment')return;$item=$this->CI->db->get_where('customer_equipment_registry',array('id'=>(int)$source['subject_id'],'company_id'=>$this->company()))->row();if(!$item)throw new BusinessException('Register barang customer tidak ditemukan.');if((int)$item->owner_customer_id!==$customerId&&(!(int)$item->deployment_allowed||trim((string)$item->authorization_reference)===''))throw new BusinessException('Barang milik customer tidak memiliki otorisasi untuk dipasang pada customer lain.');} + private function addBalance(array$subject,$type,$id,$customer,$usage,$condition,$qty,$doc){$row=array_merge($subject,array('company_id'=>$this->company(),'custodian_type'=>$type,'custodian_id'=>$id?:null,'customer_id'=>$customer?:null,'usage_status'=>$usage,'condition_status'=>$condition,'qty'=>$qty,'version'=>1,'last_document_id'=>$doc?:null,'created_at'=>$this->now(),'updated_at'=>$this->now()));$row['balance_key']=$this->key($row);$existing=$this->CI->db->query('SELECT * FROM technician_custody_allocations WHERE balance_key=? FOR UPDATE',array($row['balance_key']))->row_array();if($existing){$this->CI->db->where(array('id'=>$existing['id'],'version'=>$existing['version']))->update('technician_custody_allocations',array('qty'=>(float)$existing['qty']+$qty,'version'=>(int)$existing['version']+1,'last_document_id'=>$doc?:$existing['last_document_id'],'updated_at'=>$this->now()));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Saldo tujuan berubah saat transaksi. Silakan ulangi.');$row['id']=$existing['id'];}else{$this->CI->db->insert('technician_custody_allocations',$row);$row['id']=(int)$this->CI->db->insert_id();}return$row;} + private function consume(array$a,$qty){$left=round((float)$a['qty']-$qty,4);if($left<-.0001)throw new BusinessException('Saldo posisi barang tidak mencukupi.');if($left<=.0001)$this->CI->db->where(array('id'=>$a['id'],'version'=>$a['version']))->delete('technician_custody_allocations');else$this->CI->db->where(array('id'=>$a['id'],'version'=>$a['version']))->update('technician_custody_allocations',array('qty'=>$left,'version'=>(int)$a['version']+1,'updated_at'=>$this->now()));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Saldo barang berubah saat transaksi. Silakan ulangi.');} + private function move(array$doc,array$source,$toType,$toId,$customer,$usage,$condition,$qty,$movementType,$notes,$writeMovement=true){$this->consume($source,$qty);$subject=$this->subject($source);$target=$this->addBalance($subject,$toType,$toId,$customer,$usage,$condition,$qty,$doc['id']);$movement=null;if($writeMovement&&$source['subject_type']==='company_barcode'){$fromType=in_array($source['custodian_type'],array('quarantine','service_center'),true)?'warehouse':$source['custodian_type'];$targetType=in_array($toType,array('quarantine','service_center'),true)?'warehouse':$toType;$movement=$this->movement($subject,$qty,$fromType,$source['custodian_id'],$targetType,$toId,$movementType,$doc['id'],'[AUTO] '.$doc['document_no'].' | '.$notes);}$this->line($doc,$subject,$this->tracking($subject),$doc['document_type'],$source['custodian_type'],$source['custodian_id'],$toType,$toId,$customer,$source['usage_status'],$usage,$source['condition_status'],$condition,$qty,$movement,$notes);if($source['subject_type']==='customer_equipment')$this->syncExternal($source['subject_id']);return$target;} + private function movement(array$subject,$qty,$fromType,$fromId,$toType,$toId,$movementType,$docId,$notes){$allowed=array('warehouse','technician','customer','supplier','invoice','asset');if(!in_array($fromType,$allowed,true)||!in_array($toType,$allowed,true))return null;$row=array('item_id'=>$subject['item_id'],'barcode_id'=>$subject['barcode_id'],'qty'=>$qty,'from_type'=>$fromType,'from_id'=>$fromId?:null,'to_type'=>$toType,'to_id'=>$toId?:null,'movement_type'=>$movementType,'reference_type'=>'technician_equipment','reference_id'=>$docId,'notes'=>mb_substr($notes,0,255),'created_at'=>$this->now());if($this->CI->db->field_exists('company_id','item_movements'))$row['company_id']=$this->company();$this->CI->db->insert('item_movements',$row);return(int)$this->CI->db->insert_id();} + private function line(array$doc,array$subject,$tracking,$action,$fromType,$fromId,$toType,$toId,$customer,$fromUsage,$toUsage,$fromCondition,$toCondition,$qty,$movement,$notes){$this->CI->db->insert('technician_equipment_document_lines',array('document_id'=>$doc['id'],'company_id'=>$this->company(),'subject_type'=>$subject['subject_type'],'subject_id'=>$subject['subject_id'],'item_id'=>$subject['item_id'],'barcode_id'=>$subject['barcode_id'],'customer_equipment_id'=>$subject['customer_equipment_id'],'barcode_snapshot'=>$subject['barcode_snapshot'],'ownership_type'=>$subject['ownership_type'],'tracking_type'=>$tracking,'action_type'=>$action,'from_custodian_type'=>$fromType,'from_custodian_id'=>$fromId?:null,'to_custodian_type'=>$toType,'to_custodian_id'=>$toId?:null,'customer_id'=>$customer?:null,'from_usage_status'=>$fromUsage,'to_usage_status'=>$toUsage,'from_condition_status'=>$fromCondition,'to_condition_status'=>$toCondition,'qty'=>$qty,'item_movement_id'=>$movement,'notes'=>$notes,'created_at'=>$this->now()));} + private function releaseInternal($barcodeId,$qty,$preferred){$b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$b)throw new BusinessException('Barcode perusahaan tidak ditemukan.');$reserved=max(0,round((float)$b->reserved_qty-$qty,4));$available=(float)$b->qty_sisa-$reserved-(float)$b->return_reserved_qty;$status=$available>.0001?$preferred:'installed';$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('reserved_qty'=>$reserved,'status'=>$status,'version'=>(int)$b->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode berubah saat pemeriksaan.');} + private function reserveInternal($barcodeId,$qty){$b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$b)throw new BusinessException('Barcode perusahaan tidak ditemukan.');$available=(float)$b->qty_sisa-(float)$b->reserved_qty-(float)$b->return_reserved_qty;if((float)$qty>$available+.0001)throw new BusinessException('Koreksi ditolak karena saldo gudang telah digunakan transaksi lain.');$reserved=round((float)$b->reserved_qty+(float)$qty,4);$status=((float)$b->qty_sisa-$reserved-(float)$b->return_reserved_qty)>.0001?'available':'installed';$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('reserved_qty'=>$reserved,'status'=>$status,'version'=>(int)$b->version+1));if($this->CI->db->affected_rows()!==1)throw new BusinessException('Barcode berubah saat koreksi.');} + private function refreshBarcodeStatus($barcodeId,$fallback){$b=$this->CI->db->query('SELECT * FROM item_barcodes WHERE id=? FOR UPDATE',array((int)$barcodeId))->row();if(!$b)return;$available=(float)$b->qty_sisa-(float)$b->reserved_qty-(float)$b->return_reserved_qty;$this->CI->db->where(array('id'=>$b->id,'version'=>$b->version))->update('item_barcodes',array('status'=>$available>.0001?'available':$fallback,'version'=>(int)$b->version+1));} + private function syncExternal($id){$alloc=$this->CI->db->where(array('company_id'=>$this->company(),'subject_type'=>'customer_equipment','subject_id'=>(int)$id))->where('qty >',0)->order_by('updated_at','DESC')->get('technician_custody_allocations')->result();if(!$alloc){$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->set('lifecycle_status','inactive')->set('updated_at',$this->now())->set('version','version+1',false)->update('customer_equipment_registry');return;}$types=array_unique(array_map(function($a){return$a->custodian_type;},$alloc));$conditions=array_unique(array_map(function($a){return$a->condition_status;},$alloc));$first=$alloc[0];$condition=count($conditions)>1?'mixed':$first->condition_status;$lifecycle=$condition==='inspection_required'?'quarantined':(in_array($condition,array('repair_required','under_repair'),true)?'under_repair':(in_array($condition,array('scrapped'),true)?'disposed':(in_array($condition,array('damaged','lost'),true)?'inactive':'active')));$this->CI->db->where(array('id'=>(int)$id,'company_id'=>$this->company()))->set(array('custody_type'=>count($types)>1?'mixed':$first->custodian_type,'custody_id'=>count($types)>1?null:$first->custodian_id,'current_customer_id'=>$first->customer_id,'usage_status'=>count($types)>1?'mixed':$first->usage_status,'condition_status'=>$condition,'lifecycle_status'=>$lifecycle,'updated_at'=>$this->now()))->set('version','version+1',false)->update('customer_equipment_registry');} +} diff --git a/application/libraries/TransactionService.php b/application/libraries/TransactionService.php new file mode 100644 index 0000000..d211faf --- /dev/null +++ b/application/libraries/TransactionService.php @@ -0,0 +1,29 @@ +CI =& get_instance(); + } + + public function run(callable $callback) + { + $this->CI->db->trans_begin(); + + try { + $result = $callback(); + if ($this->CI->db->trans_status() === false) { + throw new RuntimeException('Operasi database gagal.'); + } + $this->CI->db->trans_commit(); + return $result; + } catch (Throwable $exception) { + $this->CI->db->trans_rollback(); + throw $exception; + } + } +} diff --git a/application/migrations/.gitkeep b/application/migrations/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/application/migrations/.gitkeep @@ -0,0 +1 @@ + diff --git a/application/migrations/20260831000100_create_accounting_foundation.php b/application/migrations/20260831000100_create_accounting_foundation.php new file mode 100644 index 0000000..bb5d712 --- /dev/null +++ b/application/migrations/20260831000100_create_accounting_foundation.php @@ -0,0 +1,69 @@ +db->table_exists('document_sequences')) { + $this->dbforge->add_field(array( + 'id' => array('type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true), + 'document_type' => array('type' => 'VARCHAR', 'constraint' => 50), + 'period_key' => array('type' => 'VARCHAR', 'constraint' => 20), + 'prefix' => array('type' => 'VARCHAR', 'constraint' => 50), + 'current_value' => array('type' => 'BIGINT', 'unsigned' => true, 'default' => 0), + 'updated_at' => array('type' => 'DATETIME', 'null' => true) + )); + $this->dbforge->add_key('id', true); + $this->dbforge->create_table('document_sequences', true, array('ENGINE' => 'InnoDB')); + $this->db->query('ALTER TABLE document_sequences ADD UNIQUE KEY uq_document_sequence (document_type, period_key)'); + } + + if (!$this->db->table_exists('system_account_mappings')) { + $this->dbforge->add_field(array( + 'id' => array('type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true), + 'mapping_key' => array('type' => 'VARCHAR', 'constraint' => 100), + 'account_id' => array('type' => 'BIGINT', 'unsigned' => true), + 'description' => array('type' => 'VARCHAR', 'constraint' => 255, 'null' => true), + 'is_active' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 1), + 'created_at' => array('type' => 'DATETIME', 'null' => true), + 'updated_at' => array('type' => 'DATETIME', 'null' => true) + )); + $this->dbforge->add_key('id', true); + $this->dbforge->add_key('account_id'); + $this->dbforge->create_table('system_account_mappings', true, array('ENGINE' => 'InnoDB')); + $this->db->query('ALTER TABLE system_account_mappings ADD UNIQUE KEY uq_system_account_mapping (mapping_key)'); + } + + $mappings = array( + array('inventory', 21, 'Persediaan'), + array('cost_of_goods_sold', 63, 'Harga pokok penjualan'), + array('accounts_receivable', 19, 'Piutang usaha'), + array('deferred_revenue', 78, 'Pendapatan diterima di muka'), + array('inventory_in_sales', 80, 'Persediaan dalam penjualan'), + array('goods_revenue', 62, 'Pendapatan barang'), + array('customer_savings', 70, 'Tabungan pelanggan'), + array('depreciation_expense', 54, 'Beban penyusutan aset') + ); + + foreach ($mappings as $mapping) { + $accountExists = $this->db->where('id', $mapping[1])->count_all_results('accounts') > 0; + $mappingExists = $this->db->where('mapping_key', $mapping[0])->count_all_results('system_account_mappings') > 0; + if ($accountExists && !$mappingExists) { + $this->db->insert('system_account_mappings', array( + 'mapping_key' => $mapping[0], + 'account_id' => $mapping[1], + 'description' => $mapping[2], + 'is_active' => 1, + 'created_at' => date('Y-m-d H:i:s') + )); + } + } + } + + public function down() + { + $this->dbforge->drop_table('system_account_mappings', true); + $this->dbforge->drop_table('document_sequences', true); + } +} diff --git a/application/migrations/20260831000200_add_depreciation_account_mapping.php b/application/migrations/20260831000200_add_depreciation_account_mapping.php new file mode 100644 index 0000000..fd015fd --- /dev/null +++ b/application/migrations/20260831000200_add_depreciation_account_mapping.php @@ -0,0 +1,29 @@ +db->table_exists('system_account_mappings')) { + throw new RuntimeException('Jalankan migration accounting foundation terlebih dahulu.'); + } + + $exists = $this->db->where('mapping_key', 'depreciation_expense')->count_all_results('system_account_mappings') > 0; + $accountExists = $this->db->where('id', 54)->count_all_results('accounts') > 0; + if (!$exists && $accountExists) { + $this->db->insert('system_account_mappings', array( + 'mapping_key' => 'depreciation_expense', + 'account_id' => 54, + 'description' => 'Beban penyusutan aset', + 'is_active' => 1, + 'created_at' => date('Y-m-d H:i:s') + )); + } + } + + public function down() + { + $this->db->where('mapping_key', 'depreciation_expense')->delete('system_account_mappings'); + } +} diff --git a/application/migrations/20260831000300_create_fiscal_period_and_journal_workflow.php b/application/migrations/20260831000300_create_fiscal_period_and_journal_workflow.php new file mode 100644 index 0000000..f76e0bc --- /dev/null +++ b/application/migrations/20260831000300_create_fiscal_period_and_journal_workflow.php @@ -0,0 +1,180 @@ +db->table_exists('fiscal_years')) { + $this->dbforge->add_field(array( + 'id' => array('type' => 'INT', 'unsigned' => true, 'auto_increment' => true), + 'name' => array('type' => 'VARCHAR', 'constraint' => 50), + 'start_date' => array('type' => 'DATE'), + 'end_date' => array('type' => 'DATE'), + 'status' => array('type' => 'ENUM', 'constraint' => array('open', 'closed'), 'default' => 'open'), + 'created_at' => array('type' => 'DATETIME', 'null' => true), + 'created_by' => array('type' => 'INT', 'null' => true) + )); + $this->dbforge->add_key('id', true); + $this->dbforge->create_table('fiscal_years', true, array('ENGINE' => 'InnoDB')); + $this->db->query('ALTER TABLE fiscal_years ADD UNIQUE KEY uq_fiscal_year_dates (start_date, end_date)'); + } + + if (!$this->db->table_exists('accounting_periods')) { + $this->dbforge->add_field(array( + 'id' => array('type' => 'INT', 'unsigned' => true, 'auto_increment' => true), + 'fiscal_year_id' => array('type' => 'INT', 'unsigned' => true), + 'period_number' => array('type' => 'TINYINT', 'unsigned' => true), + 'name' => array('type' => 'VARCHAR', 'constraint' => 50), + 'start_date' => array('type' => 'DATE'), + 'end_date' => array('type' => 'DATE'), + 'status' => array('type' => 'ENUM', 'constraint' => array('open', 'soft_closed', 'closed'), 'default' => 'open'), + 'closed_at' => array('type' => 'DATETIME', 'null' => true), + 'closed_by' => array('type' => 'INT', 'null' => true), + 'reopened_at' => array('type' => 'DATETIME', 'null' => true), + 'reopened_by' => array('type' => 'INT', 'null' => true), + 'updated_at' => array('type' => 'DATETIME', 'null' => true) + )); + $this->dbforge->add_key('id', true); + $this->dbforge->add_key('fiscal_year_id'); + $this->dbforge->add_key(array('start_date', 'end_date')); + $this->dbforge->create_table('accounting_periods', true, array('ENGINE' => 'InnoDB')); + $this->db->query('ALTER TABLE accounting_periods ADD UNIQUE KEY uq_accounting_period (fiscal_year_id, period_number)'); + } + + if (!$this->db->table_exists('accounting_period_logs')) { + $this->dbforge->add_field(array( + 'id' => array('type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true), + 'accounting_period_id' => array('type' => 'INT', 'unsigned' => true), + 'from_status' => array('type' => 'VARCHAR', 'constraint' => 30, 'null' => true), + 'to_status' => array('type' => 'VARCHAR', 'constraint' => 30), + 'reason' => array('type' => 'VARCHAR', 'constraint' => 500, 'null' => true), + 'changed_by' => array('type' => 'INT'), + 'changed_at' => array('type' => 'DATETIME') + )); + $this->dbforge->add_key('id', true); + $this->dbforge->add_key('accounting_period_id'); + $this->dbforge->create_table('accounting_period_logs', true, array('ENGINE' => 'InnoDB')); + } + + $journalFields = array( + 'submitted_at' => array('type' => 'DATETIME', 'null' => true), + 'submitted_by' => array('type' => 'INT', 'null' => true), + 'approved_at' => array('type' => 'DATETIME', 'null' => true), + 'approved_by' => array('type' => 'INT', 'null' => true), + 'posted_at' => array('type' => 'DATETIME', 'null' => true), + 'posted_by' => array('type' => 'INT', 'null' => true), + 'rejected_at' => array('type' => 'DATETIME', 'null' => true), + 'rejected_by' => array('type' => 'INT', 'null' => true), + 'rejection_reason' => array('type' => 'VARCHAR', 'constraint' => 500, 'null' => true), + 'reversal_reason' => array('type' => 'VARCHAR', 'constraint' => 500, 'null' => true), + 'accounting_period_id' => array('type' => 'INT', 'unsigned' => true, 'null' => true) + ); + foreach ($journalFields as $name => $definition) { + if (!$this->db->field_exists($name, 'journals')) { + $this->dbforge->add_column('journals', array($name => $definition)); + } + } + + // MySQL 8 menolak ALTER pada tabel yang masih mempunyai zero-date legacy. + // Baris tetap dipertahankan dan tanggalnya dipulihkan dari created_at. + $this->db->query("CREATE TABLE IF NOT EXISTS legacy_migration_adjustments( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + entity_type VARCHAR(60) NOT NULL, + entity_id BIGINT UNSIGNED NOT NULL, + field_name VARCHAR(60) NOT NULL, + old_value VARCHAR(255) NULL, + new_value VARCHAR(255) NULL, + reason VARCHAR(255) NOT NULL, + adjusted_at DATETIME NOT NULL, + UNIQUE KEY uq_legacy_adjustment(entity_type,entity_id,field_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("INSERT IGNORE INTO legacy_migration_adjustments(entity_type,entity_id,field_name,old_value,new_value,reason,adjusted_at) + SELECT 'journal',id,'tanggal','0000-00-00', + IF(created_at IS NOT NULL AND created_at<>'0000-00-00 00:00:00',DATE(created_at),'2000-01-01'), + 'Normalisasi zero-date sebelum modernisasi workflow jurnal',NOW() + FROM journals WHERE tanggal='0000-00-00'"); + $this->db->query("UPDATE journals + SET tanggal=IF(created_at IS NOT NULL AND created_at<>'0000-00-00 00:00:00',DATE(created_at),'2000-01-01') + WHERE tanggal='0000-00-00'"); + + // Default posted menjaga kompatibilitas modul legacy. Jurnal manual baru + // secara eksplisit dibuat draft oleh JournalService. + $this->db->query("ALTER TABLE journals MODIFY status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'posted'"); + $this->db->query("UPDATE journals SET status = 'posted', posted_at = COALESCE(posted_at, created_at), posted_by = COALESCE(posted_by, created_by) WHERE status = 'posted'"); + $this->db->query('ALTER TABLE journals ADD INDEX idx_journals_status_date (status, tanggal)'); + $this->db->query('ALTER TABLE journals ADD INDEX idx_journals_period (accounting_period_id)'); + $this->db->query('ALTER TABLE journals ADD INDEX idx_journals_reversal (reversal_of)'); + + if (!$this->db->table_exists('journal_status_histories')) { + $this->dbforge->add_field(array( + 'id' => array('type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true), + 'journal_id' => array('type' => 'INT'), + 'from_status' => array('type' => 'VARCHAR', 'constraint' => 30, 'null' => true), + 'to_status' => array('type' => 'VARCHAR', 'constraint' => 30), + 'notes' => array('type' => 'VARCHAR', 'constraint' => 500, 'null' => true), + 'changed_by' => array('type' => 'INT'), + 'changed_at' => array('type' => 'DATETIME') + )); + $this->dbforge->add_key('id', true); + $this->dbforge->add_key('journal_id'); + $this->dbforge->create_table('journal_status_histories', true, array('ENGINE' => 'InnoDB')); + } + + $accountFields = array( + 'is_header' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 0), + 'allow_posting' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 1), + 'updated_at' => array('type' => 'DATETIME', 'null' => true), + 'updated_by' => array('type' => 'INT', 'null' => true) + ); + foreach ($accountFields as $name => $definition) { + if (!$this->db->field_exists($name, 'accounts')) { + $this->dbforge->add_column('accounts', array($name => $definition)); + } + } + $this->db->query('UPDATE accounts SET is_header = 1, allow_posting = 0 WHERE id IN (SELECT parent_id FROM (SELECT DISTINCT parent_id FROM accounts WHERE parent_id IS NOT NULL) p) AND id NOT IN (SELECT account_id FROM journal_details)'); + $this->db->query('ALTER TABLE accounts ADD UNIQUE KEY uq_accounts_code (kode_akun)'); + + $years = $this->db->query('SELECT DISTINCT YEAR(tanggal) year_number FROM journals UNION SELECT YEAR(CURDATE())')->result(); + foreach ($years as $yearRow) { + $year = (int) $yearRow->year_number; + if ($year < 2000 || $year > 2200) { + continue; + } + $start = $year . '-01-01'; + $end = $year . '-12-31'; + $fiscal = $this->db->get_where('fiscal_years', array('start_date' => $start, 'end_date' => $end))->row(); + if (!$fiscal) { + $this->db->insert('fiscal_years', array('name' => 'Tahun Buku ' . $year, 'start_date' => $start, 'end_date' => $end, 'status' => 'open', 'created_at' => date('Y-m-d H:i:s'))); + $fiscalId = (int) $this->db->insert_id(); + } else { + $fiscalId = (int) $fiscal->id; + } + for ($month = 1; $month <= 12; $month++) { + $periodStart = sprintf('%04d-%02d-01', $year, $month); + $periodEnd = date('Y-m-t', strtotime($periodStart)); + $exists = $this->db->get_where('accounting_periods', array('fiscal_year_id' => $fiscalId, 'period_number' => $month))->row(); + if (!$exists) { + $this->db->insert('accounting_periods', array( + 'fiscal_year_id' => $fiscalId, + 'period_number' => $month, + 'name' => date('F Y', strtotime($periodStart)), + 'start_date' => $periodStart, + 'end_date' => $periodEnd, + 'status' => 'open' + )); + } + } + } + $this->db->query('UPDATE journals j JOIN accounting_periods p ON j.tanggal BETWEEN p.start_date AND p.end_date SET j.accounting_period_id = p.id WHERE j.accounting_period_id IS NULL'); + } + + public function down() + { + // Workflow data is intentionally retained on rollback; destructive column drops are avoided. + $this->dbforge->drop_table('journal_status_histories', true); + $this->dbforge->drop_table('accounting_period_logs', true); + $this->dbforge->drop_table('accounting_periods', true); + $this->dbforge->drop_table('fiscal_years', true); + } +} diff --git a/application/migrations/20260831000400_preserve_legacy_posting_default.php b/application/migrations/20260831000400_preserve_legacy_posting_default.php new file mode 100644 index 0000000..e5f8965 --- /dev/null +++ b/application/migrations/20260831000400_preserve_legacy_posting_default.php @@ -0,0 +1,15 @@ +db->query("ALTER TABLE journals MODIFY status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'posted'"); + } + + public function down() + { + $this->db->query("ALTER TABLE journals MODIFY status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft'"); + } +} diff --git a/application/migrations/20260831000500_preserve_historical_posting_accounts.php b/application/migrations/20260831000500_preserve_historical_posting_accounts.php new file mode 100644 index 0000000..edcfe68 --- /dev/null +++ b/application/migrations/20260831000500_preserve_historical_posting_accounts.php @@ -0,0 +1,16 @@ +db->query('UPDATE accounts SET is_header = 0, allow_posting = 1 WHERE id IN (SELECT DISTINCT account_id FROM journal_details)'); + } + + public function down() + { + // Tidak mengembalikan inferensi lama karena dapat menonaktifkan akun transaksi aktif. + } +} diff --git a/application/migrations/20260831000600_enforce_period_on_legacy_journal_writes.php b/application/migrations/20260831000600_enforce_period_on_legacy_journal_writes.php new file mode 100644 index 0000000..3de9fc5 --- /dev/null +++ b/application/migrations/20260831000600_enforce_period_on_legacy_journal_writes.php @@ -0,0 +1,46 @@ +db->query('DROP TRIGGER IF EXISTS trg_journals_period_before_insert'); + $this->db->query(" + CREATE TRIGGER trg_journals_period_before_insert + BEFORE INSERT ON journals + FOR EACH ROW + BEGIN + DECLARE resolved_period_id INT DEFAULT NULL; + DECLARE resolved_period_status VARCHAR(30) DEFAULT NULL; + + SELECT id, status INTO resolved_period_id, resolved_period_status + FROM accounting_periods + WHERE NEW.tanggal BETWEEN start_date AND end_date + ORDER BY id ASC LIMIT 1; + + IF resolved_period_id IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Periode accounting belum tersedia untuk tanggal jurnal'; + END IF; + + IF resolved_period_status <> 'open' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Periode accounting tidak terbuka'; + END IF; + + IF NEW.accounting_period_id IS NULL THEN + SET NEW.accounting_period_id = resolved_period_id; + END IF; + + IF NEW.status = 'posted' THEN + SET NEW.posted_at = COALESCE(NEW.posted_at, NOW()); + SET NEW.posted_by = COALESCE(NEW.posted_by, NEW.created_by); + END IF; + END + "); + } + + public function down() + { + $this->db->query('DROP TRIGGER IF EXISTS trg_journals_period_before_insert'); + } +} diff --git a/application/migrations/20260831000700_seed_existing_journal_workflow_history.php b/application/migrations/20260831000700_seed_existing_journal_workflow_history.php new file mode 100644 index 0000000..b5378d5 --- /dev/null +++ b/application/migrations/20260831000700_seed_existing_journal_workflow_history.php @@ -0,0 +1,22 @@ +db->query(" + INSERT INTO journal_status_histories + (journal_id, from_status, to_status, notes, changed_by, changed_at) + SELECT j.id, NULL, j.status, 'Status awal hasil migrasi Tahap 3', j.created_by, j.created_at + FROM journals j + LEFT JOIN journal_status_histories h ON h.journal_id = j.id + WHERE h.id IS NULL + "); + } + + public function down() + { + $this->db->where('notes', 'Status awal hasil migrasi Tahap 3')->delete('journal_status_histories'); + } +} diff --git a/application/migrations/20260831000800_create_approval_and_audit_foundation.php b/application/migrations/20260831000800_create_approval_and_audit_foundation.php new file mode 100644 index 0000000..0d625f4 --- /dev/null +++ b/application/migrations/20260831000800_create_approval_and_audit_foundation.php @@ -0,0 +1,61 @@ +db->query("CREATE TABLE IF NOT EXISTS approval_workflows ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, code VARCHAR(50) NOT NULL, module VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, min_amount DECIMAL(18,2) NULL, + max_amount DECIMAL(18,2) NULL, require_separation TINYINT(1) NOT NULL DEFAULT 1, + is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, + UNIQUE KEY uq_approval_workflow_code (code), KEY idx_approval_workflow_lookup (module,entity_type,is_active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS approval_workflow_steps ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, workflow_id INT UNSIGNED NOT NULL, step_order INT UNSIGNED NOT NULL, + name VARCHAR(100) NOT NULL, approver_role_id INT NULL, approver_user_id INT NULL, + UNIQUE KEY uq_approval_step (workflow_id,step_order), KEY idx_approval_step_role (approver_role_id), + CONSTRAINT fk_approval_step_workflow FOREIGN KEY (workflow_id) REFERENCES approval_workflows(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS approval_requests ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, request_no VARCHAR(60) NOT NULL, workflow_id INT UNSIGNED NOT NULL, + entity_type VARCHAR(50) NOT NULL, entity_id BIGINT UNSIGNED NOT NULL, amount DECIMAL(18,2) NOT NULL DEFAULT 0, + requested_by INT NULL, requested_at DATETIME NOT NULL, current_step INT UNSIGNED NOT NULL DEFAULT 1, + status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', snapshot LONGTEXT NULL, + completed_at DATETIME NULL, UNIQUE KEY uq_approval_request_no (request_no), + KEY idx_approval_entity (entity_type,entity_id), KEY idx_approval_inbox (status,current_step), + CONSTRAINT fk_approval_request_workflow FOREIGN KEY (workflow_id) REFERENCES approval_workflows(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS approval_actions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, request_id BIGINT UNSIGNED NOT NULL, step_order INT UNSIGNED NOT NULL, + action ENUM('submitted','approved','rejected','cancelled') NOT NULL, user_id INT NULL, notes TEXT NULL, created_at DATETIME NOT NULL, + KEY idx_approval_action_request (request_id), + CONSTRAINT fk_approval_action_request FOREIGN KEY (request_id) REFERENCES approval_requests(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS immutable_audit_logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, module VARCHAR(50) NOT NULL, entity_type VARCHAR(50) NOT NULL, + entity_id VARCHAR(64) NOT NULL, action VARCHAR(50) NOT NULL, before_data LONGTEXT NULL, after_data LONGTEXT NULL, + user_id INT NULL, ip_address VARCHAR(45) NULL, user_agent VARCHAR(255) NULL, request_id VARCHAR(80) NULL, + previous_hash CHAR(64) NULL, record_hash CHAR(64) NOT NULL, created_at DATETIME NOT NULL, + KEY idx_audit_entity (entity_type,entity_id), KEY idx_audit_created (created_at), UNIQUE KEY uq_audit_hash (record_hash) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $admin = $this->db->where('nama_role', 'Admin')->get('roles')->row(); + if ($admin && !$this->db->where('code', 'JOURNAL_DEFAULT')->count_all_results('approval_workflows')) { + $this->db->insert('approval_workflows', array('code'=>'JOURNAL_DEFAULT','module'=>'journal','entity_type'=>'journal','name'=>'Persetujuan Jurnal','require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s'))); + $this->db->insert('approval_workflow_steps', array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id)); + } + $this->db->query("DROP TRIGGER IF EXISTS trg_immutable_audit_no_update"); + $this->db->query("CREATE TRIGGER trg_immutable_audit_no_update BEFORE UPDATE ON immutable_audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Audit log is immutable'"); + $this->db->query("DROP TRIGGER IF EXISTS trg_immutable_audit_no_delete"); + $this->db->query("CREATE TRIGGER trg_immutable_audit_no_delete BEFORE DELETE ON immutable_audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Audit log is immutable'"); + } + + public function down() + { + $this->db->query('DROP TRIGGER IF EXISTS trg_immutable_audit_no_update'); + $this->db->query('DROP TRIGGER IF EXISTS trg_immutable_audit_no_delete'); + foreach (array('approval_actions','approval_requests','approval_workflow_steps','approval_workflows','immutable_audit_logs') as $table) $this->db->query('DROP TABLE IF EXISTS '.$table); + } +} diff --git a/application/migrations/20260831000900_enforce_unique_journal_reference.php b/application/migrations/20260831000900_enforce_unique_journal_reference.php new file mode 100644 index 0000000..6a9d904 --- /dev/null +++ b/application/migrations/20260831000900_enforce_unique_journal_reference.php @@ -0,0 +1,36 @@ +db->query("CREATE TABLE IF NOT EXISTS legacy_migration_adjustments( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + entity_type VARCHAR(60) NOT NULL, + entity_id BIGINT UNSIGNED NOT NULL, + field_name VARCHAR(60) NOT NULL, + old_value VARCHAR(255) NULL, + new_value VARCHAR(255) NULL, + reason VARCHAR(255) NOT NULL, + adjusted_at DATETIME NOT NULL, + UNIQUE KEY uq_legacy_adjustment(entity_type,entity_id,field_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $fieldLength=100;foreach($this->db->field_data('journals')as$field)if($field->name==='no_ref'&&!empty($field->max_length)){$fieldLength=(int)$field->max_length;break;} + $duplicates=$this->db->query("SELECT j.id,j.no_ref + FROM journals j + JOIN(SELECT no_ref,MIN(id) retained_id,COUNT(*) total FROM journals GROUP BY no_ref HAVING COUNT(*)>1)d ON j.no_ref<=>d.no_ref + WHERE j.id<>d.retained_id ORDER BY j.id")->result(); + foreach($duplicates as$journal){ + $suffix='-J'.(int)$journal->id;$old=(string)$journal->no_ref; + $new=$old!==''?substr($old,0,max(0,$fieldLength-strlen($suffix))).$suffix:'LEGACY-J'.(int)$journal->id; + if($this->db->where('no_ref',$new)->where('id !=',(int)$journal->id)->count_all_results('journals'))$new='J'.str_pad((string)$journal->id,max(1,$fieldLength-1),'0',STR_PAD_LEFT); + $this->db->insert('legacy_migration_adjustments',array('entity_type'=>'journal','entity_id'=>(int)$journal->id,'field_name'=>'no_ref','old_value'=>$old,'new_value'=>$new,'reason'=>'Nomor referensi jurnal legacy duplikat; jurnal dipertahankan dan diberi identitas unik','adjusted_at'=>date('Y-m-d H:i:s'))); + $this->db->where('id',(int)$journal->id)->update('journals',array('no_ref'=>$new)); + } + $duplicate=$this->db->query('SELECT no_ref FROM journals GROUP BY no_ref HAVING COUNT(*) > 1 LIMIT 1')->row(); + if($duplicate) throw new RuntimeException('Normalisasi no_ref jurnal duplikat belum menghasilkan nomor unik.'); + $indexes=$this->db->query("SHOW INDEX FROM journals WHERE Key_name='uq_journals_no_ref'")->result(); + if(!$indexes) $this->db->query('ALTER TABLE journals ADD UNIQUE KEY uq_journals_no_ref (no_ref)'); + } + public function down() { $this->db->query('ALTER TABLE journals DROP INDEX uq_journals_no_ref'); } +} diff --git a/application/migrations/20260831001000_create_receivable_subledger.php b/application/migrations/20260831001000_create_receivable_subledger.php new file mode 100644 index 0000000..f1387f9 --- /dev/null +++ b/application/migrations/20260831001000_create_receivable_subledger.php @@ -0,0 +1,54 @@ +db->field_exists($column,$table))$this->db->query("ALTER TABLE `$table` ADD `$column` $sql");} + public function up() + { + $dup=$this->db->query('SELECT no_invoice FROM invoices GROUP BY no_invoice HAVING COUNT(*)>1 LIMIT 1')->row(); + if($dup)throw new RuntimeException('Nomor invoice duplikat: '.$dup->no_invoice.'. Rekonsiliasi sebelum Tahap 5.'); + $this->column('customers','credit_limit','DECIMAL(18,2) NOT NULL DEFAULT 0'); + $this->column('customers','payment_term_days','INT NOT NULL DEFAULT 30'); + $this->column('customers','is_credit_blocked','TINYINT(1) NOT NULL DEFAULT 0'); + $this->column('customers','tax_number','VARCHAR(50) NULL'); + $this->column('invoices','workflow_status',"ENUM('draft','submitted','approved','posted','cancelled','reversed') NOT NULL DEFAULT 'draft'"); + $this->column('invoices','subtotal_before_tax','DECIMAL(18,2) NOT NULL DEFAULT 0'); + $this->column('invoices','tax_total','DECIMAL(18,2) NOT NULL DEFAULT 0'); + $this->column('invoices','discount_total','DECIMAL(18,2) NOT NULL DEFAULT 0'); + $this->column('invoices','currency','CHAR(3) NOT NULL DEFAULT \'IDR\''); + $this->column('invoices','posted_at','DATETIME NULL'); $this->column('invoices','posted_by','INT NULL'); + $this->column('invoices','cancelled_at','DATETIME NULL'); $this->column('invoices','cancelled_by','INT NULL'); + $this->column('invoices','reversal_journal_id','INT NULL'); $this->column('invoices','version','INT NOT NULL DEFAULT 1'); + $this->column('invoice_details','tax_rate','DECIMAL(7,4) NOT NULL DEFAULT 0'); + $this->column('invoice_details','tax_amount','DECIMAL(18,2) NOT NULL DEFAULT 0'); + $this->column('invoice_details','discount_amount','DECIMAL(18,2) NOT NULL DEFAULT 0'); + $this->column('invoice_details','stock_posted_at','DATETIME NULL'); + $this->column('invoice_details','stock_log_id','INT NULL'); + $indexes=$this->db->query("SHOW INDEX FROM invoices WHERE Key_name='uq_invoices_no_invoice'")->result(); + if(!$indexes)$this->db->query('ALTER TABLE invoices ADD UNIQUE KEY uq_invoices_no_invoice(no_invoice)'); + + $this->db->query("CREATE TABLE IF NOT EXISTS sales_documents(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,document_type ENUM('quotation','sales_order') NOT NULL,document_no VARCHAR(60) NOT NULL,customer_id INT NOT NULL,document_date DATE NOT NULL,valid_until DATE NULL,status ENUM('draft','sent','accepted','rejected','cancelled','converted') NOT NULL DEFAULT 'draft',subtotal DECIMAL(18,2) NOT NULL DEFAULT 0,tax_total DECIMAL(18,2) NOT NULL DEFAULT 0,total DECIMAL(18,2) NOT NULL DEFAULT 0,notes TEXT NULL,converted_invoice_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,updated_at DATETIME NULL,UNIQUE KEY uq_sales_document_no(document_no),KEY idx_sales_customer(customer_id,status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS sales_document_lines(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,sales_document_id BIGINT UNSIGNED NOT NULL,item_id INT NULL,description VARCHAR(255) NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_price DECIMAL(18,2) NOT NULL,discount_amount DECIMAL(18,2) NOT NULL DEFAULT 0,tax_rate DECIMAL(7,4) NOT NULL DEFAULT 0,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,line_total DECIMAL(18,2) NOT NULL,CONSTRAINT fk_sales_line_document FOREIGN KEY(sales_document_id) REFERENCES sales_documents(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->column('payments','payment_no','VARCHAR(60) NULL'); $this->column('payments','customer_id','INT NULL'); + $this->column('payments','status',"ENUM('draft','posted','cancelled','reversed') NOT NULL DEFAULT 'posted'"); + $this->column('payments','reference_no','VARCHAR(100) NULL'); $this->column('payments','notes','TEXT NULL'); + $this->column('payments','created_by','INT NULL'); $this->column('payments','reversal_journal_id','INT NULL'); + $this->db->query("UPDATE payments p JOIN invoices i ON i.id=p.invoice_id SET p.customer_id=i.customer_id WHERE p.customer_id IS NULL"); + $this->db->query("UPDATE payments SET payment_no=CONCAT('LEG-PAY-',LPAD(id,8,'0')) WHERE payment_no IS NULL OR payment_no=''"); + $idx=$this->db->query("SHOW INDEX FROM payments WHERE Key_name='uq_payments_payment_no'")->result();if(!$idx)$this->db->query('ALTER TABLE payments ADD UNIQUE KEY uq_payments_payment_no(payment_no)'); + $this->db->query("CREATE TABLE IF NOT EXISTS payment_allocations(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payment_id INT NOT NULL,invoice_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_payment_invoice(payment_id,invoice_id),KEY idx_allocation_invoice(invoice_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("INSERT IGNORE INTO payment_allocations(payment_id,invoice_id,amount,created_at) SELECT id,invoice_id,jumlah,created_at FROM payments WHERE invoice_id IS NOT NULL AND jumlah>0"); + $this->db->query("CREATE TABLE IF NOT EXISTS customer_advances(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,advance_no VARCHAR(60) NOT NULL,customer_id INT NOT NULL,payment_id INT NULL,original_amount DECIMAL(18,2) NOT NULL,available_amount DECIMAL(18,2) NOT NULL,status ENUM('available','partially_used','used','refunded') NOT NULL DEFAULT 'available',journal_id INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_advance_no(advance_no),KEY idx_advance_customer(customer_id,status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS credit_notes(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,credit_note_no VARCHAR(60) NOT NULL,invoice_id INT NOT NULL,customer_id INT NOT NULL,credit_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('draft','approved','posted','cancelled','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,reversal_journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_credit_note_no(credit_note_no),KEY idx_credit_invoice(invoice_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS customer_refunds(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,refund_no VARCHAR(60) NOT NULL,customer_id INT NOT NULL,advance_id BIGINT UNSIGNED NULL,credit_note_id BIGINT UNSIGNED NULL,refund_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,bank_account_id INT NOT NULL,reason TEXT NOT NULL,status ENUM('draft','posted','cancelled','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_refund_no(refund_no)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS receivable_writeoffs(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,writeoff_no VARCHAR(60) NOT NULL,invoice_id INT NOT NULL,writeoff_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('draft','approved','posted','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_writeoff_no(writeoff_no)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS receivable_reminders(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,invoice_id INT NOT NULL,channel ENUM('email','whatsapp','phone','manual') NOT NULL,recipient VARCHAR(150) NULL,message TEXT NULL,status ENUM('queued','sent','failed','cancelled') NOT NULL DEFAULT 'queued',scheduled_at DATETIME NULL,sent_at DATETIME NULL,created_by INT NULL,created_at DATETIME NOT NULL,KEY idx_reminder_queue(status,scheduled_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS transaction_attachments(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,module VARCHAR(50) NOT NULL,entity_type VARCHAR(50) NOT NULL,entity_id BIGINT UNSIGNED NOT NULL,original_name VARCHAR(255) NOT NULL,stored_name VARCHAR(255) NOT NULL,mime_type VARCHAR(100) NOT NULL,file_size BIGINT UNSIGNED NOT NULL,sha256 CHAR(64) NOT NULL,uploaded_by INT NULL,created_at DATETIME NOT NULL,KEY idx_attachment_entity(entity_type,entity_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("UPDATE invoices SET workflow_status=CASE WHEN status='draft' THEN 'draft' ELSE 'posted' END, subtotal_before_tax=total, posted_at=CASE WHEN status<>'draft' THEN created_at ELSE NULL END WHERE workflow_status='draft'"); + $this->db->query("UPDATE invoice_details d SET stock_posted_at=COALESCE((SELECT MIN(s.created_at) FROM stock_logs s WHERE s.ref_type='invoice_details' AND s.ref_id=d.id),stock_posted_at),stock_log_id=COALESCE((SELECT MIN(s.id) FROM stock_logs s WHERE s.ref_type='invoice_details' AND s.ref_id=d.id),stock_log_id)"); + $this->db->query("INSERT IGNORE INTO approval_workflows(code,module,entity_type,name,require_separation,is_active,created_at) VALUES('INVOICE_DEFAULT','invoice','invoice','Persetujuan Invoice',1,1,NOW()),('CREDIT_NOTE_DEFAULT','receivable','credit_note','Persetujuan Credit Note',1,1,NOW()),('WRITEOFF_DEFAULT','receivable','writeoff','Persetujuan Write-off Piutang',1,1,NOW())"); + $admin=$this->db->where('nama_role','Admin')->get('roles')->row();if($admin){foreach(array('INVOICE_DEFAULT','CREDIT_NOTE_DEFAULT','WRITEOFF_DEFAULT') as $code){$w=$this->db->get_where('approval_workflows',array('code'=>$code))->row();if($w&&!$this->db->where('workflow_id',$w->id)->count_all_results('approval_workflow_steps'))$this->db->insert('approval_workflow_steps',array('workflow_id'=>$w->id,'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));}} + } + public function down(){throw new RuntimeException('Migration Tahap 5 tidak mendukung down otomatis karena memuat backfill subledger. Gunakan backup.');} +} diff --git a/application/migrations/20260831001100_seed_receivable_control_accounts.php b/application/migrations/20260831001100_seed_receivable_control_accounts.php new file mode 100644 index 0000000..f6605e4 --- /dev/null +++ b/application/migrations/20260831001100_seed_receivable_control_accounts.php @@ -0,0 +1,15 @@ +db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[3]==='expense'?'debit':'kredit','kategori'=>$d[3]==='expense'?'laba_rugi':'neraca','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[4])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[4],'account_id'=>$id,'description'=>$d[5],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));} + } + public function down(){foreach(array('sales_tax_payable','customer_advances','bad_debt_expense') as $k)$this->db->where('mapping_key',$k)->delete('system_account_mappings');} +} diff --git a/application/migrations/20260901000100_enforce_invoice_deletion_control.php b/application/migrations/20260901000100_enforce_invoice_deletion_control.php new file mode 100644 index 0000000..0fc8500 --- /dev/null +++ b/application/migrations/20260901000100_enforce_invoice_deletion_control.php @@ -0,0 +1,11 @@ +db->query("ALTER TABLE invoices MODIFY status ENUM('draft','unpaid','partial','paid','cancelled') NOT NULL DEFAULT 'draft'"); + $this->db->query('DROP TRIGGER IF EXISTS trg_invoices_no_delete_posted'); + $this->db->query("CREATE TRIGGER trg_invoices_no_delete_posted BEFORE DELETE ON invoices FOR EACH ROW BEGIN IF OLD.workflow_status <> 'draft' OR OLD.posted=1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Posted/submitted invoice cannot be deleted'; END IF; END"); + } + public function down(){$this->db->query('DROP TRIGGER IF EXISTS trg_invoices_no_delete_posted');} +} diff --git a/application/migrations/20260901000200_enforce_automatic_journal_description.php b/application/migrations/20260901000200_enforce_automatic_journal_description.php new file mode 100644 index 0000000..4cbaded --- /dev/null +++ b/application/migrations/20260901000200_enforce_automatic_journal_description.php @@ -0,0 +1,10 @@ +db->query('DROP TRIGGER IF EXISTS trg_journals_auto_description'); + $this->db->query("CREATE TRIGGER trg_journals_auto_description BEFORE INSERT ON journals FOR EACH ROW BEGIN IF COALESCE(NEW.ref_type,'') NOT IN('','manual_journal') AND COALESCE(NEW.keterangan,'') NOT LIKE '[AUTO]%' THEN SET NEW.keterangan=CONCAT('[AUTO] ',COALESCE(NULLIF(NEW.keterangan,''),'Jurnal otomatis'),' | Tanggal transaksi: ',DATE_FORMAT(NEW.tanggal,'%Y-%m-%d'),' | Sumber: ',NEW.ref_type,'#',COALESCE(CAST(NEW.ref_id AS CHAR),'-')); END IF; END"); + } + public function down(){$this->db->query('DROP TRIGGER IF EXISTS trg_journals_auto_description');} +} diff --git a/application/migrations/20260901000300_create_purchase_and_payable_subledger.php b/application/migrations/20260901000300_create_purchase_and_payable_subledger.php new file mode 100644 index 0000000..1ddf306 --- /dev/null +++ b/application/migrations/20260901000300_create_purchase_and_payable_subledger.php @@ -0,0 +1,27 @@ +"id INT AUTO_INCREMENT PRIMARY KEY,supplier_code VARCHAR(40) NOT NULL,name VARCHAR(150) NOT NULL,tax_number VARCHAR(50) NULL,address TEXT NULL,contact_person VARCHAR(100) NULL,phone VARCHAR(30) NULL,email VARCHAR(120) NULL,bank_name VARCHAR(100) NULL,bank_account_no VARCHAR(80) NULL,bank_account_name VARCHAR(120) NULL,payment_term_days INT NOT NULL DEFAULT 30,is_active TINYINT(1) NOT NULL DEFAULT 1,opening_balance DECIMAL(18,2) NOT NULL DEFAULT 0,opening_balance_date DATE NULL,created_by INT NULL,created_at DATETIME NOT NULL,updated_at DATETIME NULL,UNIQUE KEY uq_supplier_code(supplier_code),KEY idx_supplier_active(is_active)", + "purchase_requests"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,request_no VARCHAR(60) NOT NULL,request_date DATE NOT NULL,needed_date DATE NULL,department VARCHAR(100) NULL,purpose TEXT NOT NULL,status ENUM('draft','submitted','approved','rejected','cancelled','converted') NOT NULL DEFAULT 'draft',requested_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_request_no(request_no)", + "purchase_request_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_request_id BIGINT UNSIGNED NOT NULL,item_id INT NULL,description VARCHAR(255) NOT NULL,qty DECIMAL(18,4) NOT NULL,estimated_price DECIMAL(18,2) NOT NULL DEFAULT 0,ordered_qty DECIMAL(18,4) NOT NULL DEFAULT 0,CONSTRAINT fk_pr_line_header FOREIGN KEY(purchase_request_id) REFERENCES purchase_requests(id)", + "purchase_orders"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,po_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,purchase_request_id BIGINT UNSIGNED NULL,order_date DATE NOT NULL,expected_date DATE NULL,status ENUM('draft','submitted','approved','partially_received','received','closed','cancelled') NOT NULL DEFAULT 'draft',subtotal DECIMAL(18,2) NOT NULL DEFAULT 0,tax_total DECIMAL(18,2) NOT NULL DEFAULT 0,total DECIMAL(18,2) NOT NULL DEFAULT 0,notes TEXT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_order_no(po_no),KEY idx_po_supplier(supplier_id,status)", + "purchase_order_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_order_id BIGINT UNSIGNED NOT NULL,request_line_id BIGINT UNSIGNED NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,description VARCHAR(255) NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_price DECIMAL(18,2) NOT NULL,tax_rate DECIMAL(7,4) NOT NULL DEFAULT 0,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,line_total DECIMAL(18,2) NOT NULL,received_qty DECIMAL(18,4) NOT NULL DEFAULT 0,invoiced_qty DECIMAL(18,4) NOT NULL DEFAULT 0,CONSTRAINT fk_po_line_header FOREIGN KEY(purchase_order_id) REFERENCES purchase_orders(id)", + "goods_receipts"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,receipt_no VARCHAR(60) NOT NULL,purchase_order_id BIGINT UNSIGNED NOT NULL,supplier_id INT NOT NULL,receipt_date DATE NOT NULL,supplier_delivery_no VARCHAR(100) NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',notes TEXT NULL,received_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_goods_receipt_no(receipt_no),KEY idx_receipt_po(purchase_order_id,status)", + "goods_receipt_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,goods_receipt_id BIGINT UNSIGNED NOT NULL,po_line_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,stock_log_id INT NULL,UNIQUE KEY uq_receipt_po_line(goods_receipt_id,po_line_id),CONSTRAINT fk_receipt_line_header FOREIGN KEY(goods_receipt_id) REFERENCES goods_receipts(id)", + "supplier_invoices"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_invoice_no VARCHAR(100) NOT NULL,internal_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,invoice_date DATE NOT NULL,due_date DATE NOT NULL,status ENUM('draft','submitted','approved','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'draft',subtotal DECIMAL(18,2) NOT NULL DEFAULT 0,tax_total DECIMAL(18,2) NOT NULL DEFAULT 0,total DECIMAL(18,2) NOT NULL DEFAULT 0,paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,balance DECIMAL(18,2) NOT NULL DEFAULT 0,journal_id INT NULL,reversal_journal_id INT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,posted_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_invoice(supplier_id,supplier_invoice_no),UNIQUE KEY uq_supplier_invoice_internal(internal_no),KEY idx_supplier_invoice_aging(supplier_id,due_date,status)", + "supplier_invoice_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_invoice_id BIGINT UNSIGNED NOT NULL,po_line_id BIGINT UNSIGNED NOT NULL,receipt_line_id BIGINT UNSIGNED NULL,item_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_price DECIMAL(18,2) NOT NULL,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,line_total DECIMAL(18,2) NOT NULL,match_status ENUM('matched','qty_variance','price_variance','unmatched') NOT NULL DEFAULT 'unmatched',CONSTRAINT fk_supplier_invoice_line FOREIGN KEY(supplier_invoice_id) REFERENCES supplier_invoices(id)", + "supplier_payments"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payment_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,payment_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,bank_account_id INT NOT NULL,status ENUM('posted','reversed','cancelled') NOT NULL DEFAULT 'posted',reference_no VARCHAR(100) NULL,notes TEXT NULL,journal_id INT NOT NULL,reversal_journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_payment_no(payment_no)", + "supplier_payment_allocations"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_payment_id BIGINT UNSIGNED NOT NULL,supplier_invoice_id BIGINT UNSIGNED NOT NULL,amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_payment_allocation(supplier_payment_id,supplier_invoice_id)", + "supplier_advances"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,advance_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,supplier_payment_id BIGINT UNSIGNED NULL,original_amount DECIMAL(18,2) NOT NULL,available_amount DECIMAL(18,2) NOT NULL,status ENUM('available','partially_used','used','refunded') NOT NULL DEFAULT 'available',journal_id INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_advance_no(advance_no)", + "purchase_returns"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,return_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,goods_receipt_id BIGINT UNSIGNED NOT NULL,return_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_return_no(return_no)", + "purchase_return_lines"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_return_id BIGINT UNSIGNED NOT NULL,receipt_line_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,2) NOT NULL,stock_log_id INT NULL,CONSTRAINT fk_purchase_return_header FOREIGN KEY(purchase_return_id) REFERENCES purchase_returns(id)", + "supplier_debit_notes"=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,debit_note_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,supplier_invoice_id BIGINT UNSIGNED NOT NULL,note_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_debit_note_no(debit_note_no)" + );foreach($sql as$t=>$fields)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($fields) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");$this->seedAccounts();$this->seedWorkflows(); + } + private function seedAccounts(){foreach(array(array('accounts_payable',34,'Hutang usaha supplier'),array('purchase_input_tax',null,'Pajak masukan pembelian'),array('supplier_advances',null,'Uang muka supplier'))as$d){$id=$d[1];if(!$id){$code=$d[0]==='purchase_input_tax'?'1191':'1192';$name=$d[0]==='purchase_input_tax'?'Pajak Masukan':'Uang Muka Supplier';$a=$this->db->get_where('accounts',array('kode_akun'=>$code))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$code,'nama_akun'=>$name,'tipe'=>'asset','posisi'=>'debit','kategori'=>'neraca','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;}if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}} + private function seedWorkflows(){$admin=$this->db->where('nama_role','Admin')->get('roles')->row();foreach(array(array('PURCHASE_REQUEST_DEFAULT','purchase','purchase_request','Persetujuan Purchase Request'),array('PURCHASE_ORDER_DEFAULT','purchase','purchase_order','Persetujuan Purchase Order'),array('SUPPLIER_INVOICE_DEFAULT','payable','supplier_invoice','Persetujuan Tagihan Supplier'))as$w){if(!$this->db->where('code',$w[0])->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>$w[0],'module'=>$w[1],'entity_type'=>$w[2],'name'=>$w[3],'require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));$id=$this->db->insert_id();if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$id,'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));}}} + public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 6.');} +} diff --git a/application/migrations/20260901000400_add_payable_opening_balance_account.php b/application/migrations/20260901000400_add_payable_opening_balance_account.php new file mode 100644 index 0000000..0e4b7b6 --- /dev/null +++ b/application/migrations/20260901000400_add_payable_opening_balance_account.php @@ -0,0 +1,7 @@ +db->get_where('accounts',array('kode_akun'=>'3191'))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>'3191','nama_akun'=>'Saldo Awal Hutang','tipe'=>'equity','posisi'=>'kredit','kategori'=>'neraca','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key','payable_opening_balance')->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>'payable_opening_balance','account_id'=>$id,'description'=>'Lawan jurnal saldo awal hutang supplier','is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));} + public function down(){$this->db->where('mapping_key','payable_opening_balance')->delete('system_account_mappings');} +} diff --git a/application/migrations/20260901000500_create_professional_inventory_ledger.php b/application/migrations/20260901000500_create_professional_inventory_ledger.php new file mode 100644 index 0000000..359c085 --- /dev/null +++ b/application/migrations/20260901000500_create_professional_inventory_ledger.php @@ -0,0 +1,29 @@ +db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");} + public function up(){ + $this->db->query('ALTER TABLE stock_logs MODIFY qty DECIMAL(18,4) NOT NULL');$this->db->query('ALTER TABLE items MODIFY stok DECIMAL(18,4) NOT NULL DEFAULT 0'); + $this->col('items','costing_method',"ENUM('average','fifo') NOT NULL DEFAULT 'average'");$this->col('items','minimum_stock','DECIMAL(18,4) NOT NULL DEFAULT 0');$this->col('items','reorder_point','DECIMAL(18,4) NOT NULL DEFAULT 0');$this->col('items','last_movement_at','DATETIME NULL'); + $this->col('stock_logs','idempotency_key','VARCHAR(100) NULL');$this->col('stock_logs','unit_cost','DECIMAL(18,4) NULL'); + $this->col('item_barcodes','reserved_qty','DECIMAL(18,4) NOT NULL DEFAULT 0');$this->col('item_barcodes','batch_id','BIGINT UNSIGNED NULL');$this->col('item_barcodes','bin_id','BIGINT UNSIGNED NULL');$this->col('item_barcodes','version','INT NOT NULL DEFAULT 1'); + if($this->db->query("SELECT COUNT(*) total FROM(SELECT barcode FROM item_barcodes GROUP BY barcode HAVING COUNT(*)>1)x")->row()->total)throw new RuntimeException('Barcode duplikat ditemukan. Rekonsiliasi sebelum Tahap 7.'); + if(!$this->db->query("SHOW INDEX FROM item_barcodes WHERE Key_name='uq_item_barcode'")->result())$this->db->query('ALTER TABLE item_barcodes ADD UNIQUE KEY uq_item_barcode(barcode)'); + $tables=array( + 'warehouse_bins'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,warehouse_id INT NOT NULL,code VARCHAR(50) NOT NULL,name VARCHAR(100) NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_warehouse_bin(warehouse_id,code)", + 'inventory_batches'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,item_id INT NOT NULL,batch_no VARCHAR(100) NOT NULL,manufactured_date DATE NULL,expiry_date DATE NULL,supplier_id INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_item_batch(item_id,batch_no)", + 'stock_documents'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,document_no VARCHAR(60) NOT NULL,document_type ENUM('opening','in','out','transfer','adjustment','opname','sales_return','purchase_return','damaged','lost') NOT NULL,document_date DATE NOT NULL,from_warehouse_id INT NULL,to_warehouse_id INT NULL,status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft',reason TEXT NULL,idempotency_key VARCHAR(100) NOT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,posted_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_stock_document_no(document_no),UNIQUE KEY uq_stock_document_idempotency(idempotency_key)", + 'stock_document_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,stock_document_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,barcode_id INT NULL,batch_id BIGINT UNSIGNED NULL,from_bin_id BIGINT UNSIGNED NULL,to_bin_id BIGINT UNSIGNED NULL,qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0,system_qty DECIMAL(18,4) NULL,counted_qty DECIMAL(18,4) NULL,reason VARCHAR(255) NULL,CONSTRAINT fk_stock_doc_line_header FOREIGN KEY(stock_document_id) REFERENCES stock_documents(id)", + 'inventory_ledger'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,item_id INT NOT NULL,warehouse_id INT NOT NULL,bin_id BIGINT UNSIGNED NULL,barcode_id INT NULL,batch_id BIGINT UNSIGNED NULL,movement_date DATE NOT NULL,direction ENUM('in','out') NOT NULL,qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,4) NOT NULL,value DECIMAL(18,4) NOT NULL,document_type VARCHAR(50) NOT NULL,document_id BIGINT UNSIGNED NOT NULL,document_line_id BIGINT UNSIGNED NULL,idempotency_key VARCHAR(120) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_inventory_ledger_idempotency(idempotency_key),KEY idx_inventory_balance(item_id,warehouse_id,movement_date),KEY idx_inventory_barcode(barcode_id)", + 'stock_reservations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,reservation_no VARCHAR(60) NOT NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,barcode_id INT NULL,source_type VARCHAR(50) NOT NULL,source_id BIGINT UNSIGNED NOT NULL,qty DECIMAL(18,4) NOT NULL,released_qty DECIMAL(18,4) NOT NULL DEFAULT 0,status ENUM('active','partially_released','released','consumed','cancelled') NOT NULL DEFAULT 'active',expires_at DATETIME NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_reservation_no(reservation_no),UNIQUE KEY uq_reservation_source(source_type,source_id,item_id,warehouse_id,barcode_id)", + 'inventory_cost_layers'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,item_id INT NOT NULL,warehouse_id INT NOT NULL,source_ledger_id BIGINT UNSIGNED NOT NULL,received_date DATE NOT NULL,original_qty DECIMAL(18,4) NOT NULL,remaining_qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,4) NOT NULL,status ENUM('open','consumed') NOT NULL DEFAULT 'open',KEY idx_fifo_layer(item_id,warehouse_id,status,received_date)", + 'inventory_reconciliations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,as_of_date DATE NOT NULL,ledger_value DECIMAL(18,2) NOT NULL,gl_value DECIMAL(18,2) NOT NULL,difference DECIMAL(18,2) NOT NULL,status ENUM('matched','variance','resolved') NOT NULL,notes TEXT NULL,created_by INT NULL,created_at DATETIME NOT NULL" + );foreach($tables as$t=>$f)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($f) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("INSERT IGNORE INTO inventory_ledger(item_id,warehouse_id,barcode_id,movement_date,direction,qty,unit_cost,value,document_type,document_id,idempotency_key,created_at) SELECT s.item_id,COALESCE(s.warehouse_id,0),s.barcode_id,DATE(s.created_at),IF(s.tipe='masuk','in','out'),s.qty,COALESCE(NULLIF(s.unit_cost,0),i.harga_beli),s.qty*COALESCE(NULLIF(s.unit_cost,0),i.harga_beli),COALESCE(NULLIF(s.ref_type,''),'legacy_stock_log'),s.id,CONCAT('LEGACY-STOCK-',s.id),s.created_at FROM stock_logs s JOIN items i ON i.id=s.item_id"); + $this->db->query("INSERT IGNORE INTO inventory_cost_layers(item_id,warehouse_id,source_ledger_id,received_date,original_qty,remaining_qty,unit_cost,status) SELECT l.item_id,l.warehouse_id,MIN(l.id),MIN(l.movement_date),GREATEST(SUM(IF(l.direction='in',l.qty,-l.qty)),0),GREATEST(SUM(IF(l.direction='in',l.qty,-l.qty)),0),COALESCE(SUM(IF(l.direction='in',l.value,0))/NULLIF(SUM(IF(l.direction='in',l.qty,0)),0),0),IF(SUM(IF(l.direction='in',l.qty,-l.qty))>0,'open','consumed') FROM inventory_ledger l GROUP BY l.item_id,l.warehouse_id"); + $this->db->query('DROP TRIGGER IF EXISTS trg_inventory_ledger_no_update');$this->db->query("CREATE TRIGGER trg_inventory_ledger_no_update BEFORE UPDATE ON inventory_ledger FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inventory ledger is immutable'");$this->db->query('DROP TRIGGER IF EXISTS trg_inventory_ledger_no_delete');$this->db->query("CREATE TRIGGER trg_inventory_ledger_no_delete BEFORE DELETE ON inventory_ledger FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inventory ledger is immutable'"); + $admin=$this->db->where('nama_role','Admin')->get('roles')->row();if(!$this->db->where('code','STOCK_VARIANCE_DEFAULT')->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>'STOCK_VARIANCE_DEFAULT','module'=>'inventory','entity_type'=>'stock_document','name'=>'Persetujuan Selisih Stok','require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));} + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 7.');} +} diff --git a/application/migrations/20260901000600_seed_inventory_adjustment_accounts.php b/application/migrations/20260901000600_seed_inventory_adjustment_accounts.php new file mode 100644 index 0000000..d9f8bdf --- /dev/null +++ b/application/migrations/20260901000600_seed_inventory_adjustment_accounts.php @@ -0,0 +1,7 @@ +db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[3]==='expense'?'debit':'kredit','kategori'=>'laba_rugi','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}} + public function down(){foreach(array('inventory_adjustment_gain','inventory_adjustment_loss')as$k)$this->db->where('mapping_key',$k)->delete('system_account_mappings');} +} diff --git a/application/migrations/20260901000700_enforce_inventory_concurrency_guards.php b/application/migrations/20260901000700_enforce_inventory_concurrency_guards.php new file mode 100644 index 0000000..a473cce --- /dev/null +++ b/application/migrations/20260901000700_enforce_inventory_concurrency_guards.php @@ -0,0 +1,13 @@ +db->query("SHOW INDEX FROM stock_logs WHERE Key_name='uq_stock_log_idempotency'")->result())$this->db->query('ALTER TABLE stock_logs ADD UNIQUE KEY uq_stock_log_idempotency(idempotency_key)'); + $this->db->query('DROP TRIGGER IF EXISTS trg_stock_logs_prevent_negative'); + $this->db->query("CREATE TRIGGER trg_stock_logs_prevent_negative BEFORE INSERT ON stock_logs FOR EACH ROW BEGIN DECLARE locked_item INT;DECLARE available_qty DECIMAL(18,4);IF NEW.tipe='keluar' THEN SELECT id INTO locked_item FROM items WHERE id=NEW.item_id FOR UPDATE;SELECT COALESCE(SUM(IF(tipe='masuk',qty,-qty)),0) INTO available_qty FROM stock_logs WHERE item_id=NEW.item_id AND (NEW.warehouse_id IS NULL OR warehouse_id=NEW.warehouse_id);IF available_qtydb->query('DROP TRIGGER IF EXISTS trg_item_barcodes_qty_guard'); + $this->db->query("CREATE TRIGGER trg_item_barcodes_qty_guard BEFORE UPDATE ON item_barcodes FOR EACH ROW BEGIN IF NEW.qty_sisa<0 OR NEW.reserved_qty<0 OR NEW.reserved_qty>NEW.qty_sisa THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Invalid or negative barcode quantity';END IF;END"); + } + public function down(){$this->db->query('DROP TRIGGER IF EXISTS trg_stock_logs_prevent_negative');$this->db->query('DROP TRIGGER IF EXISTS trg_item_barcodes_qty_guard');$this->db->query('ALTER TABLE stock_logs DROP INDEX uq_stock_log_idempotency');} +} diff --git a/application/migrations/20260901000800_backfill_inventory_movement_dates.php b/application/migrations/20260901000800_backfill_inventory_movement_dates.php new file mode 100644 index 0000000..116730b --- /dev/null +++ b/application/migrations/20260901000800_backfill_inventory_movement_dates.php @@ -0,0 +1,7 @@ +db->query("UPDATE items i SET last_movement_at=(SELECT MAX(s.created_at) FROM stock_logs s WHERE s.item_id=i.id)");} + public function down(){} +} diff --git a/application/migrations/20260901000900_create_fixed_asset_subledger.php b/application/migrations/20260901000900_create_fixed_asset_subledger.php new file mode 100644 index 0000000..d0f137d --- /dev/null +++ b/application/migrations/20260901000900_create_fixed_asset_subledger.php @@ -0,0 +1,24 @@ +db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");} + public function up(){ + foreach(array('category_id'=>'BIGINT UNSIGNED NULL','responsible_user_id'=>'INT NULL','source_type'=>"ENUM('purchase','warehouse','manual') NOT NULL DEFAULT 'manual'",'source_id'=>'BIGINT UNSIGNED NULL','capitalization_date'=>'DATE NULL','depreciation_method'=>"ENUM('straight_line','declining_balance') NOT NULL DEFAULT 'straight_line'",'asset_account_id'=>'INT NULL','accumulated_depreciation_account_id'=>'INT NULL','depreciation_expense_account_id'=>'INT NULL','impairment_total'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','revaluation_total'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','disposal_date'=>'DATE NULL','disposal_proceeds'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','lifecycle_status'=>"ENUM('draft','active','fully_depreciated','disposed','lost','damaged') NOT NULL DEFAULT 'active'",'qr_code'=>'VARCHAR(120) NULL','version'=>'INT NOT NULL DEFAULT 1') as$c=>$s)$this->col('assets',$c,$s); + $this->db->query("UPDATE assets SET capitalization_date=COALESCE(capitalization_date,tanggal_perolehan),source_type=IF(sumber='gudang','warehouse','manual'),asset_account_id=COALESCE(asset_account_id,account_debit_id),qr_code=COALESCE(qr_code,CONCAT('ASSET:',kode_asset)),lifecycle_status=IF(status='aktif','active','disposed')"); + $tables=array( + 'asset_categories'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,code VARCHAR(30) NOT NULL,name VARCHAR(120) NOT NULL,default_useful_life_months INT NOT NULL DEFAULT 48,default_residual_percent DECIMAL(7,4) NOT NULL DEFAULT 0,depreciation_method ENUM('straight_line','declining_balance') NOT NULL DEFAULT 'straight_line',asset_account_id INT NULL,accumulated_depreciation_account_id INT NULL,depreciation_expense_account_id INT NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_asset_category_code(code)", + 'asset_events'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,asset_id INT NOT NULL,event_no VARCHAR(60) NOT NULL,event_type ENUM('acquisition','capitalization','addition','transfer','maintenance','depreciation','impairment','revaluation','disposal','lost','damaged','opname_adjustment','reversal') NOT NULL,event_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL DEFAULT 0,book_value_before DECIMAL(18,2) NULL,book_value_after DECIMAL(18,2) NULL,from_location_id INT NULL,to_location_id INT NULL,notes TEXT NULL,source_type VARCHAR(50) NULL,source_id BIGINT UNSIGNED NULL,journal_id INT NULL,idempotency_key VARCHAR(120) NOT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_asset_event_no(event_no),UNIQUE KEY uq_asset_event_idempotency(idempotency_key),KEY idx_asset_event(asset_id,event_date)", + 'asset_depreciation_schedule'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,asset_id INT NOT NULL,period CHAR(7) NOT NULL,due_date DATE NOT NULL,opening_book_value DECIMAL(18,2) NOT NULL,depreciation_amount DECIMAL(18,2) NOT NULL,closing_book_value DECIMAL(18,2) NOT NULL,status ENUM('scheduled','posted','skipped','reversed') NOT NULL DEFAULT 'scheduled',journal_id INT NULL,asset_event_id BIGINT UNSIGNED NULL,posted_at DATETIME NULL,UNIQUE KEY uq_asset_depreciation_period(asset_id,period)", + 'asset_maintenance'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,asset_id INT NOT NULL,maintenance_date DATE NOT NULL,vendor VARCHAR(150) NULL,description TEXT NOT NULL,cost DECIMAL(18,2) NOT NULL DEFAULT 0,next_due_date DATE NULL,attachment_id BIGINT UNSIGNED NULL,created_by INT NULL,created_at DATETIME NOT NULL", + 'asset_opnames'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,opname_no VARCHAR(60) NOT NULL,opname_date DATE NOT NULL,location_id INT NULL,status ENUM('draft','submitted','approved','posted','rejected') NOT NULL DEFAULT 'draft',notes TEXT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,UNIQUE KEY uq_asset_opname_no(opname_no)", + 'asset_opname_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,asset_opname_id BIGINT UNSIGNED NOT NULL,asset_id INT NOT NULL,expected_status VARCHAR(30) NOT NULL,actual_status VARCHAR(30) NOT NULL,actual_location_id INT NULL,notes TEXT NULL,UNIQUE KEY uq_asset_opname_line(asset_opname_id,asset_id)", + 'asset_reconciliations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,as_of_date DATE NOT NULL,subledger_cost DECIMAL(18,2) NOT NULL,gl_cost DECIMAL(18,2) NOT NULL,subledger_accumulated DECIMAL(18,2) NOT NULL,gl_accumulated DECIMAL(18,2) NOT NULL,difference DECIMAL(18,2) NOT NULL,status ENUM('matched','variance','resolved') NOT NULL,notes TEXT NULL,created_by INT NULL,created_at DATETIME NOT NULL" + );foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("INSERT IGNORE INTO asset_events(asset_id,event_no,event_type,event_date,amount,book_value_before,book_value_after,notes,source_type,source_id,idempotency_key,created_at) SELECT id,CONCAT('LEGACY-AST-',id),'acquisition',tanggal_perolehan,nilai_perolehan,0,nilai_perolehan,'Backfill aset legacy tanpa jurnal baru','legacy_asset',id,CONCAT('LEGACY-ASSET-',id),COALESCE(created_at,NOW()) FROM assets"); + $this->db->query("INSERT IGNORE INTO asset_depreciation_schedule(asset_id,period,due_date,opening_book_value,depreciation_amount,closing_book_value,status) SELECT asset_id,DATE_FORMAT(tanggal_dibuat,'%Y-%m'),tanggal_dibuat,GREATEST(0,a.nilai_perolehan-SUM(m.nilai) OVER(PARTITION BY m.asset_id ORDER BY m.tanggal_dibuat,m.id)+m.nilai),m.nilai,GREATEST(a.nilai_residu,a.nilai_perolehan-SUM(m.nilai) OVER(PARTITION BY m.asset_id ORDER BY m.tanggal_dibuat,m.id)),'posted' FROM asset_mutations m JOIN assets a ON a.id=m.asset_id WHERE m.tipe='penyusutan'"); + $this->db->query('DROP TRIGGER IF EXISTS trg_asset_event_no_update');$this->db->query("CREATE TRIGGER trg_asset_event_no_update BEFORE UPDATE ON asset_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Asset event is immutable'"); + $this->db->query('DROP TRIGGER IF EXISTS trg_asset_event_no_delete');$this->db->query("CREATE TRIGGER trg_asset_event_no_delete BEFORE DELETE ON asset_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Asset event is immutable'"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 8.');} +} diff --git a/application/migrations/20260901001000_create_cash_bank_subledger.php b/application/migrations/20260901001000_create_cash_bank_subledger.php new file mode 100644 index 0000000..49e9424 --- /dev/null +++ b/application/migrations/20260901001000_create_cash_bank_subledger.php @@ -0,0 +1,20 @@ +"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,code VARCHAR(30) NOT NULL,name VARCHAR(120) NOT NULL,account_type ENUM('cash','bank','petty_cash') NOT NULL,gl_account_id INT NOT NULL,bank_name VARCHAR(120) NULL,bank_account_no VARCHAR(80) NULL,bank_account_name VARCHAR(120) NULL,currency CHAR(3) NOT NULL DEFAULT 'IDR',opening_balance DECIMAL(18,2) NOT NULL DEFAULT 0,opening_date DATE NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,created_at DATETIME NOT NULL,UNIQUE KEY uq_cash_account_code(code),UNIQUE KEY uq_cash_account_gl(gl_account_id)", + 'cash_transactions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,transaction_no VARCHAR(60) NOT NULL,transaction_date DATE NOT NULL,cash_account_id BIGINT UNSIGNED NOT NULL,transaction_type ENUM('opening','receipt','payment','transfer_in','transfer_out','petty_cash','cash_advance','advance_settlement','reimbursement','bank_charge','interest','adjustment') NOT NULL,direction ENUM('in','out') NOT NULL,amount DECIMAL(18,2) NOT NULL,counter_account_id INT NULL,party_type VARCHAR(30) NULL,party_id BIGINT UNSIGNED NULL,reference_no VARCHAR(120) NULL,description TEXT NOT NULL,status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft',requires_approval TINYINT(1) NOT NULL DEFAULT 0,source_type VARCHAR(50) NULL,source_id BIGINT UNSIGNED NULL,journal_id INT NULL,idempotency_key VARCHAR(120) NOT NULL,created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,posted_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_cash_transaction_no(transaction_no),UNIQUE KEY uq_cash_transaction_idempotency(idempotency_key),KEY idx_cash_ledger(cash_account_id,transaction_date,status)", + 'cash_transfers'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,transfer_no VARCHAR(60) NOT NULL,transfer_date DATE NOT NULL,from_cash_account_id BIGINT UNSIGNED NOT NULL,to_cash_account_id BIGINT UNSIGNED NOT NULL,amount DECIMAL(18,2) NOT NULL,bank_charge DECIMAL(18,2) NOT NULL DEFAULT 0,reference_no VARCHAR(120) NULL,status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft',out_transaction_id BIGINT UNSIGNED NULL,in_transaction_id BIGINT UNSIGNED NULL,journal_id INT NULL,idempotency_key VARCHAR(120) NOT NULL,created_by INT NULL,approved_by INT NULL,UNIQUE KEY uq_cash_transfer_no(transfer_no),UNIQUE KEY uq_cash_transfer_key(idempotency_key)", + 'bank_statement_imports'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,cash_account_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,statement_from DATE NULL,statement_to DATE NULL,opening_balance DECIMAL(18,2) NULL,closing_balance DECIMAL(18,2) NULL,file_hash CHAR(64) NOT NULL,status ENUM('imported','processing','completed','cancelled') NOT NULL DEFAULT 'imported',created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_bank_import_hash(cash_account_id,file_hash)", + 'bank_statement_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,import_id BIGINT UNSIGNED NOT NULL,line_no INT NOT NULL,transaction_date DATE NOT NULL,description TEXT NULL,reference_no VARCHAR(120) NULL,debit DECIMAL(18,2) NOT NULL DEFAULT 0,credit DECIMAL(18,2) NOT NULL DEFAULT 0,balance DECIMAL(18,2) NULL,match_status ENUM('unmatched','suggested','matched','ignored') NOT NULL DEFAULT 'unmatched',matched_cash_transaction_id BIGINT UNSIGNED NULL,match_score DECIMAL(5,2) NULL,UNIQUE KEY uq_bank_import_line(import_id,line_no)", + 'bank_reconciliations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,reconciliation_no VARCHAR(60) NOT NULL,cash_account_id BIGINT UNSIGNED NOT NULL,period_end DATE NOT NULL,book_balance DECIMAL(18,2) NOT NULL,statement_balance DECIMAL(18,2) NOT NULL,outstanding_receipts DECIMAL(18,2) NOT NULL DEFAULT 0,outstanding_payments DECIMAL(18,2) NOT NULL DEFAULT 0,difference DECIMAL(18,2) NOT NULL,status ENUM('draft','balanced','variance','closed') NOT NULL DEFAULT 'draft',notes TEXT NULL,created_by INT NULL,closed_by INT NULL,closed_at DATETIME NULL,UNIQUE KEY uq_bank_reconciliation(reconciliation_no),UNIQUE KEY uq_bank_period(cash_account_id,period_end)", + 'cash_forecasts'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,forecast_date DATE NOT NULL,cash_account_id BIGINT UNSIGNED NULL,direction ENUM('in','out') NOT NULL,amount DECIMAL(18,2) NOT NULL,probability DECIMAL(5,2) NOT NULL DEFAULT 100,description VARCHAR(255) NOT NULL,source_type VARCHAR(50) NULL,source_id BIGINT UNSIGNED NULL,status ENUM('planned','realized','cancelled') NOT NULL DEFAULT 'planned',created_by INT NULL,created_at DATETIME NOT NULL" + );foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("INSERT IGNORE INTO cash_accounts(code,name,account_type,gl_account_id,created_at) SELECT CONCAT('GL-',kode_akun),nama_akun,IF(LOWER(nama_akun) LIKE '%bank%','bank',IF(LOWER(nama_akun) LIKE '%petty%','petty_cash','cash')),id,NOW() FROM accounts WHERE sub_tipe='kas'"); + $admin=$this->db->where('nama_role','Admin')->get('roles')->row();if(!$this->db->where('code','CASH_PAYMENT_LARGE')->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>'CASH_PAYMENT_LARGE','module'=>'cash_bank','entity_type'=>'cash_transaction','name'=>'Persetujuan Pembayaran Besar','require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));} + $this->db->query('DROP TRIGGER IF EXISTS trg_cash_posted_no_delete');$this->db->query("CREATE TRIGGER trg_cash_posted_no_delete BEFORE DELETE ON cash_transactions FOR EACH ROW BEGIN IF OLD.status IN('posted','reversed') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Posted cash transaction cannot be deleted'; END IF; END"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 9.');} +} diff --git a/application/migrations/20260901001100_seed_asset_disposal_accounts.php b/application/migrations/20260901001100_seed_asset_disposal_accounts.php new file mode 100644 index 0000000..94d97f0 --- /dev/null +++ b/application/migrations/20260901001100_seed_asset_disposal_accounts.php @@ -0,0 +1,7 @@ +db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[3]==='expense'?'debit':'kredit','kategori'=>'laba_rugi','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}} + public function down(){throw new RuntimeException('Gunakan backup untuk rollback mapping Tahap 8.');} +} diff --git a/application/migrations/20260901001200_create_hr_payroll_professional.php b/application/migrations/20260901001200_create_hr_payroll_professional.php new file mode 100644 index 0000000..f256f48 --- /dev/null +++ b/application/migrations/20260901001200_create_hr_payroll_professional.php @@ -0,0 +1,32 @@ +db->table_exists($t)&&!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");} + public function up(){ + foreach(array('tax_status'=>'VARCHAR(20) NULL','tax_number'=>'VARCHAR(50) NULL','benefit_profile_id'=>'BIGINT UNSIGNED NULL','confidentiality_level'=>"ENUM('normal','restricted') NOT NULL DEFAULT 'restricted'",'version'=>'INT NOT NULL DEFAULT 1')as$c=>$s)$this->col('k_employees',$c,$s); + foreach(array('workflow_status'=>"ENUM('draft','reviewed','approved','final','posted','paid','cancelled') NOT NULL DEFAULT 'draft'",'reviewed_by'=>'INT NULL','reviewed_at'=>'DATETIME NULL','approved_by'=>'INT NULL','approved_at'=>'DATETIME NULL','finalized_by'=>'INT NULL','finalized_at'=>'DATETIME NULL','journal_id'=>'INT NULL','payment_transaction_id'=>'BIGINT UNSIGNED NULL','cancelled_by'=>'INT NULL','cancelled_at'=>'DATETIME NULL','cancellation_reason'=>'TEXT NULL','calculation_hash'=>'CHAR(64) NULL')as$c=>$s)$this->col('k_payroll_periods',$c,$s); + $this->col('k_payrolls','gross_salary','DECIMAL(18,2) NOT NULL DEFAULT 0');$this->col('k_payrolls','total_deductions','DECIMAL(18,2) NOT NULL DEFAULT 0');$this->col('k_payrolls','calculation_snapshot','LONGTEXT NULL');$this->col('k_payrolls','version','INT NOT NULL DEFAULT 1'); + $tables=array( + 'employee_employment_history'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,employee_id INT NOT NULL,effective_from DATE NOT NULL,effective_to DATE NULL,department_id INT NULL,position_id INT NULL,branch_id INT NULL,employment_status VARCHAR(30) NOT NULL,contract_no VARCHAR(80) NULL,contract_start DATE NULL,contract_end DATE NULL,basic_salary DECIMAL(18,2) NOT NULL DEFAULT 0,reason VARCHAR(255) NULL,created_by INT NULL,created_at DATETIME NOT NULL,KEY idx_employee_history(employee_id,effective_from)", + 'employee_documents'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,employee_id INT NOT NULL,document_type VARCHAR(50) NOT NULL,document_no VARCHAR(100) NULL,expiry_date DATE NULL,original_name VARCHAR(255) NOT NULL,stored_name VARCHAR(255) NOT NULL,mime_type VARCHAR(100) NOT NULL,file_size BIGINT UNSIGNED NOT NULL,sha256 CHAR(64) NOT NULL,access_level ENUM('hr','payroll_admin') NOT NULL DEFAULT 'hr',uploaded_by INT NULL,created_at DATETIME NOT NULL,KEY idx_employee_document(employee_id,document_type)", + 'attendance_devices'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,serial_no VARCHAR(100) NOT NULL,name VARCHAR(120) NULL,branch_id INT NULL,secret_hash CHAR(64) NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,last_seen_at DATETIME NULL,UNIQUE KEY uq_attendance_device(serial_no)", + 'attendance_raw_logs'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,device_id BIGINT UNSIGNED NOT NULL,employee_pin VARCHAR(80) NOT NULL,check_time DATETIME NOT NULL,event_type VARCHAR(30) NULL,raw_payload TEXT NULL,payload_hash CHAR(64) NOT NULL,validation_status ENUM('valid','invalid','duplicate','unknown_employee') NOT NULL DEFAULT 'valid',processed_attendance_id BIGINT UNSIGNED NULL,received_at DATETIME NOT NULL,UNIQUE KEY uq_attendance_raw(device_id,payload_hash),KEY idx_raw_employee_time(employee_pin,check_time)", + 'attendance_corrections'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,attendance_id INT NOT NULL,requested_checkin DATETIME NULL,requested_checkout DATETIME NULL,requested_status VARCHAR(30) NULL,reason TEXT NOT NULL,status ENUM('submitted','approved','rejected','applied') NOT NULL DEFAULT 'submitted',requested_by INT NOT NULL,requested_at DATETIME NOT NULL,approved_by INT NULL,approved_at DATETIME NULL,applied_at DATETIME NULL,UNIQUE KEY uq_attendance_pending(attendance_id,status)", + 'attendance_period_locks'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,period CHAR(7) NOT NULL,branch_id INT NULL,status ENUM('open','locked') NOT NULL DEFAULT 'open',locked_by INT NULL,locked_at DATETIME NULL,notes TEXT NULL,UNIQUE KEY uq_attendance_lock(period,branch_id)", + 'overtime_requests'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,employee_id INT NOT NULL,overtime_date DATE NOT NULL,start_time TIME NOT NULL,end_time TIME NOT NULL,hours DECIMAL(8,2) NOT NULL,reason TEXT NOT NULL,status ENUM('submitted','approved','rejected','paid') NOT NULL DEFAULT 'submitted',requested_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,UNIQUE KEY uq_overtime_employee(employee_id,overtime_date,start_time)", + 'payroll_components'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,code VARCHAR(40) NOT NULL,name VARCHAR(120) NOT NULL,component_type ENUM('earning','deduction','employer_cost') NOT NULL,category ENUM('basic','allowance','overtime','unpaid_leave','loan','bpjs','tax','thr','bonus','other') NOT NULL,formula_expression VARCHAR(500) NULL,fixed_amount DECIMAL(18,2) NULL,taxable TINYINT(1) NOT NULL DEFAULT 1,prorated TINYINT(1) NOT NULL DEFAULT 0,expense_account_id INT NULL,liability_account_id INT NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_payroll_component(code)", + 'employee_payroll_components'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,employee_id INT NOT NULL,component_id BIGINT UNSIGNED NOT NULL,amount_override DECIMAL(18,2) NULL,effective_from DATE NOT NULL,effective_to DATE NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_employee_component(employee_id,component_id,effective_from)", + 'payroll_calculation_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payroll_id INT NOT NULL,component_id BIGINT UNSIGNED NULL,component_code VARCHAR(40) NOT NULL,component_name VARCHAR(120) NOT NULL,component_type ENUM('earning','deduction','employer_cost') NOT NULL,base_amount DECIMAL(18,2) NOT NULL DEFAULT 0,rate DECIMAL(18,6) NULL,quantity DECIMAL(18,4) NULL,amount DECIMAL(18,2) NOT NULL,formula_snapshot VARCHAR(500) NULL,expense_account_id INT NULL,liability_account_id INT NULL,UNIQUE KEY uq_payroll_component_line(payroll_id,component_code)", + 'employee_loans'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,loan_no VARCHAR(60) NOT NULL,employee_id INT NOT NULL,loan_date DATE NOT NULL,principal DECIMAL(18,2) NOT NULL,balance DECIMAL(18,2) NOT NULL,installment_amount DECIMAL(18,2) NOT NULL,status ENUM('active','paid','cancelled') NOT NULL DEFAULT 'active',receivable_account_id INT NULL,created_by INT NULL,UNIQUE KEY uq_employee_loan(loan_no)", + 'payroll_payments'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payroll_period_id INT NOT NULL,payment_no VARCHAR(60) NOT NULL,payment_date DATE NOT NULL,cash_account_id BIGINT UNSIGNED NOT NULL,total_amount DECIMAL(18,2) NOT NULL,status ENUM('draft','submitted','approved','posted','rejected','reversed') NOT NULL DEFAULT 'draft',journal_id INT NULL,created_by INT NULL,approved_by INT NULL,posted_at DATETIME NULL,UNIQUE KEY uq_payroll_payment_no(payment_no),UNIQUE KEY uq_payroll_period_payment(payroll_period_id)", + 'payroll_cancellations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,payroll_period_id INT NOT NULL,reason TEXT NOT NULL,reversal_journal_id INT NULL,status ENUM('submitted','approved','rejected','posted') NOT NULL DEFAULT 'submitted',requested_by INT NOT NULL,requested_at DATETIME NOT NULL,approved_by INT NULL,approved_at DATETIME NULL" + );foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("INSERT IGNORE INTO employee_employment_history(employee_id,effective_from,department_id,position_id,branch_id,employment_status,basic_salary,reason,created_at) SELECT id,join_date,department_id,position_id,branch_id,employment_status,basic_salary,'Backfill data aktif Tahap 10',NOW() FROM k_employees"); + foreach(array(array('BASIC','Gaji Pokok','earning','basic','basic_salary',null,1,1),array('OVERTIME','Lembur','earning','overtime','overtime_hours * overtime_rate',null,1,0),array('UNPAID_LEAVE','Cuti Tidak Dibayar','deduction','unpaid_leave','unpaid_days * daily_rate',null,0,0),array('THR','THR','earning','thr',null,null,1,0),array('BONUS','Bonus','earning','bonus',null,null,1,0),array('BPJS','BPJS Karyawan','deduction','bpjs',null,null,0,0),array('PPh21','PPh 21','deduction','tax',null,null,0,0),array('LOAN','Cicilan Pinjaman','deduction','loan',null,null,0,0))as$d)if(!$this->db->where('code',$d[0])->count_all_results('payroll_components'))$this->db->insert('payroll_components',array('code'=>$d[0],'name'=>$d[1],'component_type'=>$d[2],'category'=>$d[3],'formula_expression'=>$d[4],'fixed_amount'=>$d[5],'taxable'=>$d[6],'prorated'=>$d[7])); + $this->db->query("UPDATE k_payroll_periods SET workflow_status=IF(status='draft','draft',IF(status IN('final','closed'),'final','draft'))"); + $this->db->query('DROP TRIGGER IF EXISTS trg_final_payroll_no_update');$this->db->query("CREATE TRIGGER trg_final_payroll_no_update BEFORE UPDATE ON k_payrolls FOR EACH ROW BEGIN DECLARE s VARCHAR(20); SELECT workflow_status INTO s FROM k_payroll_periods WHERE id=OLD.payroll_period_id; IF s IN('final','posted','paid') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Final payroll is locked'; END IF; END"); + $admin=$this->db->where('nama_role','Admin')->get('roles')->row();foreach(array(array('ATTENDANCE_CORRECTION','hr','attendance_correction','Koreksi Absensi'),array('PAYROLL_FINAL','payroll','payroll_period','Finalisasi Payroll'))as$w)if(!$this->db->where('code',$w[0])->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>$w[0],'module'=>$w[1],'entity_type'=>$w[2],'name'=>$w[3],'require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Admin','approver_role_id'=>$admin->id));} + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 10.');} +} diff --git a/application/migrations/20260901001300_create_configurable_tax_engine.php b/application/migrations/20260901001300_create_configurable_tax_engine.php new file mode 100644 index 0000000..d537abf --- /dev/null +++ b/application/migrations/20260901001300_create_configurable_tax_engine.php @@ -0,0 +1,18 @@ +"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,code VARCHAR(30) NOT NULL,name VARCHAR(120) NOT NULL,tax_type ENUM('sales','purchase','withholding','payroll') NOT NULL,calculation_method ENUM('exclusive','inclusive') NOT NULL DEFAULT 'exclusive',rate DECIMAL(9,6) NOT NULL DEFAULT 0,formula_expression VARCHAR(500) NULL,input_account_id INT NULL,output_account_id INT NULL,withholding_payable_account_id INT NULL,withholding_receivable_account_id INT NULL,effective_from DATE NOT NULL,effective_to DATE NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,UNIQUE KEY uq_tax_code_effective(code,effective_from)", + 'tax_periods'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,period CHAR(7) NOT NULL,tax_type ENUM('sales','purchase','withholding','payroll','all') NOT NULL DEFAULT 'all',status ENUM('open','reviewed','filed','closed') NOT NULL DEFAULT 'open',filing_reference VARCHAR(100) NULL,filed_at DATETIME NULL,filed_by INT NULL,closed_at DATETIME NULL,closed_by INT NULL,notes TEXT NULL,UNIQUE KEY uq_tax_period(period,tax_type)", + 'tax_transactions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,tax_date DATE NOT NULL,tax_period_id BIGINT UNSIGNED NULL,tax_code_id BIGINT UNSIGNED NOT NULL,direction ENUM('input','output','withheld','withholding') NOT NULL,source_type VARCHAR(50) NOT NULL,source_id BIGINT UNSIGNED NOT NULL,source_line_id BIGINT UNSIGNED NULL,counterparty_type VARCHAR(30) NULL,counterparty_id BIGINT UNSIGNED NULL,tax_invoice_no VARCHAR(100) NULL,tax_base DECIMAL(18,2) NOT NULL,tax_amount DECIMAL(18,2) NOT NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'posted',journal_id INT NULL,idempotency_key VARCHAR(140) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_tax_transaction_key(idempotency_key),KEY idx_tax_register(tax_date,direction,tax_code_id)", + 'tax_exports'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,tax_period_id BIGINT UNSIGNED NOT NULL,export_type VARCHAR(50) NOT NULL,file_name VARCHAR(255) NOT NULL,row_count INT NOT NULL,file_hash CHAR(64) NOT NULL,created_by INT NULL,created_at DATETIME NOT NULL" + );foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $out=$this->db->where('mapping_key','sales_tax_payable')->get('system_account_mappings')->row();$in=$this->db->where('mapping_key','purchase_input_tax')->get('system_account_mappings')->row();$this->db->query("INSERT IGNORE INTO tax_codes(code,name,tax_type,calculation_method,rate,input_account_id,output_account_id,effective_from,is_active)VALUES('PPN','PPN','sales','exclusive',11,?,?,?,1)",array($in?$in->account_id:null,$out?$out->account_id:null,date('Y-m-01'))); + $this->db->query("INSERT IGNORE INTO tax_transactions(tax_date,tax_code_id,direction,source_type,source_id,source_line_id,counterparty_type,counterparty_id,tax_invoice_no,tax_base,tax_amount,status,journal_id,idempotency_key,created_at) SELECT i.tanggal,t.id,'output','invoice',i.id,d.id,'customer',i.customer_id,NULL,(d.subtotal-d.tax_amount),d.tax_amount,'posted',i.journal_id,CONCAT('LEGACY-INV-TAX-',d.id),COALESCE(i.posted_at,i.created_at,NOW()) FROM invoice_details d JOIN invoices i ON i.id=d.invoice_id JOIN tax_codes t ON t.code='PPN' WHERE d.tax_amount>0 AND i.workflow_status='posted'"); + $this->db->query("INSERT IGNORE INTO tax_transactions(tax_date,tax_code_id,direction,source_type,source_id,source_line_id,counterparty_type,counterparty_id,tax_invoice_no,tax_base,tax_amount,status,journal_id,idempotency_key,created_at) SELECT s.invoice_date,t.id,'input','supplier_invoice',s.id,l.id,'supplier',s.supplier_id,s.supplier_invoice_no,(l.line_total-l.tax_amount),l.tax_amount,'posted',s.journal_id,CONCAT('LEGACY-BILL-TAX-',l.id),COALESCE(s.posted_at,s.created_at,NOW()) FROM supplier_invoice_lines l JOIN supplier_invoices s ON s.id=l.supplier_invoice_id JOIN tax_codes t ON t.code='PPN' WHERE l.tax_amount>0 AND s.status IN('posted','partial','paid')"); + $this->db->query('DROP TRIGGER IF EXISTS trg_closed_tax_period');$this->db->query("CREATE TRIGGER trg_closed_tax_period BEFORE INSERT ON tax_transactions FOR EACH ROW BEGIN DECLARE n INT; SELECT COUNT(*) INTO n FROM tax_periods WHERE period=DATE_FORMAT(NEW.tax_date,'%Y-%m') AND tax_type IN('all',(SELECT tax_type FROM tax_codes WHERE id=NEW.tax_code_id)) AND status IN('filed','closed'); IF n>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Tax period is filed or closed'; END IF; END"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 11.');} +} diff --git a/application/migrations/20260901001400_seed_payroll_control_accounts.php b/application/migrations/20260901001400_seed_payroll_control_accounts.php new file mode 100644 index 0000000..0e6510a --- /dev/null +++ b/application/migrations/20260901001400_seed_payroll_control_accounts.php @@ -0,0 +1,7 @@ +db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[4],'kategori'=>$d[5],'is_active'=>1,'is_header'=>0,'allow_posting'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));}} + public function down(){throw new RuntimeException('Gunakan backup untuk rollback mapping Tahap 10.');} +} diff --git a/application/migrations/20260901001500_enforce_hr_payroll_locks.php b/application/migrations/20260901001500_enforce_hr_payroll_locks.php new file mode 100644 index 0000000..90f8496 --- /dev/null +++ b/application/migrations/20260901001500_enforce_hr_payroll_locks.php @@ -0,0 +1,14 @@ +db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_insert');$this->db->query("CREATE TRIGGER trg_attendance_locked_insert BEFORE INSERT ON k_attendances FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(NEW.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END"); + $this->db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_update');$this->db->query("CREATE TRIGGER trg_attendance_locked_update BEFORE UPDATE ON k_attendances FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(OLD.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END"); + $this->db->query('DROP TRIGGER IF EXISTS trg_attendance_locked_delete');$this->db->query("CREATE TRIGGER trg_attendance_locked_delete BEFORE DELETE ON k_attendances FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM attendance_period_locks WHERE period=DATE_FORMAT(OLD.attendance_date,'%Y-%m') AND status='locked' AND branch_id IS NULL) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Attendance period is locked'; END IF; END"); + $this->db->query('DROP TRIGGER IF EXISTS trg_final_payroll_item_insert');$this->db->query("CREATE TRIGGER trg_final_payroll_item_insert BEFORE INSERT ON payroll_calculation_lines FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM k_payrolls x JOIN k_payroll_periods p ON p.id=x.payroll_period_id WHERE x.id=NEW.payroll_id AND p.workflow_status IN('final','posted','paid')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Final payroll detail is locked'; END IF; END"); + $this->db->query('DROP TRIGGER IF EXISTS trg_final_payroll_item_update');$this->db->query("CREATE TRIGGER trg_final_payroll_item_update BEFORE UPDATE ON payroll_calculation_lines FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM k_payrolls x JOIN k_payroll_periods p ON p.id=x.payroll_period_id WHERE x.id=OLD.payroll_id AND p.workflow_status IN('final','posted','paid')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Final payroll detail is locked'; END IF; END"); + $this->db->query('DROP TRIGGER IF EXISTS trg_final_payroll_item_delete');$this->db->query("CREATE TRIGGER trg_final_payroll_item_delete BEFORE DELETE ON payroll_calculation_lines FOR EACH ROW BEGIN IF EXISTS(SELECT 1 FROM k_payrolls x JOIN k_payroll_periods p ON p.id=x.payroll_period_id WHERE x.id=OLD.payroll_id AND p.workflow_status IN('final','posted','paid')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Final payroll detail is locked'; END IF; END"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback kontrol Tahap 10.');} +} diff --git a/application/migrations/20260901001600_reconcile_legacy_payroll_and_tax.php b/application/migrations/20260901001600_reconcile_legacy_payroll_and_tax.php new file mode 100644 index 0000000..2ec21d2 --- /dev/null +++ b/application/migrations/20260901001600_reconcile_legacy_payroll_and_tax.php @@ -0,0 +1,12 @@ +db->query("DELETE h1 FROM employee_employment_history h1 JOIN employee_employment_history h2 ON h2.employee_id=h1.employee_id AND h2.effective_from=h1.effective_from AND h2.reason='Backfill data aktif Tahap 10' AND h1.reason='Backfill data aktif Tahap 10' AND h2.iddb->query("SHOW INDEX FROM employee_employment_history WHERE Key_name='uq_employee_history_date'")->result())$this->db->query('ALTER TABLE employee_employment_history ADD UNIQUE KEY uq_employee_history_date(employee_id,effective_from)'); + $this->db->query("UPDATE k_payrolls SET gross_salary=basic_salary+allowance_amount+bonus_amount+overtime_amount,total_deductions=deduction_amount+bpjs_kesehatan_amount+bpjs_ketenagakerjaan_amount+tax_amount+attendance_cut_amount WHERE calculation_snapshot IS NULL"); + $in=$this->db->where('mapping_key','purchase_input_tax')->get('system_account_mappings')->row();$out=$this->db->where('mapping_key','sales_tax_payable')->get('system_account_mappings')->row();if(!$this->db->where('code','PPN-KELUARAN')->count_all_results('tax_codes'))$this->db->insert('tax_codes',array('code'=>'PPN-KELUARAN','name'=>'PPN Keluaran','tax_type'=>'sales','calculation_method'=>'exclusive','rate'=>11,'output_account_id'=>$out?$out->account_id:null,'effective_from'=>date('Y-m-01'),'is_active'=>1));if(!$this->db->where('code','PPN-MASUKAN')->count_all_results('tax_codes'))$this->db->insert('tax_codes',array('code'=>'PPN-MASUKAN','name'=>'PPN Masukan','tax_type'=>'purchase','calculation_method'=>'exclusive','rate'=>11,'input_account_id'=>$in?$in->account_id:null,'effective_from'=>date('Y-m-01'),'is_active'=>1)); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback rekonsiliasi Tahap 10–11.');} +} diff --git a/application/migrations/20260901001700_create_reporting_budget_dimensions.php b/application/migrations/20260901001700_create_reporting_budget_dimensions.php new file mode 100644 index 0000000..57a516b --- /dev/null +++ b/application/migrations/20260901001700_create_reporting_budget_dimensions.php @@ -0,0 +1,23 @@ +db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");} + public function up(){ + $tables=array( + 'business_dimensions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,dimension_type ENUM('branch','department','project','cost_center','profit_center','salesperson') NOT NULL,code VARCHAR(40) NOT NULL,name VARCHAR(150) NOT NULL,parent_id BIGINT UNSIGNED NULL,is_active TINYINT(1) NOT NULL DEFAULT 1,created_at DATETIME NOT NULL,UNIQUE KEY uq_business_dimension(dimension_type,code)", + 'budgets'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,budget_no VARCHAR(60) NOT NULL,fiscal_year INT NOT NULL,name VARCHAR(150) NOT NULL,version_no INT NOT NULL DEFAULT 1,status ENUM('draft','submitted','approved','active','superseded','rejected') NOT NULL DEFAULT 'draft',control_mode ENUM('none','warning','blocking') NOT NULL DEFAULT 'warning',created_by INT NULL,approved_by INT NULL,approved_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_budget_no(budget_no)", + 'budget_lines'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,budget_id BIGINT UNSIGNED NOT NULL,period CHAR(7) NOT NULL,account_id INT NOT NULL,branch_id BIGINT UNSIGNED NULL,department_id BIGINT UNSIGNED NULL,project_id BIGINT UNSIGNED NULL,cost_center_id BIGINT UNSIGNED NULL,profit_center_id BIGINT UNSIGNED NULL,amount DECIMAL(18,2) NOT NULL,notes VARCHAR(255) NULL,UNIQUE KEY uq_budget_line(budget_id,period,account_id,branch_id,department_id,project_id,cost_center_id,profit_center_id)", + 'budget_revisions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,budget_id BIGINT UNSIGNED NOT NULL,revision_no INT NOT NULL,reason TEXT NOT NULL,status ENUM('draft','submitted','approved','rejected') NOT NULL DEFAULT 'draft',snapshot LONGTEXT NOT NULL,requested_by INT NULL,requested_at DATETIME NOT NULL,approved_by INT NULL,approved_at DATETIME NULL,UNIQUE KEY uq_budget_revision(budget_id,revision_no)", + 'saved_report_filters'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,user_id INT NOT NULL,report_code VARCHAR(60) NOT NULL,name VARCHAR(120) NOT NULL,filter_json LONGTEXT NOT NULL,is_default TINYINT(1) NOT NULL DEFAULT 0,created_at DATETIME NOT NULL,UNIQUE KEY uq_saved_report_filter(user_id,report_code,name)", + 'report_export_jobs'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,job_no VARCHAR(60) NOT NULL,report_code VARCHAR(60) NOT NULL,format ENUM('csv','pdf') NOT NULL,filter_json LONGTEXT NOT NULL,status ENUM('queued','processing','completed','failed') NOT NULL DEFAULT 'queued',file_name VARCHAR(255) NULL,error_message TEXT NULL,requested_by INT NOT NULL,requested_at DATETIME NOT NULL,started_at DATETIME NULL,completed_at DATETIME NULL,UNIQUE KEY uq_report_job(job_no)", + 'report_snapshots'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,report_code VARCHAR(60) NOT NULL,period_end DATE NOT NULL,accounting_period_id BIGINT UNSIGNED NULL,filter_hash CHAR(64) NOT NULL,payload LONGTEXT NOT NULL,total_debit DECIMAL(18,2) NULL,total_credit DECIMAL(18,2) NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_report_snapshot(report_code,period_end,filter_hash)", + 'closing_checklists'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,accounting_period_id BIGINT UNSIGNED NOT NULL,check_code VARCHAR(60) NOT NULL,check_name VARCHAR(150) NOT NULL,status ENUM('pending','passed','failed','waived') NOT NULL DEFAULT 'pending',result_notes TEXT NULL,checked_by INT NULL,checked_at DATETIME NULL,UNIQUE KEY uq_closing_check(accounting_period_id,check_code)" + );foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + foreach(array('branch_id','department_id','project_id','cost_center_id','profit_center_id','salesperson_id')as$c)$this->col('journal_details',$c,'BIGINT UNSIGNED NULL'); + $this->col('purchase_order_lines','project_id','BIGINT UNSIGNED NULL');$this->col('purchase_order_lines','cost_center_id','BIGINT UNSIGNED NULL'); + $this->db->query("INSERT IGNORE INTO business_dimensions(dimension_type,code,name,is_active,created_at) SELECT 'branch',CONCAT('BR-',id),branch_name,1,NOW() FROM k_branches");$this->db->query("INSERT IGNORE INTO business_dimensions(dimension_type,code,name,is_active,created_at) SELECT 'department',CONCAT('DEP-',id),department_name,is_active,NOW() FROM k_departments"); + $admin=$this->db->where('nama_role','Admin')->get('roles')->row();if(!$this->db->where('code','BUDGET_DEFAULT')->count_all_results('approval_workflows')){$this->db->insert('approval_workflows',array('code'=>'BUDGET_DEFAULT','module'=>'budget','entity_type'=>'budget','name'=>'Persetujuan Budget','require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));if($admin)$this->db->insert('approval_workflow_steps',array('workflow_id'=>$this->db->insert_id(),'step_order'=>1,'name'=>'Persetujuan Budget Admin','approver_role_id'=>$admin->id));} + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 12–13.');} +} diff --git a/application/migrations/20260901001800_create_multicompany_currency.php b/application/migrations/20260901001800_create_multicompany_currency.php new file mode 100644 index 0000000..44f15a0 --- /dev/null +++ b/application/migrations/20260901001800_create_multicompany_currency.php @@ -0,0 +1,21 @@ +db->table_exists($t)&&!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");} + public function up(){ + $tables=array( + 'companies'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_code VARCHAR(30) NOT NULL,name VARCHAR(150) NOT NULL,legal_name VARCHAR(200) NULL,tax_number VARCHAR(60) NULL,address TEXT NULL,base_currency CHAR(3) NOT NULL DEFAULT 'IDR',is_active TINYINT(1) NOT NULL DEFAULT 1,created_at DATETIME NOT NULL,UNIQUE KEY uq_company_code(company_code)", + 'user_companies'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,user_id INT NOT NULL,company_id BIGINT UNSIGNED NOT NULL,is_default TINYINT(1) NOT NULL DEFAULT 0,can_consolidate TINYINT(1) NOT NULL DEFAULT 0,UNIQUE KEY uq_user_company(user_id,company_id)", + 'currencies'=>"code CHAR(3) PRIMARY KEY,name VARCHAR(80) NOT NULL,symbol VARCHAR(10) NULL,decimal_places TINYINT NOT NULL DEFAULT 2,is_active TINYINT(1) NOT NULL DEFAULT 1", + 'exchange_rates'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,rate_date DATE NOT NULL,from_currency CHAR(3) NOT NULL,to_currency CHAR(3) NOT NULL,rate DECIMAL(20,8) NOT NULL,source VARCHAR(80) NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_exchange_rate(company_id,rate_date,from_currency,to_currency)", + 'fx_revaluations'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,revaluation_no VARCHAR(60) NOT NULL,revaluation_date DATE NOT NULL,status ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',gain_amount DECIMAL(18,2) NOT NULL DEFAULT 0,loss_amount DECIMAL(18,2) NOT NULL DEFAULT 0,journal_id INT NULL,created_by INT NULL,posted_at DATETIME NULL,UNIQUE KEY uq_fx_revaluation(company_id,revaluation_no)", + 'intercompany_transactions'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,document_no VARCHAR(60) NOT NULL,from_company_id BIGINT UNSIGNED NOT NULL,to_company_id BIGINT UNSIGNED NOT NULL,transaction_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,currency CHAR(3) NOT NULL,exchange_rate DECIMAL(20,8) NOT NULL,status ENUM('draft','posted','eliminated','reversed') NOT NULL DEFAULT 'draft',from_journal_id INT NULL,to_journal_id INT NULL,elimination_journal_id INT NULL,created_by INT NULL,UNIQUE KEY uq_intercompany_doc(document_no)" + );foreach($tables as$t=>$s)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($s) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->count_all('companies'))$this->db->insert('companies',array('company_code'=>'MAIN','name'=>'Perusahaan Utama','base_currency'=>'IDR','is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));$company=$this->db->order_by('id')->get('companies')->row();$this->db->query("INSERT IGNORE INTO currencies(code,name,symbol,decimal_places,is_active)VALUES('IDR','Rupiah','Rp',2,1),('USD','US Dollar','$',2,1),('SGD','Singapore Dollar','S$',2,1),('EUR','Euro','EUR',2,1)");$this->db->query("INSERT IGNORE INTO user_companies(user_id,company_id,is_default,can_consolidate) SELECT id,".(int)$company->id.",1,IF(role_id=(SELECT id FROM roles WHERE nama_role='Admin' LIMIT 1),1,0) FROM users"); + $critical=array('accounts','journals','journal_details','accounting_periods','fiscal_years','document_sequences','system_account_mappings','customers','invoices','payments','suppliers','purchase_orders','supplier_invoices','supplier_payments','items','warehouses','assets','cash_accounts','cash_transactions','k_employees','k_payroll_periods','k_payrolls','tax_codes','tax_transactions','budgets','business_dimensions');foreach($critical as$t)$this->col($t,'company_id','BIGINT UNSIGNED NULL');foreach($critical as$t)if($this->db->table_exists($t)&&$this->db->field_exists('company_id',$t))$this->db->where('company_id IS NULL',null,false)->update($t,array('company_id'=>$company->id)); + foreach(array('journals','invoices','supplier_invoices','cash_transactions')as$t){$this->col($t,'transaction_currency',"CHAR(3) NOT NULL DEFAULT 'IDR'");$this->col($t,'exchange_rate','DECIMAL(20,8) NOT NULL DEFAULT 1');$this->col($t,'base_amount','DECIMAL(18,2) NULL');} + foreach(array('journal_details','payment_allocations','supplier_payment_allocations')as$t){$this->col($t,'currency_amount','DECIMAL(18,2) NULL');$this->col($t,'currency_code',"CHAR(3) NULL");$this->col($t,'exchange_rate','DECIMAL(20,8) NULL');} + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback Tahap 14.');} +} diff --git a/application/migrations/20260901001900_seed_fx_accounts.php b/application/migrations/20260901001900_seed_fx_accounts.php new file mode 100644 index 0000000..e055757 --- /dev/null +++ b/application/migrations/20260901001900_seed_fx_accounts.php @@ -0,0 +1,7 @@ +db->get_where('accounts',array('kode_akun'=>$d[1]))->row();if(!$a){$this->db->insert('accounts',array('kode_akun'=>$d[1],'nama_akun'=>$d[2],'tipe'=>$d[3],'posisi'=>$d[3]==='expense'?'debit':'kredit','kategori'=>'laba_rugi','is_active'=>1,'is_header'=>0,'allow_posting'=>1,'company_id'=>1));$id=$this->db->insert_id();}else$id=$a->id;if(!$this->db->where('mapping_key',$d[0])->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>$d[0],'account_id'=>$id,'description'=>$d[2],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s'),'company_id'=>1));}} + public function down(){throw new RuntimeException('Gunakan backup untuk rollback FX mapping.');} +} diff --git a/application/migrations/20260901002000_complete_tenant_reporting_scope.php b/application/migrations/20260901002000_complete_tenant_reporting_scope.php new file mode 100644 index 0000000..1023d49 --- /dev/null +++ b/application/migrations/20260901002000_complete_tenant_reporting_scope.php @@ -0,0 +1,8 @@ +db->table_exists($t)&&!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $sql");} + public function up(){$company=$this->db->order_by('id')->get('companies')->row();foreach(array('budget_lines','budget_revisions','saved_report_filters','report_export_jobs','report_snapshots','closing_checklists','exchange_rates','fx_revaluations','intercompany_transactions','user_companies','currencies')as$t){if($t!=='currencies'){$this->col($t,'company_id','BIGINT UNSIGNED NULL');if($this->db->field_exists('company_id',$t))$this->db->where('company_id IS NULL',null,false)->update($t,array('company_id'=>$company->id));}}$this->col('invoices','foreign_total','DECIMAL(18,2) NULL');$this->col('invoices','foreign_balance','DECIMAL(18,2) NULL');$this->col('supplier_invoices','foreign_total','DECIMAL(18,2) NULL');$this->col('supplier_invoices','foreign_balance','DECIMAL(18,2) NULL');} + public function down(){throw new RuntimeException('Gunakan backup untuk rollback tenant scope.');} +} diff --git a/application/migrations/20260901002100_add_po_budget_account.php b/application/migrations/20260901002100_add_po_budget_account.php new file mode 100644 index 0000000..9c86f69 --- /dev/null +++ b/application/migrations/20260901002100_add_po_budget_account.php @@ -0,0 +1,7 @@ +db->field_exists('budget_account_id','purchase_order_lines'))$this->db->query('ALTER TABLE purchase_order_lines ADD budget_account_id INT NULL');} + public function down(){throw new RuntimeException('Gunakan backup untuk rollback budget PO.');} +} diff --git a/application/migrations/20260901002200_repair_business_dimension_company_scope.php b/application/migrations/20260901002200_repair_business_dimension_company_scope.php new file mode 100644 index 0000000..15f6297 --- /dev/null +++ b/application/migrations/20260901002200_repair_business_dimension_company_scope.php @@ -0,0 +1,49 @@ +db->query("SHOW TABLES LIKE 'business_dimensions'")->row()) { + throw new RuntimeException('Tabel business_dimensions belum tersedia. Jalankan migration Tahap 13 terlebih dahulu.'); + } + + $company = $this->db->order_by('id', 'ASC')->get('companies')->row(); + if (!$company) { + throw new RuntimeException('Master company belum tersedia. Jalankan migration Tahap 14 terlebih dahulu.'); + } + + if (!$this->db->field_exists('company_id', 'business_dimensions')) { + $this->db->query('ALTER TABLE `business_dimensions` ADD `company_id` BIGINT UNSIGNED NULL AFTER `id`'); + } + + $this->db->where('company_id IS NULL', null, false) + ->update('business_dimensions', array('company_id' => (int) $company->id)); + + $indexes = $this->db->query('SHOW INDEX FROM `business_dimensions`')->result(); + $indexNames = array(); + foreach ($indexes as $index) { + $indexNames[$index->Key_name] = true; + } + + if (isset($indexNames['uq_business_dimension'])) { + $this->db->query('ALTER TABLE `business_dimensions` DROP INDEX `uq_business_dimension`'); + } + if (!isset($indexNames['uq_business_dimension_company'])) { + $this->db->query('ALTER TABLE `business_dimensions` ADD UNIQUE KEY `uq_business_dimension_company` (`company_id`,`dimension_type`,`code`)'); + } + if (!isset($indexNames['idx_business_dimension_company_active'])) { + $this->db->query('ALTER TABLE `business_dimensions` ADD KEY `idx_business_dimension_company_active` (`company_id`,`is_active`)'); + } + + $this->db->query('ALTER TABLE `business_dimensions` MODIFY `company_id` BIGINT UNSIGNED NOT NULL'); + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback tenant scope.'); + } +} diff --git a/application/migrations/20260901002300_repair_all_tenant_scope_columns.php b/application/migrations/20260901002300_repair_all_tenant_scope_columns.php new file mode 100644 index 0000000..703e191 --- /dev/null +++ b/application/migrations/20260901002300_repair_all_tenant_scope_columns.php @@ -0,0 +1,40 @@ +db->order_by('id', 'ASC')->get('companies')->row(); + if (!$company) throw new RuntimeException('Master company belum tersedia.'); + + $tables = array( + 'accounts','journals','journal_details','accounting_periods','fiscal_years', + 'document_sequences','system_account_mappings','customers','invoices','payments', + 'suppliers','purchase_orders','supplier_invoices','supplier_payments','items', + 'warehouses','assets','cash_accounts','cash_transactions','k_employees', + 'k_payroll_periods','k_payrolls','tax_codes','tax_transactions','business_dimensions', + 'budgets','budget_lines','budget_revisions','saved_report_filters', + 'report_export_jobs','report_snapshots','closing_checklists','exchange_rates', + 'fx_revaluations','intercompany_transactions','user_companies' + ); + + foreach ($tables as $table) { + if (!$this->db->table_exists($table)) continue; + if (!$this->db->field_exists('company_id', $table)) { + $this->db->query("ALTER TABLE `{$table}` ADD `company_id` BIGINT UNSIGNED NULL"); + } + $this->db->where('company_id IS NULL', null, false) + ->update($table, array('company_id' => (int) $company->id)); + + $index = 'idx_' . substr($table, 0, 40) . '_company'; + $existing = $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name=" . $this->db->escape($index))->row(); + if (!$existing) $this->db->query("ALTER TABLE `{$table}` ADD KEY `{$index}` (`company_id`)"); + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback tenant scope.'); + } +} diff --git a/application/migrations/20260901002400_create_professional_roles_permissions.php b/application/migrations/20260901002400_create_professional_roles_permissions.php new file mode 100644 index 0000000..7b2784c --- /dev/null +++ b/application/migrations/20260901002400_create_professional_roles_permissions.php @@ -0,0 +1,29 @@ +$level){$actions=array('can_view');if($level==='write')$actions=array('can_view','can_create','can_update','can_export','can_import');if($level==='approve')$actions=array('can_view','can_create','can_update','can_approve','can_post','can_export','can_import');$out[$key]=$actions;}return json_encode($out);} + public function up(){ + if(!$this->db->field_exists('permissions','roles'))$this->db->query("ALTER TABLE roles ADD permissions LONGTEXT NULL AFTER nama_role"); + if(!$this->db->field_exists('description','roles'))$this->db->query("ALTER TABLE roles ADD description VARCHAR(255) NULL AFTER permissions"); + if(!$this->db->field_exists('is_active','roles'))$this->db->query("ALTER TABLE roles ADD is_active TINYINT(1) NOT NULL DEFAULT 1 AFTER description"); + $roles=array( + 'Admin'=>array('Semua konfigurasi, transaksi dan administrasi pengguna.',array()), + 'Accounting Manager'=>array('Kontrol accounting, approval, closing, laporan, budget dan pajak.',array('dashboard'=>'write','approvals'=>'approve','invoices'=>'approve','purchases'=>'approve','items'=>'view','asset'=>'approve','cash_bank'=>'approve','jurnal'=>'approve','reports'=>'write','budgets'=>'approve','taxes'=>'approve')), + 'Finance & Treasury'=>array('Kas, bank, pembayaran, penerimaan dan posisi kas.',array('dashboard'=>'view','approvals'=>'approve','invoices'=>'write','purchases'=>'write','cash_bank'=>'approve','jurnal'=>'write','reports'=>'view')), + 'AR / Sales Billing'=>array('Customer, penjualan, invoice, piutang dan penerimaan.',array('dashboard'=>'view','invoices'=>'approve','cash_bank'=>'view','jurnal'=>'view','reports'=>'view')), + 'AP / Purchasing'=>array('Supplier, purchase workflow, hutang dan pembayaran supplier.',array('dashboard'=>'view','purchases'=>'approve','items'=>'view','cash_bank'=>'view','jurnal'=>'view','reports'=>'view')), + 'Inventory & Warehouse'=>array('Persediaan, gudang, barcode, mutasi dan stock opname.',array('dashboard'=>'view','items'=>'approve','purchases'=>'view','asset'=>'view','reports'=>'view')), + 'Asset Officer'=>array('Register aset, penyusutan, maintenance dan asset opname.',array('dashboard'=>'view','asset'=>'approve','items'=>'view','purchases'=>'view','jurnal'=>'view','reports'=>'view')), + 'HR & Payroll'=>array('Data karyawan, absensi dan payroll.',array('dashboard'=>'view','employees'=>'approve','cash_bank'=>'view','jurnal'=>'view','reports'=>'view')), + 'Tax Officer'=>array('Master pajak, transaksi pajak dan pelaporan pajak.',array('dashboard'=>'view','taxes'=>'approve','invoices'=>'view','purchases'=>'view','jurnal'=>'view','reports'=>'view')), + 'Auditor / Read Only'=>array('Akses baca dan export tanpa mengubah transaksi.',array('dashboard'=>'view','approvals'=>'view','invoices'=>'view','purchases'=>'view','items'=>'view','asset'=>'view','cash_bank'=>'view','jurnal'=>'view','reports'=>'view','budgets'=>'view','taxes'=>'view','employees'=>'view')), + 'Management Viewer'=>array('Dashboard, approval dan laporan manajemen.',array('dashboard'=>'view','approvals'=>'approve','reports'=>'view','budgets'=>'view')), + 'Keuangan'=>array('Role legacy keuangan; gunakan Finance & Treasury untuk user baru.',array('dashboard'=>'view','invoices'=>'write','purchases'=>'write','cash_bank'=>'approve','jurnal'=>'write','reports'=>'view')), + 'Gudang'=>array('Role legacy gudang; gunakan Inventory & Warehouse untuk user baru.',array('dashboard'=>'view','items'=>'approve','purchases'=>'view','asset'=>'view','reports'=>'view')) + ); + foreach($roles as$name=>$definition){$data=array('permissions'=>$this->rights($definition[1]),'description'=>$definition[0],'is_active'=>1);$row=$this->db->get_where('roles',array('nama_role'=>$name))->row();if($row)$this->db->where('id',$row->id)->update('roles',$data);else{$data['nama_role']=$name;$this->db->insert('roles',$data);}} + $this->db->query("CREATE OR REPLACE SQL SECURITY INVOKER VIEW v_users AS SELECT u.id,u.username,u.password,u.role_id,u.created_at,u.nama,r.nama_role,COALESCE(r.permissions,'{}') permissions,r.is_active role_is_active FROM users u LEFT JOIN roles r ON r.id=u.role_id"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback role dan permission.');} +} diff --git a/application/migrations/20260902000100_create_scalability_foundation.php b/application/migrations/20260902000100_create_scalability_foundation.php new file mode 100644 index 0000000..f066fa9 --- /dev/null +++ b/application/migrations/20260902000100_create_scalability_foundation.php @@ -0,0 +1,35 @@ +db->table_exists($table))return;foreach($columns as$c)if(!$this->db->field_exists($c,$table))return;if(!$this->db->query("SHOW INDEX FROM `$table` WHERE Key_name=".$this->db->escape($name))->row())$this->db->query("ALTER TABLE `$table` ADD INDEX `$name` (`".implode('`,`',$columns)."`)");} + public function up(){ + $tables=array( + 'ci_sessions'=>"id VARCHAR(128) NOT NULL,ip_address VARCHAR(45) NOT NULL,timestamp INT UNSIGNED NOT NULL DEFAULT 0,data BLOB NOT NULL,PRIMARY KEY(id),KEY idx_ci_sessions_timestamp(timestamp)", + 'dashboard_summaries'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,period CHAR(7) NOT NULL,total_revenue DECIMAL(18,2) NOT NULL DEFAULT 0,total_expense DECIMAL(18,2) NOT NULL DEFAULT 0,net_profit DECIMAL(18,2) NOT NULL DEFAULT 0,journal_count BIGINT UNSIGNED NOT NULL DEFAULT 0,computed_at DATETIME NOT NULL,expires_at DATETIME NOT NULL,UNIQUE KEY uq_dashboard_summary(company_id,period),KEY idx_dashboard_expiry(expires_at)", + 'app_jobs'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,queue_name VARCHAR(40) NOT NULL DEFAULT 'default',job_type VARCHAR(60) NOT NULL,payload LONGTEXT NOT NULL,status ENUM('queued','processing','completed','retry','dead') NOT NULL DEFAULT 'queued',priority TINYINT NOT NULL DEFAULT 5,attempts INT NOT NULL DEFAULT 0,max_attempts INT NOT NULL DEFAULT 3,available_at DATETIME NOT NULL,locked_at DATETIME NULL,locked_by VARCHAR(100) NULL,last_error TEXT NULL,idempotency_key VARCHAR(140) NOT NULL,created_at DATETIME NOT NULL,completed_at DATETIME NULL,UNIQUE KEY uq_app_job_key(idempotency_key),KEY idx_app_job_claim(status,queue_name,available_at,priority,id)", + 'app_dead_letters'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,job_id BIGINT UNSIGNED NOT NULL,company_id BIGINT UNSIGNED NULL,job_type VARCHAR(60) NOT NULL,payload LONGTEXT NOT NULL,attempts INT NOT NULL,last_error TEXT NULL,failed_at DATETIME NOT NULL,KEY idx_dead_letter_date(failed_at),KEY idx_dead_letter_type(job_type)", + 'database_health_metrics'=>"id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,checked_at DATETIME NOT NULL,connection_ms DECIMAL(12,3) NOT NULL,query_ms DECIMAL(12,3) NOT NULL,threads_connected INT NULL,threads_running INT NULL,slow_queries BIGINT NULL,db_size_mb DECIMAL(14,2) NULL,status ENUM('healthy','warning','critical') NOT NULL,message VARCHAR(255) NULL,KEY idx_db_health_date(checked_at,status)" + );foreach($tables as$t=>$sql)$this->db->query("CREATE TABLE IF NOT EXISTS `$t`($sql) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $indexes=array( + array('journals','idx_journal_company_status_date',array('company_id','status','tanggal','id')), + array('journal_details','idx_jd_company_account_journal',array('company_id','account_id','journal_id')), + array('invoices','idx_invoice_company_workflow_due',array('company_id','workflow_status','jatuh_tempo','customer_id')), + array('payments','idx_payment_company_customer_date',array('company_id','customer_id','tanggal')), + array('supplier_invoices','idx_bill_company_status_due',array('company_id','status','due_date','supplier_id')), + array('supplier_payments','idx_spay_company_supplier_date',array('company_id','supplier_id','payment_date')), + array('inventory_ledger','idx_ledger_item_wh_date',array('item_id','warehouse_id','movement_date','id')), + array('stock_documents','idx_stock_doc_status_date',array('status','document_date','item_id')), + array('items','idx_items_company_active_name',array('company_id','is_active','nama_barang')), + array('customers','idx_customer_company_name',array('company_id','nama')), + array('suppliers','idx_supplier_company_active_name',array('company_id','is_active','name')), + array('accounts','idx_account_company_active_code',array('company_id','is_active','kode_akun')), + array('k_employees','idx_employee_company_active_name',array('company_id','is_active','nama')), + array('activity_logs','idx_activity_created_user',array('created_at','user_id')), + array('approval_requests','idx_approval_status_created',array('status','created_at')), + array('report_export_jobs','idx_report_job_claim',array('status','requested_at','id')) + );foreach($indexes as$i)$this->index($i[0],$i[1],$i[2]); + foreach(array('report_export_jobs')as$t){if($this->db->table_exists($t)){if(!$this->db->field_exists('attempts',$t))$this->db->query("ALTER TABLE `$t` ADD attempts INT NOT NULL DEFAULT 0");if(!$this->db->field_exists('max_attempts',$t))$this->db->query("ALTER TABLE `$t` ADD max_attempts INT NOT NULL DEFAULT 3");if(!$this->db->field_exists('available_at',$t))$this->db->query("ALTER TABLE `$t` ADD available_at DATETIME NULL");}} + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback fondasi skalabilitas.');} +} diff --git a/application/migrations/20260902000200_create_application_cache.php b/application/migrations/20260902000200_create_application_cache.php new file mode 100644 index 0000000..031c6de --- /dev/null +++ b/application/migrations/20260902000200_create_application_cache.php @@ -0,0 +1,7 @@ +db->query("CREATE TABLE IF NOT EXISTS app_cache(cache_key VARCHAR(190) PRIMARY KEY,payload LONGTEXT NOT NULL,expires_at DATETIME NOT NULL,created_at DATETIME NOT NULL,KEY idx_app_cache_expiry(expires_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");} + public function down(){throw new RuntimeException('Gunakan backup untuk rollback cache aplikasi.');} +} diff --git a/application/migrations/20260902000300_create_activity_log_archive.php b/application/migrations/20260902000300_create_activity_log_archive.php new file mode 100644 index 0000000..75c395c --- /dev/null +++ b/application/migrations/20260902000300_create_activity_log_archive.php @@ -0,0 +1,7 @@ +db->table_exists('activity_logs')&&!$this->db->table_exists('activity_logs_archive')){$this->db->query('CREATE TABLE activity_logs_archive LIKE activity_logs');$this->db->query('ALTER TABLE activity_logs_archive ADD archived_at DATETIME NULL, ADD KEY idx_activity_archive_date(archived_at)');}} + public function down(){throw new RuntimeException('Arsip log tidak dihapus otomatis.');} +} diff --git a/application/migrations/20260902000400_enhance_role_management.php b/application/migrations/20260902000400_enhance_role_management.php new file mode 100644 index 0000000..91b140b --- /dev/null +++ b/application/migrations/20260902000400_enhance_role_management.php @@ -0,0 +1,19 @@ +db->field_exists('is_system', 'roles')) $this->db->query("ALTER TABLE roles ADD is_system TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active"); + if (!$this->db->field_exists('is_super_admin', 'roles')) $this->db->query("ALTER TABLE roles ADD is_super_admin TINYINT(1) NOT NULL DEFAULT 0 AFTER is_system"); + if (!$this->db->field_exists('created_at', 'roles')) $this->db->query("ALTER TABLE roles ADD created_at DATETIME NULL AFTER is_super_admin"); + if (!$this->db->field_exists('updated_at', 'roles')) $this->db->query("ALTER TABLE roles ADD updated_at DATETIME NULL AFTER created_at"); + $this->db->set('is_system', 1)->update('roles'); + $this->db->where('nama_role', 'Admin')->update('roles', array( + 'is_super_admin' => 1, + 'description' => 'Master Admin: akses penuh seluruh modul, transaksi, posting, approval, konfigurasi, pengguna dan role.', + 'is_active' => 1 + )); + } + public function down() { throw new RuntimeException('Gunakan backup untuk rollback manajemen role.'); } +} diff --git a/application/migrations/20260902000500_unify_purchase_item_asset_flow.php b/application/migrations/20260902000500_unify_purchase_item_asset_flow.php new file mode 100644 index 0000000..6a16c86 --- /dev/null +++ b/application/migrations/20260902000500_unify_purchase_item_asset_flow.php @@ -0,0 +1,20 @@ +db->field_exists('destination_type','purchase_requests'))$this->db->query("ALTER TABLE purchase_requests ADD destination_type ENUM('warehouse','asset') NOT NULL DEFAULT 'warehouse' AFTER purpose"); + foreach(array('destination_type'=>"ENUM('warehouse','asset') NOT NULL DEFAULT 'warehouse'",'asset_account_id'=>'INT NULL','asset_location_id'=>'INT NULL','useful_life_months'=>'INT NULL','residual_value'=>'DECIMAL(18,2) NOT NULL DEFAULT 0')as$c=>$s)if(!$this->db->field_exists($c,'purchase_order_lines'))$this->db->query("ALTER TABLE purchase_order_lines ADD `$c` $s"); + $this->db->query('ALTER TABLE purchase_order_lines MODIFY warehouse_id INT NULL'); + $this->db->query('ALTER TABLE purchase_order_lines MODIFY item_id INT NULL'); + if(!$this->db->field_exists('asset_id','goods_receipt_lines'))$this->db->query('ALTER TABLE goods_receipt_lines ADD asset_id INT NULL AFTER stock_log_id'); + $this->db->query('ALTER TABLE goods_receipt_lines MODIFY item_id INT NULL, MODIFY warehouse_id INT NULL'); + $this->db->query('ALTER TABLE supplier_invoice_lines MODIFY item_id INT NULL'); + if(!$this->db->field_exists('planned_qty','items'))$this->db->query('ALTER TABLE items ADD planned_qty DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER stok'); + if(!$this->db->field_exists('planned_warehouse_id','items'))$this->db->query('ALTER TABLE items ADD planned_warehouse_id INT NULL AFTER planned_qty'); + if(!$this->db->field_exists('planned_tracking_qty','items'))$this->db->query('ALTER TABLE items ADD planned_tracking_qty DECIMAL(18,4) NULL AFTER planned_warehouse_id'); + // Data lama yang sudah pernah menghasilkan stok bukan lagi barang pending. + $this->db->query("UPDATE items SET status='active' WHERE status='draft' AND stok > 0"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback penyatuan workflow pembelian.');} +} diff --git a/application/migrations/20260903000100_align_purchase_catalog_and_payment.php b/application/migrations/20260903000100_align_purchase_catalog_and_payment.php new file mode 100644 index 0000000..b419a8f --- /dev/null +++ b/application/migrations/20260903000100_align_purchase_catalog_and_payment.php @@ -0,0 +1,13 @@ +db->field_exists('kode_barang_id','purchase_request_lines'))$this->db->query('ALTER TABLE purchase_request_lines ADD kode_barang_id INT NULL AFTER item_id'); + if(!$this->db->field_exists('kode_barang_id','purchase_order_lines'))$this->db->query('ALTER TABLE purchase_order_lines ADD kode_barang_id INT NULL AFTER item_id'); + if(!$this->db->field_exists('payment_plan','purchase_orders'))$this->db->query("ALTER TABLE purchase_orders ADD payment_plan ENUM('cash','installment','credit') NOT NULL DEFAULT 'credit' AFTER expected_date"); + $this->db->query('UPDATE purchase_request_lines prl JOIN items i ON i.id=prl.item_id SET prl.kode_barang_id=i.kode_id WHERE prl.kode_barang_id IS NULL'); + $this->db->query('UPDATE purchase_order_lines pol JOIN items i ON i.id=pol.item_id SET pol.kode_barang_id=i.kode_id WHERE pol.kode_barang_id IS NULL'); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback penyelarasan katalog pembelian.');} +} diff --git a/application/migrations/20260903000200_complete_department_position_master.php b/application/migrations/20260903000200_complete_department_position_master.php new file mode 100644 index 0000000..39148e9 --- /dev/null +++ b/application/migrations/20260903000200_complete_department_position_master.php @@ -0,0 +1,7 @@ +db->field_exists('code',$t))$this->db->query("ALTER TABLE `$t` ADD code VARCHAR(30) NULL AFTER id");if(!$this->db->field_exists('description',$t))$this->db->query("ALTER TABLE `$t` ADD description VARCHAR(255) NULL");if(!$this->db->field_exists('is_active',$t))$this->db->query("ALTER TABLE `$t` ADD is_active TINYINT(1) NOT NULL DEFAULT 1");}if(!$this->db->field_exists('department_id','k_positions'))$this->db->query('ALTER TABLE k_positions ADD department_id INT NULL AFTER position_name');if(!$this->db->field_exists('department_id','purchase_requests'))$this->db->query('ALTER TABLE purchase_requests ADD department_id INT NULL AFTER department');$this->db->query("UPDATE k_departments SET code=CONCAT('DEP-',LPAD(id,3,'0')) WHERE code IS NULL OR code=''");$this->db->query("UPDATE k_positions SET code=CONCAT('POS-',LPAD(id,3,'0')) WHERE code IS NULL OR code=''");$this->db->query("UPDATE purchase_requests pr JOIN k_departments d ON CONVERT(LOWER(TRIM(d.department_name)) USING utf8mb4) COLLATE utf8mb4_general_ci=CONVERT(LOWER(TRIM(pr.department)) USING utf8mb4) COLLATE utf8mb4_general_ci SET pr.department_id=d.id WHERE pr.department_id IS NULL");} + public function down(){throw new RuntimeException('Gunakan backup untuk rollback master organisasi.');} +} diff --git a/application/migrations/20260903000300_complete_purchase_tracking_settlement.php b/application/migrations/20260903000300_complete_purchase_tracking_settlement.php new file mode 100644 index 0000000..640b541 --- /dev/null +++ b/application/migrations/20260903000300_complete_purchase_tracking_settlement.php @@ -0,0 +1,14 @@ +db->field_exists('tracking_type',$t))$this->db->query("ALTER TABLE `$t` ADD tracking_type ENUM('UNIT','QTY') NULL");if(!$this->db->field_exists('qty_per_barcode',$t))$this->db->query("ALTER TABLE `$t` ADD qty_per_barcode DECIMAL(18,4) NULL");if(!$this->db->field_exists('need_serial_number',$t))$this->db->query("ALTER TABLE `$t` ADD need_serial_number TINYINT(1) NOT NULL DEFAULT 0");} + if(!$this->db->field_exists('payment_plan','supplier_invoices'))$this->db->query("ALTER TABLE supplier_invoices ADD payment_plan ENUM('cash','installment','credit') NOT NULL DEFAULT 'credit' AFTER due_date"); + if(!$this->db->field_exists('source_receipt_line_id','item_barcodes'))$this->db->query('ALTER TABLE item_barcodes ADD source_receipt_line_id BIGINT UNSIGNED NULL'); + if(!$this->db->field_exists('barcode_id','purchase_return_lines'))$this->db->query('ALTER TABLE purchase_return_lines ADD barcode_id INT NULL AFTER item_id'); + $this->db->query("UPDATE purchase_request_lines l JOIN kode_barang k ON k.id=l.kode_barang_id SET l.tracking_type=k.tracking_type,l.need_serial_number=IF(k.need_serial_number='true',1,0),l.qty_per_barcode=IF(k.tracking_type='UNIT',1,NULL) WHERE l.tracking_type IS NULL"); + $this->db->query("UPDATE purchase_order_lines l JOIN kode_barang k ON k.id=l.kode_barang_id SET l.tracking_type=k.tracking_type,l.need_serial_number=IF(k.need_serial_number='true',1,0),l.qty_per_barcode=IF(k.tracking_type='UNIT',1,NULL) WHERE l.tracking_type IS NULL"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback workflow tracking pembelian.');} +} diff --git a/application/migrations/20260903000400_reconcile_purchase_stock_ledger.php b/application/migrations/20260903000400_reconcile_purchase_stock_ledger.php new file mode 100644 index 0000000..824d277 --- /dev/null +++ b/application/migrations/20260903000400_reconcile_purchase_stock_ledger.php @@ -0,0 +1,10 @@ +db->query("INSERT IGNORE INTO inventory_ledger(item_id,warehouse_id,barcode_id,movement_date,direction,qty,unit_cost,value,document_type,document_id,idempotency_key,created_at) SELECT s.item_id,s.warehouse_id,s.barcode_id,DATE(s.created_at),IF(s.tipe='masuk','in','out'),s.qty,COALESCE(NULLIF(s.unit_cost,0),i.harga_beli),s.qty*COALESCE(NULLIF(s.unit_cost,0),i.harga_beli),s.ref_type,s.ref_id,CONCAT('PURCHASE-BACKFILL-STOCK-',s.id),s.created_at FROM stock_logs s JOIN items i ON i.id=s.item_id LEFT JOIN inventory_ledger l ON BINARY l.document_type=BINARY s.ref_type AND l.document_id=s.ref_id AND l.item_id=s.item_id AND l.warehouse_id=s.warehouse_id AND l.qty=s.qty AND l.direction=IF(s.tipe='masuk','in','out') WHERE s.ref_type IN('goods_receipt','purchase_return') AND l.id IS NULL"); + $this->db->query("UPDATE items i SET i.stok=COALESCE((SELECT SUM(IF(l.direction='in',l.qty,-l.qty)) FROM inventory_ledger l WHERE l.item_id=i.id),0)"); + } + public function down(){throw new RuntimeException('Rekonsiliasi ledger tidak boleh dihapus tanpa audit.');} +} diff --git a/application/migrations/20260903000500_force_pr_to_warehouse.php b/application/migrations/20260903000500_force_pr_to_warehouse.php new file mode 100644 index 0000000..28adafb --- /dev/null +++ b/application/migrations/20260903000500_force_pr_to_warehouse.php @@ -0,0 +1,7 @@ +db->where_in('status',array('draft','submitted','approved'))->update('purchase_requests',array('destination_type'=>'warehouse'));} + public function down(){throw new RuntimeException('Klasifikasi PR lama tidak dapat dipulihkan otomatis.');} +} diff --git a/application/migrations/20260903000600_add_pr_applicant.php b/application/migrations/20260903000600_add_pr_applicant.php new file mode 100644 index 0000000..4a58c7b --- /dev/null +++ b/application/migrations/20260903000600_add_pr_applicant.php @@ -0,0 +1,25 @@ +db->field_exists('applicant_is_creator', 'purchase_requests')) { + $this->db->query("ALTER TABLE purchase_requests ADD applicant_is_creator TINYINT(1) NOT NULL DEFAULT 1 AFTER requested_by"); + } + if (!$this->db->field_exists('applicant_name', 'purchase_requests')) { + $this->db->query("ALTER TABLE purchase_requests ADD applicant_name VARCHAR(150) NULL AFTER applicant_is_creator"); + } + } + + public function down() + { + if ($this->db->field_exists('applicant_name', 'purchase_requests')) { + $this->db->query('ALTER TABLE purchase_requests DROP COLUMN applicant_name'); + } + if ($this->db->field_exists('applicant_is_creator', 'purchase_requests')) { + $this->db->query('ALTER TABLE purchase_requests DROP COLUMN applicant_is_creator'); + } + } +} diff --git a/application/migrations/20260903000700_professional_purchase_workflow.php b/application/migrations/20260903000700_professional_purchase_workflow.php new file mode 100644 index 0000000..5543b15 --- /dev/null +++ b/application/migrations/20260903000700_professional_purchase_workflow.php @@ -0,0 +1,24 @@ +"ENUM('online','direct','supplier') NOT NULL DEFAULT 'supplier' AFTER expected_date", + 'payment_timing'=>"ENUM('prepaid','on_receipt','installment','credit') NOT NULL DEFAULT 'credit' AFTER procurement_method", + 'marketplace'=>"VARCHAR(80) NULL AFTER payment_timing",'external_order_no'=>"VARCHAR(120) NULL AFTER marketplace",'courier'=>"VARCHAR(80) NULL AFTER external_order_no",'tracking_no'=>"VARCHAR(120) NULL AFTER courier", + 'shipping_cost'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER total",'insurance_cost'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER shipping_cost",'service_fee'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER insurance_cost",'discount_amount'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER service_fee",'cashback_amount'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER discount_amount", + 'payment_status'=>"ENUM('unpaid','partial','paid','refunded') NOT NULL DEFAULT 'unpaid' AFTER cashback_amount",'paid_amount'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER payment_status",'transit_cleared_amount'=>"DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER paid_amount" + );foreach($columns as$n=>$definition)if(!$this->db->field_exists($n,'purchase_orders'))$this->db->query("ALTER TABLE purchase_orders ADD `$n` $definition"); + if(!$this->db->field_exists('purchase_order_id','supplier_payments'))$this->db->query('ALTER TABLE supplier_payments ADD purchase_order_id BIGINT UNSIGNED NULL AFTER supplier_id'); + if(!$this->db->field_exists('payment_kind','supplier_payments'))$this->db->query("ALTER TABLE supplier_payments ADD payment_kind ENUM('invoice','prepayment','refund') NOT NULL DEFAULT 'invoice' AFTER purchase_order_id"); + if(!$this->db->field_exists('advance_journal_id','goods_receipt_lines'))$this->db->query('ALTER TABLE goods_receipt_lines ADD advance_journal_id INT NULL AFTER stock_log_id'); + $this->db->query("CREATE TABLE IF NOT EXISTS purchase_payment_sources(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_payment_id BIGINT UNSIGNED NOT NULL,account_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,KEY idx_purchase_payment_source(supplier_payment_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS purchase_installment_schedules(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_order_id BIGINT UNSIGNED NOT NULL,installment_no INT NOT NULL,due_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,status ENUM('unpaid','partial','paid','cancelled') NOT NULL DEFAULT 'unpaid',created_at DATETIME NOT NULL,UNIQUE KEY uq_po_installment(purchase_order_id,installment_no),KEY idx_installment_due(status,due_date)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS purchase_refunds(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,refund_no VARCHAR(60) NOT NULL,purchase_order_id BIGINT UNSIGNED NOT NULL,supplier_payment_id BIGINT UNSIGNED NULL,refund_date DATE NOT NULL,account_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,reason TEXT NOT NULL,status ENUM('posted','reversed') NOT NULL DEFAULT 'posted',journal_id INT NOT NULL,created_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_refund_no(refund_no)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $account=$this->db->get_where('accounts',array('kode_akun'=>'1193'))->row();if(!$account){$this->db->insert('accounts',array('kode_akun'=>'1193','nama_akun'=>'Barang Dalam Perjalanan','tipe'=>'asset','posisi'=>'debit','kategori'=>'neraca','is_active'=>1,'is_header'=>0,'allow_posting'=>1));$accountId=$this->db->insert_id();}else$accountId=$account->id; + if(!$this->db->where('mapping_key','goods_in_transit')->count_all_results('system_account_mappings'))$this->db->insert('system_account_mappings',array('mapping_key'=>'goods_in_transit','account_id'=>$accountId,'description'=>'Pembayaran barang sebelum diterima gudang','is_active'=>1,'created_at'=>date('Y-m-d H:i:s'))); + } + public function down(){throw new RuntimeException('Gunakan backup untuk rollback workflow pembelian profesional.');} +} diff --git a/application/migrations/20260903000800_fix_purchase_installment_schedule_columns.php b/application/migrations/20260903000800_fix_purchase_installment_schedule_columns.php new file mode 100644 index 0000000..bfe377b --- /dev/null +++ b/application/migrations/20260903000800_fix_purchase_installment_schedule_columns.php @@ -0,0 +1,17 @@ +db->table_exists('purchase_installment_schedules')){ + $this->db->query("CREATE TABLE IF NOT EXISTS purchase_installment_schedules(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_order_id BIGINT UNSIGNED NOT NULL,installment_no INT NOT NULL,due_date DATE NOT NULL,amount DECIMAL(18,2) NOT NULL,paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,status ENUM('unpaid','partial','paid','cancelled') NOT NULL DEFAULT 'unpaid',created_at DATETIME NOT NULL,UNIQUE KEY uq_po_installment(purchase_order_id,installment_no),KEY idx_installment_due(status,due_date)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + return; + } + if(!$this->db->field_exists('created_at','purchase_installment_schedules'))$this->db->query('ALTER TABLE purchase_installment_schedules ADD created_at DATETIME NULL AFTER status'); + $this->db->query('UPDATE purchase_installment_schedules SET created_at=NOW() WHERE created_at IS NULL'); + $this->db->query('ALTER TABLE purchase_installment_schedules MODIFY created_at DATETIME NOT NULL'); + } + public function down(){if($this->db->field_exists('created_at','purchase_installment_schedules'))$this->db->query('ALTER TABLE purchase_installment_schedules DROP COLUMN created_at');} +} diff --git a/application/migrations/20260904000100_reconcile_goods_receipt_item_movements.php b/application/migrations/20260904000100_reconcile_goods_receipt_item_movements.php new file mode 100644 index 0000000..44e7539 --- /dev/null +++ b/application/migrations/20260904000100_reconcile_goods_receipt_item_movements.php @@ -0,0 +1,45 @@ +db->query("INSERT INTO item_movements + (item_id,barcode_id,qty,from_type,from_id,to_type,to_id,movement_type,reference_type,reference_id,notes,created_at) + SELECT ib.item_id,ib.id,ib.qty_awal,'supplier',gr.supplier_id,'warehouse',ib.warehouse_id, + 'purchase','goods_receipt_line',grl.id, + CONCAT('Penerimaan barang ',gr.receipt_no,' dari supplier ke gudang'), + COALESCE(gr.created_at,NOW()) + FROM item_barcodes ib + JOIN goods_receipt_lines grl ON grl.id=ib.source_receipt_line_id + JOIN goods_receipts gr ON gr.id=grl.goods_receipt_id + LEFT JOIN item_movements im ON im.barcode_id=ib.id + AND im.reference_type='goods_receipt_line' + AND im.reference_id=grl.id + AND im.movement_type='purchase' + WHERE im.id IS NULL"); + + $this->db->query("UPDATE item_barcodes ib + JOIN goods_receipt_lines grl ON grl.id=ib.source_receipt_line_id + JOIN purchase_order_lines pol ON pol.id=grl.po_line_id + SET ib.status='pending' + WHERE pol.need_serial_number=1 + AND (ib.serial_number IS NULL OR ib.serial_number='') + AND ib.qty_sisa>0"); + + $this->db->query("UPDATE items i SET i.status='draft' + WHERE EXISTS( + SELECT 1 FROM item_barcodes ib + JOIN goods_receipt_lines grl ON grl.id=ib.source_receipt_line_id + JOIN purchase_order_lines pol ON pol.id=grl.po_line_id + WHERE ib.item_id=i.id AND pol.need_serial_number=1 + AND (ib.serial_number IS NULL OR ib.serial_number='') AND ib.qty_sisa>0 + )"); + } + + public function down() + { + // Rekonsiliasi histori tidak dihapus agar audit trail penerimaan tetap utuh. + } +} diff --git a/application/migrations/20260904000200_expand_item_barcode_status.php b/application/migrations/20260904000200_expand_item_barcode_status.php new file mode 100644 index 0000000..4ef789d --- /dev/null +++ b/application/migrations/20260904000200_expand_item_barcode_status.php @@ -0,0 +1,37 @@ +db->table_exists('item_barcodes')) { + return; + } + + // Database produksi lama hanya mengenal status barcode siap pakai. + // `pending` diperlukan untuk barang yang sudah diterima secara fisik, + // tetapi belum boleh dipakai/dijual sebelum serial number dilengkapi. + $this->db->query("ALTER TABLE item_barcodes + MODIFY status ENUM('pending','available','reserved','installed','sold_out') + NOT NULL DEFAULT 'available'"); + + if ($this->db->table_exists('goods_receipt_lines') && + $this->db->table_exists('purchase_order_lines') && + $this->db->field_exists('source_receipt_line_id', 'item_barcodes')) { + $this->db->query("UPDATE item_barcodes ib + JOIN goods_receipt_lines grl ON grl.id=ib.source_receipt_line_id + JOIN purchase_order_lines pol ON pol.id=grl.po_line_id + SET ib.status='pending' + WHERE pol.need_serial_number=1 + AND (ib.serial_number IS NULL OR ib.serial_number='') + AND ib.qty_sisa>0"); + } + } + + public function down() + { + // Tidak dipersempit kembali karena dapat merusak barcode pending yang + // telah menjadi bagian audit trail penerimaan. + } +} diff --git a/application/migrations/20260904000300_recover_incomplete_receipt_tracking.php b/application/migrations/20260904000300_recover_incomplete_receipt_tracking.php new file mode 100644 index 0000000..9d54500 --- /dev/null +++ b/application/migrations/20260904000300_recover_incomplete_receipt_tracking.php @@ -0,0 +1,47 @@ +db->table_exists('goods_receipts') || + !$this->db->table_exists('goods_receipt_lines')) { + return; + } + + $this->load->library('PurchaseService'); + $receipts = $this->db + ->select('DISTINCT gr.id, gr.received_by', false) + ->from('goods_receipts gr') + ->join('goods_receipt_lines grl', 'grl.goods_receipt_id=gr.id') + ->join('item_barcodes ib', 'ib.source_receipt_line_id=grl.id', 'left') + ->where('gr.status', 'posted') + ->where('grl.item_id IS NOT NULL', null, false) + ->where('ib.id IS NULL', null, false) + ->get()->result(); + + foreach ($receipts as $receipt) { + // Seluruh method bersifat idempotent: hanya bagian tracking/ledger + // yang belum terbentuk yang dilengkapi. + try { + $this->purchaseservice->finalizeReceiptTracking((int) $receipt->id); + $this->purchaseservice->syncReceiptLedger((int) $receipt->id); + $this->purchaseservice->clearTransitForReceipt( + (int) $receipt->id, + (int) $receipt->received_by + ); + } catch (Throwable $error) { + // Histori lama yang belum memiliki konfigurasi tracking lengkap + // tidak boleh menggagalkan pemulihan penerimaan baru lainnya. + log_message('error', 'Pemulihan tracking penerimaan #'. + $receipt->id.' dilewati: '.$error->getMessage()); + } + } + } + + public function down() + { + // Data hasil pemulihan merupakan audit trail transaksi dan tidak dihapus. + } +} diff --git a/application/migrations/20260904000400_reconcile_receipt_transit_after_tracking_recovery.php b/application/migrations/20260904000400_reconcile_receipt_transit_after_tracking_recovery.php new file mode 100644 index 0000000..b549800 --- /dev/null +++ b/application/migrations/20260904000400_reconcile_receipt_transit_after_tracking_recovery.php @@ -0,0 +1,35 @@ +load->library('PurchaseService'); + $receipts = $this->db->select('gr.id,gr.received_by') + ->from('goods_receipts gr') + ->join('purchase_orders po', 'po.id=gr.purchase_order_id') + ->where('gr.status', 'posted') + ->where('po.paid_amount > po.transit_cleared_amount', null, false) + ->get()->result(); + + foreach ($receipts as $receipt) { + try { + // CompanyContext memperoleh tenant dari user transaksi. + $this->session->set_userdata('user_id', (int) $receipt->received_by); + $this->purchaseservice->clearTransitForReceipt( + (int) $receipt->id, + (int) $receipt->received_by + ); + } catch (Throwable $error) { + log_message('error', 'Rekonsiliasi barang dalam perjalanan #'. + $receipt->id.' dilewati: '.$error->getMessage()); + } + } + } + + public function down() + { + // Jurnal rekonsiliasi merupakan audit trail dan tidak dihapus. + } +} diff --git a/application/migrations/20260904000500_add_purchase_cost_and_partial_barcode_release.php b/application/migrations/20260904000500_add_purchase_cost_and_partial_barcode_release.php new file mode 100644 index 0000000..5893a35 --- /dev/null +++ b/application/migrations/20260904000500_add_purchase_cost_and_partial_barcode_release.php @@ -0,0 +1,26 @@ +db->field_exists('allocate_purchase_cost', 'kode_barang')) { + $this->db->query("ALTER TABLE kode_barang ADD allocate_purchase_cost TINYINT(1) NOT NULL DEFAULT 0 AFTER need_serial_number"); + } + if (!$this->db->field_exists('sale_price', 'item_barcodes')) { + $this->db->query("ALTER TABLE item_barcodes ADD sale_price DECIMAL(15,2) NULL AFTER qty_sisa"); + } + if (!$this->db->field_exists('activated_at', 'item_barcodes')) { + $this->db->query("ALTER TABLE item_barcodes ADD activated_at DATETIME NULL AFTER status"); + } + if (!$this->db->field_exists('activated_by', 'item_barcodes')) { + $this->db->query("ALTER TABLE item_barcodes ADD activated_by INT NULL AFTER activated_at"); + } + } + + public function down() + { + // Kolom dipertahankan untuk menjaga histori penentuan biaya dan harga jual. + } +} diff --git a/application/migrations/20260905000100_finalize_purchase_approval_status.php b/application/migrations/20260905000100_finalize_purchase_approval_status.php new file mode 100644 index 0000000..abc8781 --- /dev/null +++ b/application/migrations/20260905000100_finalize_purchase_approval_status.php @@ -0,0 +1,7 @@ +db->table_exists('purchase_orders'))$this->db->query("ALTER TABLE purchase_orders MODIFY status ENUM('draft','submitted','approved','rejected','partially_received','received','closed','cancelled') NOT NULL DEFAULT 'draft'");} + public function down(){if($this->db->table_exists('purchase_orders')){$this->db->where('status','rejected')->update('purchase_orders',array('status'=>'cancelled'));$this->db->query("ALTER TABLE purchase_orders MODIFY status ENUM('draft','submitted','approved','partially_received','received','closed','cancelled') NOT NULL DEFAULT 'draft'");}} +} diff --git a/application/migrations/20260905000200_remove_supplier_invoice_approval.php b/application/migrations/20260905000200_remove_supplier_invoice_approval.php new file mode 100644 index 0000000..77219dd --- /dev/null +++ b/application/migrations/20260905000200_remove_supplier_invoice_approval.php @@ -0,0 +1,11 @@ +db->table_exists('approval_requests'))$this->db->query("UPDATE approval_requests SET status='rejected',completed_at=COALESCE(completed_at,NOW()) WHERE entity_type='supplier_invoice' AND status='pending'"); + if($this->db->table_exists('approval_workflows'))$this->db->where('entity_type','supplier_invoice')->update('approval_workflows',array('is_active'=>0)); + if($this->db->table_exists('supplier_invoices')&&$this->db->table_exists('transaction_attachments'))$this->db->query("UPDATE supplier_invoices si SET si.status='approved' WHERE si.status='submitted' AND EXISTS(SELECT 1 FROM transaction_attachments ta WHERE ta.entity_type='supplier_invoice' AND ta.entity_id=si.id) AND NOT EXISTS(SELECT 1 FROM supplier_invoice_lines sil WHERE sil.supplier_invoice_id=si.id AND sil.match_status<>'matched')"); + } + public function down(){if($this->db->table_exists('approval_workflows'))$this->db->where('entity_type','supplier_invoice')->update('approval_workflows',array('is_active'=>1));} +} diff --git a/application/migrations/20260905000300_add_purchase_down_payment.php b/application/migrations/20260905000300_add_purchase_down_payment.php new file mode 100644 index 0000000..989eb39 --- /dev/null +++ b/application/migrations/20260905000300_add_purchase_down_payment.php @@ -0,0 +1,19 @@ +db->field_exists('down_payment_required','purchase_orders')) + $this->db->query("ALTER TABLE purchase_orders ADD down_payment_required TINYINT(1) NOT NULL DEFAULT 0 AFTER payment_plan"); + if(!$this->db->field_exists('down_payment_amount','purchase_orders')) + $this->db->query("ALTER TABLE purchase_orders ADD down_payment_amount DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER down_payment_required"); + } + + public function down() + { + if($this->db->field_exists('down_payment_amount','purchase_orders'))$this->db->query('ALTER TABLE purchase_orders DROP COLUMN down_payment_amount'); + if($this->db->field_exists('down_payment_required','purchase_orders'))$this->db->query('ALTER TABLE purchase_orders DROP COLUMN down_payment_required'); + } +} diff --git a/application/migrations/20260905000400_simplify_supplier_invoice_status.php b/application/migrations/20260905000400_simplify_supplier_invoice_status.php new file mode 100644 index 0000000..1d91861 --- /dev/null +++ b/application/migrations/20260905000400_simplify_supplier_invoice_status.php @@ -0,0 +1,24 @@ +db->table_exists('supplier_invoices'))return; + $this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('draft','submitted','approved','verified','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'verified'"); + $this->db->query("UPDATE supplier_invoices SET status='verified' WHERE status IN('draft','submitted','approved')"); + $this->db->query("UPDATE supplier_invoices SET status='partial' WHERE status='posted' AND balance>0"); + $this->db->query("UPDATE supplier_invoices SET status='paid',balance=0 WHERE status='posted' AND balance<=0"); + $this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('verified','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'verified'"); + } + + public function down() + { + if(!$this->db->table_exists('supplier_invoices'))return; + $this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('draft','submitted','approved','verified','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'draft'"); + $this->db->query("UPDATE supplier_invoices SET status='approved' WHERE status='verified'"); + $this->db->query("UPDATE supplier_invoices SET status='posted' WHERE status='partial' AND paid_amount<=0"); + $this->db->query("ALTER TABLE supplier_invoices MODIFY status ENUM('draft','submitted','approved','posted','partial','paid','cancelled','reversed') NOT NULL DEFAULT 'draft'"); + } +} diff --git a/application/migrations/20260905000500_align_open_supplier_invoices_to_po_total.php b/application/migrations/20260905000500_align_open_supplier_invoices_to_po_total.php new file mode 100644 index 0000000..d320910 --- /dev/null +++ b/application/migrations/20260905000500_align_open_supplier_invoices_to_po_total.php @@ -0,0 +1,40 @@ +db->table_exists('supplier_invoices'))return; + $ap=$this->mapping('accounts_payable'); + $inventory=$this->mapping('inventory'); + $rows=$this->db->query("SELECT si.*,po.id po_id,po.total po_total,po.paid_amount po_paid 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 WHERE si.status IN('verified','partial') GROUP BY si.id")->result(); + foreach($rows as$row){ + $openCount=(int)$this->db->query("SELECT COUNT(DISTINCT si.id) 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 WHERE pol.purchase_order_id=? AND si.status IN('verified','partial')",array($row->po_id))->row()->total; + if($openCount!==1)continue; + $oldTotal=(float)$row->total;$newTotal=round((float)$row->po_total,2);$paid=min($newTotal,max((float)$row->paid_amount,(float)$row->po_paid));$balance=max(0,$newTotal-$paid);$delta=round($newTotal-$oldTotal,2); + if($row->status==='partial'&&$row->journal_id&&abs($delta)>.001&&$ap&&$inventory){ + $apLine=$this->db->where(array('journal_id'=>$row->journal_id,'account_id'=>$ap))->order_by('id')->get('journal_details')->row(); + $inventoryLine=$this->db->where(array('journal_id'=>$row->journal_id,'account_id'=>$inventory))->order_by('id')->get('journal_details')->row(); + if($apLine&&$inventoryLine){ + $this->db->where('id',$apLine->id)->update('journal_details',array('kredit'=>round((float)$apLine->kredit+$delta,2))); + $this->db->where('id',$inventoryLine->id)->update('journal_details',array('debit'=>round((float)$inventoryLine->debit+$delta,2))); + } + } + $status=$row->status==='verified'?'verified':($balance<=.001?'paid':'partial'); + $this->db->where('id',$row->id)->update('supplier_invoices',array('total'=>$newTotal,'paid_amount'=>$paid,'balance'=>$balance,'status'=>$status)); + } + } + + private function mapping($key) + { + if(!$this->db->table_exists('system_account_mappings'))return 0; + $row=$this->db->where(array('mapping_key'=>$key,'is_active'=>1))->order_by('id','DESC')->get('system_account_mappings')->row(); + return$row?(int)$row->account_id:0; + } + + public function down() + { + // Nilai lama tidak dikembalikan karena migrasi ini memperbaiki saldo transaksi development. + } +} diff --git a/application/migrations/20260905000600_backfill_purchase_payable_company.php b/application/migrations/20260905000600_backfill_purchase_payable_company.php new file mode 100644 index 0000000..7b6822b --- /dev/null +++ b/application/migrations/20260905000600_backfill_purchase_payable_company.php @@ -0,0 +1,47 @@ +db->table_exists('companies')) return; + $company = $this->db->order_by('id')->get('companies')->row(); + if (!$company) return; + $fallback = (int)$company->id; + + if ($this->db->table_exists('purchase_orders') && $this->db->field_exists('company_id', 'purchase_orders')) { + $this->db->query( + 'UPDATE purchase_orders po LEFT JOIN suppliers s ON s.id=po.supplier_id '. + 'SET po.company_id=COALESCE(s.company_id,?) WHERE po.company_id IS NULL', + array($fallback) + ); + } + + if ($this->db->table_exists('supplier_invoices') && $this->db->field_exists('company_id', 'supplier_invoices')) { + $this->db->query( + 'UPDATE supplier_invoices si '. + 'LEFT JOIN supplier_invoice_lines sil ON sil.supplier_invoice_id=si.id '. + 'LEFT JOIN purchase_order_lines pol ON pol.id=sil.po_line_id '. + 'LEFT JOIN purchase_orders po ON po.id=pol.purchase_order_id '. + 'LEFT JOIN suppliers s ON s.id=si.supplier_id '. + 'SET si.company_id=COALESCE(po.company_id,s.company_id,?) '. + 'WHERE si.company_id IS NULL', + array($fallback) + ); + } + + if ($this->db->table_exists('supplier_payments') && $this->db->field_exists('company_id', 'supplier_payments')) { + $this->db->query( + 'UPDATE supplier_payments sp LEFT JOIN suppliers s ON s.id=sp.supplier_id '. + 'SET sp.company_id=COALESCE(s.company_id,?) WHERE sp.company_id IS NULL', + array($fallback) + ); + } + } + + public function down() + { + // Backfill tenant tidak dibalik agar transaksi tidak kembali tanpa perusahaan. + } +} diff --git a/application/migrations/20260905000700_add_purchase_payment_bank_charge.php b/application/migrations/20260905000700_add_purchase_payment_bank_charge.php new file mode 100644 index 0000000..0364c29 --- /dev/null +++ b/application/migrations/20260905000700_add_purchase_payment_bank_charge.php @@ -0,0 +1,73 @@ +db->table_exists('supplier_payments')) { + if (!$this->db->field_exists('bank_charge_amount', 'supplier_payments')) { + $this->db->query('ALTER TABLE supplier_payments ADD bank_charge_amount DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER amount'); + } + if (!$this->db->field_exists('bank_charge_account_id', 'supplier_payments')) { + $this->db->query('ALTER TABLE supplier_payments ADD bank_charge_account_id INT NULL AFTER bank_charge_amount'); + } + if (!$this->db->field_exists('total_cash_out', 'supplier_payments')) { + $this->db->query('ALTER TABLE supplier_payments ADD total_cash_out DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER bank_charge_account_id'); + } + $this->db->query('UPDATE supplier_payments SET total_cash_out=amount WHERE total_cash_out=0'); + } + + if (!$this->db->table_exists('accounts') || !$this->db->table_exists('system_account_mappings')) return; + $account = $this->db->where('nama_akun', 'Beban Administrasi Bank')->order_by('id')->get('accounts')->row(); + if (!$account) { + $code = '6298'; + $occupied = $this->db->where('kode_akun', $code)->get('accounts')->row(); + if ($occupied) $code = '6298A'; + $data = array( + 'kode_akun' => $code, + 'nama_akun' => 'Beban Administrasi Bank', + 'tipe' => 'expense', + 'posisi' => 'debit', + 'kategori' => 'laba_rugi', + 'is_active' => 1, + 'is_header' => 0, + 'allow_posting' => 1, + ); + if ($this->db->field_exists('company_id', 'accounts') && $this->db->table_exists('companies')) { + $company = $this->db->order_by('id')->get('companies')->row(); + $data['company_id'] = $company ? (int)$company->id : null; + } + $this->db->insert('accounts', $data); + $account = (object)array('id'=>$this->db->insert_id()); + } + if (!$this->db->where('mapping_key', 'bank_charge_expense')->count_all_results('system_account_mappings')) { + $mapping = array( + 'mapping_key' => 'bank_charge_expense', + 'account_id' => (int)$account->id, + 'description' => 'Beban administrasi bank pada pembayaran pembelian', + 'is_active' => 1, + 'created_at' => date('Y-m-d H:i:s'), + ); + if ($this->db->field_exists('company_id', 'system_account_mappings') && $this->db->field_exists('company_id', 'accounts')) { + $row = $this->db->select('company_id')->get_where('accounts', array('id'=>(int)$account->id))->row(); + $mapping['company_id'] = $row ? $row->company_id : null; + } + $this->db->insert('system_account_mappings', $mapping); + } + } + + public function down() + { + if ($this->db->table_exists('system_account_mappings')) { + $this->db->where('mapping_key', 'bank_charge_expense')->delete('system_account_mappings'); + } + if ($this->db->table_exists('supplier_payments')) { + foreach (array('total_cash_out','bank_charge_account_id','bank_charge_amount') as $column) { + if ($this->db->field_exists($column, 'supplier_payments')) { + $this->db->query('ALTER TABLE supplier_payments DROP COLUMN '.$column); + } + } + } + } +} diff --git a/application/migrations/20260905000800_finalize_purchase_returns_refunds.php b/application/migrations/20260905000800_finalize_purchase_returns_refunds.php new file mode 100644 index 0000000..db21c68 --- /dev/null +++ b/application/migrations/20260905000800_finalize_purchase_returns_refunds.php @@ -0,0 +1,216 @@ +upgradeReturnHeaders(); + $this->upgradeReturnLines(); + $this->upgradeBarcodeAndMovements(); + $this->createWorkflowTables(); + $this->upgradeDebitNotes(); + $this->seedAccountingMappings(); + $this->seedWorkflowAndPermissions(); + $this->seedLegacyHistory(); + } + + private function addColumn($table, $name, $definition) + { + if (!$this->db->field_exists($name, $table)) { + $this->db->query("ALTER TABLE `{$table}` ADD `{$name}` {$definition}"); + } + } + + private function addIndex($table, $name, $columns, $unique = false) + { + $exists = $this->db->query( + 'SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? LIMIT 1', + array($table, $name) + )->row(); + if (!$exists) { + $this->db->query('ALTER TABLE `'.$table.'` ADD '.($unique ? 'UNIQUE ' : '').'KEY `'.$name.'` ('.$columns.')'); + } + } + + private function upgradeReturnHeaders() + { + $this->db->query("ALTER TABLE purchase_returns MODIFY goods_receipt_id BIGINT UNSIGNED NULL"); + $this->db->query("ALTER TABLE purchase_returns MODIFY status ENUM('draft','submitted','approved','rejected','reserved','posted','shipped','waiting_supplier','partially_resolved','resolved','cancelled','reversed') NOT NULL DEFAULT 'draft'"); + $this->db->query("UPDATE purchase_returns SET status='shipped' WHERE status='posted'"); + $this->addColumn('purchase_returns', 'company_id', 'BIGINT UNSIGNED NULL AFTER id'); + $this->addColumn('purchase_returns', 'purchase_order_id', 'BIGINT UNSIGNED NULL AFTER supplier_id'); + $this->addColumn('purchase_returns', 'problem_category', "ENUM('damaged','wrong_item','specification','over_delivery','po_mismatch','other') NOT NULL DEFAULT 'other' AFTER reason"); + $this->addColumn('purchase_returns', 'requested_resolution', "ENUM('replacement','debit_note','refund','undecided') NOT NULL DEFAULT 'undecided' AFTER problem_category"); + $this->addColumn('purchase_returns', 'notes', 'TEXT NULL AFTER requested_resolution'); + $this->addColumn('purchase_returns', 'idempotency_key', 'VARCHAR(120) NULL AFTER notes'); + $this->addColumn('purchase_returns', 'submitted_by', 'INT NULL AFTER created_by'); + $this->addColumn('purchase_returns', 'submitted_at', 'DATETIME NULL AFTER submitted_by'); + $this->addColumn('purchase_returns', 'approved_by', 'INT NULL AFTER submitted_at'); + $this->addColumn('purchase_returns', 'approved_at', 'DATETIME NULL AFTER approved_by'); + $this->addColumn('purchase_returns', 'rejected_by', 'INT NULL AFTER approved_at'); + $this->addColumn('purchase_returns', 'rejected_at', 'DATETIME NULL AFTER rejected_by'); + $this->addColumn('purchase_returns', 'rejection_reason', 'TEXT NULL AFTER rejected_at'); + $this->addColumn('purchase_returns', 'reserved_by', 'INT NULL AFTER rejection_reason'); + $this->addColumn('purchase_returns', 'reserved_at', 'DATETIME NULL AFTER reserved_by'); + $this->addColumn('purchase_returns', 'shipped_by', 'INT NULL AFTER reserved_at'); + $this->addColumn('purchase_returns', 'shipped_at', 'DATETIME NULL AFTER shipped_by'); + $this->addColumn('purchase_returns', 'resolved_by', 'INT NULL AFTER shipped_at'); + $this->addColumn('purchase_returns', 'resolved_at', 'DATETIME NULL AFTER resolved_by'); + $this->addColumn('purchase_returns', 'cancelled_by', 'INT NULL AFTER resolved_at'); + $this->addColumn('purchase_returns', 'cancelled_at', 'DATETIME NULL AFTER cancelled_by'); + $this->addColumn('purchase_returns', 'reversal_journal_id', 'INT NULL AFTER journal_id'); + $this->addColumn('purchase_returns', 'updated_by', 'INT NULL AFTER created_at'); + $this->addColumn('purchase_returns', 'updated_at', 'DATETIME NULL AFTER updated_by'); + $this->addIndex('purchase_returns', 'uq_purchase_return_idempotency', '`idempotency_key`', true); + $this->addIndex('purchase_returns', 'idx_purchase_return_action', '`company_id`,`status`,`return_date`'); + $this->addIndex('purchase_returns', 'idx_purchase_return_supplier', '`company_id`,`supplier_id`,`status`'); + $this->addIndex('purchase_returns', 'idx_purchase_return_po', '`purchase_order_id`'); + if ($this->db->field_exists('company_id', 'purchase_orders')) { + $this->db->query("UPDATE purchase_returns pr LEFT JOIN purchase_orders po ON po.id=pr.purchase_order_id SET pr.company_id=COALESCE(pr.company_id,po.company_id,(SELECT MIN(id) FROM companies)) WHERE pr.company_id IS NULL"); + } + } + + private function upgradeReturnLines() + { + $this->addColumn('purchase_return_lines', 'company_id', 'BIGINT UNSIGNED NULL AFTER id'); + $this->addColumn('purchase_return_lines', 'purchase_order_id', 'BIGINT UNSIGNED NULL AFTER purchase_return_id'); + $this->addColumn('purchase_return_lines', 'goods_receipt_id', 'BIGINT UNSIGNED NULL AFTER purchase_order_id'); + $this->addColumn('purchase_return_lines', 'description', 'VARCHAR(255) NULL AFTER warehouse_id'); + $this->addColumn('purchase_return_lines', 'tracking_type', "ENUM('UNIT','QTY') NOT NULL DEFAULT 'QTY' AFTER description"); + $this->addColumn('purchase_return_lines', 'barcode_snapshot', 'VARCHAR(100) NULL AFTER barcode_id'); + $this->addColumn('purchase_return_lines', 'serial_snapshot', 'VARCHAR(100) NULL AFTER barcode_snapshot'); + $this->addColumn('purchase_return_lines', 'received_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER serial_snapshot'); + $this->addColumn('purchase_return_lines', 'previous_return_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER received_qty'); + $this->addColumn('purchase_return_lines', 'returnable_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER previous_return_qty'); + $this->addColumn('purchase_return_lines', 'reserved_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER qty'); + $this->addColumn('purchase_return_lines', 'shipped_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER reserved_qty'); + $this->addColumn('purchase_return_lines', 'tax_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER unit_cost'); + $this->addColumn('purchase_return_lines', 'total_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER tax_amount'); + $this->addColumn('purchase_return_lines', 'requested_resolution', "ENUM('replacement','debit_note','refund','undecided') NOT NULL DEFAULT 'undecided' AFTER total_amount"); + $this->addColumn('purchase_return_lines', 'replacement_received_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER requested_resolution'); + $this->addColumn('purchase_return_lines', 'debit_note_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER replacement_received_qty'); + $this->addColumn('purchase_return_lines', 'refund_claim_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER debit_note_amount'); + $this->addColumn('purchase_return_lines', 'ledger_id', 'BIGINT UNSIGNED NULL AFTER stock_log_id'); + $this->addColumn('purchase_return_lines', 'movement_id', 'INT NULL AFTER ledger_id'); + $this->addColumn('purchase_return_lines', 'status', "ENUM('draft','reserved','shipped','partially_resolved','resolved','reversed') NOT NULL DEFAULT 'draft' AFTER movement_id"); + $this->addIndex('purchase_return_lines', 'idx_return_line_source', '`receipt_line_id`,`status`'); + $this->addIndex('purchase_return_lines', 'idx_return_line_barcode', '`barcode_id`,`status`'); + $this->db->query("UPDATE purchase_return_lines l JOIN goods_receipt_lines grl ON grl.id=l.receipt_line_id JOIN goods_receipts gr ON gr.id=grl.goods_receipt_id JOIN purchase_order_lines pol ON pol.id=grl.po_line_id LEFT JOIN item_barcodes ib ON ib.id=l.barcode_id LEFT JOIN purchase_returns pr ON pr.id=l.purchase_return_id SET l.goods_receipt_id=gr.id,l.purchase_order_id=gr.purchase_order_id,l.company_id=pr.company_id,l.description=COALESCE(l.description,pol.description),l.tracking_type=COALESCE(pol.tracking_type,'QTY'),l.barcode_snapshot=ib.barcode,l.serial_snapshot=ib.serial_number,l.received_qty=grl.qty,l.returnable_qty=GREATEST(0,grl.qty-l.qty),l.total_amount=l.qty*l.unit_cost,l.shipped_qty=IF(pr.status IN('shipped','waiting_supplier','partially_resolved','resolved'),l.qty,0),l.status=IF(pr.status IN('shipped','waiting_supplier','partially_resolved','resolved'),'shipped','draft')"); + } + + private function upgradeBarcodeAndMovements() + { + $this->db->query("ALTER TABLE item_barcodes MODIFY status ENUM('pending','available','reserved','return_reserved','returned','replaced','closed','installed','sold_out') NOT NULL DEFAULT 'available'"); + $this->addColumn('item_barcodes', 'return_reserved_qty', 'DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER reserved_qty'); + $this->db->query("ALTER TABLE item_movements MODIFY movement_type ENUM('purchase','transfer','installation','uninstallation','supplier_return','purchase_return','purchase_replacement','customer_return','adjustment','sold_out','asset') NOT NULL"); + } + + private function createWorkflowTables() + { + $queries = array( + "CREATE TABLE IF NOT EXISTS purchase_return_status_history(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_return_id BIGINT UNSIGNED NOT NULL,from_status VARCHAR(30) NULL,to_status VARCHAR(30) NOT NULL,notes TEXT NULL,user_id INT NULL,ip_address VARCHAR(45) NULL,session_id VARCHAR(128) NULL,created_at DATETIME NOT NULL,KEY idx_return_history(purchase_return_id,created_at),CONSTRAINT fk_return_history_header FOREIGN KEY(purchase_return_id) REFERENCES purchase_returns(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS purchase_return_shipments(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,shipment_no VARCHAR(60) NOT NULL,purchase_return_id BIGINT UNSIGNED NOT NULL,shipment_date DATE NOT NULL,courier VARCHAR(100) NULL,tracking_no VARCHAR(120) NULL,notes TEXT NULL,status ENUM('posted','received_by_supplier','cancelled','reversed') NOT NULL DEFAULT 'posted',idempotency_key VARCHAR(120) NOT NULL,posted_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_return_shipment_no(shipment_no),UNIQUE KEY uq_return_shipment_idempotency(idempotency_key),KEY idx_return_shipment(purchase_return_id,status),CONSTRAINT fk_return_shipment_header FOREIGN KEY(purchase_return_id) REFERENCES purchase_returns(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS purchase_return_shipment_lines(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_return_shipment_id BIGINT UNSIGNED NOT NULL,purchase_return_line_id BIGINT UNSIGNED NOT NULL,qty DECIMAL(18,4) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_return_shipment_line(purchase_return_shipment_id,purchase_return_line_id),CONSTRAINT fk_return_shipment_line_header FOREIGN KEY(purchase_return_shipment_id) REFERENCES purchase_return_shipments(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS purchase_replacements(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,replacement_no VARCHAR(60) NOT NULL,purchase_return_id BIGINT UNSIGNED NOT NULL,supplier_id INT NOT NULL,receipt_date DATE NOT NULL,supplier_delivery_no VARCHAR(120) NULL,warehouse_id INT NOT NULL,status ENUM('posted','partially_received','received','cancelled','reversed') NOT NULL DEFAULT 'posted',idempotency_key VARCHAR(120) NOT NULL,received_by INT NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_purchase_replacement_no(replacement_no),UNIQUE KEY uq_purchase_replacement_idempotency(idempotency_key),KEY idx_replacement_action(company_id,status,receipt_date),CONSTRAINT fk_replacement_return FOREIGN KEY(purchase_return_id) REFERENCES purchase_returns(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS purchase_replacement_lines(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,purchase_replacement_id BIGINT UNSIGNED NOT NULL,purchase_return_line_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,warehouse_id INT NOT NULL,barcode_id INT NULL,qty DECIMAL(18,4) NOT NULL,unit_cost DECIMAL(18,4) NOT NULL,stock_log_id INT NULL,movement_id INT NULL,ledger_id BIGINT UNSIGNED NULL,created_at DATETIME NOT NULL,KEY idx_replacement_line_return(purchase_return_line_id),CONSTRAINT fk_replacement_line_header FOREIGN KEY(purchase_replacement_id) REFERENCES purchase_replacements(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS supplier_refund_claims(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,refund_no VARCHAR(60) NOT NULL,supplier_id INT NOT NULL,purchase_order_id BIGINT UNSIGNED NULL,supplier_invoice_id BIGINT UNSIGNED NULL,purchase_return_id BIGINT UNSIGNED NULL,claim_date DATE NOT NULL,expected_date DATE NULL,reason TEXT NOT NULL,source_type ENUM('prepayment','short_delivery','paid_return','cancelled_order','overpayment','cashback','debit_note','other') NOT NULL DEFAULT 'other',eligible_amount DECIMAL(18,2) NOT NULL,claim_amount DECIMAL(18,2) NOT NULL,received_amount DECIMAL(18,2) NOT NULL DEFAULT 0,balance DECIMAL(18,2) NOT NULL,target_account_id INT NULL,status ENUM('draft','submitted','approved','rejected','waiting_funds','partial','received','reconciled','cancelled','reversed') NOT NULL DEFAULT 'draft',idempotency_key VARCHAR(120) NULL,entitlement_journal_id INT NULL,reversal_journal_id INT NULL,created_by INT NULL,created_at DATETIME NOT NULL,submitted_by INT NULL,submitted_at DATETIME NULL,approved_by INT NULL,approved_at DATETIME NULL,rejected_by INT NULL,rejected_at DATETIME NULL,rejection_reason TEXT NULL,reconciled_by INT NULL,reconciled_at DATETIME NULL,updated_by INT NULL,updated_at DATETIME NULL,UNIQUE KEY uq_supplier_refund_no(refund_no),UNIQUE KEY uq_supplier_refund_idempotency(idempotency_key),KEY idx_refund_action(company_id,status,expected_date),KEY idx_refund_supplier(company_id,supplier_id,status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS supplier_refund_allocations(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_refund_claim_id BIGINT UNSIGNED NOT NULL,source_type VARCHAR(40) NOT NULL,source_id BIGINT UNSIGNED NOT NULL,eligible_amount DECIMAL(18,2) NOT NULL,allocated_amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_refund_allocation(supplier_refund_claim_id,source_type,source_id),KEY idx_refund_allocation_source(source_type,source_id),CONSTRAINT fk_refund_allocation_header FOREIGN KEY(supplier_refund_claim_id) REFERENCES supplier_refund_claims(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS supplier_refund_receipts(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NULL,receipt_no VARCHAR(60) NOT NULL,supplier_refund_claim_id BIGINT UNSIGNED NOT NULL,receipt_date DATE NOT NULL,bank_account_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,reference_no VARCHAR(120) NOT NULL,notes TEXT NULL,status ENUM('posted','reconciled','reversed') NOT NULL DEFAULT 'posted',idempotency_key VARCHAR(120) NOT NULL,journal_id INT NOT NULL,reversal_journal_id INT NULL,received_by INT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_supplier_refund_receipt_no(receipt_no),UNIQUE KEY uq_supplier_refund_receipt_idempotency(idempotency_key),KEY idx_refund_receipt_claim(supplier_refund_claim_id,status),CONSTRAINT fk_refund_receipt_header FOREIGN KEY(supplier_refund_claim_id) REFERENCES supplier_refund_claims(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS supplier_debit_note_lines(id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,supplier_debit_note_id BIGINT UNSIGNED NOT NULL,purchase_return_line_id BIGINT UNSIGNED NULL,supplier_invoice_line_id BIGINT UNSIGNED NULL,item_id INT NULL,description VARCHAR(255) NOT NULL,qty DECIMAL(18,4) NOT NULL DEFAULT 0,base_amount DECIMAL(18,2) NOT NULL,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0,total_amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL,KEY idx_debit_note_line_header(supplier_debit_note_id),CONSTRAINT fk_debit_note_line_header FOREIGN KEY(supplier_debit_note_id) REFERENCES supplier_debit_notes(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + foreach ($queries as $sql) $this->db->query($sql); + } + + private function upgradeDebitNotes() + { + $this->db->query("ALTER TABLE supplier_debit_notes MODIFY supplier_invoice_id BIGINT UNSIGNED NULL"); + $this->db->query("ALTER TABLE supplier_debit_notes MODIFY status ENUM('draft','posted','applied','cancelled','reversed') NOT NULL DEFAULT 'draft'"); + $this->addColumn('supplier_debit_notes', 'company_id', 'BIGINT UNSIGNED NULL AFTER id'); + $this->addColumn('supplier_debit_notes', 'purchase_order_id', 'BIGINT UNSIGNED NULL AFTER supplier_invoice_id'); + $this->addColumn('supplier_debit_notes', 'purchase_return_id', 'BIGINT UNSIGNED NULL AFTER purchase_order_id'); + $this->addColumn('supplier_debit_notes', 'correction_type', "ENUM('return','quantity','price','discount','tax','chargeback','billing_error','other') NOT NULL DEFAULT 'other' AFTER note_date"); + $this->addColumn('supplier_debit_notes', 'base_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER correction_type'); + $this->addColumn('supplier_debit_notes', 'tax_amount', 'DECIMAL(18,2) NOT NULL DEFAULT 0 AFTER base_amount'); + $this->addColumn('supplier_debit_notes', 'idempotency_key', 'VARCHAR(120) NULL AFTER reason'); + $this->addColumn('supplier_debit_notes', 'posted_by', 'INT NULL AFTER journal_id'); + $this->addColumn('supplier_debit_notes', 'posted_at', 'DATETIME NULL AFTER posted_by'); + $this->addColumn('supplier_debit_notes', 'reversal_journal_id', 'INT NULL AFTER posted_at'); + $this->addColumn('supplier_debit_notes', 'updated_by', 'INT NULL AFTER created_at'); + $this->addColumn('supplier_debit_notes', 'updated_at', 'DATETIME NULL AFTER updated_by'); + $this->addIndex('supplier_debit_notes', 'uq_supplier_debit_note_idempotency', '`idempotency_key`', true); + $this->addIndex('supplier_debit_notes', 'idx_debit_note_action', '`company_id`,`status`,`note_date`'); + } + + private function seedAccountingMappings() + { + $definitions = array( + 'supplier_refund_receivable' => array('1194', 'Piutang Refund Supplier', 'asset', 'debit', 'neraca', 'Piutang atas dana yang wajib dikembalikan supplier'), + 'purchase_return_adjustment' => array('6297', 'Penyesuaian Retur Pembelian', 'expense', 'debit', 'laba_rugi', 'Akun lawan retur sebelum terbentuknya hutang'), + 'purchase_price_variance' => array('6296', 'Selisih Harga Pembelian', 'expense', 'debit', 'laba_rugi', 'Selisih nilai pembelian dan penyelesaian supplier') + ); + foreach ($definitions as $key => $definition) { + $mapping = $this->db->get_where('system_account_mappings', array('mapping_key'=>$key))->row(); + if ($mapping) continue; + $account = $this->db->get_where('accounts', array('nama_akun'=>$definition[1]))->row(); + if (!$account) { + $code = $definition[0]; + if ($this->db->get_where('accounts', array('kode_akun'=>$code))->row()) $code .= '-RR'; + $this->db->insert('accounts', array('kode_akun'=>$code,'nama_akun'=>$definition[1],'tipe'=>$definition[2],'posisi'=>$definition[3],'kategori'=>$definition[4],'is_active'=>1,'is_header'=>0,'allow_posting'=>1)); + $account = (object) array('id'=>$this->db->insert_id()); + } + $this->db->insert('system_account_mappings', array('mapping_key'=>$key,'account_id'=>$account->id,'description'=>$definition[5],'is_active'=>1,'created_at'=>date('Y-m-d H:i:s'))); + } + } + + private function seedWorkflowAndPermissions() + { + $role = $this->db->where_in('nama_role', array('Accounting Manager','Admin'))->order_by("FIELD(nama_role,'Accounting Manager','Admin')", '', false)->get('roles')->row(); + foreach (array( + array('PURCHASE_RETURN_DEFAULT','purchase_return','purchase_return','Persetujuan Retur Barang Supplier','Persetujuan Retur Pembelian'), + array('SUPPLIER_REFUND_DEFAULT','purchase_refund','supplier_refund','Persetujuan Refund Dana Supplier','Persetujuan Refund Supplier') + ) as $definition) { + $workflow = $this->db->get_where('approval_workflows', array('code'=>$definition[0]))->row(); + if ($workflow) continue; + $this->db->insert('approval_workflows', array('code'=>$definition[0],'module'=>$definition[1],'entity_type'=>$definition[2],'name'=>$definition[3],'require_separation'=>1,'is_active'=>1,'created_at'=>date('Y-m-d H:i:s'))); + $workflowId = $this->db->insert_id(); + if ($role) $this->db->insert('approval_workflow_steps', array('workflow_id'=>$workflowId,'step_order'=>1,'name'=>$definition[4],'approver_role_id'=>$role->id)); + } + + $roleRights = array( + 'Accounting Manager'=>array('purchase_returns'=>array('can_view','can_create','can_update','can_approve','can_post','can_export'),'purchase_refunds'=>array('can_view','can_create','can_update','can_approve','can_post','can_export'),'supplier_debit_notes'=>array('can_view','can_create','can_post','can_export')), + 'Finance & Treasury'=>array('purchase_returns'=>array('can_view','can_export'),'purchase_refunds'=>array('can_view','can_create','can_update','can_approve','can_post','can_export'),'supplier_debit_notes'=>array('can_view','can_create','can_post','can_export')), + 'AP / Purchasing'=>array('purchase_returns'=>array('can_view','can_create','can_update','can_export'),'purchase_refunds'=>array('can_view','can_create','can_update','can_export'),'supplier_debit_notes'=>array('can_view','can_create','can_export')), + 'Inventory & Warehouse'=>array('purchase_returns'=>array('can_view','can_update','can_post','can_export'),'purchase_refunds'=>array('can_view'),'supplier_debit_notes'=>array('can_view')), + 'Auditor / Read Only'=>array('purchase_returns'=>array('can_view','can_export'),'purchase_refunds'=>array('can_view','can_export'),'supplier_debit_notes'=>array('can_view','can_export')), + 'Keuangan'=>array('purchase_returns'=>array('can_view','can_export'),'purchase_refunds'=>array('can_view','can_create','can_update','can_approve','can_post','can_export'),'supplier_debit_notes'=>array('can_view','can_create','can_post','can_export')), + 'Gudang'=>array('purchase_returns'=>array('can_view','can_update','can_post','can_export'),'purchase_refunds'=>array('can_view'),'supplier_debit_notes'=>array('can_view')) + ); + foreach ($roleRights as $roleName => $features) { + $role = $this->db->get_where('roles', array('nama_role'=>$roleName))->row(); + if (!$role) continue; + $permissions = json_decode((string)$role->permissions, true); + if (!is_array($permissions)) $permissions = array(); + foreach ($features as $feature => $rights) $permissions[$feature] = $rights; + $this->db->where('id', $role->id)->update('roles', array('permissions'=>json_encode($permissions))); + } + } + + private function seedLegacyHistory() + { + $rows = $this->db->where('status', 'shipped')->get('purchase_returns')->result(); + foreach ($rows as $row) { + if ($this->db->where('purchase_return_id', $row->id)->count_all_results('purchase_return_status_history')) continue; + $this->db->insert('purchase_return_status_history', array('purchase_return_id'=>$row->id,'from_status'=>'posted','to_status'=>'shipped','notes'=>'Migrasi histori retur legacy; stok telah berkurang sebelum modul workflow baru.','user_id'=>$row->created_by,'created_at'=>$row->created_at)); + } + } + + public function down() + { + throw new RuntimeException('Rollback otomatis dinonaktifkan karena migration mempertahankan histori transaksi pembelian.'); + } +} diff --git a/application/migrations/20260905000900_add_purchase_adjustment_reversal_audit.php b/application/migrations/20260905000900_add_purchase_adjustment_reversal_audit.php new file mode 100644 index 0000000..5efb278 --- /dev/null +++ b/application/migrations/20260905000900_add_purchase_adjustment_reversal_audit.php @@ -0,0 +1,93 @@ +db->query("ALTER TABLE item_movements MODIFY movement_type ENUM('purchase','transfer','installation','uninstallation','supplier_return','purchase_return','purchase_return_reversal','purchase_replacement','customer_return','adjustment','sold_out','asset') NOT NULL"); + $this->addColumn('purchase_returns', 'reversal_reason', 'TEXT NULL AFTER reversal_journal_id'); + $this->addColumn('supplier_refund_claims', 'cancelled_by', 'INT NULL AFTER reconciled_at'); + $this->addColumn('supplier_refund_claims', 'cancelled_at', 'DATETIME NULL AFTER cancelled_by'); + $this->addColumn('supplier_refund_claims', 'reversal_reason', 'TEXT NULL AFTER reversal_journal_id'); + $this->addColumn('supplier_debit_notes', 'cancelled_by', 'INT NULL AFTER reversal_journal_id'); + $this->addColumn('supplier_debit_notes', 'cancelled_at', 'DATETIME NULL AFTER cancelled_by'); + $this->addColumn('supplier_debit_notes', 'reversal_reason', 'TEXT NULL AFTER cancelled_at'); + + $this->db->query("CREATE TABLE IF NOT EXISTS supplier_refund_status_history ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + supplier_refund_claim_id BIGINT UNSIGNED NOT NULL, + from_status VARCHAR(30) NULL, + to_status VARCHAR(30) NOT NULL, + notes TEXT NULL, + user_id INT NULL, + ip_address VARCHAR(45) NULL, + session_id VARCHAR(128) NULL, + created_at DATETIME NOT NULL, + KEY idx_refund_status_history(supplier_refund_claim_id,created_at), + CONSTRAINT fk_refund_status_history_header FOREIGN KEY(supplier_refund_claim_id) REFERENCES supplier_refund_claims(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("CREATE TABLE IF NOT EXISTS supplier_debit_note_status_history ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + supplier_debit_note_id BIGINT UNSIGNED NOT NULL, + from_status VARCHAR(30) NULL, + to_status VARCHAR(30) NOT NULL, + notes TEXT NULL, + user_id INT NULL, + ip_address VARCHAR(45) NULL, + session_id VARCHAR(128) NULL, + created_at DATETIME NOT NULL, + KEY idx_debit_note_status_history(supplier_debit_note_id,created_at), + CONSTRAINT fk_debit_note_status_history_header FOREIGN KEY(supplier_debit_note_id) REFERENCES supplier_debit_notes(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->seedExistingHistory(); + } + + private function addColumn($table, $name, $definition) + { + if (!$this->db->field_exists($name, $table)) { + $this->db->query("ALTER TABLE `{$table}` ADD `{$name}` {$definition}"); + } + } + + private function seedExistingHistory() + { + $refunds = $this->db->get('supplier_refund_claims')->result(); + foreach ($refunds as $row) { + if ($this->db->where('supplier_refund_claim_id', $row->id)->count_all_results('supplier_refund_status_history')) continue; + $this->db->insert('supplier_refund_status_history', array( + 'supplier_refund_claim_id'=>$row->id, + 'from_status'=>null, + 'to_status'=>$row->status, + 'notes'=>'Snapshot status awal saat aktivasi audit workflow Refund Supplier.', + 'user_id'=>$row->created_by, + 'created_at'=>$row->created_at ?: date('Y-m-d H:i:s') + )); + } + + $notes = $this->db->get('supplier_debit_notes')->result(); + foreach ($notes as $row) { + if ($this->db->where('supplier_debit_note_id', $row->id)->count_all_results('supplier_debit_note_status_history')) continue; + $this->db->insert('supplier_debit_note_status_history', array( + 'supplier_debit_note_id'=>$row->id, + 'from_status'=>null, + 'to_status'=>$row->status, + 'notes'=>'Snapshot status awal saat aktivasi audit workflow Debit Note Supplier.', + 'user_id'=>$row->created_by, + 'created_at'=>$row->created_at ?: date('Y-m-d H:i:s') + )); + } + } + + public function down() + { + throw new RuntimeException('Rollback otomatis dinonaktifkan karena tabel menyimpan histori audit transaksi.'); + } +} diff --git a/application/migrations/20260905001000_reconcile_duplicate_purchase_receipt_ledger.php b/application/migrations/20260905001000_reconcile_duplicate_purchase_receipt_ledger.php new file mode 100644 index 0000000..ca14f75 --- /dev/null +++ b/application/migrations/20260905001000_reconcile_duplicate_purchase_receipt_ledger.php @@ -0,0 +1,44 @@ +db->query("INSERT IGNORE INTO inventory_ledger + (item_id,warehouse_id,bin_id,barcode_id,batch_id,movement_date,direction,qty,unit_cost,value,document_type,document_id,document_line_id,idempotency_key,created_at) + SELECT b.item_id,b.warehouse_id,b.bin_id,b.barcode_id,b.batch_id,b.movement_date, + 'out',b.qty,b.unit_cost,b.value,'data_reconciliation',b.id,NULL, + CONCAT('REVERSE-DUPLICATE-PURCHASE-BACKFILL-',b.id),NOW() + FROM inventory_ledger b + WHERE b.direction='in' + AND b.document_type='goods_receipt' + AND b.idempotency_key LIKE 'PURCHASE-BACKFILL-STOCK-%' + AND EXISTS ( + SELECT 1 + FROM inventory_ledger p + WHERE p.document_type='goods_receipt' + AND p.document_id=b.document_id + AND p.item_id=b.item_id + AND p.warehouse_id=b.warehouse_id + AND p.direction=b.direction + AND ABS(p.qty-b.qty)<0.0001 + AND p.idempotency_key LIKE 'PURCHASE-GOODS_RECEIPT-%' + ) + AND NOT EXISTS ( + SELECT 1 + FROM inventory_ledger c + WHERE c.idempotency_key=CONCAT('REVERSE-DUPLICATE-PURCHASE-BACKFILL-',b.id) + )"); + } + + public function down() + { + // Inventory ledger bersifat immutable. Pemulihan harus melalui jurnal/mutasi koreksi baru. + } +} diff --git a/application/migrations/20260906000100_finalize_professional_inventory.php b/application/migrations/20260906000100_finalize_professional_inventory.php new file mode 100644 index 0000000..65acee9 --- /dev/null +++ b/application/migrations/20260906000100_finalize_professional_inventory.php @@ -0,0 +1,168 @@ +upgradeDocuments(); + $this->upgradeMasters(); + $this->upgradeReservations(); + $this->createHistoryTables(); + $this->addPerformanceIndexes(); + $this->backfillCompany(); + } + + private function addColumn($table, $column, $definition) + { + // Do not use CI's schema cache during a long migration. A preceding + // ALTER can otherwise leave table_exists/field_exists stale. + if ($this->tableExists($table) && !$this->fieldExists($table, $column)) { + $this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}"); + } + } + + private function addIndex($table, $name, array $columns, $unique = false) + { + if (!$this->tableExists($table)) return; + foreach ($columns as $column) if (!$this->fieldExists($table, $column)) return; + $exists = $this->db->query( + 'SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? LIMIT 1', + array($table, $name) + )->row(); + if (!$exists) { + $quoted = '`'.implode('`,`', $columns).'`'; + $this->db->query('ALTER TABLE `'.$table.'` ADD '.($unique ? 'UNIQUE ' : '').'KEY `'.$name.'` ('.$quoted.')'); + } + } + + private function tableExists($table) + { + return (bool) $this->db->query( + 'SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=? LIMIT 1', + array($table) + )->row(); + } + + private function fieldExists($table, $column) + { + return (bool) $this->db->query( + 'SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=? LIMIT 1', + array($table, $column) + )->row(); + } + + private function upgradeDocuments() + { + $this->addColumn('stock_documents', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`'); + $this->addColumn('stock_documents', 'source_type', 'VARCHAR(50) NULL AFTER `reason`'); + $this->addColumn('stock_documents', 'source_id', 'BIGINT UNSIGNED NULL AFTER `source_type`'); + $this->addColumn('stock_documents', 'submitted_by', 'INT NULL AFTER `created_by`'); + $this->addColumn('stock_documents', 'submitted_at', 'DATETIME NULL AFTER `submitted_by`'); + $this->addColumn('stock_documents', 'rejected_by', 'INT NULL AFTER `approved_at`'); + $this->addColumn('stock_documents', 'rejected_at', 'DATETIME NULL AFTER `rejected_by`'); + $this->addColumn('stock_documents', 'rejection_reason', 'TEXT NULL AFTER `rejected_at`'); + $this->addColumn('stock_documents', 'reversal_of_id', 'BIGINT UNSIGNED NULL AFTER `rejection_reason`'); + $this->addColumn('stock_documents', 'reversed_by', 'INT NULL AFTER `posted_at`'); + $this->addColumn('stock_documents', 'reversed_at', 'DATETIME NULL AFTER `reversed_by`'); + $this->addColumn('stock_documents', 'updated_by', 'INT NULL AFTER `created_at`'); + $this->addColumn('stock_documents', 'updated_at', 'DATETIME NULL AFTER `updated_by`'); + $this->addColumn('stock_document_lines', 'line_no', 'INT NOT NULL DEFAULT 1 AFTER `stock_document_id`'); + $this->addColumn('stock_document_lines', 'movement_direction', "ENUM('in','out') NULL AFTER `qty`"); + $this->addColumn('stock_document_lines', 'source_line_type', 'VARCHAR(50) NULL AFTER `reason`'); + $this->addColumn('stock_document_lines', 'source_line_id', 'BIGINT UNSIGNED NULL AFTER `source_line_type`'); + } + + private function upgradeMasters() + { + $this->addColumn('warehouses', 'code', 'VARCHAR(30) NULL AFTER `company_id`'); + $this->addColumn('warehouses', 'address', 'TEXT NULL AFTER `nama`'); + $this->addColumn('warehouses', 'is_active', 'TINYINT(1) NOT NULL DEFAULT 1 AFTER `address`'); + $this->addColumn('warehouse_bins', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`'); + $this->addColumn('warehouse_bins', 'updated_at', 'DATETIME NULL AFTER `is_active`'); + $this->addColumn('inventory_batches', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`'); + $this->addColumn('inventory_batches', 'is_active', 'TINYINT(1) NOT NULL DEFAULT 1 AFTER `supplier_id`'); + $this->addColumn('inventory_batches', 'updated_at', 'DATETIME NULL AFTER `created_at`'); + $this->addColumn('kode_barang', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`'); + $this->addColumn('kode_barang', 'is_active', 'TINYINT(1) NOT NULL DEFAULT 1'); + } + + private function upgradeReservations() + { + $this->addColumn('stock_reservations', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`'); + $this->addColumn('stock_reservations', 'notes', 'TEXT NULL AFTER `expires_at`'); + $this->addColumn('stock_reservations', 'updated_by', 'INT NULL AFTER `created_by`'); + $this->addColumn('stock_reservations', 'updated_at', 'DATETIME NULL AFTER `created_at`'); + $this->addColumn('stock_reservations', 'completed_at', 'DATETIME NULL AFTER `updated_at`'); + $this->addColumn('inventory_reconciliations', 'company_id', 'BIGINT UNSIGNED NULL AFTER `id`'); + $this->addColumn('inventory_reconciliations', 'stock_log_value', 'DECIMAL(18,2) NULL AFTER `ledger_value`'); + $this->addColumn('inventory_reconciliations', 'barcode_variance_count', 'INT NOT NULL DEFAULT 0 AFTER `difference`'); + $this->addColumn('inventory_reconciliations', 'cache_variance_count', 'INT NOT NULL DEFAULT 0 AFTER `barcode_variance_count`'); + } + + private function createHistoryTables() + { + $this->db->query("CREATE TABLE IF NOT EXISTS stock_document_status_history( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + stock_document_id BIGINT UNSIGNED NOT NULL, + from_status VARCHAR(30) NULL, + to_status VARCHAR(30) NOT NULL, + notes TEXT NULL, + user_id INT NULL, + created_at DATETIME NOT NULL, + KEY idx_stock_document_history(stock_document_id,created_at), + CONSTRAINT fk_stock_document_history FOREIGN KEY(stock_document_id) REFERENCES stock_documents(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS stock_reservation_status_history( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + stock_reservation_id BIGINT UNSIGNED NOT NULL, + from_status VARCHAR(30) NULL, + to_status VARCHAR(30) NOT NULL, + qty DECIMAL(18,4) NOT NULL DEFAULT 0, + notes TEXT NULL, + user_id INT NULL, + created_at DATETIME NOT NULL, + KEY idx_stock_reservation_history(stock_reservation_id,created_at), + CONSTRAINT fk_stock_reservation_history FOREIGN KEY(stock_reservation_id) REFERENCES stock_reservations(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + + private function addPerformanceIndexes() + { + $this->addIndex('stock_documents', 'idx_stock_doc_company_action', array('company_id','status','document_date','id')); + $this->addIndex('stock_documents', 'idx_stock_doc_source', array('company_id','source_type','source_id')); + $this->addIndex('stock_documents', 'idx_stock_doc_reversal', array('reversal_of_id')); + $this->addIndex('stock_document_lines', 'idx_stock_doc_line_item', array('stock_document_id','item_id','id')); + $this->addIndex('stock_reservations', 'idx_stock_reservation_action', array('company_id','status','expires_at','id')); + $this->addIndex('warehouse_bins', 'idx_warehouse_bin_active', array('company_id','warehouse_id','is_active','code')); + $this->addIndex('inventory_batches', 'idx_inventory_batch_active', array('company_id','item_id','is_active','expiry_date')); + $this->addIndex('item_barcodes', 'idx_item_barcode_lookup', array('item_id','warehouse_id','status','id')); + $this->addIndex('stock_logs', 'idx_stock_log_item_wh_ref', array('item_id','warehouse_id','ref_type','ref_id')); + $this->addIndex('transaction_attachments', 'idx_attachment_module_entity', array('module','entity_type','entity_id','id')); + $this->addIndex('kode_barang', 'idx_item_code_company_active', array('company_id','is_active','kode_barang')); + } + + private function backfillCompany() + { + $fallback = $this->db->select_min('id')->get('companies')->row(); + $companyId = $fallback ? (int) $fallback->id : 1; + $this->db->query("UPDATE warehouse_bins b JOIN warehouses w ON w.id=b.warehouse_id SET b.company_id=COALESCE(b.company_id,w.company_id,?) WHERE b.company_id IS NULL", array($companyId)); + $this->db->query("UPDATE inventory_batches b JOIN items i ON i.id=b.item_id SET b.company_id=COALESCE(b.company_id,i.company_id,?) WHERE b.company_id IS NULL", array($companyId)); + $this->db->query("UPDATE stock_reservations r JOIN items i ON i.id=r.item_id SET r.company_id=COALESCE(r.company_id,i.company_id,?) WHERE r.company_id IS NULL", array($companyId)); + $this->db->query("UPDATE stock_documents d LEFT JOIN stock_document_lines l ON l.stock_document_id=d.id LEFT JOIN items i ON i.id=l.item_id SET d.company_id=COALESCE(d.company_id,i.company_id,?) WHERE d.company_id IS NULL", array($companyId)); + $this->db->query("UPDATE inventory_reconciliations SET company_id=? WHERE company_id IS NULL", array($companyId)); + $this->db->query("UPDATE kode_barang k LEFT JOIN items i ON i.kode_id=k.id SET k.company_id=COALESCE(k.company_id,i.company_id,?) WHERE k.company_id IS NULL", array($companyId)); + $this->db->query("UPDATE warehouses SET code=CONCAT('GD-',LPAD(id,3,'0')) WHERE code IS NULL OR code='' "); + } + + public function down() + { + throw new RuntimeException('Rollback finalisasi Persediaan wajib menggunakan backup database.'); + } +} diff --git a/application/migrations/20260906000200_reconcile_inventory_company_and_cost.php b/application/migrations/20260906000200_reconcile_inventory_company_and_cost.php new file mode 100644 index 0000000..298cf02 --- /dev/null +++ b/application/migrations/20260906000200_reconcile_inventory_company_and_cost.php @@ -0,0 +1,18 @@ +db->select_min('id')->get('companies')->row();$companyId=$company?(int)$company->id:1; + $this->db->query('UPDATE items SET company_id=? WHERE company_id IS NULL',array($companyId)); + $this->db->query('UPDATE warehouses SET company_id=? WHERE company_id IS NULL',array($companyId)); + $this->db->query("UPDATE stock_logs s JOIN inventory_ledger l ON BINARY l.idempotency_key=BINARY s.idempotency_key SET s.unit_cost=l.unit_cost WHERE(s.unit_cost IS NULL OR s.unit_cost=0) AND s.idempotency_key IS NOT NULL"); + $this->db->query("UPDATE stock_logs s JOIN inventory_ledger l ON BINARY l.idempotency_key=BINARY CONCAT('LEGACY-STOCK-',s.id) SET s.unit_cost=l.unit_cost WHERE s.unit_cost IS NULL OR s.unit_cost=0"); + $this->db->query("UPDATE stock_logs s JOIN items i ON i.id=s.item_id SET s.unit_cost=i.harga_beli WHERE(s.unit_cost IS NULL OR s.unit_cost=0) AND i.harga_beli>0"); + $this->db->query("INSERT INTO stock_document_status_history(stock_document_id,from_status,to_status,notes,user_id,created_at) SELECT d.id,NULL,d.status,'Backfill status dokumen sebelum finalisasi Persediaan',d.created_by,d.created_at FROM stock_documents d WHERE NOT EXISTS(SELECT 1 FROM stock_document_status_history h WHERE h.stock_document_id=d.id)"); + } + public function down(){throw new RuntimeException('Gunakan backup untuk mengembalikan metadata inventory legacy.');} +} diff --git a/application/migrations/20260906000300_harden_technician_inventory_custody.php b/application/migrations/20260906000300_harden_technician_inventory_custody.php new file mode 100644 index 0000000..5e1076f --- /dev/null +++ b/application/migrations/20260906000300_harden_technician_inventory_custody.php @@ -0,0 +1,30 @@ +db->table_exists('item_technician')) return; + if (!$this->db->field_exists('company_id', 'item_technician')) { + $this->db->query('ALTER TABLE item_technician ADD company_id BIGINT UNSIGNED NULL AFTER user_id'); + } + $company=$this->db->select_min('id')->get('companies')->row();$companyId=$company?(int)$company->id:1; + $this->db->query('UPDATE item_technician it JOIN items i ON i.id=it.item_id SET it.company_id=COALESCE(it.company_id,i.company_id,?) WHERE it.company_id IS NULL',array($companyId)); + $index=$this->db->query("SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='item_technician' AND index_name='idx_item_technician_company_status' LIMIT 1")->row(); + if(!$index)$this->db->query('ALTER TABLE item_technician ADD KEY idx_item_technician_company_status(company_id,status,user_id,barcode)'); + + // Assignment aktif lama adalah bukti penguasaan. Status available hanya + // diselaraskan bila tidak mempunyai reservasi aktif atau reservasi retur. + $this->db->query("UPDATE item_barcodes ib JOIN item_technician it ON it.barcode=ib.barcode AND it.status='active' SET ib.status='installed',ib.version=COALESCE(ib.version,0)+1 WHERE ib.status='available' AND ib.qty_sisa>0 AND COALESCE(ib.reserved_qty,0)=0 AND COALESCE(ib.return_reserved_qty,0)=0"); + } + + public function down() + { + throw new RuntimeException('Gunakan backup untuk mengembalikan metadata penguasaan barang teknisi.'); + } +} diff --git a/application/migrations/20260906000400_finalize_fixed_assets.php b/application/migrations/20260906000400_finalize_fixed_assets.php new file mode 100644 index 0000000..45c4633 --- /dev/null +++ b/application/migrations/20260906000400_finalize_fixed_assets.php @@ -0,0 +1,203 @@ +db->table_exists($table) && !$this->db->field_exists($column, $table)) { + $this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}"); + } + } + + private function indexExists($table, $name) + { + return $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name=?", array($name))->num_rows() > 0; + } + + private function addIndex($table, $name, $columns, $unique = false) + { + if ($this->db->table_exists($table) && !$this->indexExists($table, $name)) { + $this->db->query('ALTER TABLE `'.$table.'` ADD '.($unique ? 'UNIQUE ' : '').'KEY `'.$name.'` ('.$columns.')'); + } + } + + public function up() + { + $this->db->query('DROP TRIGGER IF EXISTS trg_asset_event_no_update'); + $this->db->query('DROP TRIGGER IF EXISTS trg_asset_event_no_delete'); + + $assetColumns = array( + 'document_no' => 'VARCHAR(60) NULL', + 'workflow_status' => "ENUM('draft','submitted','approved','rejected','capitalized') NOT NULL DEFAULT 'capitalized'", + 'source_reference_no' => 'VARCHAR(120) NULL', + 'supplier_id' => 'BIGINT UNSIGNED NULL', + 'source_journal_mode' => "ENUM('post_journal','linked_journal','no_journal') NOT NULL DEFAULT 'no_journal'", + 'source_journal_id' => 'INT NULL', + 'acquisition_journal_id' => 'INT NULL', + 'warehouse_id' => 'INT NULL', + 'bin_id' => 'BIGINT UNSIGNED NULL', + 'responsible_employee_id' => 'BIGINT NULL', + 'department_id' => 'BIGINT NULL', + 'project_id' => 'BIGINT UNSIGNED NULL', + 'cost_center_id' => 'BIGINT UNSIGNED NULL', + 'idempotency_key' => 'VARCHAR(120) NULL', + 'created_by' => 'INT NULL', + 'submitted_by' => 'INT NULL', + 'submitted_at' => 'DATETIME NULL', + 'approved_by' => 'INT NULL', + 'approved_at' => 'DATETIME NULL', + 'rejected_by' => 'INT NULL', + 'rejected_at' => 'DATETIME NULL', + 'rejection_reason' => 'VARCHAR(500) NULL', + 'capitalized_by' => 'INT NULL', + 'capitalized_at' => 'DATETIME NULL', + 'updated_by' => 'INT NULL', + 'updated_at' => 'DATETIME NULL' + ); + foreach ($assetColumns as $column => $definition) $this->column('assets', $column, $definition); + $this->db->query("ALTER TABLE assets MODIFY source_type ENUM('purchase','warehouse','manual','opening','donation','other') NOT NULL DEFAULT 'manual'"); + $this->db->query("UPDATE assets SET company_id=COALESCE(company_id,1),document_no=COALESCE(NULLIF(document_no,''),CONCAT('LEGACY-AST-',id)),workflow_status=COALESCE(NULLIF(workflow_status,''),'capitalized'),idempotency_key=COALESCE(NULLIF(idempotency_key,''),CONCAT('LEGACY-ASSET-',id)),created_by=COALESCE(created_by,1),capitalized_at=COALESCE(capitalized_at,created_at),capitalized_by=COALESCE(capitalized_by,created_by,1)"); + $this->addIndex('assets', 'uq_asset_document_company', '`company_id`,`document_no`', true); + $this->addIndex('assets', 'uq_asset_idempotency', '`idempotency_key`', true); + $this->addIndex('assets', 'idx_asset_register_filter', '`company_id`,`workflow_status`,`lifecycle_status`,`category_id`,`lokasi_asset_id`,`tanggal_perolehan`'); + $this->addIndex('assets', 'idx_asset_source_trace', '`company_id`,`source_type`,`source_id`'); + $this->addIndex('assets', 'idx_asset_inventory_trace', '`item_id`,`barcode_id`,`warehouse_id`'); + + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL', + 'updated_at' => 'DATETIME NULL', + 'updated_by' => 'INT NULL' + ) as $column => $definition) $this->column('asset_categories', $column, $definition); + if ($this->indexExists('asset_categories', 'uq_asset_category_code')) $this->db->query('ALTER TABLE asset_categories DROP INDEX uq_asset_category_code'); + $this->db->query('UPDATE asset_categories SET company_id=COALESCE(company_id,1)'); + $this->addIndex('asset_categories', 'uq_asset_category_company_code', '`company_id`,`code`', true); + $this->addIndex('asset_categories', 'idx_asset_category_active', '`company_id`,`is_active`,`name`'); + + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL', + 'code' => 'VARCHAR(30) NULL', + 'address' => 'TEXT NULL', + 'responsible_employee_id' => 'BIGINT NULL', + 'is_active' => 'TINYINT(1) NOT NULL DEFAULT 1', + 'updated_at' => 'DATETIME NULL', + 'updated_by' => 'INT NULL' + ) as $column => $definition) $this->column('lokasi_asset', $column, $definition); + $this->db->query("UPDATE lokasi_asset SET company_id=COALESCE(company_id,1),code=COALESCE(NULLIF(code,''),CONCAT('LOC-',LPAD(id,4,'0'))),is_active=COALESCE(is_active,1)"); + $this->addIndex('lokasi_asset', 'uq_asset_location_company_code', '`company_id`,`code`', true); + $this->addIndex('lokasi_asset', 'idx_asset_location_active', '`company_id`,`is_active`,`nama`'); + + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL', + 'reversal_of_event_id' => 'BIGINT UNSIGNED NULL' + ) as $column => $definition) $this->column('asset_events', $column, $definition); + $this->db->query("ALTER TABLE asset_events MODIFY event_type ENUM('acquisition','capitalization','addition','transfer','responsibility_change','maintenance','depreciation','impairment','revaluation','disposal','lost','damaged','opname_adjustment','reversal') NOT NULL"); + $this->db->query('UPDATE asset_events e JOIN assets a ON a.id=e.asset_id SET e.company_id=COALESCE(e.company_id,a.company_id,1)'); + $this->addIndex('asset_events', 'idx_asset_event_company_date', '`company_id`,`event_date`,`asset_id`'); + $this->addIndex('asset_events', 'idx_asset_event_journal', '`journal_id`'); + $this->addIndex('asset_events', 'idx_asset_event_reversal', '`reversal_of_event_id`'); + + $this->column('asset_depreciation_schedule', 'company_id', 'BIGINT UNSIGNED NULL'); + $this->column('asset_depreciation_schedule', 'reversal_journal_id', 'INT NULL'); + $this->column('asset_depreciation_schedule', 'reversed_at', 'DATETIME NULL'); + $this->db->query('UPDATE asset_depreciation_schedule s JOIN assets a ON a.id=s.asset_id SET s.company_id=COALESCE(s.company_id,a.company_id,1)'); + $this->addIndex('asset_depreciation_schedule', 'idx_asset_dep_company_period', '`company_id`,`period`,`status`,`asset_id`'); + + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL', + 'maintenance_no' => 'VARCHAR(60) NULL', + 'status' => "ENUM('posted','cancelled') NOT NULL DEFAULT 'posted'", + 'journal_id' => 'INT NULL', + 'asset_event_id' => 'BIGINT UNSIGNED NULL', + 'idempotency_key' => 'VARCHAR(120) NULL' + ) as $column => $definition) $this->column('asset_maintenance', $column, $definition); + $this->db->query("UPDATE asset_maintenance m JOIN assets a ON a.id=m.asset_id SET m.company_id=COALESCE(m.company_id,a.company_id,1),m.maintenance_no=COALESCE(NULLIF(m.maintenance_no,''),CONCAT('LEGACY-MNT-',m.id)),m.idempotency_key=COALESCE(NULLIF(m.idempotency_key,''),CONCAT('LEGACY-MNT-',m.id))"); + $this->addIndex('asset_maintenance', 'uq_asset_maintenance_no', '`company_id`,`maintenance_no`', true); + $this->addIndex('asset_maintenance', 'uq_asset_maintenance_key', '`idempotency_key`', true); + $this->addIndex('asset_maintenance', 'idx_asset_maintenance_due', '`company_id`,`next_due_date`,`status`'); + + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL', + 'snapshot_at' => 'DATETIME NULL', + 'submitted_by' => 'INT NULL', + 'submitted_at' => 'DATETIME NULL', + 'rejected_by' => 'INT NULL', + 'rejected_at' => 'DATETIME NULL', + 'rejection_reason' => 'VARCHAR(500) NULL', + 'posted_by' => 'INT NULL', + 'posted_at' => 'DATETIME NULL', + 'idempotency_key' => 'VARCHAR(120) NULL' + ) as $column => $definition) $this->column('asset_opnames', $column, $definition); + $this->db->query("UPDATE asset_opnames SET company_id=COALESCE(company_id,1),snapshot_at=COALESCE(snapshot_at,NOW()),idempotency_key=COALESCE(NULLIF(idempotency_key,''),CONCAT('LEGACY-OPNAME-',id))"); + $this->addIndex('asset_opnames', 'uq_asset_opname_key', '`idempotency_key`', true); + $this->addIndex('asset_opnames', 'idx_asset_opname_company_status', '`company_id`,`status`,`opname_date`'); + + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL', + 'expected_location_id' => 'INT NULL', + 'is_found' => 'TINYINT(1) NOT NULL DEFAULT 1', + 'checked_at' => 'DATETIME NULL', + 'checked_by' => 'INT NULL' + ) as $column => $definition) $this->column('asset_opname_lines', $column, $definition); + $this->db->query('UPDATE asset_opname_lines l JOIN asset_opnames o ON o.id=l.asset_opname_id JOIN assets a ON a.id=l.asset_id SET l.company_id=COALESCE(l.company_id,o.company_id,a.company_id,1),l.expected_location_id=COALESCE(l.expected_location_id,a.lokasi_asset_id)'); + $this->addIndex('asset_opname_lines', 'idx_asset_opname_line_company', '`company_id`,`asset_opname_id`,`asset_id`'); + + if ($this->db->table_exists('asset_reconciliations')) { + $this->column('asset_reconciliations', 'company_id', 'BIGINT UNSIGNED NULL'); + $this->db->query('UPDATE asset_reconciliations SET company_id=COALESCE(company_id,1)'); + $this->addIndex('asset_reconciliations', 'idx_asset_recon_company_date', '`company_id`,`as_of_date`,`status`'); + } + + $this->db->query("CREATE TABLE IF NOT EXISTS asset_workflow_history ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id BIGINT UNSIGNED NOT NULL, + asset_id INT NOT NULL, + from_status VARCHAR(30) NULL, + to_status VARCHAR(30) NOT NULL, + notes VARCHAR(500) NULL, + user_id INT NULL, + created_at DATETIME NOT NULL, + KEY idx_asset_workflow (company_id,asset_id,id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + $this->db->query("CREATE TABLE IF NOT EXISTS asset_responsibility_history ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id BIGINT UNSIGNED NOT NULL, + asset_id INT NOT NULL, + effective_date DATE NOT NULL, + from_employee_id BIGINT NULL, + to_employee_id BIGINT NULL, + notes VARCHAR(500) NOT NULL, + asset_event_id BIGINT UNSIGNED NULL, + created_by INT NULL, + created_at DATETIME NOT NULL, + KEY idx_asset_responsibility (company_id,asset_id,effective_date,id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->column('transaction_attachments', 'company_id', 'BIGINT UNSIGNED NULL'); + $this->db->query("UPDATE transaction_attachments t JOIN assets a ON t.entity_type='asset' AND t.entity_id=a.id SET t.company_id=COALESCE(t.company_id,a.company_id,1)"); + $this->db->query("UPDATE transaction_attachments t JOIN asset_maintenance m ON t.entity_type='asset_maintenance' AND t.entity_id=m.id SET t.company_id=COALESCE(t.company_id,m.company_id,1)"); + $this->db->query("UPDATE transaction_attachments t JOIN asset_opnames o ON t.entity_type='asset_opname' AND t.entity_id=o.id SET t.company_id=COALESCE(t.company_id,o.company_id,1)"); + $this->addIndex('transaction_attachments', 'idx_attachment_company_entity', '`company_id`,`entity_type`,`entity_id`,`id`'); + + $this->db->query("CREATE TABLE IF NOT EXISTS asset_report_snapshots ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id BIGINT UNSIGNED NOT NULL, + report_type VARCHAR(40) NOT NULL, + as_of_date DATE NOT NULL, + payload_json LONGTEXT NOT NULL, + created_by INT NULL, + created_at DATETIME NOT NULL, + KEY idx_asset_snapshot (company_id,report_type,as_of_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("INSERT IGNORE INTO asset_workflow_history(company_id,asset_id,from_status,to_status,notes,user_id,created_at) SELECT COALESCE(company_id,1),id,NULL,workflow_status,'Backfill status aset legacy',COALESCE(created_by,1),COALESCE(created_at,NOW()) FROM assets"); + + $this->db->query("CREATE TRIGGER trg_asset_event_no_update BEFORE UPDATE ON asset_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Asset event is immutable'"); + $this->db->query("CREATE TRIGGER trg_asset_event_no_delete BEFORE DELETE ON asset_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Asset event is immutable'"); + } + + public function down() + { + throw new RuntimeException('Rollback finalisasi Aset Tetap wajib melalui restore backup database.'); + } +} diff --git a/application/migrations/20260906000500_fixed_asset_decimal_values.php b/application/migrations/20260906000500_fixed_asset_decimal_values.php new file mode 100644 index 0000000..68d8192 --- /dev/null +++ b/application/migrations/20260906000500_fixed_asset_decimal_values.php @@ -0,0 +1,37 @@ +db->table_exists('assets')) return; + if ($this->db->table_exists('legacy_migration_adjustments')) { + $this->db->query("INSERT IGNORE INTO legacy_migration_adjustments(entity_type,entity_id,field_name,old_value,new_value,reason,adjusted_at) + SELECT 'asset',id,'qty',IF(qty IS NULL,'NULL',CAST(qty AS CHAR)),'1','Qty aset legacy kosong dinormalisasi menjadi satu unit',NOW() + FROM assets WHERE qty IS NULL OR qty<=0"); + } + // Aset lama tanpa kolom qty berasal dari satu baris per aset, sehingga satu + // baris tersebut secara aman diperlakukan sebagai satu unit. + $this->db->query('UPDATE assets SET qty=1 WHERE qty IS NULL OR qty<=0'); + $this->db->query('UPDATE assets SET harga_per_unit=COALESCE(nilai_perolehan,0) WHERE harga_per_unit IS NULL'); + $this->db->query('UPDATE assets SET nilai_perolehan=COALESCE(harga_per_unit*qty,0) WHERE nilai_perolehan IS NULL'); + $this->db->query('UPDATE assets SET nilai_residu=0 WHERE nilai_residu IS NULL'); + $this->db->query('UPDATE assets SET penyusutan_per_bulan=0 WHERE penyusutan_per_bulan IS NULL'); + $this->db->query('UPDATE assets SET akumulasi_penyusutan=0 WHERE akumulasi_penyusutan IS NULL'); + $this->db->query('UPDATE assets SET nilai_buku=GREATEST(COALESCE(nilai_perolehan,0)-COALESCE(akumulasi_penyusutan,0),0) WHERE nilai_buku IS NULL'); + $this->db->query("ALTER TABLE assets + MODIFY qty DECIMAL(18,4) NOT NULL DEFAULT 1, + MODIFY harga_per_unit DECIMAL(18,2) NOT NULL DEFAULT 0, + MODIFY nilai_perolehan DECIMAL(18,2) NOT NULL DEFAULT 0, + MODIFY nilai_residu DECIMAL(18,2) NOT NULL DEFAULT 0, + MODIFY penyusutan_per_bulan DECIMAL(18,2) NOT NULL DEFAULT 0, + MODIFY akumulasi_penyusutan DECIMAL(18,2) NOT NULL DEFAULT 0, + MODIFY nilai_buku DECIMAL(18,2) NOT NULL DEFAULT 0"); + } + + public function down() + { + // Presisi desimal tidak diturunkan kembali agar nilai accounting tidak rusak. + } +} diff --git a/application/migrations/20260906000600_repair_fixed_asset_posting_accounts.php b/application/migrations/20260906000600_repair_fixed_asset_posting_accounts.php new file mode 100644 index 0000000..f950a11 --- /dev/null +++ b/application/migrations/20260906000600_repair_fixed_asset_posting_accounts.php @@ -0,0 +1,30 @@ +db->table_exists('accounts')) return; + + $this->db->query("UPDATE accounts parent + LEFT JOIN accounts child ON child.parent_id=parent.id + SET parent.is_header=0, parent.allow_posting=1 + WHERE parent.tipe='asset' + AND parent.kode_akun IN ('114','115','116') + AND parent.is_active=1 + AND parent.allow_posting=0 + AND child.id IS NULL"); + } + + public function down() + { + // Tidak dikembalikan menjadi non-posting karena dapat membuat jurnal + // aset dan dokumen approved kembali tidak dapat diproses. + } +} diff --git a/application/migrations/20260906000700_create_fixed_asset_cost_leaf_accounts.php b/application/migrations/20260906000700_create_fixed_asset_cost_leaf_accounts.php new file mode 100644 index 0000000..7205f34 --- /dev/null +++ b/application/migrations/20260906000700_create_fixed_asset_cost_leaf_accounts.php @@ -0,0 +1,66 @@ +db->table_exists('asset_categories') || !$this->db->table_exists('accounts')) return; + + $parents=$this->db->distinct()->select('a.*')->from('asset_categories c') + ->join('accounts a','a.id=c.asset_account_id') + ->where('a.allow_posting',0)->get()->result(); + + foreach($parents as$parent){ + $leaf=$this->db->where(array( + 'parent_id'=>(int)$parent->id, + 'company_id'=>(int)$parent->company_id, + 'is_active'=>1, + 'allow_posting'=>1, + 'is_kontra'=>0 + ))->order_by('id')->limit(1)->get('accounts')->row(); + + if(!$leaf){ + $base=preg_replace('/[^A-Za-z0-9]/','',(string)$parent->kode_akun); + $suffix=1; + do{$code=$base.str_pad($suffix++,2,'0',STR_PAD_LEFT);$exists=$this->db->get_where('accounts',array('company_id'=>(int)$parent->company_id,'kode_akun'=>$code))->row();}while($exists&&$suffix<100); + if($exists)throw new RuntimeException('Tidak dapat membuat kode akun detail untuk '.$parent->kode_akun.'.'); + $this->db->insert('accounts',array( + 'kode_akun'=>$code, + 'nama_akun'=>'Harga Perolehan '.$parent->nama_akun, + 'tipe'=>$parent->tipe, + 'posisi'=>'debit', + 'kategori'=>$parent->kategori, + 'is_kontra'=>0, + 'is_active'=>1, + 'parent_id'=>(int)$parent->id, + 'sub_tipe'=>$parent->sub_tipe, + 'priority'=>$parent->priority, + 'is_header'=>0, + 'allow_posting'=>1, + 'company_id'=>(int)$parent->company_id, + 'created_at'=>date('Y-m-d H:i:s') + )); + $leaf=(object)array('id'=>(int)$this->db->insert_id()); + } + + $this->db->where(array('company_id'=>(int)$parent->company_id,'asset_account_id'=>(int)$parent->id)) + ->update('asset_categories',array('asset_account_id'=>(int)$leaf->id,'updated_at'=>date('Y-m-d H:i:s'))); + if($this->db->table_exists('assets')){ + $this->db->where(array('company_id'=>(int)$parent->company_id,'asset_account_id'=>(int)$parent->id)) + ->where_in('workflow_status',array('draft','submitted','approved')) + ->update('assets',array('asset_account_id'=>(int)$leaf->id,'account_debit_id'=>(int)$leaf->id,'updated_at'=>date('Y-m-d H:i:s'))); + } + } + } + + public function down() + { + // Akun tidak dihapus agar histori dan referensi dokumen tetap utuh. + } +} diff --git a/application/migrations/20260906000800_backfill_pending_warehouse_asset_values.php b/application/migrations/20260906000800_backfill_pending_warehouse_asset_values.php new file mode 100644 index 0000000..0caf924 --- /dev/null +++ b/application/migrations/20260906000800_backfill_pending_warehouse_asset_values.php @@ -0,0 +1,35 @@ +db->table_exists('assets')||!$this->db->table_exists('inventory_ledger'))return; + $this->db->query("UPDATE assets a + JOIN ( + SELECT item_id,warehouse_id, + SUM(IF(direction='in',qty,-qty)) ledger_qty, + SUM(IF(direction='in',value,-value)) ledger_value + FROM inventory_ledger + GROUP BY item_id,warehouse_id + ) l ON l.item_id=a.item_id AND l.warehouse_id=a.warehouse_id + SET a.harga_per_unit=ROUND(l.ledger_value/NULLIF(l.ledger_qty,0),4), + a.nilai_perolehan=ROUND((l.ledger_value/NULLIF(l.ledger_qty,0))*a.qty,2), + a.nilai_buku=ROUND((l.ledger_value/NULLIF(l.ledger_qty,0))*a.qty,2), + a.penyusutan_per_bulan=ROUND(CASE WHEN a.depreciation_method='declining_balance' + THEN ((l.ledger_value/NULLIF(l.ledger_qty,0))*a.qty)*(2/GREATEST(a.masa_manfaat,1)) + ELSE GREATEST(((l.ledger_value/NULLIF(l.ledger_qty,0))*a.qty)-a.nilai_residu,0)/GREATEST(a.masa_manfaat,1) END,2), + a.updated_at=NOW() + WHERE a.source_type='warehouse' + AND a.workflow_status IN('draft','submitted','approved') + AND a.nilai_perolehan=0 + AND l.ledger_qty>0"); + } + + public function down() + { + // Nilai hasil ledger tidak dikembalikan ke nol. + } +} diff --git a/application/migrations/20260906000900_add_company_scope_to_stock_logs.php b/application/migrations/20260906000900_add_company_scope_to_stock_logs.php new file mode 100644 index 0000000..ee948c5 --- /dev/null +++ b/application/migrations/20260906000900_add_company_scope_to_stock_logs.php @@ -0,0 +1,24 @@ +db->table_exists('stock_logs'))return; + if(!$this->db->field_exists('company_id','stock_logs')){ + $this->db->query('ALTER TABLE stock_logs ADD company_id BIGINT UNSIGNED NULL AFTER id'); + } + $fallback=$this->db->select_min('id')->get('companies')->row(); + $companyId=$fallback?(int)$fallback->id:1; + $this->db->query('UPDATE stock_logs s LEFT JOIN items i ON i.id=s.item_id SET s.company_id=COALESCE(s.company_id,i.company_id,?) WHERE s.company_id IS NULL',array($companyId)); + $index=$this->db->query("SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='stock_logs' AND index_name='idx_stock_log_company_item_wh' LIMIT 1")->row(); + if(!$index)$this->db->query('ALTER TABLE stock_logs ADD KEY idx_stock_log_company_item_wh(company_id,item_id,warehouse_id,id)'); + } + + public function down() + { + // Tenant scope dan histori stock log tidak dihapus saat rollback. + } +} diff --git a/application/migrations/20260906001000_add_account_hidden_metadata.php b/application/migrations/20260906001000_add_account_hidden_metadata.php new file mode 100644 index 0000000..ae40985 --- /dev/null +++ b/application/migrations/20260906001000_add_account_hidden_metadata.php @@ -0,0 +1,18 @@ +db->table_exists('accounts')&&!$this->db->field_exists('is_hidden','accounts')){ + $this->db->query('ALTER TABLE accounts ADD is_hidden TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active'); + } + } + + public function down() + { + // Metadata tidak dihapus agar konfigurasi visibilitas tidak hilang. + } +} diff --git a/application/migrations/20260906001100_finalize_budget_dimensions.php b/application/migrations/20260906001100_finalize_budget_dimensions.php new file mode 100644 index 0000000..caa3856 --- /dev/null +++ b/application/migrations/20260906001100_finalize_budget_dimensions.php @@ -0,0 +1,142 @@ +tableExists($table) && !$this->fieldExists($table, $column)) { + $this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}"); + } + } + + private function hasIndex($table, $name) + { + if (!$this->tableExists($table)) return false; + foreach ($this->db->query("SHOW INDEX FROM `{$table}`")->result() as $index) { + if ($index->Key_name === $name) return true; + } + return false; + } + + private function tableExists($table) + { + return (bool) $this->db->query( + 'SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=? LIMIT 1', + array($table) + )->row(); + } + + private function fieldExists($table, $column) + { + return (bool) $this->db->query( + 'SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=? LIMIT 1', + array($table, $column) + )->row(); + } + + public function up() + { + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL AFTER `id`', + 'description' => 'TEXT NULL AFTER `control_mode`', + 'revision_no' => 'INT NOT NULL DEFAULT 1 AFTER `version_no`', + 'parent_budget_id' => 'BIGINT UNSIGNED NULL AFTER `revision_no`', + 'submitted_by' => 'INT NULL AFTER `created_by`', + 'submitted_at' => 'DATETIME NULL AFTER `submitted_by`', + 'rejected_by' => 'INT NULL AFTER `approved_at`', + 'rejected_at' => 'DATETIME NULL AFTER `rejected_by`', + 'rejection_reason' => 'TEXT NULL AFTER `rejected_at`', + 'updated_by' => 'INT NULL AFTER `created_at`', + 'updated_at' => 'DATETIME NULL AFTER `updated_by`' + ) as $column => $definition) $this->column('budgets', $column, $definition); + + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL AFTER `id`', + 'salesperson_id' => 'BIGINT UNSIGNED NULL AFTER `profit_center_id`', + 'updated_by' => 'INT NULL AFTER `notes`', + 'created_at' => 'DATETIME NULL AFTER `updated_by`', + 'updated_at' => 'DATETIME NULL AFTER `created_at`' + ) as $column => $definition) $this->column('budget_lines', $column, $definition); + + foreach (array( + 'company_id' => 'BIGINT UNSIGNED NULL AFTER `id`', + 'description' => 'VARCHAR(255) NULL AFTER `name`', + 'source_type' => 'VARCHAR(40) NULL AFTER `parent_id`', + 'source_id' => 'BIGINT UNSIGNED NULL AFTER `source_type`', + 'created_by' => 'INT NULL AFTER `is_active`', + 'updated_by' => 'INT NULL AFTER `created_by`', + 'updated_at' => 'DATETIME NULL AFTER `created_at`' + ) as $column => $definition) $this->column('business_dimensions', $column, $definition); + + foreach (array('require_branch','require_department','require_project','require_cost_center','require_profit_center') as $column) { + $this->column('accounts', $column, 'TINYINT(1) NOT NULL DEFAULT 0 AFTER `is_hidden`'); + } + + foreach (array('branch_id','department_id','profit_center_id','salesperson_id') as $column) { + $this->column('purchase_order_lines', $column, 'BIGINT UNSIGNED NULL AFTER `budget_account_id`'); + } + + if (!$this->tableExists('budget_commitments')) { + $this->db->query("CREATE TABLE `budget_commitments` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `source_type` VARCHAR(50) NOT NULL, + `source_id` BIGINT UNSIGNED NOT NULL, + `source_line_id` BIGINT UNSIGNED NULL, + `account_id` INT NOT NULL, + `period` CHAR(7) NOT NULL, + `branch_id` BIGINT UNSIGNED NULL, + `department_id` BIGINT UNSIGNED NULL, + `project_id` BIGINT UNSIGNED NULL, + `cost_center_id` BIGINT UNSIGNED NULL, + `profit_center_id` BIGINT UNSIGNED NULL, + `salesperson_id` BIGINT UNSIGNED NULL, + `original_amount` DECIMAL(18,2) NOT NULL DEFAULT 0, + `realized_amount` DECIMAL(18,2) NOT NULL DEFAULT 0, + `released_amount` DECIMAL(18,2) NOT NULL DEFAULT 0, + `status` ENUM('active','partially_realized','realized','released') NOT NULL DEFAULT 'active', + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_budget_commitment_source` (`company_id`,`source_type`,`source_line_id`), + KEY `idx_budget_commitment_report` (`company_id`,`period`,`account_id`,`status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + + if (!$this->tableExists('budget_audit_logs')) { + $this->db->query("CREATE TABLE `budget_audit_logs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `budget_id` BIGINT UNSIGNED NULL, + `entity_type` VARCHAR(40) NOT NULL, + `entity_id` BIGINT UNSIGNED NULL, + `action` VARCHAR(50) NOT NULL, + `old_values` LONGTEXT NULL, + `new_values` LONGTEXT NULL, + `reason` TEXT NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_budget_audit_entity` (`company_id`,`entity_type`,`entity_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + + if (!$this->hasIndex('budgets', 'idx_budget_company_year_status')) + $this->db->query('ALTER TABLE `budgets` ADD KEY `idx_budget_company_year_status` (`company_id`,`fiscal_year`,`status`)'); + if (!$this->hasIndex('budget_lines', 'idx_budget_line_report')) + $this->db->query('ALTER TABLE `budget_lines` ADD KEY `idx_budget_line_report` (`company_id`,`period`,`account_id`)'); + if (!$this->hasIndex('business_dimensions', 'idx_dimension_lookup')) + $this->db->query('ALTER TABLE `business_dimensions` ADD KEY `idx_dimension_lookup` (`company_id`,`dimension_type`,`is_active`,`name`)'); + + $this->db->query('UPDATE `budgets` SET `revision_no`=`version_no` WHERE `revision_no`=1 AND `version_no`>1'); + if ($this->tableExists('k_departments')) { + $this->db->query("UPDATE business_dimensions bd JOIN k_departments d ON bd.dimension_type='department' AND bd.code=CONCAT('DEP-',d.id) SET bd.source_type='k_departments',bd.source_id=d.id,bd.name=d.department_name,bd.description=d.description WHERE bd.source_id IS NULL"); + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback finalisasi Budget & Dimensi.'); + } +} diff --git a/application/migrations/20260907000100_finalize_sales_invoice_workflow.php b/application/migrations/20260907000100_finalize_sales_invoice_workflow.php new file mode 100644 index 0000000..01a93ca --- /dev/null +++ b/application/migrations/20260907000100_finalize_sales_invoice_workflow.php @@ -0,0 +1,126 @@ +db->table_exists($table)&&!$this->db->field_exists($column,$table)) + $this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}"); + } + private function index($table,$name,$columns,$unique=false) + { + if(!$this->db->table_exists($table))return; + foreach($this->db->query("SHOW INDEX FROM `{$table}`")->result()as$i)if($i->Key_name===$name)return; + $this->db->query("ALTER TABLE `{$table}` ADD ".($unique?'UNIQUE ':'')."KEY `{$name}` ({$columns})"); + } + public function up() + { + foreach(array( + 'company_id'=>'BIGINT UNSIGNED NULL', + 'invoice_type'=>"ENUM('one_time','running') NOT NULL DEFAULT 'one_time'", + 'period_start'=>'DATE NULL','period_end'=>'DATE NULL', + 'recognition_policy'=>"ENUM('accrual','on_payment') NOT NULL DEFAULT 'accrual'", + 'source_sales_order_id'=>'BIGINT UNSIGNED NULL','delivery_status'=>"VARCHAR(30) NOT NULL DEFAULT 'not_delivered'", + 'finalized_at'=>'DATETIME NULL','finalized_by'=>'INT NULL','recognized_revenue'=>'DECIMAL(18,2) NOT NULL DEFAULT 0', + 'recognized_cogs'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','updated_by'=>'INT NULL','updated_at'=>'DATETIME NULL' + )as$c=>$d)$this->column('invoices',$c,$d); + foreach(array( + 'company_id'=>'BIGINT UNSIGNED NULL','service_date'=>'DATE NULL','specification'=>'VARCHAR(255) NULL', + 'unit'=>'VARCHAR(30) NULL','delivery_line_id'=>'BIGINT UNSIGNED NULL','net_amount'=>'DECIMAL(18,2) NOT NULL DEFAULT 0', + 'recognized_revenue'=>'DECIMAL(18,2) NOT NULL DEFAULT 0','recognized_cogs'=>'DECIMAL(18,2) NOT NULL DEFAULT 0' + )as$c=>$d)$this->column('invoice_details',$c,$d); + foreach(array('company_id'=>'BIGINT UNSIGNED NULL','parent_document_id'=>'BIGINT UNSIGNED NULL','source_quotation_id'=>'BIGINT UNSIGNED NULL','updated_by'=>'INT NULL')as$c=>$d)$this->column('sales_documents',$c,$d); + foreach(array('company_id'=>'BIGINT UNSIGNED NULL','account_id'=>'INT NULL','specification'=>'VARCHAR(255) NULL','unit'=>'VARCHAR(30) NULL','discount_amount'=>'DECIMAL(18,2) NOT NULL DEFAULT 0')as$c=>$d)$this->column('sales_document_lines',$c,$d); + + if(!$this->db->table_exists('sales_deliveries'))$this->db->query("CREATE TABLE sales_deliveries( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,delivery_no VARCHAR(60) NOT NULL, + customer_id INT NOT NULL,sales_order_id BIGINT UNSIGNED NULL,invoice_id INT NULL,warehouse_id INT NOT NULL, + delivery_date DATE NOT NULL,address TEXT NULL,driver_name VARCHAR(120) NULL,vehicle_no VARCHAR(60) NULL, + courier VARCHAR(120) NULL,tracking_no VARCHAR(120) NULL,recipient_name VARCHAR(120) NULL,received_at DATETIME NULL, + notes TEXT NULL,status ENUM('draft','posted','partially_returned','returned','cancelled','reversed') NOT NULL DEFAULT 'draft', + journal_id INT NULL,reversal_journal_id INT NULL,created_by INT NOT NULL,created_at DATETIME NOT NULL, + posted_by INT NULL,posted_at DATETIME NULL,reversed_by INT NULL,reversed_at DATETIME NULL,reversal_reason TEXT NULL, + version INT NOT NULL DEFAULT 1,UNIQUE KEY uq_sales_delivery_no(company_id,delivery_no), + KEY idx_sales_delivery_customer(company_id,customer_id,delivery_date),KEY idx_sales_delivery_invoice(company_id,invoice_id,status) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('sales_delivery_lines'))$this->db->query("CREATE TABLE sales_delivery_lines( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_delivery_id BIGINT UNSIGNED NOT NULL, + line_no INT NOT NULL,item_id INT NOT NULL,invoice_detail_id INT NULL,description VARCHAR(255) NOT NULL, + specification VARCHAR(255) NULL,unit VARCHAR(30) NULL,qty DECIMAL(18,4) NOT NULL,returned_qty DECIMAL(18,4) NOT NULL DEFAULT 0, + unit_cost DECIMAL(18,2) NOT NULL DEFAULT 0,total_cost DECIMAL(18,2) NOT NULL DEFAULT 0,condition_notes VARCHAR(255) NULL, + stock_posted_at DATETIME NULL,stock_log_id BIGINT UNSIGNED NULL,created_at DATETIME NOT NULL, + UNIQUE KEY uq_sales_delivery_line(sales_delivery_id,line_no),KEY idx_sales_delivery_item(company_id,item_id) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('sales_delivery_barcodes'))$this->db->query("CREATE TABLE sales_delivery_barcodes( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_delivery_id BIGINT UNSIGNED NOT NULL, + sales_delivery_line_id BIGINT UNSIGNED NOT NULL,item_id INT NOT NULL,barcode_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL, + returned_qty DECIMAL(18,4) NOT NULL DEFAULT 0,status ENUM('allocated','delivered','partially_returned','returned','reversed') NOT NULL DEFAULT 'allocated', + created_at DATETIME NOT NULL,UNIQUE KEY uq_delivery_barcode_line(sales_delivery_line_id,barcode_id), + KEY idx_delivery_barcode_trace(company_id,barcode_id,status) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('invoice_line_barcodes'))$this->db->query("CREATE TABLE invoice_line_barcodes( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,invoice_id INT NOT NULL, + invoice_detail_id INT NOT NULL,sales_delivery_id BIGINT UNSIGNED NULL,sales_delivery_line_id BIGINT UNSIGNED NULL, + barcode_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL,created_at DATETIME NOT NULL, + UNIQUE KEY uq_invoice_line_barcode(invoice_detail_id,barcode_id,sales_delivery_line_id), + KEY idx_invoice_barcode_trace(company_id,barcode_id,invoice_id) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('revenue_recognition_ledger'))$this->db->query("CREATE TABLE revenue_recognition_ledger( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,invoice_id INT NOT NULL, + invoice_detail_id INT NULL,payment_id INT NULL,recognition_date DATE NOT NULL,revenue_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + cogs_amount DECIMAL(18,2) NOT NULL DEFAULT 0,journal_id INT NOT NULL,idempotency_key VARCHAR(150) NOT NULL, + created_by INT NOT NULL,created_at DATETIME NOT NULL,UNIQUE KEY uq_revenue_recognition_key(company_id,idempotency_key), + KEY idx_revenue_recognition_invoice(company_id,invoice_id,recognition_date) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('payment_sources'))$this->db->query("CREATE TABLE payment_sources( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,payment_id INT NOT NULL, + account_id INT NOT NULL,amount DECIMAL(18,2) NOT NULL,created_at DATETIME NOT NULL, + UNIQUE KEY uq_payment_source(payment_id,account_id),KEY idx_payment_source_company(company_id,account_id) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('sales_returns'))$this->db->query("CREATE TABLE sales_returns( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,return_no VARCHAR(60) NOT NULL, + customer_id INT NOT NULL,invoice_id INT NULL,sales_delivery_id BIGINT UNSIGNED NULL,return_date DATE NOT NULL, + reason TEXT NOT NULL,problem_category VARCHAR(60) NULL,resolution ENUM('restock','quarantine','damaged','replacement','credit_note','refund') NOT NULL, + status ENUM('draft','submitted','approved','rejected','received','posted','completed','cancelled','reversed') NOT NULL DEFAULT 'draft', + notes TEXT NULL,credit_note_id BIGINT UNSIGNED NULL,refund_id BIGINT UNSIGNED NULL,journal_id INT NULL,reversal_journal_id INT NULL, + created_by INT NOT NULL,created_at DATETIME NOT NULL,submitted_by INT NULL,submitted_at DATETIME NULL, + approved_by INT NULL,approved_at DATETIME NULL,rejected_by INT NULL,rejected_at DATETIME NULL,rejection_reason TEXT NULL, + received_by INT NULL,received_at DATETIME NULL,posted_by INT NULL,posted_at DATETIME NULL,version INT NOT NULL DEFAULT 1, + UNIQUE KEY uq_sales_return_no(company_id,return_no),KEY idx_sales_return_action(company_id,status,return_date) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('sales_return_lines'))$this->db->query("CREATE TABLE sales_return_lines( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_return_id BIGINT UNSIGNED NOT NULL, + sales_delivery_line_id BIGINT UNSIGNED NOT NULL,invoice_detail_id INT NULL,item_id INT NOT NULL,qty DECIMAL(18,4) NOT NULL, + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0,net_amount DECIMAL(18,2) NOT NULL DEFAULT 0,tax_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + unit_cost DECIMAL(18,2) NOT NULL DEFAULT 0,physical_condition ENUM('saleable','quarantine','damaged','lost') NOT NULL DEFAULT 'saleable', + notes VARCHAR(255) NULL,posted_at DATETIME NULL,created_at DATETIME NOT NULL, + KEY idx_sales_return_line(sales_return_id,item_id) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('sales_return_barcodes'))$this->db->query("CREATE TABLE sales_return_barcodes( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_return_id BIGINT UNSIGNED NOT NULL, + sales_return_line_id BIGINT UNSIGNED NOT NULL,sales_delivery_barcode_id BIGINT UNSIGNED NOT NULL,barcode_id INT NOT NULL, + qty DECIMAL(18,4) NOT NULL,status ENUM('requested','received','posted','reversed') NOT NULL DEFAULT 'requested',created_at DATETIME NOT NULL, + UNIQUE KEY uq_sales_return_delivery_barcode(sales_return_id,sales_delivery_barcode_id), + KEY idx_sales_return_barcode(company_id,barcode_id,status) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + if(!$this->db->table_exists('sales_return_history'))$this->db->query("CREATE TABLE sales_return_history( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,sales_return_id BIGINT UNSIGNED NOT NULL, + from_status VARCHAR(30) NULL,to_status VARCHAR(30) NOT NULL,notes TEXT NULL,user_id INT NOT NULL,created_at DATETIME NOT NULL, + KEY idx_sales_return_history(sales_return_id,id) + )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $companyRow=$this->db->select('id')->order_by('id')->limit(1)->get('companies')->row(); + if(!$companyRow)throw new RuntimeException('Master company belum tersedia; migration Penjualan dihentikan agar backfill tenant tidak salah.'); + $company=(int)$companyRow->id; + $this->db->query("UPDATE invoices i LEFT JOIN customers c ON c.id=i.customer_id SET i.company_id=COALESCE(i.company_id,c.company_id,?) WHERE i.company_id IS NULL",array($company)); + $this->db->query("UPDATE invoice_details d JOIN invoices i ON i.id=d.invoice_id SET d.company_id=i.company_id,d.service_date=COALESCE(d.service_date,d.tanggal,i.tanggal),d.net_amount=CASE WHEN d.net_amount=0 THEN GREATEST(d.subtotal-COALESCE(d.tax_amount,0),0) ELSE d.net_amount END WHERE d.company_id IS NULL OR d.service_date IS NULL"); + if($this->db->table_exists('sales_documents'))$this->db->query("UPDATE sales_documents s LEFT JOIN customers c ON c.id=s.customer_id SET s.company_id=COALESCE(s.company_id,c.company_id,?) WHERE s.company_id IS NULL",array($company)); + if($this->db->table_exists('sales_document_lines'))$this->db->query("UPDATE sales_document_lines l JOIN sales_documents s ON s.id=l.sales_document_id SET l.company_id=s.company_id WHERE l.company_id IS NULL"); + $this->index('invoices','idx_invoice_company_workflow','`company_id`,`workflow_status`,`status`,`tanggal`'); + $this->index('invoices','idx_invoice_company_customer_due','`company_id`,`customer_id`,`jatuh_tempo`,`sisa_piutang`'); + $this->index('invoice_details','idx_invoice_detail_company_date','`company_id`,`invoice_id`,`service_date`,`items_id`'); + $this->index('payments','idx_payment_company_customer','`company_id`,`customer_id`,`tanggal`'); + } + public function down(){throw new RuntimeException('Gunakan backup database untuk rollback finalisasi Penjualan.');} +} diff --git a/application/migrations/20260907000200_complete_sales_document_metadata.php b/application/migrations/20260907000200_complete_sales_document_metadata.php new file mode 100644 index 0000000..f95b27d --- /dev/null +++ b/application/migrations/20260907000200_complete_sales_document_metadata.php @@ -0,0 +1,15 @@ +db->table_exists($t)&&!$this->db->field_exists($c,$t))$this->db->query("ALTER TABLE `$t` ADD `$c` $d");} + public function up(){ + foreach(array('created_by'=>'INT NULL','created_at'=>'DATETIME NULL','submitted_by'=>'INT NULL','submitted_at'=>'DATETIME NULL','approved_by'=>'INT NULL','approved_at'=>'DATETIME NULL','rejected_by'=>'INT NULL','rejected_at'=>'DATETIME NULL','rejection_reason'=>'TEXT NULL')as$c=>$d)$this->col('invoices',$c,$d); + foreach(array('company_id'=>'BIGINT UNSIGNED NULL','updated_at'=>'DATETIME NULL')as$c=>$d)$this->col('transaction_attachments',$c,$d); + if($this->db->table_exists('transaction_attachments')&&$this->db->field_exists('company_id','transaction_attachments')){ + $company=$this->db->select('id')->order_by('id')->limit(1)->get('companies')->row(); + if($company)$this->db->where('company_id IS NULL',null,false)->update('transaction_attachments',array('company_id'=>$company->id)); + } + } + public function down(){throw new RuntimeException('Gunakan backup database untuk rollback metadata Penjualan.');} +} diff --git a/application/migrations/20260907000300_expand_sales_return_barcode_status.php b/application/migrations/20260907000300_expand_sales_return_barcode_status.php new file mode 100644 index 0000000..c08daf1 --- /dev/null +++ b/application/migrations/20260907000300_expand_sales_return_barcode_status.php @@ -0,0 +1,7 @@ +db->table_exists('item_barcodes'))$this->db->query("ALTER TABLE item_barcodes MODIFY status ENUM('pending','available','reserved','return_reserved','returned','replaced','closed','installed','sold_out','quarantine','damaged') NOT NULL DEFAULT 'available'");} + public function down(){throw new RuntimeException('Status barcode retur tidak dapat dipersempit tanpa rekonsiliasi data.');} +} diff --git a/application/migrations/20260907000400_scope_document_numbering_by_company.php b/application/migrations/20260907000400_scope_document_numbering_by_company.php new file mode 100644 index 0000000..b6da738 --- /dev/null +++ b/application/migrations/20260907000400_scope_document_numbering_by_company.php @@ -0,0 +1,17 @@ +db->table_exists($table))return;$indexes=array();foreach($this->db->query("SHOW INDEX FROM `$table`")->result()as$i){if(!$i->Non_unique&&$i->Key_name!=='PRIMARY')$indexes[$i->Key_name][]=$i->Column_name;}foreach($indexes as$name=>$columns)if($columns===array($column))$this->db->query("ALTER TABLE `$table` DROP INDEX `$name`"); + } + private function addUnique($table,$name,$columns){if(!$this->db->table_exists($table))return;foreach($this->db->query("SHOW INDEX FROM `$table`")->result()as$i)if($i->Key_name===$name)return;$this->db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$name` ($columns)");} + public function up() + { + if($this->db->table_exists('document_sequences')){$company=$this->db->select('id')->order_by('id')->limit(1)->get('companies')->row();if($company)$this->db->where('company_id IS NULL',null,false)->update('document_sequences',array('company_id'=>$company->id));$this->dropSingleColumnUnique('document_sequences','document_type');foreach($this->db->query("SHOW INDEX FROM document_sequences")->result()as$i)if($i->Key_name==='uq_document_sequence'){$this->db->query('ALTER TABLE document_sequences DROP INDEX uq_document_sequence');break;}$this->addUnique('document_sequences','uq_document_sequence_company','`company_id`,`document_type`,`period_key`');} + $this->dropSingleColumnUnique('invoices','no_invoice');$this->addUnique('invoices','uq_invoice_company_no','`company_id`,`no_invoice`'); + $this->dropSingleColumnUnique('sales_documents','document_no');$this->addUnique('sales_documents','uq_sales_document_company_no','`company_id`,`document_no`'); + } + public function down(){throw new RuntimeException('Gunakan backup database untuk rollback numbering multi-company.');} +} diff --git a/application/migrations/20260907000500_finalize_invoice_editor.php b/application/migrations/20260907000500_finalize_invoice_editor.php new file mode 100644 index 0000000..b5f98ef --- /dev/null +++ b/application/migrations/20260907000500_finalize_invoice_editor.php @@ -0,0 +1,55 @@ +db->table_exists($table) && !$this->db->field_exists($column, $table)) { + $this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}"); + } + } + + private function index($table, $name, $columns, $unique = false) + { + if (!$this->db->table_exists($table)) return; + foreach ($this->db->query("SHOW INDEX FROM `{$table}`")->result() as $index) { + if ($index->Key_name === $name) return; + } + $this->db->query("ALTER TABLE `{$table}` ADD ".($unique ? 'UNIQUE ' : '')."KEY `{$name}` ({$columns})"); + } + + public function up() + { + $this->column('invoices', 'idempotency_key', 'VARCHAR(100) NULL AFTER `source_sales_order_id`'); + $this->column('invoices', 'version', 'INT NOT NULL DEFAULT 1'); + $this->column('invoice_details', 'line_type', "ENUM('inventory','service') NOT NULL DEFAULT 'inventory' AFTER `invoice_id`"); + $this->column('invoice_details', 'idempotency_key', 'VARCHAR(100) NULL'); + + if ($this->db->table_exists('invoice_details') && $this->db->field_exists('line_type', 'invoice_details')) { + $this->db->query("UPDATE invoice_details SET line_type=IF(items_id IS NULL,'service','inventory')"); + } + + $this->index('invoices', 'uq_invoice_editor_idempotency', '`company_id`,`idempotency_key`', true); + $this->index('invoices', 'idx_invoice_list_filters', '`company_id`,`workflow_status`,`status`,`invoice_type`,`tanggal`'); + $this->index('invoice_details', 'uq_invoice_line_idempotency', '`company_id`,`idempotency_key`', true); + $this->index('invoice_details', 'idx_invoice_line_page', '`company_id`,`invoice_id`,`service_date`,`id`'); + $this->index('sales_deliveries', 'idx_delivery_draft_group', '`company_id`,`invoice_id`,`delivery_date`,`warehouse_id`,`status`'); + + if ($this->db->table_exists('roles')) { + foreach ($this->db->select('id,permissions')->get('roles')->result() as $role) { + $permissions = json_decode((string)$role->permissions, true); + if (!is_array($permissions) || empty($permissions['invoices']) || !is_array($permissions['invoices'])) continue; + if ((in_array('can_create', $permissions['invoices'], true) || in_array('can_update', $permissions['invoices'], true)) && !in_array('can_submit', $permissions['invoices'], true)) { + $permissions['invoices'][] = 'can_submit'; + $this->db->where('id', $role->id)->update('roles', array('permissions'=>json_encode($permissions))); + } + } + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback finalisasi editor Invoice.'); + } +} diff --git a/application/migrations/20260907000600_finalize_sales_payment_permissions.php b/application/migrations/20260907000600_finalize_sales_payment_permissions.php new file mode 100644 index 0000000..bb81168 --- /dev/null +++ b/application/migrations/20260907000600_finalize_sales_payment_permissions.php @@ -0,0 +1,22 @@ +db->table_exists($table))return;foreach($this->db->query("SHOW INDEX FROM `{$table}`")->result()as$i)if($i->Key_name===$name)return;$this->db->query("ALTER TABLE `{$table}` ADD KEY `{$name}` ({$columns})"); + } + + public function up() + { + if($this->db->table_exists('roles'))foreach($this->db->select('id,permissions,is_super_admin')->get('roles')->result()as$role){ + if((int)$role->is_super_admin===1)continue;$permissions=json_decode((string)$role->permissions,true);if(!is_array($permissions))continue;$invoice=$permissions['invoices']??array();$cash=$permissions['cash_bank']??array();if(!is_array($invoice)||!is_array($cash))continue; + if(in_array('can_view',$invoice,true)&&in_array('can_post',$cash,true)&&!in_array('can_post',$invoice,true)){$permissions['invoices'][]='can_post';$this->db->where('id',$role->id)->update('roles',array('permissions'=>json_encode($permissions),'updated_at'=>date('Y-m-d H:i:s')));} + } + $this->index('payments','idx_customer_payment_list','`company_id`,`customer_id`,`status`,`tanggal`,`id`'); + if($this->db->table_exists('payment_allocations')){$fields=$this->db->list_fields('payment_allocations');$columns=in_array('company_id',$fields,true)?'`company_id`,`invoice_id`,`payment_id`':'`invoice_id`,`payment_id`';$this->index('payment_allocations','idx_payment_allocation_invoice',$columns);} + } + + public function down(){throw new RuntimeException('Gunakan backup database untuk rollback permission pembayaran Penjualan.');} +} diff --git a/application/migrations/20260908000100_reconcile_legacy_active_inventory_cost_layers.php b/application/migrations/20260908000100_reconcile_legacy_active_inventory_cost_layers.php new file mode 100644 index 0000000..56340e8 --- /dev/null +++ b/application/migrations/20260908000100_reconcile_legacy_active_inventory_cost_layers.php @@ -0,0 +1,144 @@ +db->table_exists('items') + || !$this->db->table_exists('item_barcodes') + || !$this->db->table_exists('inventory_ledger') + || !$this->db->table_exists('inventory_cost_layers')) { + return; + } + + $this->db->trans_begin(); + try { + /* + * Barcode aktif lama sudah mewakili stok fisik, tetapi sebagian dibuat + * sebelum inventory ledger dan lapisan HPP diterapkan. Rekonsiliasi ini + * hanya membentuk subledger persediaan yang hilang. Jurnal GL sengaja + * tidak dibuat agar saldo awal persediaan produksi tidak terduplikasi; + * rekonsiliasi akun persediaan tetap harus ditinjau saat deploy produksi. + */ + $rows = $this->db->query( + "SELECT p.item_id, + p.warehouse_id, + p.physical_qty, + p.opening_date, + COALESCE(l.ledger_qty,0) ledger_qty, + COALESCE(c.layer_qty,0) layer_qty, + COALESCE(NULLIF(c.layer_unit_cost,0),NULLIF(i.harga_beli,0),0) unit_cost + FROM ( + SELECT item_id, + warehouse_id, + SUM(qty_sisa) physical_qty, + MIN(DATE(COALESCE(activated_at,created_at,NOW()))) opening_date + FROM item_barcodes + WHERE status='available' AND qty_sisa>0 + GROUP BY item_id,warehouse_id + ) p + JOIN items i ON i.id=p.item_id + LEFT JOIN ( + SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) ledger_qty + FROM inventory_ledger + GROUP BY item_id,warehouse_id + ) l ON l.item_id=p.item_id AND l.warehouse_id=p.warehouse_id + LEFT JOIN ( + SELECT item_id, + warehouse_id, + SUM(IF(status='open',remaining_qty,0)) layer_qty, + SUM(IF(status='open',remaining_qty*unit_cost,0)) + / NULLIF(SUM(IF(status='open',remaining_qty,0)),0) layer_unit_cost + FROM inventory_cost_layers + GROUP BY item_id,warehouse_id + ) c ON c.item_id=p.item_id AND c.warehouse_id=p.warehouse_id + WHERE p.physical_qty>COALESCE(l.ledger_qty,0)+0.0001 + OR p.physical_qty>COALESCE(c.layer_qty,0)+0.0001" + )->result(); + + $affectedItems = array(); + foreach ($rows as $row) { + $physicalQty = round((float) $row->physical_qty, 4); + $ledgerQty = round((float) $row->ledger_qty, 4); + $layerQty = round((float) $row->layer_qty, 4); + $unitCost = round((float) $row->unit_cost, 4); + if ($unitCost < 0) { + throw new RuntimeException('Harga beli stok lama tidak valid untuk item #' . (int) $row->item_id . '.'); + } + + $ledgerShortage = round(max(0, $physicalQty - $ledgerQty), 4); + $sourceLedgerId = 0; + if ($ledgerShortage > 0.0001) { + $key = 'LEGACY-ACTIVE-STOCK-' . (int) $row->item_id . '-' . (int) $row->warehouse_id . '-20260908'; + $existing = $this->db->select('id')->where('idempotency_key', $key)->get('inventory_ledger')->row(); + if ($existing) { + $sourceLedgerId = (int) $existing->id; + } else { + $this->db->insert('inventory_ledger', array( + 'item_id' => (int) $row->item_id, + 'warehouse_id' => (int) $row->warehouse_id, + 'movement_date' => $row->opening_date ?: date('Y-m-d'), + 'direction' => 'in', + 'qty' => $ledgerShortage, + 'unit_cost' => $unitCost, + 'value' => round($ledgerShortage * $unitCost, 4), + 'document_type' => 'legacy_opening_reconciliation', + 'document_id' => (int) $row->item_id, + 'document_line_id' => null, + 'idempotency_key' => $key, + 'created_at' => date('Y-m-d H:i:s') + )); + $sourceLedgerId = (int) $this->db->insert_id(); + } + } + + $layerShortage = round(max(0, $physicalQty - $layerQty), 4); + if ($layerShortage > 0.0001) { + if (!$sourceLedgerId) { + $source = $this->db->select('id') + ->where(array('item_id' => (int) $row->item_id, 'warehouse_id' => (int) $row->warehouse_id, 'direction' => 'in')) + ->order_by('movement_date', 'ASC')->order_by('id', 'ASC') + ->limit(1)->get('inventory_ledger')->row(); + $sourceLedgerId = $source ? (int) $source->id : 0; + } + if (!$sourceLedgerId) { + throw new RuntimeException('Sumber ledger stok lama tidak ditemukan untuk item #' . (int) $row->item_id . '.'); + } + $this->db->insert('inventory_cost_layers', array( + 'item_id' => (int) $row->item_id, + 'warehouse_id' => (int) $row->warehouse_id, + 'source_ledger_id' => $sourceLedgerId, + 'received_date' => $row->opening_date ?: date('Y-m-d'), + 'original_qty' => $layerShortage, + 'remaining_qty' => $layerShortage, + 'unit_cost' => $unitCost, + 'status' => 'open' + )); + } + $affectedItems[(int) $row->item_id] = true; + } + + foreach (array_keys($affectedItems) as $itemId) { + $balance = $this->db->query( + "SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) qty FROM inventory_ledger WHERE item_id=?", + array($itemId) + )->row(); + $this->db->where('id', $itemId)->update('items', array('stok' => max(0, (float) $balance->qty))); + } + + if ($this->db->trans_status() === false) { + throw new RuntimeException('Rekonsiliasi lapisan biaya persediaan lama gagal.'); + } + $this->db->trans_commit(); + } catch (Throwable $e) { + $this->db->trans_rollback(); + throw $e; + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback rekonsiliasi stok lama.'); + } +} diff --git a/application/migrations/20260908000200_reconcile_legacy_active_stock_logs.php b/application/migrations/20260908000200_reconcile_legacy_active_stock_logs.php new file mode 100644 index 0000000..e718b64 --- /dev/null +++ b/application/migrations/20260908000200_reconcile_legacy_active_stock_logs.php @@ -0,0 +1,88 @@ +db->list_fields('stock_logs'))); + } + + public function up() + { + if (!$this->db->table_exists('items') + || !$this->db->table_exists('item_barcodes') + || !$this->db->table_exists('stock_logs')) { + return; + } + + $rows = $this->db->query( + "SELECT p.item_id, + p.warehouse_id, + p.physical_qty, + COALESCE(s.log_qty,0) log_qty, + COALESCE(i.harga_beli,0) unit_cost, + i.company_id, + k.tracking_type + FROM ( + SELECT item_id,warehouse_id,SUM(qty_sisa) physical_qty + FROM item_barcodes + WHERE status='available' AND qty_sisa>0 + GROUP BY item_id,warehouse_id + ) p + JOIN items i ON i.id=p.item_id + LEFT JOIN kode_barang k ON k.id=i.kode_id + LEFT JOIN ( + SELECT item_id,warehouse_id,SUM(IF(tipe='masuk',qty,-qty)) log_qty + FROM stock_logs + GROUP BY item_id,warehouse_id + ) s ON s.item_id=p.item_id AND s.warehouse_id=p.warehouse_id + WHERE p.physical_qty>COALESCE(s.log_qty,0)+0.0001" + )->result(); + + $this->db->trans_begin(); + try { + foreach ($rows as $row) { + /* + * Gunakan total qty_sisa, bukan COUNT(barcode). Dengan demikian + * UNIT tetap 1 barcode = 1 barang, sedangkan QTY dapat menyimpan + * banyak barang pada satu barcode tanpa salah menghitung stok. + */ + $shortage = round((float) $row->physical_qty - (float) $row->log_qty, 4); + if ($shortage <= 0.0001) continue; + + $key = 'LEGACY-ACTIVE-STOCK-LOG-' . (int) $row->item_id . '-' . (int) $row->warehouse_id . '-20260908'; + if ($this->db->where('idempotency_key', $key)->count_all_results('stock_logs')) continue; + + $tracking = strtoupper((string) $row->tracking_type) === 'UNIT' ? 'UNIT' : 'QTY'; + $this->db->insert('stock_logs', $this->columns(array( + 'company_id' => (int) $row->company_id, + 'item_id' => (int) $row->item_id, + 'warehouse_id' => (int) $row->warehouse_id, + 'barcode_id' => null, + 'qty' => $shortage, + 'tipe' => 'masuk', + 'unit_cost' => round((float) $row->unit_cost, 4), + 'keterangan' => '[AUTO] Rekonsiliasi saldo awal stok aktif lama | Tipe tracking ' . $tracking . ' | Qty ' . number_format($shortage, 4, ',', '.') . ' | Sumber total qty_sisa barcode aktif', + 'ref_type' => 'legacy_stock_reconciliation', + 'ref_id' => (int) $row->item_id, + 'idempotency_key' => $key, + 'created_at' => date('Y-m-d H:i:s') + ))); + } + + if ($this->db->trans_status() === false) { + throw new RuntimeException('Rekonsiliasi stock log lama gagal.'); + } + $this->db->trans_commit(); + } catch (Throwable $e) { + $this->db->trans_rollback(); + throw $e; + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback rekonsiliasi stock log lama.'); + } +} diff --git a/application/migrations/20260908000300_normalize_account_codes_to_four_digits.php b/application/migrations/20260908000300_normalize_account_codes_to_four_digits.php new file mode 100644 index 0000000..4b26d51 --- /dev/null +++ b/application/migrations/20260908000300_normalize_account_codes_to_four_digits.php @@ -0,0 +1,115 @@ +'1','liability'=>'2','equity'=>'3','revenue'=>'4','expense'=>'6'); + $first = isset($class[$type]) ? $class[$type] : '9'; + for ($number = 0; $number <= 999; $number++) { + $candidate = $first . str_pad((string) $number, 3, '0', STR_PAD_LEFT); + if (!isset($used[$candidate])) { + $used[$candidate] = true; + return $candidate; + } + } + throw new RuntimeException('Tidak tersedia kode akun 4 digit untuk akun ' . $oldCode . '.'); + } + + public function up() + { + if (!$this->db->table_exists('accounts')) return; + + $this->db->query("CREATE TABLE IF NOT EXISTS account_code_change_logs( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT NOT NULL, + company_id BIGINT UNSIGNED NULL, + old_code VARCHAR(20) NOT NULL, + new_code CHAR(4) NOT NULL, + changed_at DATETIME NOT NULL, + UNIQUE KEY uq_account_code_change(account_id,new_code), + KEY idx_account_code_change_old(old_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $accounts = $this->db->select('id,kode_akun,tipe' . ($this->db->field_exists('company_id','accounts') ? ',company_id' : '')) + ->order_by('id','ASC')->get('accounts')->result(); + $used = array(); + foreach ($accounts as $account) { + $code = trim((string) $account->kode_akun); + if (preg_match('/^[0-9]{4}$/', $code)) $used[$code] = true; + } + + $changes = array(); + foreach ($accounts as $account) { + $oldCode = trim((string) $account->kode_akun); + if (preg_match('/^[0-9]{4}$/', $oldCode)) continue; + $changes[] = array( + 'account' => $account, + 'old_code' => $oldCode, + 'new_code' => $this->nextCode($oldCode, (string) $account->tipe, $used) + ); + } + + $this->db->trans_begin(); + try { + foreach ($changes as $change) { + $account = $change['account']; + $log = array( + 'account_id' => (int) $account->id, + 'company_id' => isset($account->company_id) ? $account->company_id : null, + 'old_code' => $change['old_code'], + 'new_code' => $change['new_code'], + 'changed_at' => date('Y-m-d H:i:s') + ); + if (!$this->db->where(array('account_id'=>(int)$account->id,'new_code'=>$change['new_code']))->count_all_results('account_code_change_logs')) { + $this->db->insert('account_code_change_logs', $log); + } + $this->db->where('id', (int) $account->id)->update('accounts', array('kode_akun'=>$change['new_code'])); + + if ($this->db->table_exists('cash_accounts') && $this->db->field_exists('code','cash_accounts')) { + $this->db->where('code', 'GL-' . $change['old_code']) + ->update('cash_accounts', array('code'=>'GL-' . $change['new_code'])); + } + } + if ($this->db->trans_status() === false) throw new RuntimeException('Normalisasi kode akun gagal.'); + $this->db->trans_commit(); + } catch (Throwable $exception) { + $this->db->trans_rollback(); + throw $exception; + } + + $invalid = (int) $this->db->query("SELECT COUNT(*) total FROM accounts WHERE TRIM(kode_akun) NOT REGEXP '^[0-9]{4}$'")->row()->total; + $duplicates = (int) $this->db->query("SELECT COUNT(*) total FROM(SELECT kode_akun FROM accounts GROUP BY kode_akun HAVING COUNT(*)>1)x")->row()->total; + if ($invalid || $duplicates) throw new RuntimeException('Validasi kode akun 4 digit gagal.'); + + $this->db->query('ALTER TABLE accounts MODIFY kode_akun VARCHAR(4) NOT NULL'); + $this->db->query('DROP TRIGGER IF EXISTS trg_accounts_code_four_insert'); + $this->db->query("CREATE TRIGGER trg_accounts_code_four_insert BEFORE INSERT ON accounts FOR EACH ROW BEGIN IF NEW.kode_akun IS NULL OR NEW.kode_akun NOT REGEXP '^[0-9]{4}$' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Kode akun wajib terdiri dari 4 angka'; END IF; END"); + $this->db->query('DROP TRIGGER IF EXISTS trg_accounts_code_four_update'); + $this->db->query("CREATE TRIGGER trg_accounts_code_four_update BEFORE UPDATE ON accounts FOR EACH ROW BEGIN IF NEW.kode_akun IS NULL OR NEW.kode_akun NOT REGEXP '^[0-9]{4}$' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Kode akun wajib terdiri dari 4 angka'; END IF; END"); + } + + public function down() + { + throw new RuntimeException('Gunakan account_code_change_logs dan backup database untuk rollback kode akun.'); + } +} diff --git a/application/migrations/20260909000100_extend_invoice_financial_lines.php b/application/migrations/20260909000100_extend_invoice_financial_lines.php new file mode 100644 index 0000000..a4da28c --- /dev/null +++ b/application/migrations/20260909000100_extend_invoice_financial_lines.php @@ -0,0 +1,42 @@ +db->table_exists($table)&&!$this->db->field_exists($column,$table)) + $this->db->query("ALTER TABLE `{$table}` ADD `{$column}` {$definition}"); + } + + private function index($table,$name,$columns) + { + if(!$this->db->table_exists($table))return; + foreach($this->db->query("SHOW INDEX FROM `{$table}`")->result()as$i)if($i->Key_name===$name)return; + $this->db->query("ALTER TABLE `{$table}` ADD KEY `{$name}` ({$columns})"); + } + + public function up() + { + if($this->db->table_exists('invoice_details')&&$this->db->field_exists('line_type','invoice_details')) + $this->db->query("ALTER TABLE `invoice_details` MODIFY `line_type` ENUM('inventory','service','loan','savings') NOT NULL DEFAULT 'inventory'"); + + $this->column('invoice_details','counter_account_id','INT NULL AFTER `account_id`'); + $this->index('invoice_details','idx_invoice_line_counter_account','`company_id`,`counter_account_id`'); + + if($this->db->table_exists('invoice_line_barcodes')){ + foreach($this->db->query("SHOW INDEX FROM `invoice_line_barcodes`")->result()as$i){ + if($i->Key_name==='uq_invoice_line_barcode'){ + $this->db->query('ALTER TABLE `invoice_line_barcodes` DROP INDEX `uq_invoice_line_barcode`'); + break; + } + } + $this->db->query('ALTER TABLE `invoice_line_barcodes` ADD UNIQUE KEY `uq_invoice_line_barcode` (`invoice_detail_id`,`barcode_id`)'); + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback perluasan kategori item invoice.'); + } +} diff --git a/application/migrations/20260909000200_create_account_budget_entries.php b/application/migrations/20260909000200_create_account_budget_entries.php new file mode 100644 index 0000000..61bf1dc --- /dev/null +++ b/application/migrations/20260909000200_create_account_budget_entries.php @@ -0,0 +1,62 @@ +db->table_exists('account_budget_entries')) { + $this->db->query("CREATE TABLE `account_budget_entries` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `account_id` INT NOT NULL, + `period` CHAR(7) NOT NULL, + `entry_type` ENUM('recurring','override') NOT NULL DEFAULT 'recurring', + `amount` DECIMAL(18,2) NOT NULL DEFAULT 0, + `notes` VARCHAR(255) NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL, + `updated_by` INT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_account_budget_entry` (`company_id`,`account_id`,`period`,`entry_type`), + KEY `idx_account_budget_period` (`company_id`,`period`,`account_id`,`is_active`), + CONSTRAINT `fk_account_budget_account` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + } + + // Pertahankan rincian budget aktif lama sebagai nilai khusus per bulan. + if ($this->db->table_exists('budgets') && $this->db->table_exists('budget_lines')) { + $this->db->query("INSERT IGNORE INTO account_budget_entries + (company_id,account_id,period,entry_type,amount,notes,is_active,created_by,created_at) + SELECT COALESCE(bl.company_id,b.company_id),bl.account_id,bl.period,'override',bl.amount, + CONCAT('Migrasi dari ',b.budget_no),1,b.created_by,COALESCE(bl.created_at,b.created_at,NOW()) + FROM budget_lines bl + JOIN budgets b ON b.id=bl.budget_id + WHERE b.status IN('active','approved') AND COALESCE(bl.company_id,b.company_id) IS NOT NULL"); + + // Nilai bulan terakhir sampai bulan migrasi menjadi dasar berulang ke depan. + $currentPeriod = date('Y-m'); + $this->db->query("INSERT IGNORE INTO account_budget_entries + (company_id,account_id,period,entry_type,amount,notes,is_active,created_by,created_at) + SELECT COALESCE(bl.company_id,b.company_id),bl.account_id,?,'recurring',bl.amount, + CONCAT('Budget berulang hasil migrasi ',b.budget_no),1,b.created_by,NOW() + FROM budget_lines bl + JOIN budgets b ON b.id=bl.budget_id + JOIN ( + SELECT COALESCE(bl2.company_id,b2.company_id) company_id,bl2.account_id,MAX(bl2.period) period + FROM budget_lines bl2 JOIN budgets b2 ON b2.id=bl2.budget_id + WHERE b2.status IN('active','approved') AND bl2.period<=? + GROUP BY COALESCE(bl2.company_id,b2.company_id),bl2.account_id + ) latest ON latest.company_id=COALESCE(bl.company_id,b.company_id) + AND latest.account_id=bl.account_id AND latest.period=bl.period + WHERE b.status IN('active','approved')", array($currentPeriod, $currentPeriod)); + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback data budget per akun.'); + } +} diff --git a/application/migrations/20260910000100_expand_budget_schedules_and_reminders.php b/application/migrations/20260910000100_expand_budget_schedules_and_reminders.php new file mode 100644 index 0000000..17b672e --- /dev/null +++ b/application/migrations/20260910000100_expand_budget_schedules_and_reminders.php @@ -0,0 +1,53 @@ +db->query("SHOW INDEX FROM `{$table}`")->result() as $index) { + if ($index->Key_name === $name) return true; + } + return false; + } + + public function up() + { + if (!$this->db->field_exists('title', 'account_budget_entries')) + $this->db->query("ALTER TABLE `account_budget_entries` ADD `title` VARCHAR(150) NOT NULL DEFAULT 'Budget' AFTER `account_id`"); + if (!$this->db->field_exists('reminder_enabled', 'account_budget_entries')) + $this->db->query("ALTER TABLE `account_budget_entries` ADD `reminder_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `amount`"); + if (!$this->db->field_exists('reminder_day', 'account_budget_entries')) + $this->db->query("ALTER TABLE `account_budget_entries` ADD `reminder_day` TINYINT UNSIGNED NULL AFTER `reminder_enabled`"); + + $this->db->query("UPDATE account_budget_entries e JOIN accounts a ON a.id=e.account_id + SET e.title=CASE WHEN NULLIF(TRIM(e.notes),'') IS NOT NULL THEN LEFT(e.notes,150) ELSE CONCAT('Budget ',a.nama_akun) END + WHERE e.title='Budget' OR TRIM(e.title)=''"); + + // Satu akun dan periode boleh mempunyai banyak jadwal budget yang berbeda. + if ($this->hasIndex('account_budget_entries', 'uq_account_budget_entry')) + $this->db->query('ALTER TABLE `account_budget_entries` DROP INDEX `uq_account_budget_entry`'); + if (!$this->hasIndex('account_budget_entries', 'idx_account_budget_schedule')) + $this->db->query('ALTER TABLE `account_budget_entries` ADD KEY `idx_account_budget_schedule` (`company_id`,`account_id`,`period`,`entry_type`,`is_active`)'); + + if (!$this->db->table_exists('account_budget_reminder_completions')) { + $this->db->query("CREATE TABLE `account_budget_reminder_completions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` BIGINT UNSIGNED NOT NULL, + `budget_entry_id` BIGINT UNSIGNED NOT NULL, + `period` CHAR(7) NOT NULL, + `completed_by` INT NULL, + `completed_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_budget_reminder_completion` (`company_id`,`budget_entry_id`,`period`), + KEY `idx_budget_reminder_period` (`company_id`,`period`,`budget_entry_id`), + CONSTRAINT `fk_budget_reminder_entry` FOREIGN KEY (`budget_entry_id`) REFERENCES `account_budget_entries` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup database untuk rollback jadwal dan pengingat budget.'); + } +} diff --git a/application/migrations/20260910000200_complete_technician_item_usage.php b/application/migrations/20260910000200_complete_technician_item_usage.php new file mode 100644 index 0000000..a69ff58 --- /dev/null +++ b/application/migrations/20260910000200_complete_technician_item_usage.php @@ -0,0 +1,40 @@ +db->table_exists('item_technician')) return; + + $columns = array( + 'usage_status' => "VARCHAR(30) NOT NULL DEFAULT 'carried' AFTER status", + 'usage_date' => 'DATE NULL AFTER usage_status', + 'customer_id' => 'BIGINT UNSIGNED NULL AFTER usage_date', + 'usage_notes' => 'TEXT NULL AFTER customer_id', + 'updated_by' => 'BIGINT UNSIGNED NULL AFTER usage_notes', + 'updated_at' => 'DATETIME NULL AFTER updated_by' + ); + foreach ($columns as $name => $definition) { + if (!$this->db->field_exists($name, 'item_technician')) { + $this->db->query('ALTER TABLE item_technician ADD `'.$name.'` '.$definition); + } + } + + $this->db->query("UPDATE item_technician SET usage_status='carried' WHERE usage_status IS NULL OR usage_status='' OR usage_status NOT IN('carried','installed')"); + $index = $this->db->query("SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='item_technician' AND index_name='idx_item_technician_usage' LIMIT 1")->row(); + if (!$index) { + $this->db->query('ALTER TABLE item_technician ADD KEY idx_item_technician_usage(company_id,user_id,status,usage_status,customer_id,usage_date)'); + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup untuk mengembalikan metadata penggunaan barang teknisi.'); + } +} diff --git a/application/migrations/20260910000300_expand_technician_item_status_flow.php b/application/migrations/20260910000300_expand_technician_item_status_flow.php new file mode 100644 index 0000000..c8bda9f --- /dev/null +++ b/application/migrations/20260910000300_expand_technician_item_status_flow.php @@ -0,0 +1,82 @@ +db->table_exists('item_technician')) return; + + $columns = array( + 'assigned_qty' => "DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER usage_status", + 'carried_qty' => "DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER assigned_qty", + 'installed_qty' => "DECIMAL(18,4) NOT NULL DEFAULT 0 AFTER carried_qty" + ); + foreach ($columns as $name => $definition) { + if (!$this->db->field_exists($name, 'item_technician')) { + $this->db->query('ALTER TABLE item_technician ADD `'.$name.'` '.$definition); + } + } + + $this->db->query( + "UPDATE item_technician it + JOIN item_barcodes ib ON ib.barcode=it.barcode + SET it.assigned_qty=CASE WHEN it.status='active' THEN GREATEST(ib.qty_sisa,0) ELSE 0 END, + it.carried_qty=CASE WHEN it.status='active' AND COALESCE(it.usage_status,'carried')<>'installed' THEN GREATEST(ib.qty_sisa,0) ELSE 0 END, + it.installed_qty=CASE WHEN it.status='active' AND it.usage_status='installed' THEN GREATEST(ib.qty_sisa,0) ELSE 0 END + WHERE it.assigned_qty=0 AND it.carried_qty=0 AND it.installed_qty=0" + ); + + $this->db->query( + "CREATE TABLE IF NOT EXISTS technician_item_status_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + company_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + item_id BIGINT UNSIGNED NOT NULL, + barcode_id BIGINT UNSIGNED NOT NULL, + barcode VARCHAR(191) NOT NULL, + from_status VARCHAR(30) NOT NULL, + to_status VARCHAR(30) NOT NULL, + qty DECIMAL(18,4) NOT NULL, + customer_id BIGINT UNSIGNED NULL, + event_date DATE NOT NULL, + notes TEXT NOT NULL, + attachment_id BIGINT UNSIGNED NULL, + idempotency_key VARCHAR(100) NULL, + created_by BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_technician_status_idempotency (company_id,idempotency_key), + KEY idx_technician_status_history (company_id,user_id,event_date), + KEY idx_technician_status_barcode (company_id,barcode_id,created_at), + KEY idx_technician_status_customer (company_id,customer_id,event_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + $hasInitial = $this->db->query("SELECT 1 FROM technician_item_status_logs LIMIT 1")->row(); + if (!$hasInitial) { + $this->db->query( + "INSERT INTO technician_item_status_logs + (company_id,user_id,item_id,barcode_id,barcode,from_status,to_status,qty,customer_id,event_date,notes,created_by,created_at) + SELECT COALESCE(it.company_id,i.company_id,1),it.user_id,it.item_id,ib.id,it.barcode,'initial', + CASE WHEN it.status='inactive' THEN 'returned' ELSE COALESCE(it.usage_status,'carried') END, + CASE WHEN it.status='inactive' THEN 0 ELSE GREATEST(ib.qty_sisa,0) END, + it.customer_id,DATE(COALESCE(it.usage_date,it.created_at,NOW())), + 'Saldo awal riwayat peralatan teknisi',it.updated_by,COALESCE(it.updated_at,it.created_at,NOW()) + FROM item_technician it + JOIN item_barcodes ib ON ib.barcode=it.barcode + JOIN items i ON i.id=it.item_id" + ); + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup untuk mengembalikan perluasan status peralatan teknisi.'); + } +} diff --git a/application/migrations/20260911000100_finalize_technician_equipment_operations.php b/application/migrations/20260911000100_finalize_technician_equipment_operations.php new file mode 100644 index 0000000..4f6e037 --- /dev/null +++ b/application/migrations/20260911000100_finalize_technician_equipment_operations.php @@ -0,0 +1,96 @@ +db->query("CREATE TABLE IF NOT EXISTS customer_equipment_registry( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL, + owner_customer_id BIGINT UNSIGNED NULL,current_customer_id BIGINT UNSIGNED NULL, + equipment_name VARCHAR(150) NOT NULL,category VARCHAR(100) NULL,brand VARCHAR(100) NULL,model VARCHAR(100) NULL, + external_barcode VARCHAR(120) NOT NULL,serial_number VARCHAR(120) NULL,description TEXT NULL, + ownership_type VARCHAR(40) NOT NULL DEFAULT 'customer',deployment_allowed TINYINT(1) NOT NULL DEFAULT 0, + authorization_reference VARCHAR(150) NULL,tracking_type VARCHAR(10) NOT NULL DEFAULT 'UNIT',total_qty DECIMAL(18,4) NOT NULL DEFAULT 1, + custody_type VARCHAR(30) NOT NULL DEFAULT 'customer',custody_id BIGINT UNSIGNED NULL,usage_status VARCHAR(30) NOT NULL DEFAULT 'installed', + condition_status VARCHAR(30) NOT NULL DEFAULT 'good',lifecycle_status VARCHAR(30) NOT NULL DEFAULT 'active',version INT NOT NULL DEFAULT 1, + registered_by BIGINT UNSIGNED NULL,registered_at DATETIME NOT NULL,updated_by BIGINT UNSIGNED NULL,updated_at DATETIME NULL, + UNIQUE KEY uq_customer_equipment_barcode(company_id,external_barcode),KEY idx_customer_equipment_owner(company_id,owner_customer_id,lifecycle_status), + KEY idx_customer_equipment_location(company_id,custody_type,custody_id,condition_status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("CREATE TABLE IF NOT EXISTS technician_equipment_documents( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,document_no VARCHAR(70) NOT NULL, + document_type VARCHAR(40) NOT NULL,document_date DATE NOT NULL,status VARCHAR(20) NOT NULL DEFAULT 'posted', + customer_id BIGINT UNSIGNED NULL,technician_id BIGINT UNSIGNED NULL,target_technician_id BIGINT UNSIGNED NULL,warehouse_id BIGINT UNSIGNED NULL, + work_order_no VARCHAR(100) NULL,reason VARCHAR(255) NULL,notes TEXT NULL,reversal_of_id BIGINT UNSIGNED NULL, + idempotency_key VARCHAR(120) NOT NULL,created_by BIGINT UNSIGNED NOT NULL,created_at DATETIME NOT NULL,reversed_by BIGINT UNSIGNED NULL,reversed_at DATETIME NULL, + UNIQUE KEY uq_technician_document_no(company_id,document_no),UNIQUE KEY uq_technician_document_idem(company_id,idempotency_key), + KEY idx_technician_document(company_id,document_date,document_type,status),KEY idx_technician_document_people(company_id,technician_id,customer_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("CREATE TABLE IF NOT EXISTS technician_custody_allocations( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL, + subject_type VARCHAR(30) NOT NULL,subject_id BIGINT UNSIGNED NOT NULL,item_id BIGINT UNSIGNED NULL,barcode_id BIGINT UNSIGNED NULL, + customer_equipment_id BIGINT UNSIGNED NULL,barcode_snapshot VARCHAR(191) NOT NULL,ownership_type VARCHAR(40) NOT NULL, + custodian_type VARCHAR(30) NOT NULL,custodian_id BIGINT UNSIGNED NULL,customer_id BIGINT UNSIGNED NULL, + usage_status VARCHAR(30) NOT NULL,condition_status VARCHAR(30) NOT NULL DEFAULT 'good',qty DECIMAL(18,4) NOT NULL, + balance_key CHAR(64) NOT NULL,version INT NOT NULL DEFAULT 1,last_document_id BIGINT UNSIGNED NULL,created_at DATETIME NOT NULL,updated_at DATETIME NOT NULL, + UNIQUE KEY uq_technician_allocation(balance_key),KEY idx_technician_allocation_subject(company_id,subject_type,subject_id), + KEY idx_technician_allocation_holder(company_id,custodian_type,custodian_id,usage_status), + KEY idx_technician_allocation_customer(company_id,customer_id,condition_status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("CREATE TABLE IF NOT EXISTS technician_equipment_document_lines( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,document_id BIGINT UNSIGNED NOT NULL,company_id BIGINT UNSIGNED NOT NULL, + subject_type VARCHAR(30) NOT NULL,subject_id BIGINT UNSIGNED NOT NULL,item_id BIGINT UNSIGNED NULL,barcode_id BIGINT UNSIGNED NULL, + customer_equipment_id BIGINT UNSIGNED NULL,barcode_snapshot VARCHAR(191) NOT NULL,ownership_type VARCHAR(40) NOT NULL,tracking_type VARCHAR(10) NOT NULL, + action_type VARCHAR(40) NOT NULL,from_custodian_type VARCHAR(30) NULL,from_custodian_id BIGINT UNSIGNED NULL, + to_custodian_type VARCHAR(30) NULL,to_custodian_id BIGINT UNSIGNED NULL,customer_id BIGINT UNSIGNED NULL, + from_usage_status VARCHAR(30) NULL,to_usage_status VARCHAR(30) NULL,from_condition_status VARCHAR(30) NULL,to_condition_status VARCHAR(30) NULL, + qty DECIMAL(18,4) NOT NULL,item_movement_id BIGINT UNSIGNED NULL,notes TEXT NULL,created_at DATETIME NOT NULL, + KEY idx_technician_line_document(document_id),KEY idx_technician_line_subject(company_id,subject_type,subject_id), + KEY idx_technician_line_movement(item_movement_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $this->db->query("CREATE TABLE IF NOT EXISTS technician_equipment_inspections( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,company_id BIGINT UNSIGNED NOT NULL,document_id BIGINT UNSIGNED NOT NULL, + source_allocation_id BIGINT UNSIGNED NULL,subject_type VARCHAR(30) NOT NULL,subject_id BIGINT UNSIGNED NOT NULL, + result_status VARCHAR(30) NOT NULL,qty DECIMAL(18,4) NOT NULL,inspection_date DATE NOT NULL,notes TEXT NOT NULL, + inspected_by BIGINT UNSIGNED NOT NULL,created_at DATETIME NOT NULL, + KEY idx_technician_inspection_queue(company_id,result_status,inspection_date),KEY idx_technician_inspection_subject(company_id,subject_type,subject_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + if ($this->db->table_exists('item_technician') && $this->db->table_exists('item_barcodes')) { + $this->db->query("INSERT IGNORE INTO technician_custody_allocations + (company_id,subject_type,subject_id,item_id,barcode_id,customer_equipment_id,barcode_snapshot,ownership_type,custodian_type,custodian_id,customer_id,usage_status,condition_status,qty,balance_key,version,created_at,updated_at) + SELECT COALESCE(it.company_id,i.company_id,1),'company_barcode',ib.id,it.item_id,ib.id,NULL,it.barcode,'company','technician',it.user_id,NULL,'carried','good',it.carried_qty, + SHA2(CONCAT_WS('|',COALESCE(it.company_id,i.company_id,1),'company_barcode',ib.id,'technician',it.user_id,0,'carried','good'),256),1,COALESCE(it.created_at,NOW()),NOW() + FROM item_technician it JOIN item_barcodes ib ON ib.barcode=it.barcode JOIN items i ON i.id=it.item_id + WHERE it.status='active' AND it.carried_qty>0"); + $this->db->query("INSERT IGNORE INTO technician_custody_allocations + (company_id,subject_type,subject_id,item_id,barcode_id,customer_equipment_id,barcode_snapshot,ownership_type,custodian_type,custodian_id,customer_id,usage_status,condition_status,qty,balance_key,version,created_at,updated_at) + SELECT COALESCE(it.company_id,i.company_id,1),'company_barcode',ib.id,it.item_id,ib.id,NULL,it.barcode,'company','customer',it.customer_id,it.customer_id,'installed','good',it.installed_qty, + SHA2(CONCAT_WS('|',COALESCE(it.company_id,i.company_id,1),'company_barcode',ib.id,'customer',COALESCE(it.customer_id,0),COALESCE(it.customer_id,0),'installed','good'),256),1,COALESCE(it.created_at,NOW()),NOW() + FROM item_technician it JOIN item_barcodes ib ON ib.barcode=it.barcode JOIN items i ON i.id=it.item_id + WHERE it.status='active' AND it.installed_qty>0 AND it.customer_id IS NOT NULL"); + $this->db->query("UPDATE item_barcodes ib JOIN(SELECT barcode_id,SUM(qty) qty FROM technician_custody_allocations WHERE subject_type='company_barcode' GROUP BY barcode_id)x ON x.barcode_id=ib.id SET ib.reserved_qty=LEAST(ib.qty_sisa,GREATEST(ib.reserved_qty,x.qty)),ib.status=CASE WHEN ib.qty_sisa-GREATEST(ib.reserved_qty,x.qty)>.0001 THEN 'available' ELSE 'installed' END,ib.version=ib.version+1 WHERE ib.qty_sisa>0"); + } + + if ($this->db->table_exists('roles')) { + $roles=$this->db->get('roles')->result(); + $features=array('technician_equipment','technician_handover','technician_installation','technician_transfer','technician_return','technician_inspection','technician_correction'); + foreach($roles as$role){$permissions=json_decode((string)$role->permissions,true);if(!is_array($permissions)||!isset($permissions['items']))continue;foreach($features as$feature)if(!isset($permissions[$feature]))$permissions[$feature]=$permissions['items'];$this->db->where('id',$role->id)->update('roles',array('permissions'=>json_encode($permissions),'updated_at'=>date('Y-m-d H:i:s')));} + } + } + + public function down() + { + throw new RuntimeException('Gunakan backup untuk rollback subledger Peralatan Teknisi.'); + } +} diff --git a/application/migrations/20260911000200_link_application_users_to_employees.php b/application/migrations/20260911000200_link_application_users_to_employees.php new file mode 100644 index 0000000..74f1365 --- /dev/null +++ b/application/migrations/20260911000200_link_application_users_to_employees.php @@ -0,0 +1,16 @@ +db->table_exists('users')&&!$this->db->field_exists('employee_id','users')){ + $this->load->dbforge();$this->dbforge->add_column('users',array('employee_id'=>array('type'=>'INT','constraint'=>11,'unsigned'=>true,'null'=>true,'after'=>'role_id'))); + $this->db->query('CREATE INDEX idx_users_employee ON users(employee_id)'); + } + if($this->db->table_exists('roles'))foreach($this->db->get('roles')->result()as$role){$permissions=json_decode((string)$role->permissions,true);if(!is_array($permissions))$permissions=array();$name=strtolower((string)$role->nama_role);if((int)($role->is_super_admin??0)===1||(strpos($name,'teknisi')===false&&isset($permissions['items'])&&in_array('can_view',(array)$permissions['items'],true))){if(!isset($permissions['technician_equipment_all']))$permissions['technician_equipment_all']=array('can_view');$this->db->where('id',$role->id)->update('roles',array('permissions'=>json_encode($permissions),'updated_at'=>date('Y-m-d H:i:s')));}} + } + public function down(){throw new RuntimeException('Kolom tautan karyawan dipertahankan untuk keamanan histori.');} +} diff --git a/application/migrations/20260911000300_add_customer_equipment_source_fields.php b/application/migrations/20260911000300_add_customer_equipment_source_fields.php new file mode 100644 index 0000000..8b87f83 --- /dev/null +++ b/application/migrations/20260911000300_add_customer_equipment_source_fields.php @@ -0,0 +1,7 @@ +db->table_exists('customer_equipment_registry'))return;$this->load->dbforge();$fields=array();if(!$this->db->field_exists('source_date','customer_equipment_registry'))$fields['source_date']=array('type'=>'DATE','null'=>true,'after'=>'description');if(!$this->db->field_exists('source_document_no','customer_equipment_registry'))$fields['source_document_no']=array('type'=>'VARCHAR','constraint'=>150,'null'=>true,'after'=>'source_date');if(!$this->db->field_exists('source_reason','customer_equipment_registry'))$fields['source_reason']=array('type'=>'VARCHAR','constraint'=>255,'null'=>true,'after'=>'source_document_no');if($fields)$this->dbforge->add_column('customer_equipment_registry',$fields);} + public function down(){throw new RuntimeException('Kolom sumber register dipertahankan untuk audit.');} +} diff --git a/application/migrations/20260911000400_add_technician_document_location.php b/application/migrations/20260911000400_add_technician_document_location.php new file mode 100644 index 0000000..dc335dd --- /dev/null +++ b/application/migrations/20260911000400_add_technician_document_location.php @@ -0,0 +1,7 @@ +db->table_exists('technician_equipment_documents')&&!$this->db->field_exists('installation_location','technician_equipment_documents')){$this->load->dbforge();$this->dbforge->add_column('technician_equipment_documents',array('installation_location'=>array('type'=>'VARCHAR','constraint'=>255,'null'=>true,'after'=>'work_order_no')));}} + public function down(){throw new RuntimeException('Lokasi pemasangan dipertahankan untuk audit.');} +} diff --git a/application/migrations/20260911000500_add_technician_condition_permission.php b/application/migrations/20260911000500_add_technician_condition_permission.php new file mode 100644 index 0000000..eaa06ea --- /dev/null +++ b/application/migrations/20260911000500_add_technician_condition_permission.php @@ -0,0 +1,7 @@ +db->table_exists('roles'))return;foreach($this->db->get('roles')->result()as$role){$p=json_decode((string)$role->permissions,true);if(!is_array($p)||isset($p['technician_condition']))continue;$source=$p['technician_inspection']??($p['items']??array());$allowed=array_values(array_intersect((array)$source,array('can_view','can_update','can_post')));if($allowed){$p['technician_condition']=$allowed;$this->db->where('id',$role->id)->update('roles',array('permissions'=>json_encode($p),'updated_at'=>date('Y-m-d H:i:s')));}}} + public function down(){throw new RuntimeException('Permission kondisi dipertahankan.');} +} diff --git a/application/migrations/20260911000600_document_technician_opening_balances.php b/application/migrations/20260911000600_document_technician_opening_balances.php new file mode 100644 index 0000000..4601682 --- /dev/null +++ b/application/migrations/20260911000600_document_technician_opening_balances.php @@ -0,0 +1,15 @@ +db->table_exists('technician_custody_allocations')||!$this->db->table_exists('technician_equipment_documents'))return;$user=$this->db->select_min('id')->get('users')->row();$userId=(int)($user?$user->id:0);if($userId<1)return; + foreach($this->db->select('company_id')->where('last_document_id IS NULL',null,false)->group_by('company_id')->get('technician_custody_allocations')->result()as$group){$company=(int)$group->company_id;$key='MIGRATION-TECHNICIAN-OPENING-'.$company;$doc=$this->db->get_where('technician_equipment_documents',array('company_id'=>$company,'idempotency_key'=>$key))->row();if(!$doc){$row=array('company_id'=>$company,'document_no'=>'TEQ-OPEN-'.$company.'-20260911','document_type'=>'opening','document_date'=>'2026-09-11','status'=>'posted','notes'=>'Saldo awal migrasi dari item_technician. Tidak membuat ulang item_movements, stock_logs, inventory_ledger, atau jurnal.','idempotency_key'=>$key,'created_by'=>$userId,'created_at'=>date('Y-m-d H:i:s'));$this->db->insert('technician_equipment_documents',$row);$doc=(object)array_merge($row,array('id'=>(int)$this->db->insert_id()));} + $allocations=$this->db->where(array('company_id'=>$company,'last_document_id'=>null))->get('technician_custody_allocations')->result_array();foreach($allocations as$a){$exists=$this->db->where(array('document_id'=>$doc->id,'subject_type'=>$a['subject_type'],'subject_id'=>$a['subject_id'],'to_custodian_type'=>$a['custodian_type'],'to_custodian_id'=>$a['custodian_id'],'to_usage_status'=>$a['usage_status'],'to_condition_status'=>$a['condition_status']))->get('technician_equipment_document_lines')->row();if(!$exists)$this->db->insert('technician_equipment_document_lines',array('document_id'=>$doc->id,'company_id'=>$company,'subject_type'=>$a['subject_type'],'subject_id'=>$a['subject_id'],'item_id'=>$a['item_id'],'barcode_id'=>$a['barcode_id'],'customer_equipment_id'=>$a['customer_equipment_id'],'barcode_snapshot'=>$a['barcode_snapshot'],'ownership_type'=>$a['ownership_type'],'tracking_type'=>$this->trackingType($a),'action_type'=>'opening','from_custodian_type'=>null,'from_custodian_id'=>null,'to_custodian_type'=>$a['custodian_type'],'to_custodian_id'=>$a['custodian_id'],'customer_id'=>$a['customer_id'],'from_usage_status'=>null,'to_usage_status'=>$a['usage_status'],'from_condition_status'=>null,'to_condition_status'=>$a['condition_status'],'qty'=>$a['qty'],'item_movement_id'=>null,'notes'=>'Saldo awal migrasi; movement lama tidak digandakan.','created_at'=>date('Y-m-d H:i:s')));$this->db->where(array('id'=>$a['id'],'last_document_id'=>null))->update('technician_custody_allocations',array('last_document_id'=>$doc->id,'updated_at'=>date('Y-m-d H:i:s')));}} + } + private function trackingType(array$a){if($a['subject_type']==='customer_equipment'){$r=$this->db->select('tracking_type')->get_where('customer_equipment_registry',array('id'=>$a['subject_id']))->row();return$r?$r->tracking_type:'UNIT';}$r=$this->db->select("COALESCE(k.tracking_type,'QTY') tracking_type",false)->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where('i.id',$a['item_id'])->get()->row();return$r?$r->tracking_type:'QTY';} + public function down(){throw new RuntimeException('Dokumen saldo awal dipertahankan untuk audit.');} +} diff --git a/application/migrations/20260911000700_add_technician_attachment_permission.php b/application/migrations/20260911000700_add_technician_attachment_permission.php new file mode 100644 index 0000000..1ff3336 --- /dev/null +++ b/application/migrations/20260911000700_add_technician_attachment_permission.php @@ -0,0 +1,27 @@ +db->table_exists('roles')) return; + foreach ($this->db->get('roles')->result() as $role) { + $permissions = json_decode((string) $role->permissions, true); + if (!is_array($permissions) || isset($permissions['technician_attachment'])) continue; + $source = $permissions['technician_equipment'] ?? ($permissions['items'] ?? array()); + if (in_array('can_view', (array) $source, true)) { + $permissions['technician_attachment'] = array('can_view'); + $this->db->where('id', $role->id)->update('roles', array( + 'permissions' => json_encode($permissions), + 'updated_at' => date('Y-m-d H:i:s') + )); + } + } + } + + public function down() + { + throw new RuntimeException('Permission lampiran peralatan teknisi dipertahankan.'); + } +} diff --git a/application/models/AccountModel.php b/application/models/AccountModel.php new file mode 100644 index 0000000..e381c81 --- /dev/null +++ b/application/models/AccountModel.php @@ -0,0 +1,21 @@ +db + ->select('id') + ->where_in('id', array_values(array_unique($ids))) + ->where('is_active', 1) + ->where('allow_posting', 1) + ->get('accounts') + ->result_array(); + return array_map('intval', array_column($rows, 'id')); + } +} diff --git a/application/models/FiscalPeriodModel.php b/application/models/FiscalPeriodModel.php new file mode 100644 index 0000000..9e32d21 --- /dev/null +++ b/application/models/FiscalPeriodModel.php @@ -0,0 +1,31 @@ +db->get_where('accounting_periods', array('id' => (int) $id))->row(); + } + + public function findByDate($date) + { + return $this->db + ->where('start_date <=', $date) + ->where('end_date >=', $date) + ->limit(1) + ->get('accounting_periods') + ->row(); + } + + public function allWithFiscalYear() + { + return $this->db + ->select('accounting_periods.*, fiscal_years.name fiscal_year_name') + ->from('accounting_periods') + ->join('fiscal_years', 'fiscal_years.id = accounting_periods.fiscal_year_id') + ->order_by('accounting_periods.start_date', 'DESC') + ->get() + ->result(); + } +} diff --git a/application/models/FixedAssetModel.php b/application/models/FixedAssetModel.php new file mode 100644 index 0000000..e1ba2fd --- /dev/null +++ b/application/models/FixedAssetModel.php @@ -0,0 +1,143 @@ +db->query("SELECT + COUNT(*) total_assets, + SUM(workflow_status='capitalized' AND lifecycle_status IN('active','fully_depreciated')) active_assets, + COALESCE(SUM(IF(workflow_status='capitalized' AND lifecycle_status NOT IN('disposed','lost','damaged'),nilai_perolehan,0)),0) acquisition_cost, + COALESCE(SUM(IF(workflow_status='capitalized' AND lifecycle_status NOT IN('disposed','lost','damaged'),akumulasi_penyusutan,0)),0) accumulated_depreciation, + COALESCE(SUM(IF(workflow_status='capitalized' AND lifecycle_status NOT IN('disposed','lost','damaged'),nilai_buku,0)),0) book_value, + SUM(lifecycle_status='fully_depreciated') fully_depreciated, + SUM(workflow_status IN('submitted','approved')) pending_workflow + FROM assets WHERE company_id=?", array((int) $companyId))->row(); + $row->maintenance_due = (int) $this->db->where('company_id', (int) $companyId)->where('status', 'posted')->where('next_due_date IS NOT NULL', null, false)->where('next_due_date <=', date('Y-m-d', strtotime('+30 days')))->count_all_results('asset_maintenance'); + $row->open_opnames = (int) $this->db->where('company_id', (int) $companyId)->where_in('status', array('draft','submitted','approved'))->count_all_results('asset_opnames'); + $row->depreciation_due = (int) $this->db->where('company_id', (int) $companyId)->where('workflow_status', 'capitalized')->where('lifecycle_status', 'active')->where('nilai_buku > nilai_residu', null, false)->count_all_results('assets'); + return $row; + } + + private function registerQuery($companyId, array $filters, $search = '') + { + $this->db->select('a.id,a.document_no,a.kode_asset,a.nama_asset,a.tanggal_perolehan,a.capitalization_date,a.source_type,a.workflow_status,a.lifecycle_status,a.nilai_perolehan,a.akumulasi_penyusutan,a.nilai_buku,a.nilai_residu,a.masa_manfaat,a.qty,a.qr_code,c.name category_name,l.nama location_name,e.full_name responsible_name,i.kode_detail,b.barcode,b.serial_number'); + $this->db->from('assets a'); + $this->db->join('asset_categories c', 'c.id=a.category_id', 'left'); + $this->db->join('lokasi_asset l', 'l.id=a.lokasi_asset_id', 'left'); + $this->db->join('k_employees e', 'e.id=a.responsible_employee_id', 'left'); + $this->db->join('items i', 'i.id=a.item_id', 'left'); + $this->db->join('item_barcodes b', 'b.id=CAST(a.barcode_id AS UNSIGNED)', 'left', false); + $this->db->where('a.company_id', (int) $companyId); + foreach (array('category_id','lokasi_asset_id','source_type','workflow_status','lifecycle_status','responsible_employee_id') as $field) { + if (!empty($filters[$field])) $this->db->where('a.'.$field, $filters[$field]); + } + if (!empty($filters['date_from'])) $this->db->where('a.tanggal_perolehan >=', $filters['date_from']); + if (!empty($filters['date_to'])) $this->db->where('a.tanggal_perolehan <=', $filters['date_to']); + if ($search !== '') { + $this->db->group_start()->like('a.document_no', $search)->or_like('a.kode_asset', $search)->or_like('a.nama_asset', $search)->or_like('c.name', $search)->or_like('l.nama', $search)->or_like('e.full_name', $search)->or_like('b.barcode', $search)->or_like('b.serial_number', $search)->group_end(); + } + } + + public function registerPage($companyId, array $filters, $start, $length, $search) + { + $this->registerQuery($companyId, $filters, $search); + $filtered = (int) $this->db->count_all_results('', false); + $rows = $this->db->order_by('a.id', 'DESC')->limit((int) $length, (int) $start)->get()->result(); + $total = (int) $this->db->where('company_id', (int) $companyId)->count_all_results('assets'); + return array('rows'=>$rows,'total'=>$total,'filtered'=>$filtered); + } + + public function detail($id, $companyId) + { + $asset = $this->db->select('a.*,c.name category_name,c.code category_code,l.nama location_name,l.code location_code,e.full_name responsible_name,i.kode_detail,i.nama_barang,w.nama warehouse_name,bin.code bin_code,b.barcode,b.serial_number,uc.nama creator_name,us.nama submitter_name,ua.nama approver_name,up.nama capitalizer_name,j.no_ref source_journal_no,aj.no_ref acquisition_journal_no') + ->from('assets a')->join('asset_categories c','c.id=a.category_id','left')->join('lokasi_asset l','l.id=a.lokasi_asset_id','left')->join('k_employees e','e.id=a.responsible_employee_id','left')->join('items i','i.id=a.item_id','left')->join('warehouses w','w.id=a.warehouse_id','left')->join('warehouse_bins bin','bin.id=a.bin_id','left')->join('item_barcodes b','b.id=CAST(a.barcode_id AS UNSIGNED)','left',false)->join('users uc','uc.id=a.created_by','left')->join('users us','us.id=a.submitted_by','left')->join('users ua','ua.id=a.approved_by','left')->join('users up','up.id=a.capitalized_by','left')->join('journals j','j.id=a.source_journal_id','left')->join('journals aj','aj.id=a.acquisition_journal_id','left')->where(array('a.id'=>(int)$id,'a.company_id'=>(int)$companyId))->get()->row(); + if (!$asset) return null; + $asset->events = $this->db->select('ev.*,u.nama user_name,fl.nama from_location,tl.nama to_location,j.no_ref journal_no')->from('asset_events ev')->join('users u','u.id=ev.created_by','left')->join('lokasi_asset fl','fl.id=ev.from_location_id','left')->join('lokasi_asset tl','tl.id=ev.to_location_id','left')->join('journals j','j.id=ev.journal_id','left')->where(array('ev.asset_id'=>$asset->id,'ev.company_id'=>(int)$companyId))->order_by('ev.event_date','DESC')->order_by('ev.id','DESC')->get()->result(); + $asset->schedule = $this->db->where(array('asset_id'=>$asset->id,'company_id'=>(int)$companyId))->order_by('period','DESC')->get('asset_depreciation_schedule')->result(); + $asset->maintenance = $this->db->where(array('asset_id'=>$asset->id,'company_id'=>(int)$companyId))->order_by('maintenance_date','DESC')->get('asset_maintenance')->result(); + $asset->workflow = $this->db->select('h.*,u.nama user_name')->from('asset_workflow_history h')->join('users u','u.id=h.user_id','left')->where(array('h.asset_id'=>$asset->id,'h.company_id'=>(int)$companyId))->order_by('h.id')->get()->result(); + $asset->responsibilities = $this->db->select('h.*,f.full_name from_name,t.full_name to_name,u.nama creator_name')->from('asset_responsibility_history h')->join('k_employees f','f.id=h.from_employee_id','left')->join('k_employees t','t.id=h.to_employee_id','left')->join('users u','u.id=h.created_by','left')->where(array('h.asset_id'=>$asset->id,'h.company_id'=>(int)$companyId))->order_by('h.effective_date','DESC')->get()->result(); + $asset->attachments = $this->db->where(array('entity_type'=>'asset','entity_id'=>$asset->id,'company_id'=>(int)$companyId))->order_by('id')->get('transaction_attachments')->result(); + return $asset; + } + + public function categories($companyId, $activeOnly = false) + { + $this->db->where('company_id', (int) $companyId); + if ($activeOnly) $this->db->where('is_active', 1); + return $this->db->order_by('name')->get('asset_categories')->result(); + } + + public function locations($companyId, $activeOnly = false) + { + $this->db->where('company_id', (int) $companyId); + if ($activeOnly) $this->db->where('is_active', 1); + return $this->db->order_by('nama')->get('lokasi_asset')->result(); + } + + public function availableInventory($companyId, $search, $page = 1, $limit = 20) + { + $offset = (max(1, (int) $page)-1) * (int) $limit; + $params = array((int) $companyId); + $where = ''; + if (trim($search) !== '') { + $where = ' AND (i.kode_detail LIKE ? OR i.nama_barang LIKE ? OR b.barcode LIKE ? OR b.serial_number LIKE ?)'; + $needle = '%'.trim($search).'%'; + array_push($params, $needle, $needle, $needle, $needle); + } + $params[] = (int) $limit + 1; + $params[] = $offset; + return $this->db->query("SELECT b.id barcode_id,b.item_id,b.barcode,b.serial_number,b.warehouse_id,b.bin_id,b.batch_id,b.qty_sisa,b.reserved_qty,b.return_reserved_qty,i.kode_detail,i.nama_barang,i.harga_beli,k.tracking_type,k.need_serial_number,w.nama warehouse_name,wb.code bin_code,bt.batch_no, + GREATEST(b.qty_sisa-b.reserved_qty-b.return_reserved_qty,0) available_qty, + COALESCE((SELECT SUM(IF(il.direction='in',il.value,-il.value))/NULLIF(SUM(IF(il.direction='in',il.qty,-il.qty)),0) FROM inventory_ledger il WHERE il.item_id=b.item_id AND il.warehouse_id=b.warehouse_id),i.harga_beli,0) ledger_unit_cost + FROM item_barcodes b JOIN items i ON i.id=b.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id LEFT JOIN warehouses w ON w.id=b.warehouse_id LEFT JOIN warehouse_bins wb ON wb.id=b.bin_id LEFT JOIN inventory_batches bt ON bt.id=b.batch_id + WHERE i.company_id=? AND i.status='active' AND b.status='available' AND b.warehouse_id IS NOT NULL AND b.qty_sisa-b.reserved_qty-b.return_reserved_qty>0 {$where} + ORDER BY i.nama_barang,b.barcode LIMIT ? OFFSET ?", $params)->result(); + } + + public function employees($companyId, $search, $page = 1, $limit = 20) + { + $this->db->select('id,employee_code,full_name')->where(array('company_id'=>(int)$companyId,'is_active'=>1)); + if (trim($search) !== '') $this->db->group_start()->like('employee_code',$search)->or_like('full_name',$search)->group_end(); + return $this->db->order_by('full_name')->limit((int)$limit+1,(max(1,(int)$page)-1)*(int)$limit)->get('k_employees')->result(); + } + + public function accounts($companyId) + { + return $this->db->select('id,kode_akun,nama_akun,tipe,sub_tipe') + ->where(array('company_id'=>(int)$companyId,'is_active'=>1,'allow_posting'=>1)) + ->order_by('kode_akun')->get('accounts')->result(); + } + + public function opnames($companyId) + { + return $this->db->select('o.*,l.nama location_name,u.nama creator_name,a.nama approver_name')->from('asset_opnames o')->join('lokasi_asset l','l.id=o.location_id','left')->join('users u','u.id=o.created_by','left')->join('users a','a.id=o.approved_by','left')->where('o.company_id',(int)$companyId)->order_by('o.id','DESC')->limit(200)->get()->result(); + } + + public function opnameDetail($id, $companyId) + { + $row=$this->db->select('o.*,l.nama location_name,u.nama creator_name,s.nama submitter_name,a.nama approver_name,p.nama poster_name')->from('asset_opnames o')->join('lokasi_asset l','l.id=o.location_id','left')->join('users u','u.id=o.created_by','left')->join('users s','s.id=o.submitted_by','left')->join('users a','a.id=o.approved_by','left')->join('users p','p.id=o.posted_by','left')->where(array('o.id'=>(int)$id,'o.company_id'=>(int)$companyId))->get()->row(); + if(!$row)return null; + $row->lines=$this->db->select('ol.*,a.kode_asset,a.nama_asset,el.nama expected_location,al.nama actual_location')->from('asset_opname_lines ol')->join('assets a','a.id=ol.asset_id')->join('lokasi_asset el','el.id=ol.expected_location_id','left')->join('lokasi_asset al','al.id=ol.actual_location_id','left')->where(array('ol.asset_opname_id'=>$row->id,'ol.company_id'=>(int)$companyId))->order_by('a.kode_asset')->get()->result(); + $row->attachments=$this->db->where(array('entity_type'=>'asset_opname','entity_id'=>$row->id,'company_id'=>(int)$companyId))->order_by('id')->get('transaction_attachments')->result(); + return$row; + } + + public function reconciliation($companyId, $asOf) + { + $costRows=$this->db->query("SELECT a.asset_account_id account_id,ac.kode_akun,ac.nama_akun,SUM(a.nilai_perolehan) subledger,COALESCE((SELECT SUM(jd.debit-jd.kredit) FROM journal_details jd JOIN journals j ON j.id=jd.journal_id WHERE j.company_id=? AND j.status='posted' AND j.tanggal<=? AND jd.account_id=a.asset_account_id),0) gl_value FROM assets a LEFT JOIN accounts ac ON ac.id=a.asset_account_id WHERE a.company_id=? AND a.workflow_status='capitalized' AND a.lifecycle_status NOT IN('disposed','lost','damaged') AND a.capitalization_date<=? GROUP BY a.asset_account_id,ac.kode_akun,ac.nama_akun",array((int)$companyId,$asOf,(int)$companyId,$asOf))->result(); + $accRows=$this->db->query("SELECT a.accumulated_depreciation_account_id account_id,ac.kode_akun,ac.nama_akun,SUM(a.akumulasi_penyusutan) subledger,COALESCE((SELECT SUM(jd.kredit-jd.debit) FROM journal_details jd JOIN journals j ON j.id=jd.journal_id WHERE j.company_id=? AND j.status='posted' AND j.tanggal<=? AND jd.account_id=a.accumulated_depreciation_account_id),0) gl_value FROM assets a LEFT JOIN accounts ac ON ac.id=a.accumulated_depreciation_account_id WHERE a.company_id=? AND a.workflow_status='capitalized' AND a.lifecycle_status NOT IN('disposed','lost','damaged') AND a.capitalization_date<=? GROUP BY a.accumulated_depreciation_account_id,ac.kode_akun,ac.nama_akun",array((int)$companyId,$asOf,(int)$companyId,$asOf))->result(); + $costSub=$costGl=$accSub=$accGl=0.0;foreach($costRows as$r){$costSub+=(float)$r->subledger;$costGl+=(float)$r->gl_value;$r->difference=(float)$r->subledger-(float)$r->gl_value;}foreach($accRows as$r){$accSub+=(float)$r->subledger;$accGl+=(float)$r->gl_value;$r->difference=(float)$r->subledger-(float)$r->gl_value;} + return array('as_of'=>$asOf,'cost_subledger'=>$costSub,'cost_gl'=>$costGl,'cost_difference'=>$costSub-$costGl,'accumulated_subledger'=>$accSub,'accumulated_gl'=>$accGl,'accumulated_difference'=>$accSub-$accGl,'book_value'=>$costSub-$accSub,'status'=>abs($costSub-$costGl)<.01&&abs($accSub-$accGl)<.01?'matched':'variance','cost_details'=>$costRows,'accumulated_details'=>$accRows); + } + + public function report($companyId, $type, array $filters) + { + if ($type === 'depreciation') return $this->db->select('s.period,a.kode_asset,a.nama_asset,s.opening_book_value,s.depreciation_amount,s.closing_book_value,s.status,j.no_ref journal_no')->from('asset_depreciation_schedule s')->join('assets a','a.id=s.asset_id')->join('journals j','j.id=s.journal_id','left')->where('s.company_id',(int)$companyId)->where('s.due_date >=',$filters['date_from'])->where('s.due_date <=',$filters['date_to'])->order_by('s.period','DESC')->order_by('a.kode_asset')->get()->result(); + if ($type === 'maintenance') return $this->db->select('m.maintenance_no,m.maintenance_date,m.next_due_date,a.kode_asset,a.nama_asset,m.vendor,m.description,m.cost,m.status')->from('asset_maintenance m')->join('assets a','a.id=m.asset_id')->where('m.company_id',(int)$companyId)->where('m.maintenance_date >=',$filters['date_from'])->where('m.maintenance_date <=',$filters['date_to'])->order_by('m.maintenance_date','DESC')->get()->result(); + if ($type === 'events') return $this->db->select('e.event_no,e.event_date,e.event_type,a.kode_asset,a.nama_asset,e.amount,e.book_value_before,e.book_value_after,e.notes,j.no_ref journal_no')->from('asset_events e')->join('assets a','a.id=e.asset_id')->join('journals j','j.id=e.journal_id','left')->where('e.company_id',(int)$companyId)->where('e.event_date >=',$filters['date_from'])->where('e.event_date <=',$filters['date_to'])->order_by('e.event_date','DESC')->order_by('e.id','DESC')->get()->result(); + $page=$this->registerPage($companyId,$filters,0,10000,'');return$page['rows']; + } +} diff --git a/application/models/InventoryModel.php b/application/models/InventoryModel.php new file mode 100644 index 0000000..8fffda4 --- /dev/null +++ b/application/models/InventoryModel.php @@ -0,0 +1,79 @@ +db->query("SELECT COALESCE(SUM(IF(direction='in',qty,-qty)),0) stok FROM inventory_ledger WHERE item_id=?",array((int)$itemId))->row(); + return $row?(float)$row->stok:0.0; + } + + public function synchronizeStock($itemId) + { + $stock=$this->calculatedStock($itemId); + if(!$this->db->where('id',(int)$itemId)->update('items',array('stok'=>$stock)))throw new RuntimeException('Gagal menyinkronkan cache stok barang.'); + return $stock; + } + + public function synchronizeCompany($companyId) + { + $items=$this->db->select('id')->where('company_id',(int)$companyId)->get('items')->result(); + foreach($items as$item)$this->synchronizeStock($item->id); + return count($items); + } + + public function dashboard($companyId) + { + $companyId=(int)$companyId; + $row=$this->db->query("SELECT COUNT(CASE WHEN i.status='active' THEN 1 END) active_items,COUNT(CASE WHEN i.status='draft' OR EXISTS(SELECT 1 FROM item_barcodes ib WHERE ib.item_id=i.id AND ib.status='pending') THEN 1 END) pending_items,COALESCE(SUM(CASE WHEN x.qty>0 THEN x.value ELSE 0 END),0) inventory_value,COUNT(CASE WHEN x.qty<=i.reorder_point THEN 1 END) reorder_items FROM items i LEFT JOIN(SELECT item_id,SUM(IF(direction='in',qty,-qty)) qty,SUM(IF(direction='in',value,-value)) value FROM inventory_ledger GROUP BY item_id)x ON x.item_id=i.id WHERE i.company_id=?",array($companyId))->row(); + $row->open_documents=(int)$this->db->where('company_id',$companyId)->where_in('status',array('draft','submitted','approved'))->count_all_results('stock_documents'); + $row->open_reservations=(int)$this->db->where('company_id',$companyId)->where_in('status',array('active','partially_released'))->count_all_results('stock_reservations'); + $row->expiring_batches=(int)$this->db->where('company_id',$companyId)->where('is_active',1)->where('expiry_date IS NOT NULL',null,false)->where('expiry_date <=',date('Y-m-d',strtotime('+30 days')))->count_all_results('inventory_batches'); + return$row; + } + + public function recentDocuments($companyId,$limit=8) + { + return$this->db->select('d.*,wf.nama from_warehouse,wt.nama to_warehouse,u.nama creator_name')->from('stock_documents d')->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')->where('d.company_id',(int)$companyId)->order_by('d.id','DESC')->limit((int)$limit)->get()->result(); + } + + public function actionItems($companyId,$limit=8) + { + return$this->db->query("SELECT i.id,i.kode_detail,i.nama_barang,i.harga_beli,i.harga_jual,COUNT(DISTINCT CASE WHEN ib.status='pending' THEN ib.id END) pending_barcodes,COUNT(DISTINCT CASE WHEN ib.status='pending' AND (kb.need_serial_number<>'true' OR NULLIF(ib.serial_number,'') IS NOT NULL) THEN ib.id END) ready_barcodes FROM items i LEFT JOIN kode_barang kb ON kb.id=i.kode_id LEFT JOIN item_barcodes ib ON ib.item_id=i.id WHERE i.company_id=? AND EXISTS(SELECT 1 FROM goods_receipt_lines grl WHERE grl.item_id=i.id) AND(i.status='draft' OR ib.status='pending') GROUP BY i.id,i.kode_detail,i.nama_barang,i.harga_beli,i.harga_jual ORDER BY i.id DESC LIMIT ".(int)$limit,array((int)$companyId))->result(); + } + + public function documentDetail($id,$companyId) + { + $d=$this->db->select('d.*,c.name company_name,c.company_code,wf.nama from_warehouse,wt.nama to_warehouse,uc.nama creator_name,us.nama submitter_name,ua.nama approver_name,up.nama poster_name,ur.nama rejecter_name')->from('stock_documents d')->join('companies c','c.id=d.company_id','left')->join('warehouses wf','wf.id=d.from_warehouse_id','left')->join('warehouses wt','wt.id=d.to_warehouse_id','left')->join('users uc','uc.id=d.created_by','left')->join('users us','us.id=d.submitted_by','left')->join('users ua','ua.id=d.approved_by','left')->join('users up','up.id=d.posted_by','left')->join('users ur','ur.id=d.rejected_by','left')->where(array('d.id'=>(int)$id,'d.company_id'=>(int)$companyId))->get()->row(); + if(!$d)return null; + $d->lines=$this->db->select('l.*,i.kode_detail,i.nama_barang,k.unit,k.tracking_type,k.need_serial_number,b.barcode,b.serial_number,bt.batch_no,fb.code from_bin,tb.code to_bin')->from('stock_document_lines l')->join('items i','i.id=l.item_id')->join('kode_barang k','k.id=i.kode_id','left')->join('item_barcodes b','b.id=l.barcode_id','left')->join('inventory_batches bt','bt.id=l.batch_id','left')->join('warehouse_bins fb','fb.id=l.from_bin_id','left')->join('warehouse_bins tb','tb.id=l.to_bin_id','left')->where('l.stock_document_id',$d->id)->order_by('l.line_no')->order_by('l.id')->get()->result(); + $d->history=$this->db->select('h.*,u.nama user_name')->from('stock_document_status_history h')->join('users u','u.id=h.user_id','left')->where('h.stock_document_id',$d->id)->order_by('h.id')->get()->result(); + $d->attachments=$this->db->where(array('entity_type'=>'stock_document','entity_id'=>$d->id))->order_by('id')->get('transaction_attachments')->result(); + return$d; + } + + public function valuation($companyId,array$filters=array()) + { + $params=array((int)$companyId);$where=''; + if(!empty($filters['warehouse_id'])){$where.=' AND l.warehouse_id=?';$params[]=(int)$filters['warehouse_id'];} + if(!empty($filters['item_id'])){$where.=' AND l.item_id=?';$params[]=(int)$filters['item_id'];} + return$this->db->query("SELECT i.id,i.kode_detail,i.nama_barang,l.warehouse_id,w.nama warehouse_name,i.costing_method,i.minimum_stock,i.reorder_point,i.last_movement_at,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 i.company_id=? {$where} GROUP BY i.id,i.kode_detail,i.nama_barang,l.warehouse_id,w.nama,i.costing_method,i.minimum_stock,i.reorder_point,i.last_movement_at ORDER BY i.nama_barang,w.nama",$params)->result(); + } + + public function reconciliation($companyId,$asOf) + { + $companyId=(int)$companyId; + $ledger=(float)$this->db->query("SELECT COALESCE(SUM(IF(l.direction='in',l.value,-l.value)),0) total FROM inventory_ledger l JOIN items i ON i.id=l.item_id WHERE i.company_id=? AND l.movement_date<=?",array($companyId,$asOf))->row()->total; + $stockLog=(float)$this->db->query("SELECT COALESCE(SUM(IF(s.tipe='masuk',s.qty*COALESCE(s.unit_cost,0),-s.qty*COALESCE(s.unit_cost,0))),0) total FROM stock_logs s JOIN items i ON i.id=s.item_id WHERE i.company_id=? AND DATE(s.created_at)<=?",array($companyId,$asOf))->row()->total; + $mapping=$this->db->where(array('mapping_key'=>'inventory','is_active'=>1))->get('system_account_mappings')->row();$gl=0; + if($mapping)$gl=(float)$this->db->query("SELECT COALESCE(SUM(d.debit-d.kredit),0) total FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE d.account_id=? AND j.company_id=? AND j.tanggal<=? AND j.status IN('posted','reversed')",array($mapping->account_id,$companyId,$asOf))->row()->total; + $cache=(int)$this->db->query("SELECT COUNT(*) total FROM items i LEFT JOIN(SELECT item_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger WHERE movement_date<=? GROUP BY item_id)l ON l.item_id=i.id WHERE i.company_id=? AND ABS(i.stok-COALESCE(l.qty,0))>.0001",array($asOf,$companyId))->row()->total; + $barcode=(int)$this->db->query("SELECT COUNT(*) total FROM(SELECT l.item_id,l.warehouse_id,l.qty-COALESCE(b.qty,0) variance FROM(SELECT il.item_id,il.warehouse_id,SUM(IF(il.direction='in',il.qty,-il.qty)) qty FROM inventory_ledger il JOIN items i ON i.id=il.item_id WHERE i.company_id=? AND il.movement_date<=? GROUP BY il.item_id,il.warehouse_id)l 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(variance)>.0001)x",array($companyId,$asOf))->row()->total; + $details=array(); + $details['ledger_vs_stock_log']=$this->db->query("SELECT i.id item_id,i.kode_detail,i.nama_barang,w.nama warehouse_name,COALESCE(l.qty,0) ledger_qty,COALESCE(s.qty,0) stock_log_qty,COALESCE(l.inventory_value,0) ledger_value,COALESCE(s.stock_log_value,0) stock_log_value,COALESCE(l.qty,0)-COALESCE(s.qty,0) qty_difference,COALESCE(l.inventory_value,0)-COALESCE(s.stock_log_value,0) value_difference FROM(SELECT item_id,warehouse_id FROM inventory_ledger WHERE movement_date<=? UNION SELECT item_id,COALESCE(warehouse_id,0) FROM stock_logs WHERE DATE(created_at)<=?)p JOIN items i ON i.id=p.item_id LEFT JOIN warehouses w ON w.id=p.warehouse_id LEFT JOIN(SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty,SUM(IF(direction='in',value,-value)) inventory_value FROM inventory_ledger WHERE movement_date<=? GROUP BY item_id,warehouse_id)l ON l.item_id=p.item_id AND l.warehouse_id=p.warehouse_id LEFT JOIN(SELECT item_id,COALESCE(warehouse_id,0) warehouse_id,SUM(IF(tipe='masuk',qty,-qty)) qty,SUM(IF(tipe='masuk',qty*COALESCE(unit_cost,0),-qty*COALESCE(unit_cost,0))) stock_log_value FROM stock_logs WHERE DATE(created_at)<=? GROUP BY item_id,COALESCE(warehouse_id,0))s ON s.item_id=p.item_id AND s.warehouse_id=p.warehouse_id WHERE i.company_id=? HAVING ABS(qty_difference)>.0001 OR ABS(value_difference)>.01 ORDER BY ABS(value_difference) DESC,ABS(qty_difference) DESC LIMIT 50",array($asOf,$asOf,$asOf,$asOf,$companyId))->result(); + $details['barcode']=$this->db->query("SELECT i.id item_id,i.kode_detail,i.nama_barang,w.nama warehouse_name,l.qty ledger_qty,COALESCE(b.qty,0) barcode_qty,l.qty-COALESCE(b.qty,0) difference FROM(SELECT il.item_id,il.warehouse_id,SUM(IF(il.direction='in',il.qty,-il.qty)) qty FROM inventory_ledger il WHERE il.movement_date<=? GROUP BY il.item_id,il.warehouse_id)l JOIN items i ON i.id=l.item_id LEFT JOIN warehouses w ON w.id=l.warehouse_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 WHERE i.company_id=? HAVING ABS(difference)>.0001 ORDER BY ABS(difference) DESC LIMIT 50",array($asOf,$companyId))->result(); + $details['cache']=$this->db->query("SELECT i.id item_id,i.kode_detail,i.nama_barang,i.stok cache_qty,COALESCE(l.qty,0) ledger_qty,i.stok-COALESCE(l.qty,0) difference FROM items i LEFT JOIN(SELECT item_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger WHERE movement_date<=? GROUP BY item_id)l ON l.item_id=i.id WHERE i.company_id=? HAVING ABS(difference)>.0001 ORDER BY ABS(difference) DESC LIMIT 50",array($asOf,$companyId))->result(); + return array('ledger_value'=>$ledger,'stock_log_value'=>$stockLog,'gl_value'=>$gl,'difference'=>$ledger-$gl,'cache_variance_count'=>$cache,'barcode_variance_count'=>$barcode,'status'=>abs($ledger-$gl)<.01&&abs($ledger-$stockLog)<.01&&$cache===0&&$barcode===0?'matched':'variance','details'=>$details); + } +} diff --git a/application/models/InvoiceModel.php b/application/models/InvoiceModel.php new file mode 100644 index 0000000..06285fb --- /dev/null +++ b/application/models/InvoiceModel.php @@ -0,0 +1,24 @@ +db->get_where('invoices', array('id' => (int) $id))->row(); + } + + public function createDraft(array $data) + { + $data['status'] = 'draft'; + if (!$this->db->insert('invoices', $data)) { + throw new RuntimeException('Gagal membuat draft invoice.'); + } + return (int) $this->db->insert_id(); + } + + public function updateById($id, array $data) + { + return $this->db->where('id', (int) $id)->update('invoices', $data); + } +} diff --git a/application/models/JournalModel.php b/application/models/JournalModel.php new file mode 100644 index 0000000..46257af --- /dev/null +++ b/application/models/JournalModel.php @@ -0,0 +1,69 @@ +db->insert('journals', $data)) { + throw new RuntimeException('Gagal membuat header jurnal.'); + } + + return (int) $this->db->insert_id(); + } + + public function addLines(array $lines) + { + if (empty($lines) || !$this->db->insert_batch('journal_details', $lines)) { + throw new RuntimeException('Gagal membuat detail jurnal.'); + } + } + + public function referenceExists($reference) + { + return $this->db->where('no_ref', $reference)->count_all_results('journals') > 0; + } + + public function sourceExists($type, $id) + { + return $this->db + ->where('ref_type', $type) + ->where('ref_id', $id) + ->count_all_results('journals') > 0; + } + + public function find($id) + { + return $this->db->get_where('journals', array('id' => (int) $id))->row(); + } + + public function findForUpdate($id) + { + return $this->db->query('SELECT * FROM journals WHERE id = ? FOR UPDATE', array((int) $id))->row(); + } + + public function lines($journalId) + { + return $this->db->where('journal_id', (int) $journalId)->order_by('id', 'ASC')->get('journal_details')->result_array(); + } + + public function updateStatus($journalId, $status, array $extra = array()) + { + return $this->db->where('id', (int) $journalId)->update('journals', array_merge(array( + 'status' => $status, + 'updated_at' => date('Y-m-d H:i:s') + ), $extra)); + } + + public function addStatusHistory($journalId, $from, $to, $userId, $notes = null) + { + return $this->db->insert('journal_status_histories', array( + 'journal_id' => (int) $journalId, + 'from_status' => $from, + 'to_status' => $to, + 'notes' => $notes, + 'changed_by' => (int) $userId, + 'changed_at' => date('Y-m-d H:i:s') + )); + } +} diff --git a/application/models/PeralatanTeknisi_model.php b/application/models/PeralatanTeknisi_model.php index 911d9ae..c109c2b 100644 --- a/application/models/PeralatanTeknisi_model.php +++ b/application/models/PeralatanTeknisi_model.php @@ -8,6 +8,12 @@ class PeralatanTeknisi_model extends CI_Model { parent::__construct(); } + private function companyId() + { + $companyId = (int) $this->session->userdata('company_id'); + return $companyId > 0 ? $companyId : 1; + } + public function get_teknisi_list() { return $this->db @@ -15,8 +21,9 @@ class PeralatanTeknisi_model extends CI_Model { ->from('k_employees e') ->join('k_positions p', 'p.id = e.position_id', 'left') ->join('k_departments d', 'd.id = e.department_id', 'left') + ->where('e.company_id', $this->companyId()) + ->where('e.is_active', 1) ->where('(e.resign_date IS NULL OR e.resign_date = \'0000-00-00\')') - ->where_in('LOWER(p.position_name)', ['teknisi', 'noc']) ->order_by('e.full_name', 'ASC') ->get() ->result(); @@ -42,6 +49,8 @@ class PeralatanTeknisi_model extends CI_Model { ->join('items i', 'i.id = it.item_id', 'left') ->join('k_employees ke', 'ke.id = it.user_id', 'left'); + $this->db->where('i.company_id', $this->companyId()); + if ($user_id) { $this->db->where('it.user_id', $user_id); } @@ -65,6 +74,7 @@ class PeralatanTeknisi_model extends CI_Model { ->from('item_barcodes ib') ->join('items i', 'i.id = ib.item_id', 'left') ->where('ib.barcode', $barcode) + ->where('i.company_id', $this->companyId()) ->where('ib.qty_sisa >', 0) ->get() ->row(); @@ -75,6 +85,7 @@ class PeralatanTeknisi_model extends CI_Model { ->from('item_barcodes ib') ->join('items i', 'i.id = ib.item_id', 'left') ->where('ib.serial_number', $barcode) + ->where('i.company_id', $this->companyId()) ->where('ib.qty_sisa >', 0) ->get() ->row(); @@ -89,8 +100,10 @@ class PeralatanTeknisi_model extends CI_Model { ->select('it.user_id, ke.full_name, ke.employee_code') ->from('item_technician it') ->join('k_employees ke', 'ke.id = it.user_id', 'left') + ->join('items i', 'i.id = it.item_id', 'inner') ->where('it.barcode', $barcode) ->where('it.status', 'active') + ->where('i.company_id', $this->companyId()) ->get() ->row(); } @@ -106,31 +119,36 @@ class PeralatanTeknisi_model extends CI_Model { $barcode = trim($barcode); if ($barcode == '') continue; - $item = $this->db - ->select('it.*, ib.id as barcode_id, tec.full_name as teknisi_name') - ->from('item_technician it') - ->join('item_barcodes ib', 'ib.barcode = it.barcode', 'left') - ->join('k_employees tec', 'tec.id = it.user_id', 'left') - ->where('it.barcode', $barcode) - ->where('it.status', 'active') - ->get() - ->row(); + $item = $this->db->query( + "SELECT it.*, ib.id AS barcode_id, ib.warehouse_id, ib.qty_sisa, tec.full_name AS teknisi_name + FROM item_technician it + JOIN item_barcodes ib ON ib.barcode = it.barcode + JOIN items i ON i.id = it.item_id AND i.company_id = ? + LEFT JOIN k_employees tec ON tec.id = it.user_id + WHERE it.barcode = ? AND it.status = 'active' + FOR UPDATE", + [$this->companyId(), $barcode] + )->row(); if (!$item) { $errors[] = "Barcode $barcode tidak ditemukan atau sudah dikembalikan"; continue; } + $assigned=(float)($item->assigned_qty?:$item->qty_sisa);$carried=(float)$item->carried_qty;$installed=(float)$item->installed_qty; + if($carried>0&&$installed>0){$errors[]="Barcode $barcode memiliki status sebagian dibawa dan sebagian dipasang; gunakan Detail Teknisi untuk menentukan jumlah dan asal pengembalian";continue;} + $source=$installed>0?'installed':'carried';$qty=$assigned; + $this->db->insert('item_movements', [ 'item_id' => $item->item_id, 'barcode_id' => $item->barcode_id, - 'qty' => 1, - 'from_type' => 'technician', - 'from_id' => $item->user_id, + 'qty' => $qty, + 'from_type' => $source === 'installed' ? 'customer' : 'technician', + 'from_id' => $source === 'installed' ? $item->customer_id : $item->user_id, 'to_type' => 'warehouse', - 'to_id' => null, + 'to_id' => $item->warehouse_id, 'movement_type' => 'transfer', - 'notes' => 'Pengembalian dari: ' . $item->teknisi_name . ' ke gudang', + 'notes' => (($item->usage_status??'carried') === 'installed' ? 'Pengembalian dari customer melalui teknisi: ' : 'Pengembalian dari teknisi: ') . $item->teknisi_name . ' ke gudang', 'created_at' => $now ]); @@ -139,9 +157,14 @@ class PeralatanTeknisi_model extends CI_Model { ->where('status', 'active') ->update('item_technician', [ 'status' => 'inactive', + 'assigned_qty' => 0, + 'carried_qty' => 0, + 'installed_qty' => 0, 'deleted_at' => $now ]); + $this->db->insert('technician_item_status_logs',array('company_id'=>$this->companyId(),'user_id'=>$item->user_id,'item_id'=>$item->item_id,'barcode_id'=>$item->barcode_id,'barcode'=>$item->barcode,'from_status'=>$source,'to_status'=>'returned','qty'=>$qty,'customer_id'=>$source==='installed'?$item->customer_id:null,'event_date'=>date('Y-m-d'),'notes'=>'Pengembalian batch ke gudang','created_by'=>(int)$this->session->userdata('user_id'),'created_at'=>$now)); + if ($item->barcode_id) { $this->db ->where('id', $item->barcode_id) @@ -171,8 +194,10 @@ class PeralatanTeknisi_model extends CI_Model { return $this->db ->select("DATE(it.created_at) as tanggal") ->from('item_technician it') + ->join('items i', 'i.id = it.item_id', 'inner') ->where('it.user_id', $user_id) ->where('it.status', 'active') + ->where('i.company_id', $this->companyId()) ->group_by("DATE(it.created_at)") ->order_by("DATE(it.created_at)", "DESC") ->get() @@ -189,22 +214,34 @@ class PeralatanTeknisi_model extends CI_Model { ->select('id, full_name, employee_code') ->from('k_employees') ->where('id', $user_id) + ->where('company_id', $this->companyId()) ->get() ->row(); + if (!$technician) { + return ['status' => false, 'message' => 'Teknisi tidak ditemukan pada perusahaan aktif.']; + } + $this->db->trans_start(); foreach ($barcodes as $barcode) { $barcode = trim($barcode); if ($barcode == '') continue; - $barcode_row = $this->db - ->select('ib.id as barcode_id, ib.item_id, ib.barcode') - ->from('item_barcodes ib') - ->where('ib.barcode', $barcode) - ->where_not_in('ib.status', ['damaged', 'lost', 'returned_supplier', 'sold_out']) - ->get() - ->row(); + $barcode_row = $this->db->query( + "SELECT ib.id AS barcode_id, ib.item_id, ib.barcode, ib.warehouse_id,ib.qty_sisa, + COALESCE(k.tracking_type,'QTY') tracking_type + FROM item_barcodes ib + JOIN items i ON i.id = ib.item_id AND i.company_id = ? + LEFT JOIN kode_barang k ON k.id=i.kode_id + WHERE ib.barcode = ? + AND ib.status = 'available' + AND ib.qty_sisa > 0 + AND COALESCE(ib.reserved_qty, 0) = 0 + AND COALESCE(ib.return_reserved_qty, 0) = 0 + FOR UPDATE", + [$this->companyId(), $barcode] + )->row(); if (!$barcode_row) { $errors[] = "Barcode $barcode tidak valid atau tidak tersedia"; @@ -228,9 +265,14 @@ class PeralatanTeknisi_model extends CI_Model { $this->db->insert('item_technician', [ 'user_id' => $user_id, + 'company_id' => $this->companyId(), 'barcode' => $barcode_row->barcode, 'item_id' => $barcode_row->item_id, 'status' => 'active', + 'usage_status' => 'carried', + 'assigned_qty' => (float)$barcode_row->qty_sisa, + 'carried_qty' => (float)$barcode_row->qty_sisa, + 'installed_qty' => 0, 'created_at' => $now ]); @@ -242,7 +284,7 @@ class PeralatanTeknisi_model extends CI_Model { ->row(); $from_type = 'warehouse'; - $from_id = null; + $from_id = $barcode_row->warehouse_id; if ($last_movement) { $from_type = $last_movement->to_type; $from_id = $last_movement->to_id; @@ -251,7 +293,7 @@ class PeralatanTeknisi_model extends CI_Model { $this->db->insert('item_movements', [ 'item_id' => $barcode_row->item_id, 'barcode_id' => $barcode_row->barcode_id, - 'qty' => 1, + 'qty' => (float)$barcode_row->qty_sisa, 'from_type' => $from_type, 'from_id' => $from_id, 'to_type' => 'technician', @@ -307,6 +349,7 @@ class PeralatanTeknisi_model extends CI_Model { ->from('k_employees e') ->join('k_positions p', 'p.id = e.position_id', 'left') ->join('k_departments d', 'd.id = e.department_id', 'left') + ->where('e.company_id', $this->companyId()) ->where('(e.resign_date IS NULL OR e.resign_date = \'0000-00-00\')') ->where_in('LOWER(p.position_name)', ['teknisi', 'noc']); @@ -336,6 +379,7 @@ class PeralatanTeknisi_model extends CI_Model { $this->db ->from('k_employees e') ->join('k_positions p', 'p.id = e.position_id', 'left') + ->where('e.company_id', $this->companyId()) ->where('(e.resign_date IS NULL OR e.resign_date = \'0000-00-00\')') ->where_in('LOWER(p.position_name)', ['teknisi', 'noc']); @@ -348,6 +392,7 @@ class PeralatanTeknisi_model extends CI_Model { ->from('k_employees e') ->join('k_positions p', 'p.id = e.position_id', 'left') ->join('k_departments d', 'd.id = e.department_id', 'left') + ->where('e.company_id', $this->companyId()) ->where('(e.resign_date IS NULL OR e.resign_date = \'0000-00-00\')') ->where_in('LOWER(p.position_name)', ['teknisi', 'noc']); @@ -370,6 +415,7 @@ class PeralatanTeknisi_model extends CI_Model { ->join('k_positions p', 'p.id = e.position_id', 'left') ->join('k_departments d', 'd.id = e.department_id', 'left') ->where('e.id', $user_id) + ->where('e.company_id', $this->companyId()) ->get() ->row(); } @@ -387,17 +433,32 @@ class PeralatanTeknisi_model extends CI_Model { $this->db ->select(" it.barcode, + it.user_id, i.nama_barang, ib.serial_number, ib.qty_sisa, + COALESCE(k.tracking_type, 'QTY') tracking_type, + COALESCE(NULLIF(it.assigned_qty,0),ib.qty_sisa) assigned_qty, + COALESCE(it.carried_qty,CASE WHEN COALESCE(it.usage_status,'carried')='carried' THEN ib.qty_sisa ELSE 0 END) carried_qty, + COALESCE(it.installed_qty,CASE WHEN it.usage_status='installed' THEN ib.qty_sisa ELSE 0 END) installed_qty, it.status, + COALESCE(it.usage_status, 'carried') usage_status, + it.usage_date, + it.customer_id, + c.nama customer_name, + ib.id barcode_id, + COALESCE((SELECT MAX(l.attachment_id) FROM technician_item_status_logs l WHERE l.company_id=it.company_id AND l.barcode_id=ib.id), + (SELECT MAX(ta.id) FROM transaction_attachments ta WHERE ta.entity_type='technician_installation' AND ta.entity_id=ib.id)) proof_id, DATE_FORMAT(it.created_at, '%d/%m/%Y') as tanggal ") ->from('item_technician it') ->join('items i', 'i.id = it.item_id', 'left') + ->join('kode_barang k', 'k.id = i.kode_id', 'left') ->join('item_barcodes ib', 'ib.barcode = it.barcode', 'left') + ->join('customers c', 'c.id = it.customer_id', 'left') ->where('it.user_id', $user_id) - ->where('it.status', 'active'); + ->where('it.status', 'active') + ->where('i.company_id', $this->companyId()); if (!empty($search)) { $this->db->group_start() @@ -424,8 +485,10 @@ class PeralatanTeknisi_model extends CI_Model { { $this->db ->from('item_technician it') + ->join('items i', 'i.id = it.item_id', 'inner') ->where('it.user_id', $user_id) - ->where('it.status', 'active'); + ->where('it.status', 'active') + ->where('i.company_id', $this->companyId()); return $this->db->count_all_results(); } @@ -437,7 +500,8 @@ class PeralatanTeknisi_model extends CI_Model { ->join('items i', 'i.id = it.item_id', 'left') ->join('item_barcodes ib', 'ib.barcode = it.barcode', 'left') ->where('it.user_id', $user_id) - ->where('it.status', 'active'); + ->where('it.status', 'active') + ->where('i.company_id', $this->companyId()); if (!empty($search)) { $this->db->group_start() @@ -469,9 +533,10 @@ class PeralatanTeknisi_model extends CI_Model { ->join('item_barcodes ib', 'ib.id = im.barcode_id', 'left') ->join('k_employees from_ke', 'from_ke.id = im.from_id', 'left') ->join('k_employees to_ke', 'to_ke.id = im.to_id', 'left') - ->where('im.movement_type', 'installation') + ->where_in('im.movement_type', ['installation', 'transfer']) ->where('im.from_type', 'technician') ->where('im.to_type', 'technician') + ->where('i.company_id', $this->companyId()) ->order_by('im.created_at', 'DESC') ->limit(200) ->get() @@ -496,6 +561,7 @@ class PeralatanTeknisi_model extends CI_Model { ->join('items i', 'i.id = it.item_id', 'left') ->join('k_employees ke', 'ke.id = it.user_id', 'left') ->where('it.barcode', $barcode) + ->where('i.company_id', $this->companyId()) ->get() ->row(); } @@ -515,7 +581,8 @@ class PeralatanTeknisi_model extends CI_Model { ->join('items i', 'i.id = it.item_id', 'left') ->join('item_barcodes ib', 'ib.barcode = it.barcode', 'left') ->where('it.user_id', $user_id) - ->where('it.status', 'active'); + ->where('it.status', 'active') + ->where('i.company_id', $this->companyId()); if ($tanggal AND $tanggal != 'all') { $this->db->where('DATE(it.created_at)', $tanggal); @@ -534,23 +601,108 @@ class PeralatanTeknisi_model extends CI_Model { public function get_log_barang() { - return $this->db + return $this->get_log_barang_filtered(array()); + } + + public function get_log_barang_filtered(array $filters) + { + $this->db ->select(" it.barcode, it.item_id, it.user_id, it.status, + COALESCE(it.usage_status, 'carried') usage_status, + COALESCE(it.assigned_qty,0) assigned_qty, + COALESCE(it.carried_qty,0) carried_qty, + COALESCE(it.installed_qty,0) installed_qty, + it.usage_date, + it.customer_id, + it.usage_notes, it.created_at, it.deleted_at, ke.full_name as teknisi_name, + ke.employee_code, + c.nama customer_name, i.kode_detail as item_code, - i.nama_barang + i.nama_barang, + ib.serial_number, + ib.qty_sisa, + (SELECT COUNT(*) FROM transaction_attachments ta WHERE ta.entity_type='technician_installation' AND ta.entity_id=ib.id) attachment_count ") ->from('item_technician it') ->join('k_employees ke', 'ke.id = it.user_id', 'left') ->join('items i', 'i.id = it.item_id', 'left') + ->join('item_barcodes ib', 'ib.barcode = it.barcode', 'left') + ->join('customers c', 'c.id = it.customer_id', 'left') + ->where('i.company_id', $this->companyId()); + if(!empty($filters['user_id']))$this->db->where('it.user_id',(int)$filters['user_id']); + if(!empty($filters['customer_id']))$this->db->where('it.customer_id',(int)$filters['customer_id']); + $status=trim((string)($filters['status']??'')); + if($status==='returned'){ + $this->db->where('it.status','inactive'); + }elseif($status==='installed'){ + $this->db->where('it.status','active')->where('it.installed_qty >',0); + }elseif($status==='carried'){ + $this->db->where('it.status','active')->where('it.carried_qty >',0); + } + $from=trim((string)($filters['date_from']??''));$to=trim((string)($filters['date_to']??'')); + if($from!==''||$to!==''){ + $from=$from!==''?$from:'1900-01-01';$to=$to!==''?$to:'2999-12-31'; + $this->db->group_start()->where("DATE(it.created_at) BETWEEN ".$this->db->escape($from).' AND '.$this->db->escape($to),null,false)->or_where("it.usage_date BETWEEN ".$this->db->escape($from).' AND '.$this->db->escape($to),null,false)->or_where("DATE(it.deleted_at) BETWEEN ".$this->db->escape($from).' AND '.$this->db->escape($to),null,false)->group_end(); + } + return $this->db ->order_by('it.created_at', 'DESC') ->get() ->result(); } + + public function change_item_status($barcode,$userId,array $data,$actorId) + { + $barcode=trim((string)$barcode);$company=$this->companyId();$now=date('Y-m-d H:i:s'); + $this->db->trans_begin(); + try{ + $key=trim((string)($data['idempotency_key']??'')); + if($key!==''){$duplicate=$this->db->where(array('company_id'=>$company,'idempotency_key'=>$key))->get('technician_item_status_logs')->row();if($duplicate){$this->db->trans_commit();return array('duplicate'=>true,'log_id'=>(int)$duplicate->id,'qty'=>(float)$duplicate->qty,'status'=>$duplicate->to_status);}} + $row=$this->db->query("SELECT it.*,ib.id barcode_id,ib.qty_sisa,ib.warehouse_id,i.nama_barang,e.full_name technician_name,COALESCE(k.tracking_type,'QTY') tracking_type FROM item_technician it JOIN item_barcodes ib ON ib.barcode=it.barcode JOIN items i ON i.id=it.item_id JOIN k_employees e ON e.id=it.user_id LEFT JOIN kode_barang k ON k.id=i.kode_id WHERE it.company_id=? AND it.user_id=? AND it.barcode=? AND it.status='active' FOR UPDATE",array($company,(int)$userId,$barcode))->row(); + if(!$row)throw new RuntimeException('Barang aktif pada teknisi tidak ditemukan.'); + + $target=(string)$data['status'];$tracking=strtoupper((string)$row->tracking_type)==='UNIT'?'UNIT':'QTY'; + $assigned=(float)$row->assigned_qty;$carried=(float)$row->carried_qty;$installed=(float)$row->installed_qty; + if($assigned<=0){$assigned=(float)$row->qty_sisa;if(($row->usage_status??'carried')==='installed')$installed=$assigned;else$carried=$assigned;} + $qty=$tracking==='UNIT'?1:(float)($data['qty']??0); + if($qty<=0)throw new RuntimeException('Jumlah yang diubah wajib lebih dari 0.'); + + $source='';$customerId=null;$customerName='-'; + if($target==='installed'){$source='carried';$available=$carried;$customerId=(int)($data['customer_id']??0);if($customerId<1)throw new RuntimeException('Customer wajib dipilih untuk status Dipasang.');$customer=$this->db->select('id,nama')->where(array('id'=>$customerId,'company_id'=>$company))->get('customers')->row();if(!$customer)throw new RuntimeException('Customer tidak ditemukan pada perusahaan aktif.');$customerName=$customer->nama;if($installed>0&&!empty($row->customer_id)&&(int)$row->customer_id!==$customerId)throw new RuntimeException('Sebagian barcode ini sudah dipasang pada customer lain. Kembalikan dahulu ke teknisi sebelum memilih customer berbeda.');} + elseif($target==='carried'){$source='installed';$available=$installed;$customerId=(int)$row->customer_id;if($customerId<1)throw new RuntimeException('Customer asal pemasangan tidak ditemukan.');$customer=$this->db->select('nama')->where(array('id'=>$customerId,'company_id'=>$company))->get('customers')->row();$customerName=$customer?$customer->nama:'-';} + elseif($target==='warehouse'){$source=(string)($data['source_status']??'');if(!in_array($source,array('carried','installed'),true)){if($carried>0&&$installed<=0)$source='carried';elseif($installed>0&&$carried<=0)$source='installed';else throw new RuntimeException('Pilih asal barang yang akan dikembalikan ke gudang.');}$available=$source==='installed'?$installed:$carried;if($source==='installed'){$customerId=(int)$row->customer_id;$customer=$customerId?$this->db->select('nama')->where(array('id'=>$customerId,'company_id'=>$company))->get('customers')->row():null;$customerName=$customer?$customer->nama:'-';}} + else throw new RuntimeException('Status tujuan tidak valid.'); + if($qty>$available+0.00001)throw new RuntimeException('Jumlah melebihi saldo status asal ('.number_format($available,4,',','.').').'); + + if($source==='carried')$carried-=$qty;else$installed-=$qty; + if($target==='installed')$installed+=$qty;elseif($target==='carried')$carried+=$qty;else$assigned-=$qty; + $carried=max(0,$carried);$installed=max(0,$installed);$assigned=max(0,$assigned); + $usage=$installed>0&&$carried>0?'mixed':($installed>0?'installed':'carried');$inactive=$assigned<=0.00001; + $update=array('assigned_qty'=>$assigned,'carried_qty'=>$carried,'installed_qty'=>$installed,'usage_status'=>$usage,'usage_date'=>$installed>0?($target==='installed'?$data['date']:$row->usage_date):null,'customer_id'=>$installed>0?$customerId:null,'usage_notes'=>$data['notes'],'updated_by'=>(int)$actorId,'updated_at'=>$now); + if($inactive){$update['status']='inactive';$update['deleted_at']=$now;} + $this->db->where(array('company_id'=>$company,'user_id'=>(int)$userId,'barcode'=>$barcode,'status'=>'active'))->update('item_technician',$update); + + $labels=array('carried'=>'Dibawa Teknisi','installed'=>'Dipasang di Customer','warehouse'=>'Dikembalikan ke Gudang'); + $notes='[AUTO] Perubahan status peralatan | Barang '.$row->nama_barang.' | Barcode '.$row->barcode.' | Teknisi '.$row->technician_name.' | Dari '.$labels[$source].' ke '.$labels[$target].' | Qty '.number_format($qty,4,',','.').' | Tanggal '.$data['date'].' | Customer '.$customerName.' | Keterangan '.$data['notes']; + $movement=array('item_id'=>$row->item_id,'barcode_id'=>$row->barcode_id,'qty'=>$qty,'from_type'=>$source==='installed'?'customer':'technician','from_id'=>$source==='installed'?$customerId:$row->user_id,'to_type'=>$target==='installed'?'customer':($target==='carried'?'technician':'warehouse'),'to_id'=>$target==='installed'?$customerId:($target==='carried'?$row->user_id:$row->warehouse_id),'movement_type'=>$target==='installed'?'installation':($target==='carried'?'uninstallation':'transfer'),'notes'=>mb_substr($notes,0,250),'created_at'=>$now); + $this->db->insert('item_movements',$movement); + $this->db->insert('technician_item_status_logs',array('company_id'=>$company,'user_id'=>(int)$userId,'item_id'=>$row->item_id,'barcode_id'=>$row->barcode_id,'barcode'=>$row->barcode,'from_status'=>$source,'to_status'=>$target==='warehouse'?'returned':$target,'qty'=>$qty,'customer_id'=>$customerId?:null,'event_date'=>$data['date'],'notes'=>$data['notes'],'idempotency_key'=>$key?:null,'created_by'=>(int)$actorId,'created_at'=>$now)); + $logId=(int)$this->db->insert_id(); + $this->db->where('id',$row->barcode_id)->update('item_barcodes',array('status'=>$inactive?'available':'installed')); + if($this->db->trans_status()===false)throw new RuntimeException('Perubahan status barang gagal disimpan.'); + $this->db->trans_commit(); + return array('log_id'=>$logId,'barcode_id'=>(int)$row->barcode_id,'status'=>$target,'qty'=>$qty,'customer_name'=>$customerName,'technician_name'=>$row->technician_name,'remaining_qty'=>$assigned); + }catch(Throwable$e){$this->db->trans_rollback();throw$e;} + } + + public function get_status_history($userId) + { + return $this->db->select('l.*,i.kode_detail,i.nama_barang,c.nama customer_name,u.nama actor_name')->from('technician_item_status_logs l')->join('items i','i.id=l.item_id','left')->join('customers c','c.id=l.customer_id','left')->join('users u','u.id=l.created_by','left')->where(array('l.company_id'=>$this->companyId(),'l.user_id'=>(int)$userId))->order_by('l.event_date','DESC')->order_by('l.id','DESC')->get()->result(); + } } diff --git a/application/views/accounting_periods/layout.php b/application/views/accounting_periods/layout.php new file mode 100644 index 0000000..d3a1ce3 --- /dev/null +++ b/application/views/accounting_periods/layout.php @@ -0,0 +1,47 @@ +
+
+
+
+
+
Periode Accounting
+ Soft close dan close hanya dapat dilakukan bila tidak ada jurnal pending. +
+ +
+
+ + + +
Tahun BukuNoPeriodeMulaiSelesaiStatusAksi
+
+
+
+
+ + diff --git a/application/views/accounts/layout.php b/application/views/accounts/layout.php index 454b16d..9d4700f 100644 --- a/application/views/accounts/layout.php +++ b/application/views/accounts/layout.php @@ -18,6 +18,9 @@ Tipe Posisi Kategori + Jenis + Visibilitas + Status Aksi @@ -43,7 +46,8 @@ - + + Gunakan tepat 4 angka. @@ -70,6 +74,23 @@ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ @@ -84,7 +85,8 @@ $(function(){ $('#nama').val(''); $('#alamat').val(''); $('#telp').val(''); - $('#email').val(''); + $('#email').val(''); + $('#credit_limit').val(0); $('#payment_term_days').val(30); $('#tax_number').val(''); $('#is_credit_blocked').prop('checked',false); } // ADD @@ -103,7 +105,8 @@ $(function(){ $('#nama').val(res.nama); $('#alamat').val(res.alamat); $('#telp').val(res.telp); - $('#email').val(res.email); + $('#email').val(res.email); + $('#credit_limit').val(res.credit_limit); $('#payment_term_days').val(res.payment_term_days); $('#tax_number').val(res.tax_number); $('#is_credit_blocked').prop('checked',res.is_credit_blocked==1); action = 'edit'; $('#modalCustomer').modal('show'); @@ -118,7 +121,7 @@ $(function(){ nama: $('#nama').val(), alamat: $('#alamat').val(), telp: $('#telp').val(), - email: $('#email').val() + email: $('#email').val(), credit_limit: $('#credit_limit').val(), payment_term_days: $('#payment_term_days').val(), tax_number: $('#tax_number').val(), is_credit_blocked: $('#is_credit_blocked').is(':checked') ? 1 : 0 }; if(!data.nama){ @@ -162,4 +165,4 @@ $(function(){ }); }); - \ No newline at end of file + diff --git a/application/views/fixed_assets/asset_pdf.php b/application/views/fixed_assets/asset_pdf.php new file mode 100644 index 0000000..5166d9f --- /dev/null +++ b/application/views/fixed_assets/asset_pdf.php @@ -0,0 +1,6 @@ + +

name??'Perusahaan')?>

KARTU DAN REGISTER ASET TETAP

+

kode_asset.' - '.$asset->nama_asset)?>

Dicetak · Status lifecycle_status))?>

+
Dokumen Perolehandocument_no)?>Sumbersource_type.' / '.($asset->source_reference_no?:'-'))?>
Kategoricategory_name?:'-')?>Lokasilocation_name?:'-')?>
Penanggung Jawabresponsible_name?:'-')?>Barcode / Serialbarcode?:'-').' / '.($asset->serial_number?:'-'))?>
Tanggal Perolehantanggal_perolehan)?>Kapitalisasicapitalization_date?:'-')?>
Nilai PerolehanRp nilai_perolehan,2,',','.')?>Nilai ResiduRp nilai_residu,2,',','.')?>
Akumulasi PenyusutanRp akumulasi_penyusutan,2,',','.')?>Nilai BukuRp nilai_buku,2,',','.')?>
Masa Manfaatmasa_manfaat?> bulanMetodedepreciation_method)?>
+

Riwayat Mutasi dan Jurnal

events as$e):?>events):?>
TanggalDokumenJenisNilaiNilai BukuJurnal / Keterangan
event_date?>event_no)?>event_type)?>amount,2,',','.')?>book_value_after,2,',','.')?>journal_no?:'-').' · '.($e->notes?:'-'))?>
Belum ada mutasi.
+
Dokumen ini dihasilkan dari subledger Aset Tetap. Verifikasi identitas melalui QR.
diff --git a/application/views/fixed_assets/index.php b/application/views/fixed_assets/index.php new file mode 100644 index 0000000..bd5654d --- /dev/null +++ b/application/views/fixed_assets/index.php @@ -0,0 +1,27 @@ +Pilih akun':'';foreach($accounts as$a)$html.='';return$html;} ?> + +
+
SUBLEDGER ASET TETAP

Siklus Aset Tetap

Perolehan, persetujuan, kapitalisasi, penyusutan, mutasi, opname, dan rekonsiliasi dalam satu alur.

+
Total registertotal_assets,0,',','.')?>active_assets,0,',','.')?> aset aktif
Nilai perolehanRp acquisition_cost,2,',','.')?>Subledger terkapitalisasi
Nilai bukuRp book_value,2,',','.')?>Akumulasi Rp accumulated_depreciation,2,',','.')?>
Perlu tindakanpending_workflow+(int)$summary->open_opnames,0,',','.')?>pending_workflow?> approval · open_opnames?> opname
+ +
+
AsetKategori/LokasiSumberStatusPerolehanNilai BukuAksi
+

Asset Opname

Hitung fisik per lokasi dengan bukti, approval, dan audit trail.

DokumenTanggal/LokasiStatusPembuatAksi
opname_no)?>opname_date)?>
location_name?:'-')?>
status))?>creator_name?:'-')?>
Belum ada dokumen opname.
+

Master Aset

Kategori menentukan default masa manfaat dan akun. Lokasi dipakai oleh register, transfer, dan opname.

Kategori Aset
Kode/NamaMasa ManfaatMetodeStatus
code)?>
name)?>
default_useful_life_months?> bulandepreciation_method)?>is_active?'Aktif':'Nonaktif'?>
Lokasi Aset
Kode/NamaStatus
code?:'-')?>
nama)?>
is_active?'Aktif':'Nonaktif'?>
+

Laporan & Rekonsiliasi

Laporan berasal dari subledger aset dan dapat ditelusuri ke jurnal.

Pilih jenis laporan lalu klik Tampilkan.
+
+
+ + + + + + + + diff --git a/application/views/fixed_assets/opname_pdf.php b/application/views/fixed_assets/opname_pdf.php new file mode 100644 index 0000000..8ee18e1 --- /dev/null +++ b/application/views/fixed_assets/opname_pdf.php @@ -0,0 +1 @@ +

name??'Perusahaan')?>

BERITA ACARA ASSET OPNAME

opname_no)?>

Tanggal opname_date)?> · Lokasi location_name)?> · Status status))?>

lines as$l):?>
KodeNama AsetLokasi CatatanLokasi FisikDitemukanKondisiCatatan
kode_asset)?>nama_asset)?>expected_location?:'-')?>actual_location?:'-')?>is_found?'Ya':'Tidak'?>actual_status)?>notes?:'-')?>
Dibuat oleh


creator_name?:'-')?>
Disetujui oleh


approver_name?:'-')?>
Diposting oleh


poster_name?:'-')?>
Selisih kondisi tidak otomatis menjurnal. Pelepasan, hilang, atau rusak diproses melalui mutasi aset resmi.
diff --git a/application/views/fixed_assets/pdf_style.css b/application/views/fixed_assets/pdf_style.css new file mode 100644 index 0000000..2d62875 --- /dev/null +++ b/application/views/fixed_assets/pdf_style.css @@ -0,0 +1 @@ +@page{margin:24px}body{font-family:DejaVu Sans,Arial,sans-serif;color:#1d2738;font-size:9px}header{display:flex;border-bottom:2px solid #1769e0;padding-bottom:10px;margin-bottom:14px}header h1{margin:0;font-size:17px}header p{margin:3px 0;color:#607087}.qr{width:75px;margin-left:auto}.title{margin-bottom:13px}.title h2{font-size:15px;margin:0 0 3px}.title p{margin:0;color:#607087}h3{font-size:11px;margin:16px 0 6px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #d6dde8;padding:6px;vertical-align:top}thead th{background:#edf3fa;text-transform:uppercase;font-size:7px;letter-spacing:.04em}.kv th{width:16%;background:#f5f7fa;text-align:left}.num{text-align:right}.signatures{display:flex;justify-content:space-between;margin-top:28px;text-align:center}.signatures div{width:30%}footer{position:fixed;bottom:-13px;border-top:1px solid #ccd5e1;width:100%;padding-top:5px;color:#6a7687;font-size:7px} diff --git a/application/views/fixed_assets/qr_label.php b/application/views/fixed_assets/qr_label.php new file mode 100644 index 0000000..c2a62c4 --- /dev/null +++ b/application/views/fixed_assets/qr_label.php @@ -0,0 +1 @@ +

kode_asset)?>

nama_asset)?>

location_name?:'-')?> · lifecycle_status))?>


diff --git a/application/views/fixed_assets/report_pdf.php b/application/views/fixed_assets/report_pdf.php new file mode 100644 index 0000000..4893d8e --- /dev/null +++ b/application/views/fixed_assets/report_pdf.php @@ -0,0 +1 @@ +

name??'Perusahaan')?>

LAPORAN ASET TETAP

Periode s.d. · Dicetak

$label):?>
$key??null; echo html_escape(is_numeric($v)&&preg_match('/value|nilai|cost|amount|selisih|buku|perolehan|akumulasi/i',$key)?number_format($v,2,',','.'):$v);?>
Tidak ada data.
Laporan dihasilkan dari subledger Aset Tetap dan dibatasi pada perusahaan aktif.
diff --git a/application/views/hr_payroll/index.php b/application/views/hr_payroll/index.php new file mode 100644 index 0000000..62fc47b --- /dev/null +++ b/application/views/hr_payroll/index.php @@ -0,0 +1,3 @@ +

HR & Payroll Control

Lock absensi → Draft → Review HR → Approval → Final → Jurnal → Pembayaran.

session->flashdata($f)):?>
session->flashdata($f))?>
1. Lock Absensi Bulanan
2. Hitung Draft Payroll

Menggunakan snapshot absensi terkunci dan formula komponen aktif.

Formula Configurable
Variabel: basic_salary, work_days, present_days, unpaid_days, overtime_hours, overtime_rate, daily_rate.

+
Payroll Register
PeriodeKaryawanBrutoPotonganNetoStatusWorkflow
period_name)?>employee_count?>gross,0,',','.')?>deductions,0,',','.')?>net,0,',','.')?>workflow_status?>
workflow_status==='draft'):?>
workflow_status==='reviewed'):?>
workflow_status==='approved'):?>
workflow_status==='final'):?>
workflow_status==='posted'):?>
+
Komponen Aktif
KodeNamaJenisKategoriFormula/NominalPajak
code?>name)?>component_type?>category?>formula_expression?:number_format($c->fixed_amount,0,',','.'))?>taxable?'Ya':'Tidak'?>
diff --git a/application/views/hr_payroll/organization_master.php b/application/views/hr_payroll/organization_master.php new file mode 100644 index 0000000..0d66de7 --- /dev/null +++ b/application/views/hr_payroll/organization_master.php @@ -0,0 +1,37 @@ +
+

Master Departemen & Posisi

Satu sumber data untuk karyawan, Purchase Request, payroll, jadwal, dan modul lainnya.

+
+
Departemen
+
+
KodeDepartemenStatus
+
+
Posisi / Jabatan
+
+
KodePosisiDepartemenStatus
+
+
+
+ diff --git a/application/views/hr_payroll/slip.php b/application/views/hr_payroll/slip.php new file mode 100644 index 0000000..cc5473c --- /dev/null +++ b/application/views/hr_payroll/slip.php @@ -0,0 +1 @@ +Slip Gaji

SLIP GAJI

full_name)?> — employee_code)?>
Periode period_name)?>

KomponenJenisNilai
component_name)?>component_type?>amount,0,',','.')?>
Gaji Netototal_salary,0,',','.')?>
Dokumen rahasia. Status payroll: workflow_status?> diff --git a/application/views/inventory_professional/_module_nav.php b/application/views/inventory_professional/_module_nav.php new file mode 100644 index 0000000..b7ad1ff --- /dev/null +++ b/application/views/inventory_professional/_module_nav.php @@ -0,0 +1,15 @@ + +
+
PERSEDIAAN TERINTEGRASI

+ Scan Barcode +
+ diff --git a/application/views/inventory_professional/dashboard.php b/application/views/inventory_professional/dashboard.php new file mode 100644 index 0000000..271c727 --- /dev/null +++ b/application/views/inventory_professional/dashboard.php @@ -0,0 +1,22 @@ +load->view('inventory_professional/_module_nav',get_defined_vars());?> +
+ active_items,'bi-box-seam','primary'),array('Perlu Diaktifkan',$summary->pending_items,'bi-hourglass-split','warning'), + array('Nilai Persediaan','Rp '.number_format($summary->inventory_value,2,',','.'),'bi-cash-stack','success'),array('Perlu Reorder',$summary->reorder_items,'bi-exclamation-triangle','danger'), + array('Dokumen Terbuka',$summary->open_documents,'bi-file-earmark-text','info'),array('Reservasi Aktif',$summary->open_reservations,'bi-bookmark-check','secondary') + )as$m):?>
+
+
+
Pekerjaan Gudang

Barang yang sudah diterima tetapi belum siap digunakan atau dijual.

Buka Barang Pending
+
Tidak ada barang pendingSemua penerimaan sudah ditangani.
+
+
Scan Barcode / Serial

Lihat posisi, status, saldo, dan riwayat unit.

Hasil scan tampil di sini.
+
+
Dokumen Mutasi Terbaru

Jejak dokumen stok dari seluruh gudang.

Lihat Semua
DokumenJenisTanggalLokasiStatusPembuat
document_no)?>document_type)))?>document_date))?>from_warehouse?:'-').' → '.($d->to_warehouse?:'-'))?>status))?>creator_name?:'-')?>
+ diff --git a/application/views/inventory_professional/document_pdf.php b/application/views/inventory_professional/document_pdf.php new file mode 100644 index 0000000..cd81873 --- /dev/null +++ b/application/views/inventory_professional/document_pdf.php @@ -0,0 +1,85 @@ +company_code ? $document->company_code.' - ' : '').($document->company_name ?: 'Perusahaan')); +?> + + + + + + + +
+
+

Dokumen Mutasi Persediaan

+

document_no)?> · document_type)))?>

+
+ + + + + + + + + + + + +
Tanggal
document_date))?>
Gudang Asal
from_warehouse ?: '-')?>
Gudang Tujuan
to_warehouse ?: '-')?>
Status
status))?>
Dokumen Sumber
source_type ?: '-').' #'.($document->source_id ?: '-'))?>
Alasan
reason ?: '-')?>
+ + + + + + + lines as $line): ?> + + + + + + + + + + + +
NoBarangBarcode / SerialBatchQtyBiaya SatuanCatatan
line_no?>kode_detail)?>
nama_barang)?>
barcode ?: '-')?>serial_number): ?>
SN serial_number)?>
batch_no ?: '-')?>document_type === 'opname' ? $line->counted_qty : $line->qty)?> unit ?: '')?>Rp unit_cost, 2, ',', '.')?>reason ?: '-')?>
+ +
+
Lampiran Pendukung
+ attachments): ?> +
    attachments as $attachment): ?>
  • original_name)?>
+ +

Tidak ada lampiran.

+ +
+ + + + + + + + +
Dibuat oleh


creator_name ?: '-')?>
Diajukan oleh


submitter_name ?: '-')?>
Disetujui oleh


approver_name ?: '-')?>
Diposting oleh


poster_name ?: '-')?>
+ +
Dicetak · Dokumen elektronik dapat diverifikasi melalui riwayat status aplikasi.
+ + diff --git a/application/views/inventory_professional/locations.php b/application/views/inventory_professional/locations.php new file mode 100644 index 0000000..01e6683 --- /dev/null +++ b/application/views/inventory_professional/locations.php @@ -0,0 +1,23 @@ +load->view('inventory_professional/_module_nav',get_defined_vars());?> +
+
Master Lokasi Persediaan

Data yang sudah digunakan dinonaktifkan, bukan dihapus.

+ +
+
Daftar Gudang

Pusat penyimpanan fisik barang.

KodeNamaAlamatStatusAksi
+
Lokasi Rak / Bin

Lokasi spesifik di dalam gudang.

GudangKode RakNamaStatusAksi
+
Batch / Lot

Pelacakan tanggal produksi dan kedaluwarsa.

BarangBatchProduksiKedaluwarsaSupplierStatusAksi
+
+
+ + diff --git a/application/views/inventory_professional/operations.php b/application/views/inventory_professional/operations.php new file mode 100644 index 0000000..afb7d5d --- /dev/null +++ b/application/views/inventory_professional/operations.php @@ -0,0 +1,67 @@ +load->view('inventory_professional/_module_nav',get_defined_vars());?> + +
+
Dokumen Operasional

Penerimaan dan retur pembelian tetap dibuat dari Purchase Workflow.

+
+
+
+ +
+
DokumenTanggalJenisGudangBarisStatusPembuatAksi
+
+ +
+
Reservasi / Alokasi Stok

Stok dialokasikan ke dokumen sumber dan dapat dilepas atau digunakan bertahap.

+
ReservasiBarangGudangSumberQtyBerlaku SampaiStatusAksi
+
+ + + + + + + + + diff --git a/application/views/inventory_professional/report_pdf.php b/application/views/inventory_professional/report_pdf.php new file mode 100644 index 0000000..88a8529 --- /dev/null +++ b/application/views/inventory_professional/report_pdf.php @@ -0,0 +1,10 @@ + + +
name??$company->company_name??'Perusahaan')?>

Periode sampai
+ + +$label):$value=$row->$key??null;$numeric=is_numeric($value)&&!in_array($key,array('source_id'),true);$money=in_array($key,array('inventory_value','value','unit_cost','ledger_value','stock_log_value','gl_value','difference'),true);?> +
Tidak ada data pada filter yang dipilih.
Rp
Dicetak . Sumber saldo: inventory ledger. Filter dan histori dokumen dipertahankan untuk kebutuhan audit.
+ diff --git a/application/views/inventory_professional/reports.php b/application/views/inventory_professional/reports.php new file mode 100644 index 0000000..324c5a9 --- /dev/null +++ b/application/views/inventory_professional/reports.php @@ -0,0 +1,63 @@ +load->view('inventory_professional/_module_nav',get_defined_vars()); +?> +
+
+
+
+
Nilai Persediaan

Gunakan filter untuk menelusuri saldo dan dokumen sumber.

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
Rekonsiliasi

Ledger dibandingkan dengan stock log, barcode, cache, dan GL.

+
Belum diperiksa.
Selisih tidak membuat jurnal koreksi otomatis. Anomali ditampilkan agar dapat ditelusuri ke dokumen sumber.
+
+
+
+ + diff --git a/application/views/invoices/detail.php b/application/views/invoices/detail.php index 0613307..02bc377 100644 --- a/application/views/invoices/detail.php +++ b/application/views/invoices/detail.php @@ -137,6 +137,7 @@ +
@@ -689,7 +690,9 @@ $(function(){ nama_item: nama_item, item_id: item_id || null, qty: parseFloat($('#qty').val()) || 0, - harga: parseFloat($('#harga').val()) || 0, + harga: window.parseMoney($('#harga').val()), + discount_amount: window.parseMoney($('#discount_amount').val()), + tax_rate: parseFloat($('#tax_rate').val()) || 0, keterangan: $('#keterangan').val().trim(), barcode_id: $('#barcode_select').val() || 0, @@ -1210,4 +1213,5 @@ $(function(){ $(this).addClass('table-secondary'); }); - \ No newline at end of file + +
Lampiran Invoice
Reminder Tagihan
diff --git a/application/views/invoices/editor.php b/application/views/invoices/editor.php new file mode 100644 index 0000000..21d6789 --- /dev/null +++ b/application/views/invoices/editor.php @@ -0,0 +1,114 @@ +workflow_status==='draft'; +$editable=$draft&&$p['update']; +$csrfName=$this->security->get_csrf_token_name(); +$csrfHash=$this->security->get_csrf_hash(); +$lineDate=$invoice?max($invoice->tanggal,date('Y-m-d')):date('Y-m-d'); +function invLabel($value){$labels=array('draft'=>'Draft','submitted'=>'Menunggu Approval','approved'=>'Disetujui','posted'=>'Posted','one_time'=>'Sekali Transaksi','running'=>'Berjalan / Akumulasi','accrual'=>'Diakui saat Invoice Diposting','on_payment'=>'Diakui Proporsional Setelah Dibayar','not_delivered'=>'Belum Dikirim','partial'=>'Sebagian Dikirim','delivered'=>'Sudah Dikirim');return$labels[$value]??ucwords(str_replace('_',' ',$value));} +?> + + + +
+
+
no_invoice)?>

+ +
+ +
Header Draft inventory_line_count?'Pengiriman':'Tanpa Surat Jalan'?> Diajukan Approved & Posted
+ +
+
+
+
Daftar Item Invoice
0 item
+
+
Invoice belum disimpan

Lengkapi data invoice pada panel sebelah kiri.

TanggalBarang / Jasa / TabunganAlokasi AkunBarcode / SerialQtyHargaDiskonPajakSubtotalAksi
+
+
Subtotal BersihRp 0,00
DiskonRp 0,00
PajakRp 0,00
Total InvoiceRp 0,00
+
+
+ + +
+
+ + + + + + + +line_count):?> + + + + + diff --git a/application/views/invoices/index.php b/application/views/invoices/index.php new file mode 100644 index 0000000..2b7043e --- /dev/null +++ b/application/views/invoices/index.php @@ -0,0 +1,36 @@ +security->get_csrf_token_name();$csrfHash=$this->security->get_csrf_hash(); ?> + +
+
+
SIKLUS PENJUALAN

Invoice Penjualan

Kelola tagihan customer, pengiriman barang bila diperlukan, dan penerimaan pembayaran.

+ Buat Invoice +
+ +
+
Penjualan bulan iniRp
+
Invoice belum lunas sudah jatuh tempo
+
Draft / approvalMemerlukan tindakan
+
Pengiriman DraftBelum mengurangi stok
+
+
+
Daftar InvoiceGunakan filter untuk menemukan invoice tanpa memuat seluruh data.
+
+
+
+
+
+
+
+
Invoice / CustomerJatuh TempoPengakuanTotalTerbayarSisaStatusPengirimanDibuat olehAksi
+
+
+ + + diff --git a/application/views/invoices/layout.php b/application/views/invoices/layout.php index 75577bf..4b8429e 100644 --- a/application/views/invoices/layout.php +++ b/application/views/invoices/layout.php @@ -243,7 +243,7 @@ $(document).on('input','.qty,.harga',function(){ let tr = $(this).closest('tr'); let qty = parseFloat(tr.find('.qty').val()) || 0; - let harga = parseFloat(tr.find('.harga').val()) || 0; + let harga = window.parseMoney(tr.find('.harga').val()); let sub = qty * harga; @@ -385,7 +385,7 @@ function hitungBayar(){ let totalBayar = 0; $('#tableBayar .debit').each(function(){ - totalBayar += parseFloat($(this).val()) || 0; + totalBayar += window.parseMoney($(this).val()); }); let invoiceTotal = parseFloat($('#invoiceTotal').text().replace(/\./g,'')) || 0; @@ -423,7 +423,7 @@ $('#btnProsesBayar').click(function(){ $('#tableBayar tbody tr').each(function(){ let account_id = $(this).find('.account_id').val(); - let debit = parseFloat($(this).find('.debit').val()) || 0; + let debit = window.parseMoney($(this).find('.debit').val()); if(account_id && debit > 0){ detail.push({ account_id, amount: debit }); @@ -570,6 +570,15 @@ $(document).on('click', '.btn-delete', function () { }); + diff --git a/application/views/items/barcode_sn.php b/application/views/items/barcode_sn.php index 1cd0e60..df6c3c6 100644 --- a/application/views/items/barcode_sn.php +++ b/application/views/items/barcode_sn.php @@ -1,3 +1,4 @@ +load->view('inventory_professional/_module_nav',get_defined_vars());?> - -
-
- -
-
-
-

- - Riwayat Perpindahan Barang -

-
- Silahkan scan / input QR id Barang -
-
-
-
-
-
- -
-
- -
-
-
-
- -
-
-
- Scan QR ID -
-
-
- -
-
- - - - - Scanner akan otomatis memanggil riwayat. - - - - -
- - -
- -
-
-
- -
-
-
-
-
- Riwayat Perpindahan Barang -
-
-
-
- Belum ada data -
-
- - - - - - - - - - - - - - - - - - - - - - -
NoDateTypeFromToNOtes
-
-
-
-
-
-
+load->view('inventory_professional/_module_nav',get_defined_vars());?> +
+
Pencarian Unit

Gunakan barcode, QR, atau serial number.

Scanner hanya membaca data. Mutasi barang tetap dilakukan melalui dokumen sumber yang sesuai.
+
Identitas dan Posisi Barang

Belum ada barcode yang dipilih.

Siap memindaiHasil akan tampil di sini tanpa membuka halaman baru.
- +
Riwayat Perpindahan

Urutan terbaru dari barcode atau serial yang dipilih.

WaktuJenisDariKeQtyDokumen SumberKeterangan
Scan barcode untuk melihat riwayat.
+ diff --git a/application/views/items/layout.php b/application/views/items/layout.php index f3f3082..5263084 100644 --- a/application/views/items/layout.php +++ b/application/views/items/layout.php @@ -1,1107 +1,13 @@ -
-
-
- -
-
Data Barang
-
- - -
-
- -
- - - - - - - - - - - - - - - -
NoTanggal BeliKode BarangNamaGudangStokHarga BeliHarga JualCreate atAksi
-
- -
-
-
- - - - - - - - - - - - - - - - - +load->view('inventory_professional/_module_nav',get_defined_vars());?> +
Barang Siap Digunakan

Barcode dan serial barang aktif tidak dapat diubah. Pengeluaran dibuat melalui Operasional Stok.

Operasional Stok
NoTanggal BeliKodeBarangGudangBarcode / StokHarga BeliHarga JualDibuatAksi
+ + \ No newline at end of file + diff --git a/application/views/jurnal/layout.php b/application/views/jurnal/layout.php index 6d7faf3..0f487e7 100644 --- a/application/views/jurnal/layout.php +++ b/application/views/jurnal/layout.php @@ -34,9 +34,10 @@ No Tanggal No Ref - Keterangan + Keterangan Debit Kredit + Status Aksi @@ -46,7 +47,7 @@ TOTAL 0 0 - + @@ -71,8 +72,8 @@
- - + +
@@ -138,7 +139,8 @@ Tanggal:
No Ref:
Keterangan:
- Dibuat oleh: + Dibuat oleh:
+ Status:
@@ -160,6 +162,9 @@
+
Riwayat Status
+
+
@@ -177,7 +182,7 @@ $(function(){ let accounts = []; - let mode = 'auto'; + let mode = 'manual'; // ================= LOAD ACCOUNTS function loadAccounts(){ @@ -282,7 +287,7 @@ $('#modeAuto').click(function(){ mode = 'auto'; - $(this).addClass('btn-warning active').removeClass('btn-outline-warning'); + $(this).addClass('btn-warning active').removeClass('btn-outline-secondary'); $('#modeManual').removeClass('btn-warning active').addClass('btn-outline-secondary'); $('#autoSection').show(); @@ -305,7 +310,7 @@ $('#modeManual').click(function(){ mode = 'manual'; $(this).addClass('btn-warning active').removeClass('btn-outline-secondary'); - $('#modeAuto').removeClass('btn-warning active').addClass('btn-outline-warning'); + $('#modeAuto').removeClass('btn-warning active').addClass('btn-outline-secondary'); $('#autoSection').hide(); $('#tableDetail tbody').html(''); @@ -313,6 +318,7 @@ $('#modeManual').click(function(){ $('.btn-add-row').show(); // ⬅️ tampilkan lagi addRow(); + addRow(); }); // ================= PILIH TEMPLATE @@ -342,8 +348,8 @@ $('#modeManual').click(function(){ let td=0, tk=0; $('#tableDetail tbody tr').each(function(){ - td += parseFloat($(this).find('.debit').val()) || 0; - tk += parseFloat($(this).find('.kredit').val()) || 0; + td += window.parseMoney($(this).find('.debit').val()); + tk += window.parseMoney($(this).find('.kredit').val()); }); $('#formTotalDebit').text(td.toLocaleString('id-ID')); @@ -367,7 +373,7 @@ $('.btn-add').click(async function(){ $('#formTotalDebit').text('0'); $('#formTotalKredit').text('0'); - $('#modeAuto').click(); // default + $('#modeManual').click(); // default jurnal manual dengan dua akun }); $('#btnSimpan').click(function(){ @@ -381,8 +387,10 @@ $('#btnSimpan').click(function(){ $('#tableDetail tbody tr').each(function(){ - let d = parseFloat($(this).find('.debit').val()) || 0; - let k = parseFloat($(this).find('.kredit').val()) || 0; + let d = window.parseMoney($(this).find('.debit').val()); + let k = window.parseMoney($(this).find('.kredit').val()); + + if(d <= 0 && k <= 0) return; account_id.push($(this).find('.account_id').val()); debit.push(d); @@ -393,13 +401,18 @@ $('#btnSimpan').click(function(){ }); // ✅ VALIDASI DI SINI - if(totalDebit !== totalKredit){ + if(Math.abs(totalDebit - totalKredit) > 0.009){ Swal.fire('Warning','Total Debit dan Kredit harus sama!','warning'); return; } if(totalDebit === 0 && totalKredit === 0){ - Swal.fire('Warning','Harap Isi Semua Kolom!','warning'); // ✅ FIX add all collumn must be filled + Swal.fire('Warning','Minimal satu pasangan debit dan kredit harus diisi.','warning'); + return; + } + + if(account_id.length < 2 || account_id.some(id => !id)){ + Swal.fire('Warning','Setiap baris yang memiliki nominal wajib memilih akun.','warning'); return; } @@ -412,7 +425,6 @@ $('#btnSimpan').click(function(){ Swal.fire('Warning','Tanggal tidak boleh kosong!','warning'); return; } - // lanjut simpan $.post("", { tanggal: $('#tanggal').val(), @@ -474,13 +486,37 @@ let table = $('#tableJurnal').DataTable({ showCancelButton:true }).then(r=>{ if(r.isConfirmed){ - $.get(""+id,function(){ - table.ajax.reload(null,false); - }); + $.post(""+id,{},function(res){ + if(res.status){ table.ajax.reload(null,false); Swal.fire('Sukses',res.message,'success'); } + else Swal.fire('Error',res.message,'error'); + },'json'); } }); }); + function workflowPost(endpoint, id, data, confirmation){ + Swal.fire({title:confirmation,icon:'question',showCancelButton:true}).then(function(result){ + if(!result.isConfirmed) return; + $.post(""+endpoint+'/'+id, data || {}, function(res){ + if(res.status){ table.ajax.reload(null,false); Swal.fire('Sukses',res.message,'success'); } + else Swal.fire('Error',res.message,'error'); + },'json').fail(function(xhr){ + Swal.fire('Error', xhr.responseJSON?.message || 'Proses workflow gagal.','error'); + }); + }); + } + + $(document).on('click','.btn-submit',function(){ workflowPost('submit',$(this).data('id'),{},'Ajukan jurnal ini?'); }); + $(document).on('click','.btn-approve',function(){ workflowPost('approve',$(this).data('id'),{},'Setujui jurnal ini?'); }); + $(document).on('click','.btn-reject',async function(){ + const result=await Swal.fire({title:'Tolak jurnal',input:'textarea',inputLabel:'Alasan',showCancelButton:true,inputValidator:v=>!v?'Alasan wajib diisi':undefined}); + if(result.isConfirmed) workflowPost('reject',$(this).data('id'),{reason:result.value},'Konfirmasi penolakan jurnal?'); + }); + $(document).on('click','.btn-reverse',async function(){ + const result=await Swal.fire({title:'Reversal jurnal',html:'',showCancelButton:true,preConfirm:()=>{const date=$('#reverseDate').val(),reason=$('#reverseReason').val().trim();if(!date||!reason){Swal.showValidationMessage('Tanggal dan alasan wajib diisi');return false;}return {date,reason};}}); + if(result.isConfirmed) workflowPost('reverse',$(this).data('id'),result.value,'Buat jurnal reversal?'); + }); + }); // DETAIL @@ -493,7 +529,10 @@ $(document).on('click','.btn-detail',function(){ $('#d_tanggal').text(res.header.tanggal); $('#d_noref').text(res.header.no_ref); $('#d_keterangan').text(res.header.keterangan); - $('#d_createdby').text(res.user.nama); + $('#d_createdby').text(res.user ? res.user.nama : '-'); + $('#d_status').text(String(res.header.status || '').toUpperCase()); + let history=(res.history||[]).map(h=>`${h.from_status||'-'} → ${h.to_status} oleh ${h.changed_by_name||'-'} (${h.changed_at})${h.notes?' — '+h.notes:''}`).join('
'); + $('#statusHistory').html(history || 'Belum ada perubahan status.'); let html = ''; let totalDebit = 0; @@ -524,4 +563,4 @@ $(document).on('click','.btn-detail',function(){ }); - \ No newline at end of file + diff --git a/application/views/kode_barang/layout.php b/application/views/kode_barang/layout.php index b1963dd..aae14f4 100644 --- a/application/views/kode_barang/layout.php +++ b/application/views/kode_barang/layout.php @@ -1,11 +1,11 @@ - +load->view('inventory_professional/_module_nav',get_defined_vars());?>
-
Kode Barang
- +
Jenis / Kode Barang
Master yang sudah digunakan dinonaktifkan, bukan dihapus.
+ session->userdata('role')==='Admin'||check_permission('items','can_create')):?>
@@ -16,9 +16,11 @@ Kode Nama Limit Stok - Tracking Type - Need Serial + Tracking + Perlu Serial + Biaya ke Harga Beli Unit + Status Aksi @@ -64,6 +66,12 @@ +
+ + + Ongkir, asuransi, dan layanan bersih dari diskon/cashback dialokasikan per unit saat penerimaan. +
+ +
+ + +
+
+ + + + + - - - - - - + + diff --git a/application/views/partials/header.php b/application/views/partials/header.php index 2abbdce..c573fab 100644 --- a/application/views/partials/header.php +++ b/application/views/partials/header.php @@ -1,415 +1,44 @@ -session->userdata('role'); ?> - - - - - - - Accounting - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- 🌿 Sekolah Islami - -
- -
- - - - - - Logout - - -
-
+load->library('NavigationService'); +$menu_items=$CI->navigationservice->items(); +$breadcrumbs=$CI->navigationservice->breadcrumb($active_menu); +$quick_actions=$CI->navigationservice->quickActions(); +$page_title=end($breadcrumbs); +$CI->config->load('ui_assets',true); +$uiAssets=$CI->config->item('ui_assets'); +$dataPages=isset($uiAssets['ui_data_pages'])?$uiAssets['ui_data_pages']:array(); +$selectPages=isset($uiAssets['ui_select_pages'])?$uiAssets['ui_select_pages']:array(); +$userName=$this->session->userdata('nama')?:$this->session->userdata('username')?:'Pengguna'; +$userRole=$this->session->userdata('role')?:'User'; +?> + + +<?=html_escape($page_title)?> · Accounting + + + + + + + + + + +
+ +
+
+
Accounting Workspace

+
+
+
+ + +
diff --git a/application/views/partials/navigation_items.php b/application/views/partials/navigation_items.php new file mode 100644 index 0000000..4fe7431 --- /dev/null +++ b/application/views/partials/navigation_items.php @@ -0,0 +1,14 @@ + + + + +
+ diff --git a/application/views/payables/index.php b/application/views/payables/index.php new file mode 100644 index 0000000..d083afb --- /dev/null +++ b/application/views/payables/index.php @@ -0,0 +1,48 @@ +
+

Hutang & Pembayaran Supplier

Pilih supplier dan invoice, tentukan nominal pembayaran di panel kanan, lalu gunakan satu atau beberapa akun Kas/Bank.

+
+
Daftar Tagihan Belum LunasDaftar ini hanya untuk memilih tagihan; nominal pembayaran diisi di panel kanan.
+ +
Pilih semua untuk mengalokasikan pembayaran dari tagihan terlama, atau pilih satu invoice tertentu.
+
Pilih supplier untuk melihat tagihan.
+
+
Detail & PembayaranKredit dan cicilan boleh dibayar sebagian, maksimal sebesar sisa tagihan.
+
Pilih tagihan di sebelah kiri untuk melihat detail pesanan.
+
+
+
Isi nilai yang benar-benar keluar dari rekening. Sistem memisahkan admin bank dari pokok pembayaran.
+
Ditambahkan ke total uang keluar.
+
Total tagihan dipilihRp 0,00
Pokok pembayaranRp 0,00
Admin bankRp 0,00
Total sumber Kas/BankRp 0,00
Dialokasikan ke invoiceRp 0,00
Sisa tagihan setelah pembayaranRp 0,00
Nominal pembayaran boleh lebih kecil dari saldo untuk kredit atau cicilan.
+

Unggah bukti transfer, foto struk, atau dokumen pembayaran. Bisa memilih beberapa file sekaligus.

Wajib. Maksimal 10 file, masing-masing 5 MB.
Belum ada bukti pembayaran.
+
+
+

Riwayat Pembayaran Supplier
Belum ada supplier yang dipilih.
+
+
+
Ringkasan Aging Hutang
SupplierBelum Jatuh Tempo1–30 Hari31–60 Hari61–90 Hari>90 HariTotal
name)?>$f,2,',','.')?>Statement
+
+ + + diff --git a/application/views/payables/statement.php b/application/views/payables/statement.php new file mode 100644 index 0000000..296f5e1 --- /dev/null +++ b/application/views/payables/statement.php @@ -0,0 +1 @@ +

Supplier Statement

name)?>

TanggalDokumenDebit/PembayaranKredit/TagihanSaldo
invoice_date?>supplier_invoice_no)?>total,0,',','.')?>balance,0,',','.')?>
payment_date?>payment_no?>amount,0,',','.')?>
diff --git a/application/views/peralatan_teknisi/_module_nav.php b/application/views/peralatan_teknisi/_module_nav.php new file mode 100644 index 0000000..5e5d242 --- /dev/null +++ b/application/views/peralatan_teknisi/_module_nav.php @@ -0,0 +1,16 @@ + + diff --git a/application/views/peralatan_teknisi/_status_change_js.php b/application/views/peralatan_teknisi/_status_change_js.php new file mode 100644 index 0000000..92f667f --- /dev/null +++ b/application/views/peralatan_teknisi/_status_change_js.php @@ -0,0 +1,28 @@ +window.technicianStatusCustomers=(int)$customer->id,'name'=>$customer->nama);},$customers),JSON_UNESCAPED_UNICODE|JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT)?>; +window.technicianStatusHtmlSafe=function(value){return $('
').text(value==null?'':value).html();}; +$(document).off('click.technicianStatus','.btn-change-item-status').on('click.technicianStatus','.btn-change-item-status',function(){ + const trigger=this,tracking=trigger.dataset.tracking==='UNIT'?'UNIT':'QTY',carried=Number(trigger.dataset.carried||0),installed=Number(trigger.dataset.installed||0),ownerElement=trigger.closest('.modal.show'),ownerModal=ownerElement&&window.bootstrap?bootstrap.Modal.getInstance(ownerElement):null; + if(ownerModal)ownerModal.hide(); + const safe=window.technicianStatusHtmlSafe,customers=window.technicianStatusCustomers||[],statusOptions=[]; + if(carried>0)statusOptions.push(''); + if(installed>0)statusOptions.push(''); + if(carried>0||installed>0)statusOptions.push(''); + const customerOptions=customers.map(customer=>'').join(''); + const sourceOptions=(carried>0?'':'')+(installed>0?'':''); + const formHtml='
Barang / Barcode
'+safe(trigger.dataset.item)+'
'+safe(trigger.dataset.barcode)+' · '+tracking+'
Dibawa: '+carried.toLocaleString('id-ID')+' · Dipasang: '+installed.toLocaleString('id-ID')+'
'; + const requestKey='TECH-'+Date.now()+'-'+Math.random().toString(36).slice(2); + Swal.fire({title:'Ubah Status Peralatan',html:formHtml,icon:'question',showCancelButton:true,confirmButtonText:'Simpan Status',cancelButtonText:'Batal',width:650,focusConfirm:false,allowOutsideClick:false, + didOpen:()=>{ + const status=document.getElementById('techUsageStatus'),source=document.getElementById('techSourceStatus'),customerGroup=document.getElementById('techCustomerGroup'),sourceGroup=document.getElementById('techSourceGroup'),qty=document.getElementById('techStatusQty'),qtyHelp=document.getElementById('techQtyHelp'); + const refresh=()=>{const target=status.value;customerGroup.classList.toggle('d-none',target!=='installed');sourceGroup.classList.toggle('d-none',target!=='warehouse'||!(carried>0&&installed>0));let available=target==='installed'?carried:(target==='carried'?installed:((source&&source.value==='installed')?installed:carried));if(qty){qty.max=available;qty.value=available;qtyHelp.textContent='Maksimal '+available.toLocaleString('id-ID')+' dari status asal.';}}; + status.addEventListener('change',refresh);if(source)source.addEventListener('change',refresh);refresh(); + const notes=document.getElementById('techUsageNotes');notes.removeAttribute('readonly');notes.disabled=false;setTimeout(()=>notes.focus(),50); + }, + preConfirm:async()=>{ + const status=document.getElementById('techUsageStatus').value,date=document.getElementById('techStatusDate').value,customer=document.getElementById('techCustomer').value,source=document.getElementById('techSourceStatus').value,notes=document.getElementById('techUsageNotes').value.trim(),proof=document.getElementById('techUsageProof').files[0],qty=tracking==='UNIT'?1:Number(document.getElementById('techStatusQty').value||0); + if(!date||!status||!notes){Swal.showValidationMessage('Tanggal, status tujuan, dan keterangan wajib diisi.');return false;}if(status==='installed'&&!customer){Swal.showValidationMessage('Customer wajib dipilih untuk barang yang dipasang.');return false;}if(tracking==='QTY'&&qty<=0){Swal.showValidationMessage('Jumlah yang diubah wajib lebih dari 0.');return false;} + const body=new FormData();body.append('security->get_csrf_token_name()?>','security->get_csrf_hash()?>');body.append('barcode',trigger.dataset.barcode);body.append('user_id',trigger.dataset.user);body.append('status',status);body.append('date',date);body.append('customer_id',customer);body.append('source_status',source);body.append('qty',qty);body.append('notes',notes);body.append('idempotency_key',requestKey);if(proof)body.append('proof',proof); + try{const response=await fetch('',{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body}),result=await response.json().catch(()=>({status:false,message:'Respons server tidak valid.'}));if(!response.ok||!result.status)throw Error(result.message||'Status gagal diperbarui.');return result;}catch(error){Swal.showValidationMessage(error.message);return false;} + } + }).then(result=>{if(ownerModal)ownerModal.show();if(!result.isConfirmed)return;if(window.AppAlert)AppAlert.toast(result.value.message,'success');if(typeof window.afterTechnicianStatusChange==='function')window.afterTechnicianStatusChange(result.value);}); +}); diff --git a/application/views/peralatan_teknisi/bulk_workflow.php b/application/views/peralatan_teknisi/bulk_workflow.php new file mode 100644 index 0000000..82ac4f4 --- /dev/null +++ b/application/views/peralatan_teknisi/bulk_workflow.php @@ -0,0 +1,56 @@ +load->view('inventory_professional/_module_nav',get_defined_vars()); +?> + +
+ load->view('peralatan_teknisi/_module_nav',get_defined_vars()); ?> + +
Role Anda belum mempunyai permission untuk memproses pergerakan batch.
+
+
+
1 · Pilih proses satu kali
Jenis Pergerakan

Data header berlaku untuk seluruh barang dalam daftar.

+
+ + + +
+ + + +
+ + + + + +
+
+ +
+
2 · Pilih barang
Tambahkan Banyak Barang

Pilih teknisi asal untuk menampilkan seluruh barang yang sedang dikuasai. Barcode dan pencarian tetap dapat digunakan untuk mempercepat pemilihan.

0 dipilih
+
+
+
+
Barang pada Teknisi AsalPilih barang yang akan diproses.
+
BarangBarcode / SerialStatusJenisSaldo
Pilih teknisi asal.
+
+
Barang yang DipilihJumlah QTY dapat disesuaikan sebelum diposting.
+
BarangBarcode / SerialJenisSaldoJumlah DiprosesKeterangan Baris
Belum ada barang yang dipilih.
+
+ +
+
3 · Bukti batch
Bukti Pengembalian

Satu atau beberapa foto/PDF dapat menjadi bukti untuk seluruh barang dalam dokumen ini.

+
+
+
Belum ada barang dipilihSemua baris diposting sebagai satu dokumen dan satu transaksi database.
+
+ +
+ + + diff --git a/application/views/peralatan_teknisi/customer_equipment_notes_pdf.php b/application/views/peralatan_teknisi/customer_equipment_notes_pdf.php new file mode 100644 index 0000000..9bcd0e9 --- /dev/null +++ b/application/views/peralatan_teknisi/customer_equipment_notes_pdf.php @@ -0,0 +1,12 @@ + + +'Saldo Awal','handover'=>'Penyerahan','install'=>'Pemasangan','remove'=>'Pelepasan','transfer'=>'Transfer','return'=>'Pengembalian','return_customer'=>'Kembali ke Customer','inspection'=>'Pemeriksaan','replacement'=>'Penggantian','correction'=>'Koreksi'); ?> +
name??$company->company_name??'Perusahaan')?>

Log Keterangan Barang Customer

Histori keterangan operasional setiap barang tanpa mengubah jurnal accounting
+
Customer
nama)?>
Jumlah Catatan
Tanggal Cetak
Dicetak oleh
+ +$row):?> +
NoTanggal / DokumenBarangBarcode / SerialProsesQtyKaryawanKeterangan


SN:
Belum ada log keterangan untuk customer ini.
+ + diff --git a/application/views/peralatan_teknisi/customer_equipment_pdf.php b/application/views/peralatan_teknisi/customer_equipment_pdf.php new file mode 100644 index 0000000..fd389cd --- /dev/null +++ b/application/views/peralatan_teknisi/customer_equipment_pdf.php @@ -0,0 +1,18 @@ + + +'Milik Perusahaan','customer'=>'Milik Customer','transferred_non_inventory'=>'Milik Perusahaan (Non-Persediaan)','unknown'=>'Belum Diketahui'); +$statusLabels=array('carried'=>'Sedang Dibawa','installed'=>'Terpasang','removed'=>'Hasil Pelepasan','returned'=>'Dikembalikan','available'=>'Tersedia'); +$conditionLabels=array('good'=>'Baik','inspection_required'=>'Perlu Diperiksa','repair_required'=>'Perlu Perbaikan','under_repair'=>'Dalam Perbaikan','damaged'=>'Rusak','lost'=>'Hilang','scrapped'=>'Dihapuskan'); +$position=function($row){if($row['custodian_type']==='technician')return'Teknisi: '.($row['technician_name']?:'-');if($row['custodian_type']==='customer')return($row['usage_status']==='installed'?'Terpasang di ':'Tersimpan di ').($row['customer_name']?:'Customer');if($row['custodian_type']==='warehouse')return'Gudang perusahaan: '.($row['warehouse_name']?:'-');if($row['custodian_type']==='quarantine')return'Karantina: '.($row['warehouse_name']?:'-');if($row['custodian_type']==='service_center')return'Pusat perbaikan: '.($row['warehouse_name']?:'-');return$row['custodian_type']?:'-';}; +?> +
name??$company->company_name??'Perusahaan')?>

Daftar Barang Customer

Laporan posisi operasional non-accounting dan barang perusahaan yang ditempatkan pada customer
+
Customer
nama)?>
Filter Status
Tanggal Cetak
Dicetak oleh
+ +$row):?> +
NoBarangBarcode / SerialPemilikQtyPosisiStatusKondisi


SN:
Tidak ada barang sesuai filter yang dipilih.
+ + diff --git a/application/views/peralatan_teknisi/detail.php b/application/views/peralatan_teknisi/detail.php new file mode 100644 index 0000000..aa52a1b --- /dev/null +++ b/application/views/peralatan_teknisi/detail.php @@ -0,0 +1,30 @@ +load->view('inventory_professional/_module_nav',get_defined_vars()); +?> +
+
+

Detail Peralatan Teknisi

Kelola barang dibawa, dipasang, dan dikembalikan tanpa menghilangkan histori.

+
Kembali
+
+
+
Nama Teknisifull_name)?>
+
Kode Karyawanemployee_code?:'-')?>
+
Jabatanposition_name?:'-')?>
+
Departemendepartment_name?:'-')?>
+
+
Barang Aktif pada Teknisi
Barcode QTY dapat memiliki sebagian jumlah dibawa dan sebagian dipasang.
NoTanggalBarcodeBarangSerial NumberJumlah AktifStatusAksi
+
Riwayat Perubahan Status
Setiap perpindahan disimpan sebagai audit trail dan tidak menimpa kejadian sebelumnya.
NoTanggalBarangBarcodePerubahanJumlahCustomerKeteranganDiproses OlehBukti
+
+ + diff --git a/application/views/peralatan_teknisi/detail_professional.php b/application/views/peralatan_teknisi/detail_professional.php new file mode 100644 index 0000000..268838d --- /dev/null +++ b/application/views/peralatan_teknisi/detail_professional.php @@ -0,0 +1,28 @@ + +load->view('inventory_professional/_module_nav',get_defined_vars()); ?> + +
+ load->view('peralatan_teknisi/_module_nav',get_defined_vars()); ?> +
Penanggung jawab operasional

full_name)?>

employee_code??'-').' · '.($technician->department_name??'-').' · '.($technician->position_name??'-'))?>

Scan Pergerakan
+
+ +
Barcode / SerialBarangKepemilikanJumlahCustomer / PosisiStatusKondisiAksi

Proses
+
Riwayat Dokumen

Histori tidak dapat dihapus atau diedit.

DokumenTanggalProsesCustomerKeteranganPDF
Belum ada riwayat.
+
+ + diff --git a/application/views/peralatan_teknisi/document_pdf.php b/application/views/peralatan_teknisi/document_pdf.php new file mode 100644 index 0000000..428c29e --- /dev/null +++ b/application/views/peralatan_teknisi/document_pdf.php @@ -0,0 +1,11 @@ + +'SALDO AWAL MIGRASI','handover'=>'SERAH TERIMA BARANG TEKNISI','install'=>'PEMASANGAN PERALATAN','remove'=>'PELEPASAN PERALATAN','transfer'=>'TRANSFER ANTAR TEKNISI','return'=>'PENGEMBALIAN KE GUDANG','return_customer'=>'PENGEMBALIAN KE CUSTOMER','replacement'=>'PENGGANTIAN PERALATAN','inspection'=>'HASIL PEMERIKSAAN','correction'=>'KOREKSI / REVERSAL');?> +

name??$company->nama??'Perusahaan')?>

address??$company->alamat??'')?>

DOKUMEN

+
Nomor Dokumen
Teknisi / Customer
Gudang / Work Order / Lokasi

+
BARANGBARCODE / SERIALKEPEMILIKANPOSISI ASAL → TUJUANQTYKONDISI

+
Alasan / Keterangan
+
Dibuat / Diproses


Teknisi


Customer / Gudang


+ + diff --git a/application/views/peralatan_teknisi/layout.php b/application/views/peralatan_teknisi/layout.php index a27f7c7..2097348 100644 --- a/application/views/peralatan_teknisi/layout.php +++ b/application/views/peralatan_teknisi/layout.php @@ -1,5 +1,4 @@ - - +load->view('inventory_professional/_module_nav',get_defined_vars());?> - -
- -
-
- -
-
-
Log Barang Teknisi
- Riwayat barang yang pernah dibawa oleh teknisi -
- -
- -
-
-
-
-
- - - - - - - - - - - - - - -
NoNama TeknisiKode BarangBarcodeNama BarangTanggal DibawaTanggal KembaliStatus
-
-
-
-
-
- -
- +load->view('inventory_professional/_module_nav',get_defined_vars()); +?> + +load->view('peralatan_teknisi/_module_nav',get_defined_vars()); ?> +
+
Filter Riwayat Peralatan

Tanggal mencakup tanggal dibawa, dipasang, atau dikembalikan.

Kembali
+
+
+
+
+
+
+
+
+
+
+
+
NoDokumenTeknisiBarangBarcode / SNPemilikQtyCustomerPosisiStatusKondisiKeteranganAksi
+
+ + \ No newline at end of file + diff --git a/application/views/peralatan_teknisi/log_pdf.php b/application/views/peralatan_teknisi/log_pdf.php new file mode 100644 index 0000000..24e063d --- /dev/null +++ b/application/views/peralatan_teknisi/log_pdf.php @@ -0,0 +1,10 @@ +Log Peralatan Teknisi +
name??$company->company_name??'Perusahaan'):'Perusahaan')?>
Dokumen Persediaan
Dicetak
+'Dibawa','installed'=>'Dipasang','removed'=>'Dilepas','returned'=>'Dikembalikan');?> +

Log Peralatan Teknisi

Periode: s.d. · Customer: · Teknisi: · Status: · Kondisi: · Kepemilikan: · Barang:
+ +$row):?> +
NoDokumenTeknisiBarangBarcode / SerialPemilikQtyCustomerPosisiStatus / KondisiKeterangan


Tidak ada riwayat sesuai filter yang dipilih.
+ diff --git a/application/views/peralatan_teknisi/technician_inventory_pdf.php b/application/views/peralatan_teknisi/technician_inventory_pdf.php new file mode 100644 index 0000000..415460b --- /dev/null +++ b/application/views/peralatan_teknisi/technician_inventory_pdf.php @@ -0,0 +1,12 @@ + + +
name??$company->company_name??'Perusahaan')?>

Daftar Peralatan per Teknisi

Status: · Snapshot posisi operasional saat laporan dibuat
+
Teknisi
employee_code.' - '.$technician->full_name)?>
Departemen / Posisi
department_name??'-').' / '.($technician->position_name??'-'))?>
Tanggal Cetak
Dicetak oleh
+ +$row):$qty=(float)$row['qty'];?> +
NoBarangBarcode / SerialPemilikQtyPosisiCustomerStatusKondisiTerakhir Diperbarui

SN:
Tidak ada peralatan dengan status .
+
Diserahkan / Gudang


(______________________)
Teknisi


(full_name)?>)
Mengetahui


(______________________)
+ + diff --git a/application/views/peralatan_teknisi/workbench.php b/application/views/peralatan_teknisi/workbench.php new file mode 100644 index 0000000..a3ed685 --- /dev/null +++ b/application/views/peralatan_teknisi/workbench.php @@ -0,0 +1,59 @@ +array(),'technicians'=>array(),'inspection_count'=>0); +function tech_qty($value){$n=(float)$value;return abs($n-round($n))<.0001?number_format($n,0,',','.'):number_format($n,4,',','.');} +?> +load->view('inventory_professional/_module_nav',get_defined_vars()); ?> + +
+ load->view('peralatan_teknisi/_module_nav',get_defined_vars()); ?> + + +
+ array('Dibawa Teknisi','bi-person-bag'),'installed'=>array('Terpasang','bi-house-check'),'inspection'=>array('Menunggu Periksa','bi-clipboard-pulse'),'repair'=>array('Dalam Perbaikan','bi-wrench-adjustable'),'damaged'=>array('Rusak','bi-exclamation-octagon'),'lost'=>array('Hilang','bi-question-diamond')) as$key=>$meta):?> +
+ +
+
+
Scan terlebih dahulu
Temukan Posisi dan Tindakan Barang

Scan barcode/serial perusahaan maupun barang customer. Sistem hanya menampilkan tindakan yang valid.

+
+
Belum ada barcode yang diperiksa.
+
+
+
Penguasaan per Teknisi

Buka halaman detail untuk melihat barang dibawa, terpasang, hasil pelepasan, dan riwayat.

+
+ +
TeknisiDepartemenJenis BarangDibawaTerpasangAksi
full_name)?>
employee_code)?>
department_name?:'-')?>subject_count)?>carried_qty)?>installed_qty)?> Detail
Belum ada penguasaan barang teknisi.
+
+
Dokumen Operasional Terakhir

Histori immutable; koreksi tidak dilakukan dengan menghapus dokumen.

DokumenTanggalProsesTeknisi / CustomerItemAksi
Memuat riwayat...
+ +
+
Satu dokumen atomik
Pemasangan Sekaligus Penggantian

Barang baru dipasang dan barang lama dilepas dalam satu posting. Jika salah satu gagal, seluruh proses dibatalkan.

+
+
+
+
Barang dipasang
Pengganti dari Teknisi
Pilih teknisi terlebih dahulu.
Belum ada barang dipilih.
Barang harus berada dalam penguasaan teknisi.
+
Barang dilepas
Barang Lama di Customer
Pilih customer terlebih dahulu.
Bisa milik perusahaan maupun milik customer.
+
+
+
+ +
+
Pemeriksaan terarah
Antrean Pemeriksaan Barang Kembali

Barang yang masih dibawa teknisi ditampilkan sebagai pengingat. Barang yang telah kembali dapat diperiksa dari gudang perusahaan maupun penyimpanan customer.

+
+
BarangBarcode / SerialPemilikPosisi SekarangTeknisi / Customer AsalQtyLangkah Berikutnya
Memuat antrean...
+
+ +
Cek terlebih dahulu
Scan Barcode / Serial Barang Customer

Jika barang pernah dicatat, gunakan data lama dan lanjutkan pergerakan tanpa mengisi identitas lagi.

+
Ringkasan per customer
Daftar Customer yang Memiliki Barang

Klik detail untuk melihat barang perusahaan maupun barang customer, posisi terakhir, dan statusnya.

CustomerJenis BarangTotal QtyMilik PerusahaanMilik CustomerTerpasangKondisi BaikAksi
Memuat ringkasan...
+
Non-accounting
Catat Barang Milik Customer

Gunakan form ini hanya jika barcode/SN belum pernah tercatat. Data tidak masuk persediaan, costing, atau jurnal.

+ +
+ + + + + + + + diff --git a/application/views/professional_reports/index.php b/application/views/professional_reports/index.php new file mode 100644 index 0000000..1a50733 --- /dev/null +++ b/application/views/professional_reports/index.php @@ -0,0 +1,41 @@ +'Neraca Saldo','general_ledger'=>'Buku Besar','profit_loss'=>'Laporan Laba Rugi','balance_sheet'=>'Laporan Neraca','cash_flow'=>'Laporan Arus Kas','changes_equity'=>'Perubahan Ekuitas','journal_register'=>'Register Jurnal','account_detail'=>'Detail Transaksi Akun','actual_budget'=>'Aktual vs Budget','sales_summary'=>'Rekap Penjualan','sales_by_customer'=>'Penjualan per Customer','sales_by_item'=>'Penjualan per Barang','sales_by_barcode'=>'Penjualan per Barcode','running_invoices'=>'Invoice Berjalan Belum Final','sales_returns'=>'Retur Penjualan','revenue_recognition'=>'Pendapatan Diakui vs Ditangguhkan','unbilled_deliveries'=>'Surat Jalan Belum Ditagihkan','receivable_reconciliation'=>'Rekonsiliasi Piutang','purchase_returns'=>'Laporan Retur Pembelian','supplier_refunds'=>'Laporan Refund Supplier','outstanding_supplier_refunds'=>'Outstanding Refund Supplier','supplier_debit_notes'=>'Laporan Debit Note Supplier'); +$money=function($value){return number_format((float)$value,2,',','.');}; +$accountCode=function($value){return preg_replace('/(?:[\.,]0+)$/','',(string)$value);}; +?> +
+
+

name)?> ·
+ +
+
+
+
+
+
+
+
+ +
Selisih: Rp
+
+
AKTIVA
TOTAL AKTIVARp
+
PASIVA
Liabilitas
Ekuitas
Laba Bersih BerjalanRp
TOTAL PASIVARp
+
+ +
+
PENDAPATAN
TOTAL PENDAPATANRp
+
BEBAN
TOTAL BEBANRp
+
+
=0?'LABA BERSIH':'RUGI BERSIH'?>

Rp

+ + 'purchase_return','refund_no'=>'supplier_refund','debit_note_no'=>'supplier_debit_note'); ?> + + + +
diff --git a/application/views/purchase_returns/index.php b/application/views/purchase_returns/index.php new file mode 100644 index 0000000..911edcf --- /dev/null +++ b/application/views/purchase_returns/index.php @@ -0,0 +1,128 @@ +session->userdata('role')==='Admin'; +$can=function($feature,$action)use($master){return$master||check_permission($feature,$action);}; +$money=function($value){return number_format((float)$value,2,',','.');}; +$statusLabel=array('draft'=>'Draft','submitted'=>'Diajukan','approved'=>'Disetujui','rejected'=>'Ditolak','reserved'=>'Siap Dikeluarkan','shipped'=>'Sudah Dikirim','waiting_supplier'=>'Menunggu Supplier','partially_resolved'=>'Penyelesaian Parsial','resolved'=>'Selesai','waiting_funds'=>'Menunggu Dana','partial'=>'Parsial','received'=>'Dana Diterima','reconciled'=>'Terekonsiliasi','posted'=>'Diposting','applied'=>'Diterapkan','cancelled'=>'Dibatalkan','reversed'=>'Direversal'); +$statusClass=function($status){if(in_array($status,array('resolved','received','reconciled','applied'),true))return'success';if(in_array($status,array('rejected','cancelled','reversed'),true))return'danger';if(in_array($status,array('submitted','reserved','waiting_supplier','waiting_funds','partial','partially_resolved'),true))return'warning';return'secondary';}; +$tabs=array('summary'=>array('bi-grid','Ringkasan','Status dan pekerjaan aktif','attention')); +if($can('purchase_returns','can_view'))$tabs+=array( + 'return'=>array('bi-arrow-return-left','Pengajuan Retur','Draft dan persetujuan','return'), + 'shipment'=>array('bi-truck','Pengiriman Retur','Keluar gudang ke supplier','shipment'), + 'replacement'=>array('bi-box-seam','Barang Pengganti','Penerimaan tanpa hutang','replacement') +); +if($can('purchase_refunds','can_view'))$tabs['refund']=array('bi-cash-coin','Refund Dana','Klaim dan penerimaan dana','refund'); +if($can('supplier_debit_notes','can_view'))$tabs['debit_note']=array('bi-receipt-cutoff','Debit Note','Pengurang hutang supplier','debit_note'); +$tabs['history']=array('bi-clock-history','Riwayat','Audit seluruh penyelesaian',null); +?> + + + +
+
+

Retur & Refund Pembelian

Kelola pengecualian setelah PO atau penerimaan tanpa mengganggu lima langkah pembelian normal.

+ Purchase Workflow +
+ 'success','error'=>'error')as$key=>$icon)if($this->session->flashdata($key)):?>
+ + + + + +
Alur Retur BarangStok tidak berkurang saat draft atau pengajuan.
DraftDiajukanDisetujui & ReservedKeluar GudangMenunggu SupplierSelesai
Pengurangan stok, stock log, item movement, inventory ledger, dan jurnal hanya diposting pada langkah Pengiriman Retur.
Pemisahan Tugas
PurchasingMembuat pengajuan dan melengkapi bukti.
ApproverMenyetujui atau menolak sebelum stok direservasi.
GudangMem-posting barang keluar dan menerima pengganti.
FinanceDebit Note, refund receivable, penerimaan, dan rekonsiliasi.
+ + +
+
Buat Draft Pengajuan ReturSatu batch boleh beberapa barang/penerimaan, tetapi wajib satu supplier.

Barang yang Diretur
Maksimal 10 file, masing-masing 5 MB. Gambar dan PDF didukung.
+
Daftar Pengajuan ReturDraft tidak mengubah stok. Approval akan mereservasi barcode/batch.
status,array('draft','submitted','approved','rejected'),true))continue;?>
DokumenSupplier / PONilaiStatusAksi
return_no?>return_date?> · problem_category)?>supplier_code.' - '.$r->supplier_name)?>po_no?:'Beberapa penerimaan')?>Rp amount)?>status]??strtoupper($r->status)?> status==='draft'&&(int)$r->created_by===(int)$this->session->userdata('user_id')&&$can('purchase_returns','can_update')):?>
status==='submitted'&&$can('purchase_returns','can_approve')):?>
Tidak ada pengajuan pada tahap ini.
+
+ + +
Pengiriman Retur dari GudangAksi ini mengurangi stok dan mem-posting stock log, movement, ledger, serta jurnal terkait.
status,array('reserved','shipped','waiting_supplier'),true))continue;?>
ReturSupplier / POTanggal PengajuanNilaiStatusAksi Gudang
return_no?>supplier_name)?>po_no?>return_date?>Rp amount)?>status]??$r->status?>status==='reserved'&&$can('purchase_returns','can_post')):?>
Tidak ada retur yang siap dikirim.
+ + +
Barang Pengganti dari SupplierPenerimaan dapat parsial; tidak membentuk hutang baru bila nilainya sama.
status,array('shipped','waiting_supplier','partially_resolved'),true))continue;?>
Retur SumberSupplierPenyelesaianStatusAksi
return_no?>po_no?>supplier_name)?>requested_resolution)?>status]??$r->status?>
Tidak ada barang pengganti yang menunggu diterima.
+ + +
Buat Draft Klaim RefundDapat mengalokasikan beberapa dokumen dari supplier yang sama.

Daftar Refund Supplier
KlaimSupplier / SumberDiajukanDiterima / SisaStatusAksi
refund_no?>claim_date?> · Est. expected_date?:'-'?>supplier_name)?>po_no?:($r->internal_no?:$r->source_type))?>Rp claim_amount)?>Rp received_amount)?>Sisa Rp balance)?>status]??$r->status?> status==='draft'&&(int)$r->created_by===(int)$this->session->userdata('user_id')&&$can('purchase_refunds','can_update')):?>
status==='submitted'&&$can('purchase_refunds','can_approve')):?>
status,array('waiting_funds','partial'),true)&&$can('purchase_refunds','can_post')):?>status==='received'&&$can('purchase_refunds','can_post')):?>
Belum ada klaim refund.
+ + +
Buat Debit Note SupplierMengurangi saldo hutang; pada invoice lunas otomatis menjadi Piutang Refund Supplier.
Total Debit NoteRp 0,00
Daftar Debit Note
Debit NoteSupplier / InvoiceJenisNilaiStatusAksi
debit_note_no?>note_date?>supplier_name)?>internal_no?> / po_no?>correction_type)?>Rp amount)?>status]??$n->status?> status==='draft'&&$can('supplier_debit_notes','can_post')):?>
Belum ada Debit Note.
+ + +
Riwayat Retur, Pengganti, Refund, dan Debit NoteSeluruh dokumen posted tetap dipertahankan untuk audit.
JenisNomorSupplierTanggalNilai / QtyStatusDokumen
Retur Barangreturn_no?>supplier_name)?>return_date?>Rp amount)?>status]??$r->status?>
Barang Penggantireplacement_no?>supplier_name)?>receipt_date?>-status]??$r->status?>
Refund Danarefund_no?>supplier_name)?>claim_date?>Rp claim_amount)?>status]??$r->status?>
Debit Notedebit_note_no?>supplier_name)?>note_date?>Rp amount)?>status]??$r->status?>
+ +
Bukti Pengiriman dan Penerimaan DanaDokumen operasional terpisah dari pengajuan induknya.
+ + +
DokumenSumberSupplierTanggalNilaiStatusAksi
shipment_no?>Surat Pengiriman Returreturn_no?>po_no?:'-'?>supplier_name)?>shipment_date?>-status]??$r->status?>
receipt_no?>Bukti Penerimaan Refundrefund_no?>kode_akun?:'').' '.($r->nama_akun?:'')))?>supplier_name)?>receipt_date?>Rp amount)?>status]??$r->status?>
+ + status,array('resolved','reversed'),true);});if($resolvedReturns&&$can('purchase_returns','can_export')):?>
Ringkasan PenyelesaianDokumen penutup untuk audit retur yang sudah selesai.
return_no?> · supplier_name)?> · status]??$r->status?>
+ +
Kontrol Pembatalan & ReversalTidak ada dokumen posted yang dihapus. Setiap tindakan meminta alasan dan meninggalkan audit trail.
+ status,array('draft','submitted','rejected','reserved'),true)&&$can('purchase_returns','can_update')):?>
return_no?> · supplier_name)?> status]??$r->status?>
status,array('shipped','waiting_supplier','partially_resolved'),true)&&$can('purchase_returns','can_post')):?>
return_no?> · supplier_name)?> status]??$r->status?>
+ status,array('draft','submitted','rejected'),true)&&$can('purchase_refunds','can_update')):?>
refund_no?> · supplier_name)?> status]??$r->status?>
+ status,array('posted','reconciled'),true)&&$can('purchase_refunds','can_post')):?>
receipt_no?> · Penerimaan refund Rp amount)?>
+ status,array('posted','applied'),true)&&$can('supplier_debit_notes','can_post')):?>
debit_note_no?> · supplier_name)?> · Rp amount)?>
+
+ +
+ + + + + + + + + + + + + + diff --git a/application/views/purchases/index.php b/application/views/purchases/index.php new file mode 100644 index 0000000..4708522 --- /dev/null +++ b/application/views/purchases/index.php @@ -0,0 +1,60 @@ +';} +$step=$this->input->get('step')?:'request';$allowedSteps=array('request','order','payment','receipt','invoice');if(!in_array($step,$allowedSteps,true))$step='request';$departments=$this->db->where('is_active',1)->order_by('department_name')->get('k_departments')->result();$paymentAccounts=$this->db->where(array('is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->order_by('kode_akun')->get('accounts')->result();$returnBarcodes=array();$isMasterAdmin=$this->session->userdata('role')==='Admin';$canPrView=$isMasterAdmin||check_permission('purchases','can_view');$canPrCreate=$isMasterAdmin||check_permission('purchases','can_create');$canPrApprove=$isMasterAdmin||check_permission('purchases','can_approve');$canPrExport=$isMasterAdmin||check_permission('purchases','can_export'); +?> +
+

Purchase Workflow

Kelola proses normal permintaan, pemesanan, pembayaran awal, penerimaan, dan Invoice Supplier.

Retur & Refund
+ 'success','error'=>'error')as$key=>$icon)if($this->session->flashdata($key)):?>
+ array('bi-file-earmark-plus','Permintaan','Kebutuhan barang'),'order'=>array('bi-cart-check','Purchase Order','Pesanan disetujui'),'payment'=>array('bi-wallet2','Pembayaran Awal/DP','Jika disyaratkan'),'receipt'=>array('bi-box-arrow-in-down','Penerimaan','Pemeriksaan gudang'),'invoice'=>array('bi-receipt','Invoice Supplier','Final matching'));?> + + + + +
Buat Purchase Request
+ +
+
+
Diisi manual dan akan ditampilkan pada detail serta tanda tangan PDF.
+ +
UNIT: satu barang satu barcode. QTY: satu barcode dapat memuat beberapa barang.
Wajib untuk tracking QTY.
+ +
Daftar BarangSatu PR dapat berisi beberapa barang.
+ +
+
+ +
Daftar Purchase Request
requested_by===(int)$this->session->userdata('user_id');if($r->status==='draft'&&!$isOwner)continue;?>
Nomor/TanggalDepartemenKebutuhanStatusAksi
request_no?>request_date?>department?:'-')?>purpose)?>status?> PDF status==='draft'&&$isOwner)echo purchase_action_form('submit_request',$r->id,'Ajukan',$this,'request');elseif($canPrApprove&&$r->status==='submitted')echo purchase_action_form('approve_request',$r->id,'Approve',$this,'request');?> status==='draft'&&$isOwner):?>
+ + +
Buat Purchase Order dari PR Disetujui
+
+
Pembayaran dicatat setelah tagihan supplier diposting.
+
+
+
+
Daftar Purchase Order
db->where('purchase_order_id',$o->id)->get('purchase_order_lines')->row();?>
POSupplierTujuanTotalStatusAksi
po_no?>name)?>destination_type==='asset'?'Aset Tetap':'Gudang'?>total,2,',','.')?>status?>status==='draft')echo purchase_action_form('submit_order',$o->id,'Ajukan',$this,'order');elseif($o->status==='submitted'&&$this->session->userdata('role')==='Admin')echo purchase_action_form('approve_order',$o->id,'Approve',$this,'order');?>
+ + +
Penerimaan Barang/AsetPosting penerimaan adalah satu-satunya proses yang mengaktifkan stok atau register aset.
status,array('approved','partially_received')))continue;$lines=$this->db->where('purchase_order_id',$o->id)->get('purchase_order_lines')->result();?>
PO/SupplierTujuanItem dan SisaPenerimaan
po_no?>name)?>destination_type==='asset'?'Aset Tetap':'Gudang'?>description)?> · sisa qty-$l->received_qty,2,',','.')?>
+
Riwayat Penerimaan
NomorPOSupplierTanggalSurat Jalan
receipt_no?>po_no?>name)?>receipt_date?>supplier_delivery_no?:'-')?>
+ + + + status!=='approved'||!in_array($b->payment_plan,array('cash','installment')))continue;?>
payment_plan==='cash'?'Pembayaran Langsung':'Cicilan Pertama'?> — internal_no)?>
payment_plan==='installment'):?>
+
Tagihan Supplier / Three-way Matching
+
Daftar Tagihan
TagihanSupplierRencana BayarJatuh TempoSaldoStatusAksi
internal_no?>supplier_invoice_no)?>name)?>'Langsung','installment'=>'Cicilan','credit'=>'Kredit')[$b->payment_plan]??'Kredit'?>due_date?>balance,0,',','.')?>'Perlu Finalisasi','partial'=>'Parsial','paid'=>'Lunas')[$b->status]??$b->status)?>status==='verified'):?>Finalisasi
+ + +
Retur Pembelian
Debit Note Supplier
+ +
+ + + + + + + + diff --git a/application/views/purchases/workflow.php b/application/views/purchases/workflow.php new file mode 100644 index 0000000..20d2551 --- /dev/null +++ b/application/views/purchases/workflow.php @@ -0,0 +1,222 @@ +input->get('step')?:'order';if(!in_array($step,array('order','payment','receipt','invoice'),true))$step='order'; +$paymentAccounts=$this->db->where(array('is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end()->order_by('kode_akun')->get('accounts')->result(); +$bankChargeMapping=$this->db->get_where('system_account_mappings',array('mapping_key'=>'bank_charge_expense','is_active'=>1))->row();$bankChargeAccountId=$bankChargeMapping?(int)$bankChargeMapping->account_id:0; +$bankChargeAccounts=$this->db->where(array('is_active'=>1,'allow_posting'=>1,'tipe'=>'expense'))->order_by('kode_akun')->get('accounts')->result(); +$payments=$this->db->select('sp.*,po.po_no,s.name supplier_name')->from('supplier_payments sp')->join('purchase_orders po','po.id=sp.purchase_order_id','left')->join('suppliers s','s.id=sp.supplier_id')->order_by('sp.id','DESC')->limit(200)->get()->result(); +$invoiceReceipts=$this->db->select('gr.*,po.po_no,s.name')->from('goods_receipts gr')->join('purchase_orders po','po.id=gr.purchase_order_id')->join('suppliers s','s.id=gr.supplier_id')->where('gr.status','posted')->where_in('po.status',array('partially_received','received'))->where("EXISTS(SELECT 1 FROM goods_receipt_lines grl WHERE grl.goods_receipt_id=gr.id AND grl.qty-COALESCE((SELECT SUM(prl.shipped_qty) FROM purchase_return_lines prl JOIN purchase_returns pr ON pr.id=prl.purchase_return_id WHERE prl.receipt_line_id=grl.id AND pr.status IN('shipped','waiting_supplier','partially_resolved','resolved')),0)>COALESCE((SELECT SUM(sil.qty) FROM supplier_invoice_lines sil WHERE sil.receipt_line_id=grl.id),0))",null,false)->order_by('gr.id','DESC')->get()->result(); +$paymentItems=array();$paymentItemRows=$this->db->select('pol.purchase_order_id,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')->order_by('pol.purchase_order_id')->order_by('pol.id')->get()->result();foreach($paymentItemRows as$paymentItem)$paymentItems[$paymentItem->purchase_order_id][]=$paymentItem; +$returnBarcodes=$this->db->select('ib.id,ib.barcode,ib.serial_number,ib.qty_sisa,ib.source_receipt_line_id,pol.tracking_type')->from('item_barcodes ib')->join('goods_receipt_lines grl','grl.id=ib.source_receipt_line_id')->join('purchase_order_lines pol','pol.id=grl.po_line_id')->where('ib.qty_sisa >',0)->order_by('ib.barcode')->get()->result_array(); +$budgetCompanyId=(int)$this->session->userdata('company_id');$poBudgetAccounts=$this->db->select('id,kode_akun,nama_akun')->where(array('company_id'=>$budgetCompanyId,'tipe'=>'expense','is_active'=>1,'allow_posting'=>1))->order_by('kode_akun')->get('accounts')->result_array(); +function p_action($route,$id,$label,$ci,$step,$class='outline-primary'){return'
';} +function p_docs($type,$id,$status=null){$ci=&get_instance();$master=$ci->session->userdata('role')==='Admin';$canReturnView=$master||check_permission('purchase_returns','can_view');$canReturnCreate=$master||check_permission('purchase_returns','can_create');$canRefundView=$master||check_permission('purchase_refunds','can_view');$canRefundCreate=$master||check_permission('purchase_refunds','can_create');$canDebitView=$master||check_permission('supplier_debit_notes','can_view');$canDebitCreate=$master||check_permission('supplier_debit_notes','can_create');$detailClass=$type==='purchase_order'?'purchase-order-detail':($type==='supplier_payment'?'purchase-payment-detail':($type==='goods_receipt'?'purchase-receipt-detail':($type==='supplier_invoice'?'purchase-invoice-detail':'purchase-detail')));$pdfUrl=$type==='supplier_payment'?'purchasedocuments/payment_bank_pdf/'.$id:($type==='goods_receipt'?'purchasedocuments/receipt_pdf/'.$id:($type==='supplier_invoice'?'purchasedocuments/invoice_pdf/'.$id:'purchasedocuments/pdf/'.$type.'/'.$id));$links=array();if($type==='purchase_order'&&in_array($status,array('approved','partially_received','received'),true)){if($canReturnCreate)$links[]=array('Ajukan Retur','return&po_id='.$id);if($canRefundCreate)$links[]=array('Ajukan Refund','refund&po_id='.$id);if($canReturnView||$canRefundView)$links[]=array('Lihat Retur/Refund Terkait','history&po_id='.$id);}elseif($type==='goods_receipt'&&$status==='posted'){if($canReturnCreate){$links[]=array('Retur Barang','return&receipt_id='.$id);$links[]=array('Laporkan Kekurangan','return&category=specification&receipt_id='.$id);$links[]=array('Laporkan Barang Rusak','return&category=damaged&receipt_id='.$id);}if($canReturnView){$links[]=array('Catat Barang Pengganti','replacement&receipt_id='.$id);$links[]=array('Lihat Retur Terkait','history&receipt_id='.$id);}}elseif($type==='supplier_invoice'&&in_array($status,array('partial','paid'),true)){if($canDebitCreate)$links[]=array('Buat Debit Note','debit_note&invoice_id='.$id);if($canRefundCreate)$links[]=array('Ajukan Refund','refund&invoice_id='.$id);if($canDebitView||$canRefundView)$links[]=array('Lihat Penyesuaian Tagihan','history&invoice_id='.$id);}elseif($type==='supplier_payment'&&$status==='posted'){if($canRefundCreate)$links[]=array('Ajukan Refund','refund&payment_id='.$id);if($canRefundView)$links[]=array('Lihat Refund Terkait','history&payment_id='.$id);}$context='';if($links){$context=' ';foreach($links as$link)$context.=''.$link[0].'';$context.='';}$pdf=$master||check_permission('purchases','can_export')?' PDF':'';return''.$pdf.$context;} +?> +
+

Workflow Pembelian

Alur normal dari permintaan sampai finalisasi Invoice Supplier.

Retur & Refund
+ 'success','error'=>'error')as$key=>$icon)if($this->session->flashdata($key)):?>
+ array('bi-file-earmark-plus','Permintaan','Kebutuhan barang'),'order'=>array('bi-cart-check','Purchase Order','Pesanan disetujui'),'payment'=>array('bi-wallet2','Pembayaran Awal/DP','Jika disyaratkan'),'receipt'=>array('bi-box-arrow-in-down','Penerimaan','Pemeriksaan gudang'),'invoice'=>array('bi-receipt','Invoice Supplier','Final matching'));?> + + + +
Buat PO dari PR DisetujuiIsi data order di kiri dan periksa rincian serta total PO di kanan.
+
+ +
+
Data Purchase Order
+
+
+
+
+
+
+
Contoh total Rp600.000 tanpa DP dan 3 cicilan = Rp200.000 per bulan.
Hanya DP ini yang muncul di Menu Pembayaran.
+ 'Ongkos Kirim','insurance_cost'=>'Asuransi','service_fee'=>'Biaya Layanan','discount_amount'=>'Diskon/Voucher','cashback_amount'=>'Cashback')as$n=>$label):?>
+

Unggah penawaran, konfirmasi order, atau dokumen kesepakatan supplier.

Maksimal 10 file, masing-masing 5 MB.
+
+
+
Detail PemesananHarga dan pajak dapat disesuaikan sebelum PO disimpan.
+
Pilih PR untuk memuat semua barang.
+
+
Subtotal barangRp 0,00
PajakRp 0,00
Biaya tambahanRp 0,00
Diskon & cashback- Rp 0,00
Total PORp 0,00
+
+
+
+
+
+
+
Daftar Purchase Order
POSupplierMetodePembayaranTotalStatusAksi
po_no)?>name)?>procurement_method)?>payment_timing)?> · payment_status)?>total,2,',','.')?>status?>id,$o->status)?> status==='draft')echo p_action('submit_order',$o->id,'Ajukan',$this,'order');elseif($o->status==='submitted'&&($this->session->userdata('role')==='Admin'||check_permission('purchases','can_approve')))echo p_action('approve_order',$o->id,'Approve',$this,'order','success');?>
+ + +
Pembayaran sebelum barang diterima dicatat ke Barang Dalam Perjalanan. Stok baru bertambah saat Penerimaan Gudang.
+payment_timing==='prepaid';$isRequiredDp=$o->payment_timing==='installment'&&(int)($o->down_payment_required??0)===1;$paymentTarget=$isRequiredDp?(float)$o->down_payment_amount:(float)$o->total;if(!in_array($o->status,array('approved','partially_received'),true)||(!$isPrepaid&&!$isRequiredDp)||(float)$o->paid_amount>=$paymentTarget-.001)continue;$requiredPayment=round($paymentTarget-$o->paid_amount,2);$paymentLabel=$isRequiredDp?'DP wajib dibayar':'Wajib dibayar';$poPaymentItems=$paymentItems[$o->id]??array();?> +
+
+
po_no.' · '.$o->name)?>procurement_method==='online'?'Pesanan online '.($o->marketplace?:''):'Pembelian '.$o->procurement_method)?>
+ Rp +
+
+
+
Form Pembayaran
+
+ +
+
+
Isi nominal yang benar-benar keluar dari akun Kas/Bank.
+
+
Tidak mengurangi saldo PO.
+
(pokok)Rp
Admin bankRp 0,00
Total wajib keluarRp
Total sumber Kas/BankRp 0,00
Kekurangan sumber danaRp
Nominal pembayaran belum sesuai.
+
Wajib. Maksimal 10 file, masing-masing 5 MB.
+
+
+
+
+
+
Detail PemesananPeriksa barang dan nilai PO sebelum pembayaran.
+
Kode - BarangSpesifikasiQtyHargaTotal
kode_barang?:'-').' - '.($item->nama_barang?:'-'))?>tracking_type?:'-')?>description?:'-')?>qty,0,',','.')?>Rp unit_price,2,',','.')?>Rp line_total,2,',','.')?>
+
SubtotalRp subtotal,2,',','.')?>
PajakRp tax_total,2,',','.')?>
Ongkir, asuransi & layananRp shipping_cost+$o->insurance_cost+$o->service_fee,2,',','.')?>
Diskon & cashback- Rp discount_amount+$o->cashback_amount,2,',','.')?>
Total PORp total,2,',','.')?>
Sudah dibayarRp paid_amount,2,',','.')?>
Wajib dibayarRp
+
+
+
+ +
Riwayat Pembayaran Pembelian
No.POSupplierTanggalJenisPokokAdmin BankTotal KeluarDokumen
payment_no)?>po_no?:'-')?>supplier_name)?>payment_date?>payment_kind?>amount,2,',','.')?>bank_charge_amount??0,2,',','.')?>total_cash_out??0)>0?$p->total_cash_out:($p->amount+($p->bank_charge_amount??0)),2,',','.')?>id,$p->status)?>
+ + +
Penerimaan Barang ke Gudang

Periksa barang fisik, catat surat jalan, kuantitas aktual, dan bukti penerimaan.

Petugas Gudang
+ status,array('approved','partially_received'),true))continue;$availableReceipt=true;$lines=$this->db->where('purchase_order_id',$o->id)->get('purchase_order_lines')->result();?> +
po_no)?>name)?>
Kurir / Resicourier?:'-').' / '.($o->tracking_no?:'-'))?>
qty-$l->received_qty;?>
BarangDipesanSudah DiterimaDiterima Sekarang
description)?>qty,2,',','.')?>received_qty,2,',','.')?>

Unggah foto kondisi paket/barang, surat jalan bertanda tangan, atau dokumen penerimaan. Bisa memilih beberapa file sekaligus.

Wajib. Maksimal 10 file, masing-masing 5 MB.
+
Tidak ada Purchase Order yang siap diterima.
+
Riwayat Penerimaan
No.POSupplierTanggalSurat Jalan/ResiDokumen
receipt_no?>po_no?>name)?>receipt_date?>supplier_delivery_no?:'-')?>id,$r->status)?>
+ + +
Finalisasi Dokumen Invoice SupplierPilih penerimaan untuk memeriksa final hubungan PO, pembayaran, penerimaan, dan invoice supplier.

Unggah invoice/nota final dan faktur pajak bila ada. Dokumen pendukung PO tidak perlu diunggah ulang.

Wajib. Maksimal 10 file, masing-masing 5 MB.
Ringkasan Dokumen Sumber
Pilih penerimaan untuk melihat PO, supplier, dan barang.
+
Daftar Invoice SupplierPemeriksaan final matching → Parsial atau Lunas. Tidak memerlukan approval ulang.
total;$displayPaid=max((float)($b->po_paid_amount??0),(float)$b->paid_amount);$displayBalance=max(0,$displayTotal-$displayPaid);?>
InvoiceSupplierJatuh TempoTotal InvoiceTotal Sudah DibayarSisa TagihanStatusAksi
internal_no?>supplier_invoice_no)?>name)?>due_date?>'PERLU FINALISASI','partial'=>'PARSIAL','paid'=>'LUNAS')[$b->status]??strtoupper($b->status))?>id,$b->status)?> status==='verified'){$prepaidReconcile=in_array($b->po_payment_timing,array('prepaid','on_receipt'),true)&&(float)$b->po_paid_amount>=(float)$b->total-.001;echo '';}?>
+ + +
Retur Barang
+
Debit Note
+
Refund Pesanan
+
Riwayat Retur, Refund & Debit Note
JenisNomorTanggalNilaiDokumen
Retur Barangreturn_no?>return_date?>amount,2,',','.')?>id)?>
Refundrefund_no?>refund_date?>amount,2,',','.')?>id)?>
Debit Notedebit_note_no?>note_date?>amount,2,',','.')?>id)?>
+ +
+ + + + + -
- +
+ -
+
+
+ + + Wajib ditautkan untuk role Teknisi agar hanya dapat memproses barang miliknya. +
@@ -97,8 +103,9 @@ $(document).ready(function () { { data: 0 }, { data: 1 }, { data: 2 }, - { data: 3 }, - { data: 4, orderable: false } + { data: 3 }, + { data: 4 }, + { data: 5, orderable: false } ] }); @@ -128,7 +135,8 @@ $(document).ready(function () { id: $('#inputId').val(), username: $('#inputUsername').val(), nama: $('#inputNama').val(), - role_id: $('#inputRole').val(), + role_id: $('#inputRole').val(), + employee_id: $('#inputEmployee').val(), password: $('#inputPassword').val() }; @@ -183,7 +191,8 @@ $(document).ready(function () { $('#inputUsername').val(res.data.username); $('#inputNama').val(res.data.nama); - loadRole(res.data.role_id); + loadRole(res.data.role_id); + loadEmployee(res.data.employee_id); $('.password-group').hide(); $('#inputPassword').val(''); @@ -247,7 +256,7 @@ $(document).ready(function () { // ====================== // LOAD ROLE (FIXED) // ====================== - function loadRole(selected = '') { + function loadRole(selected = '') { $.ajax({ url: "", @@ -272,7 +281,11 @@ $(document).ready(function () { } }); - } + } + + function loadEmployee(selected = '') { + $.getJSON("").done(function(res){const select=$('#inputEmployee').empty().append('');(res.data||[]).forEach(function(e){select.append($('