Update besar apk finance

This commit is contained in:
Wian Drs
2026-09-11 16:03:00 +07:00
parent d42483b350
commit 4ef15c22ae
897 changed files with 98056 additions and 15979 deletions
+237
View File
@@ -0,0 +1,237 @@
<?php
use PHPUnit\Framework\TestCase;
final class ApplicationContractTest extends TestCase
{
public function testInternalControllersUseAuthenticatedBaseController(): void
{
$exceptions = array('Auth.php', 'Migrate.php', 'Accountingrepair.php', 'AttendanceWebhook.php');
foreach (glob(APPPATH . 'controllers/*.php') as $file) {
if (in_array(basename($file), $exceptions, true) || stripos(basename($file), 'check.php') !== false) continue;
$source = file_get_contents($file);
if (strpos($source, 'class ') === false) continue;
$isAuthenticated = preg_match('/extends\s+(MY_Controller|MY_Admin_Controller)/', $source);
$isCliOnly = strpos($source, 'is_cli_request()') !== false || strpos($source, 'is_cli()') !== false;
self::assertTrue((bool) ($isAuthenticated || $isCliOnly), basename($file) . ' melewati kontrol session/permission terpusat dan bukan controller CLI.');
}
}
public function testAjaxSessionExpiryReturnsJson401(): void
{
$source = file_get_contents(APPPATH . 'core/MY_Controller.php');
self::assertStringContainsString('is_ajax_request()', $source);
self::assertStringContainsString('set_status_header(401)', $source);
self::assertStringContainsString("'status' => false", $source);
}
public function testResponsiveShellHasDesktopAndMobileRules(): void
{
$css = file_get_contents(dirname(APPPATH) . '/assets/css/app-final.css');
self::assertStringContainsString('@media', $css);
self::assertMatchesRegularExpression('/\.mobile-action-bar\s*\{display\s*:\s*none!important\}/i', $css, 'Menu bawah wajib disembunyikan pada desktop.');
self::assertMatchesRegularExpression('/max-width\s*:\s*991(?:\.98)?px[\s\S]*\.mobile-action-bar\s*\{[\s\S]*display\s*:\s*grid!important/i', $css, 'Menu bawah wajib tersedia pada HP.');
self::assertMatchesRegularExpression('/max-width\s*:\s*(991|768|767)(?:\.98)?px/i', $css, 'Breakpoint HP belum tersedia.');
}
public function testUiPreventsRepeatedSubmitAndHandlesAjaxFailure(): void
{
$js = file_get_contents(dirname(APPPATH) . '/assets/js/app-ui.min.js');
$network = file_get_contents(dirname(APPPATH) . '/assets/js/app-network.js');
self::assertMatchesRegularExpression('/submitting|is-submitting|disabled/i', $js);
self::assertMatchesRegularExpression('/ajaxError|status\s*===\s*401|\.fail\s*\(/i', $network);
}
public function testMoneyInputsUseCentralParserBeforeAjax(): void
{
$ui = file_get_contents(dirname(APPPATH) . '/assets/js/app-ui.min.js');
$journal = file_get_contents(APPPATH . 'views/jurnal/layout.php');
self::assertStringContainsString('window.parseMoney=', $ui);
self::assertStringContainsString("replace(/,/g,'')", $ui);
self::assertStringContainsString("+(hasDecimal?','+decimal:'')", $ui);
self::assertStringNotContainsString("parseFloat($(this).find('.debit').val())", $journal);
self::assertStringContainsString("window.parseMoney($(this).find('.debit').val())", $journal);
}
public function testMasterAdminHasFullAccessAndApprovalOverride(): void
{
$base = file_get_contents(APPPATH . 'core/MY_Controller.php');
$approval = file_get_contents(APPPATH . 'libraries/ApprovalService.php');
$roles = file_get_contents(APPPATH . 'controllers/Roles.php');
self::assertStringContainsString("userdata('role') === 'Admin'", $base);
self::assertStringContainsString('$masterAdmin', $approval);
self::assertStringContainsString('is_super_admin', $approval);
self::assertStringContainsString('Role wajib mempunyai minimal satu permission.', $roles);
self::assertStringContainsString('Role bawaan aplikasi tidak dapat dihapus.', $roles);
}
public function testProfessionalReportsHaveIndonesianFinancialStatements(): void
{
$navigation = file_get_contents(APPPATH . 'libraries/NavigationService.php');
$view = file_get_contents(APPPATH . 'views/professional_reports/index.php');
self::assertStringContainsString("'label'=>'Laporan Profesional'", $navigation);
self::assertStringContainsString("'balance_sheet'=>'Laporan Neraca'", $view);
self::assertStringContainsString("'profit_loss'=>'Laporan Laba Rugi'", $view);
self::assertStringContainsString('TOTAL AKTIVA', $view);
self::assertStringContainsString('TOTAL PASIVA', $view);
self::assertStringContainsString('Laba Bersih Berjalan', $view);
self::assertStringContainsString('TOTAL PENDAPATAN', $view);
self::assertStringContainsString('TOTAL BEBAN', $view);
self::assertStringContainsString('$accountCode', $view);
}
public function testPurchasePaymentsSeparatePrincipalAndBankCharge(): void
{
$service = file_get_contents(APPPATH . 'libraries/PurchaseService.php');
$purchases = file_get_contents(APPPATH . 'controllers/Purchases.php');
$payables = file_get_contents(APPPATH . 'controllers/Payables.php');
$migration = file_get_contents(APPPATH . 'migrations/20260905000700_add_purchase_payment_bank_charge.php');
self::assertStringContainsString('bank_charge_amount', $migration);
self::assertStringContainsString('total_cash_out', $migration);
self::assertStringContainsString("accountmappingservice->get('bank_charge_expense')", $service);
self::assertStringContainsString('$principal=round($gross-$charge,2)', $service);
self::assertStringContainsString('prepayOrderWithBankCharge', $purchases);
self::assertStringContainsString('settleSupplierInvoiceMultiWithBankCharge', $purchases);
self::assertStringContainsString('paymentMultiWithBankCharge', $payables);
}
public function testPurchaseReturnsAreSeparatedAndIntegrated(): void
{
$navigation=file_get_contents(APPPATH.'libraries/NavigationService.php');
$workflow=file_get_contents(APPPATH.'views/purchases/workflow.php');
$view=file_get_contents(APPPATH.'views/purchase_returns/index.php');
$service=file_get_contents(APPPATH.'libraries/PurchaseAdjustmentService.php');
$controller=file_get_contents(APPPATH.'controllers/Purchasereturns.php');
self::assertStringContainsString("'label'=>'Retur & Refund'",$navigation);
self::assertStringNotContainsString("'adjustment'=>'6",$workflow);
self::assertStringContainsString('purchase-workflow-stepper',$workflow);
self::assertStringContainsString('adjustment-stepper',$view);
self::assertStringContainsString('purchase_return_status_history',$service);
self::assertStringContainsString("movement_type'=>'purchase_return'",$service);
self::assertStringContainsString("writeLedger('purchase_return'",$service);
self::assertStringContainsString("accountmappingservice->get('supplier_refund_receivable')",$service);
self::assertStringContainsString('FOR UPDATE',$service);
self::assertStringContainsString('idempotency_key',$service);
self::assertStringContainsString("requirePermission('purchase_returns'",$controller);
self::assertStringContainsString("requirePermission('purchase_refunds'",$controller);
self::assertStringContainsString("requirePermission('supplier_debit_notes'",$controller);
self::assertStringContainsString('attachment-input',$view);
self::assertStringContainsString('DataTransfer',$view);
self::assertStringContainsString('<iframe',$view);
}
public function testPurchaseAdjustmentsSupportReversalAuditAndProfessionalReports(): void
{
$service=file_get_contents(APPPATH.'libraries/PurchaseAdjustmentService.php');
$purchaseService=file_get_contents(APPPATH.'libraries/PurchaseService.php');
$reportService=file_get_contents(APPPATH.'libraries/ProfessionalReportService.php');
$reportView=file_get_contents(APPPATH.'views/professional_reports/index.php');
$adjustmentView=file_get_contents(APPPATH.'views/purchase_returns/index.php');
self::assertStringContainsString('reverseReturn',$service);
self::assertStringContainsString('reverseRefundReceipt',$service);
self::assertStringContainsString('reverseDebitNote',$service);
self::assertStringContainsString('supplier_refund_status_history',$service);
self::assertStringContainsString('supplier_debit_note_status_history',$service);
self::assertStringContainsString("'purchase_returns'",$reportService);
self::assertStringContainsString("'outstanding_supplier_refunds'",$reportService);
self::assertStringContainsString('Laporan Retur Pembelian',$reportView);
self::assertStringContainsString('Outstanding Refund Supplier',$reportView);
self::assertStringContainsString('preInvoiceReturnDeduction',$purchaseService);
self::assertStringContainsString("join('goods_receipt_lines grl0'",$purchaseService);
self::assertStringContainsString('si0.created_at<=pr.shipped_at',$purchaseService);
self::assertStringNotContainsString('window.confirm(',$adjustmentView);
self::assertStringNotContainsString('window.alert(',$adjustmentView);
}
public function testProfessionalInventoryUsesOneLedgerAndSafeWorkflow(): void
{
$service=file_get_contents(APPPATH.'libraries/ProfessionalInventoryService.php');
$model=file_get_contents(APPPATH.'models/InventoryModel.php');
$controller=file_get_contents(APPPATH.'controllers/Inventoryprofessional.php');
$operations=file_get_contents(APPPATH.'views/inventory_professional/operations.php');
$pending=file_get_contents(APPPATH.'views/items/draft_items.php');
self::assertStringContainsString("FROM inventory_ledger WHERE item_id=?",$model);
self::assertStringContainsString('FOR UPDATE',$service);
self::assertStringContainsString('idempotency_key',$service);
self::assertStringContainsString('reservationAction',$service);
self::assertStringContainsString('reversal_of_id',$service);
self::assertStringContainsString("requirePermission('items','can_post')",$controller);
self::assertStringContainsString('serverSide:true',$operations);
self::assertStringContainsString('Satu dokumen dapat memuat beberapa barang.',$operations);
self::assertStringNotContainsString('Tambah Master Barang',$pending);
self::assertStringNotContainsString('window.alert(',$operations);
self::assertStringNotContainsString('window.confirm(',$operations);
}
public function testInventoryFinalizationCoversRequiredOperationalScenarios(): void
{
$service=file_get_contents(APPPATH.'libraries/ProfessionalInventoryService.php');
$purchase=file_get_contents(APPPATH.'libraries/PurchaseService.php');
$sales=file_get_contents(APPPATH.'libraries/ReceivableService.php');
$returns=file_get_contents(APPPATH.'libraries/PurchaseAdjustmentService.php');
$asset=file_get_contents(APPPATH.'libraries/FixedAssetService.php');
$technician=file_get_contents(APPPATH.'models/PeralatanTeknisi_model.php');
$items=file_get_contents(APPPATH.'controllers/Items.php');
$reports=file_get_contents(APPPATH.'controllers/Inventoryprofessional.php');
self::assertStringContainsString("\$tracking==='UNIT'?1",$purchase,'Penerimaan UNIT wajib menghasilkan satu unit per barcode.');
self::assertStringContainsString('qty_per_barcode',$purchase,'Penerimaan QTY wajib menghormati isi per barcode.');
self::assertStringContainsString("'posted_count'=>\$posted",$items,'Aktivasi barang berserial harus dapat diposting bertahap.');
self::assertStringContainsString("\$doc->document_type==='transfer'",$service,'Transfer gudang harus melalui dua sisi ledger.');
self::assertStringContainsString('reservationAction',$service);
self::assertStringContainsString("array('release','consume','cancel')",$service);
self::assertStringContainsString('stok negatif ditolak',$service);
self::assertStringContainsString('idempotency_key',$service);
self::assertStringContainsString("\$doc->document_type==='opname'",$service);
self::assertStringContainsString('reversal_of_id',$service);
self::assertStringContainsString("writeLedger('purchase_return'",$returns);
self::assertStringContainsString('purchase_replacements',$returns);
self::assertStringContainsString("ib.status = 'available'",$technician);
self::assertStringContainsString('FOR UPDATE',$technician);
self::assertStringContainsString("where('i.company_id'",$technician);
self::assertStringContainsString("'document_type'=>'invoice_details'",$sales);
self::assertStringContainsString("'document_type'=>'asset_capitalization'",$asset);
self::assertStringContainsString("'reconciliation'",$reports);
self::assertStringContainsString("'stock_card'",$reports);
self::assertStringContainsString('limit($length, $start)',$items,'Daftar barcode wajib dipaginasi di server.');
}
public function testInventoryWriteEndpointsAndButtonsArePermissionAware(): void
{
$controller=file_get_contents(APPPATH.'controllers/Inventoryprofessional.php');
$items=file_get_contents(APPPATH.'controllers/Items.php');
$operations=file_get_contents(APPPATH.'views/inventory_professional/operations.php');
$pending=file_get_contents(APPPATH.'views/items/draft_items.php');
self::assertStringContainsString("input->method())!=='POST'",$controller);
self::assertStringContainsString("input->method()) !== 'POST'",$items);
self::assertStringContainsString("requirePermission('items','can_approve')",$controller);
self::assertStringContainsString('permissions.approve',$operations);
self::assertStringContainsString('$canPost',$items);
self::assertStringNotContainsString('btn-adjust',$pending);
self::assertStringNotContainsString('btn-delete',$pending);
}
public function testFixedAssetsUseSingleProfessionalWorkflow(): void
{
$controller=file_get_contents(APPPATH.'controllers/Fixedassets.php');
$legacy=file_get_contents(APPPATH.'controllers/Asset.php');
$service=file_get_contents(APPPATH.'libraries/FixedAssetService.php');
$model=file_get_contents(APPPATH.'models/FixedAssetModel.php');
$view=file_get_contents(APPPATH.'views/fixed_assets/index.php');
self::assertStringContainsString('FixedAssetService',$controller);
self::assertStringContainsString("redirect('fixedassets'",$legacy);
self::assertStringNotContainsString("insert('assets'",$legacy);
self::assertStringContainsString('FOR UPDATE',$service);
self::assertStringContainsString("'document_type'=>'asset_capitalization'",$service);
self::assertStringContainsString("'movement_type'=>'installation'",$service);
self::assertStringContainsString('idempotency_key',$service);
self::assertStringContainsString("event_type==='depreciation'",$service);
self::assertStringContainsString("lifecycle_status NOT IN('disposed','lost','damaged')",$model);
self::assertStringContainsString('serverSide:true',file_get_contents(dirname(APPPATH).'/assets/js/fixed-assets.js'));
self::assertStringContainsString('Laporan & Rekonsiliasi',$view);
self::assertStringNotContainsString('window.alert(',$view);
self::assertStringNotContainsString('window.confirm(',$view);
}
}
@@ -0,0 +1,68 @@
<?php
use PHPUnit\Framework\TestCase;
final class AccountingDatabaseTest extends TestCase
{
public function testAccountingInvariantsAgainstDevelopmentDatabase(): void
{
$root = dirname(__DIR__, 2);
$command = array(PHP_BINARY, $root . '/index.php', 'stage17check');
$descriptor = array(1 => array('pipe', 'w'), 2 => array('pipe', 'w'));
$process = proc_open($command, $descriptor, $pipes, $root);
self::assertIsResource($process, 'Tidak dapat menjalankan pemeriksaan integrasi CI3.');
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
$exit = proc_close($process);
self::assertSame(0, $exit, trim($stderr . PHP_EOL . $stdout));
$result = json_decode($stdout, true);
self::assertIsArray($result, 'Output Stage17check bukan JSON: ' . $stdout);
self::assertSame('ok', $result['status']);
foreach ($result['checks'] as $name => $check) {
self::assertSame('pass', $check['status'], $name . ': ' . $check['detail']);
}
}
public function testPurchaseAdjustmentInvariantsAgainstDevelopmentDatabase(): void
{
$root=dirname(__DIR__,2);$command=array(PHP_BINARY,$root.'/index.php','purchaseadjustmentcheck','integrity');
$descriptor=array(1=>array('pipe','w'),2=>array('pipe','w'));$process=proc_open($command,$descriptor,$pipes,$root);
self::assertIsResource($process);$stdout=stream_get_contents($pipes[1]);$stderr=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$exit=proc_close($process);
self::assertSame(0,$exit,trim($stderr.PHP_EOL.$stdout));$result=json_decode($stdout,true);self::assertIsArray($result,'Output Purchaseadjustmentcheck bukan JSON: '.$stdout);self::assertSame('ok',$result['status']);
}
public function testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema(): void
{
$root=dirname(__DIR__,2);$command=array(PHP_BINARY,$root.'/index.php','purchaseadjustmentcheck','runtime');
$descriptor=array(1=>array('pipe','w'),2=>array('pipe','w'));$process=proc_open($command,$descriptor,$pipes,$root);
self::assertIsResource($process);$stdout=stream_get_contents($pipes[1]);$stderr=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$exit=proc_close($process);
self::assertSame(0,$exit,trim($stderr.PHP_EOL.$stdout));$result=json_decode($stdout,true);self::assertIsArray($result,'Output runtime Retur/Refund bukan JSON: '.$stdout);self::assertSame('ok',$result['status']);self::assertArrayHasKey('purchase_returns',$result['reports']);self::assertArrayHasKey('supplier_refunds',$result['reports']);self::assertArrayHasKey('supplier_debit_notes',$result['reports']);
}
public function testProfessionalInventoryRuntimeAgainstDevelopmentDatabase(): void
{
$root=dirname(__DIR__,2);$command=array(PHP_BINARY,$root.'/index.php','inventorycheck','professional');
$descriptor=array(1=>array('pipe','w'),2=>array('pipe','w'));$process=proc_open($command,$descriptor,$pipes,$root);
self::assertIsResource($process);$stdout=stream_get_contents($pipes[1]);$stderr=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$exit=proc_close($process);
self::assertSame(0,$exit,trim($stderr.PHP_EOL.$stdout));$result=json_decode($stdout,true);self::assertIsArray($result,'Output Inventorycheck bukan JSON: '.$stdout);self::assertSame('ok',$result['status']);self::assertArrayHasKey('reconciliation',$result);
}
public function testInventoryIntegrityAndNegativeStockGuardAgainstDevelopmentDatabase(): void
{
$root=dirname(__DIR__,2);
foreach(array('integrity','trigger_test') as$action){$command=array(PHP_BINARY,$root.'/index.php','inventorycheck',$action);$descriptor=array(1=>array('pipe','w'),2=>array('pipe','w'));$process=proc_open($command,$descriptor,$pipes,$root);self::assertIsResource($process);$stdout=stream_get_contents($pipes[1]);$stderr=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$exit=proc_close($process);self::assertSame(0,$exit,$action.': '.trim($stderr.PHP_EOL.$stdout));self::assertStringContainsString('[OK]',$stdout,$action.' tidak menghasilkan bukti pemeriksaan.');}
}
public function testInventoryReconciliationAlwaysProducesAuditableNumbers(): void
{
$root=dirname(__DIR__,2);$command=array(PHP_BINARY,$root.'/index.php','inventorycheck','reconciliation');$descriptor=array(1=>array('pipe','w'),2=>array('pipe','w'));$process=proc_open($command,$descriptor,$pipes,$root);self::assertIsResource($process);$stdout=stream_get_contents($pipes[1]);$stderr=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$exit=proc_close($process);self::assertContains($exit,array(0,1),trim($stderr.PHP_EOL.$stdout));self::assertStringContainsString('ledger_value=',$stdout);self::assertStringContainsString('gl_value=',$stdout);self::assertStringContainsString('difference=',$stdout);
}
public function testFixedAssetIntegrityAndRuntimeQueries(): void
{
$root=dirname(__DIR__,2);$command=array(PHP_BINARY,$root.'/index.php','fixedassetcheck');
$descriptor=array(1=>array('pipe','w'),2=>array('pipe','w'));$process=proc_open($command,$descriptor,$pipes,$root);
self::assertIsResource($process);$stdout=stream_get_contents($pipes[1]);$stderr=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$exit=proc_close($process);
self::assertSame(0,$exit,trim($stderr.PHP_EOL.$stdout));self::assertStringContainsString('Fixed asset integrity checks passed.',$stdout);self::assertStringContainsString('model_query_smoke_company_',$stdout);
}
}
+22
View File
@@ -0,0 +1,22 @@
# QA perangkat Tahap 17
Automated suite memeriksa kontrak responsive, session AJAX, double-submit, permission, serta invariant database. Sebelum rilis, jalankan juga exploratory test berikut karena rendering Safari/iOS dan perilaku jaringan nyata tidak dapat dibuktikan hanya dari unit test.
| Target | Viewport / kondisi | Alur wajib |
|---|---:|---|
| Laptop | 1366×768 | Login, menu, invoice, pembayaran, jurnal, laporan |
| Desktop | 1920×1080 | Pastikan bottom navigation tidak tampil dan tabel tetap nyaman |
| Android kecil | 360×640 | Menu lengkap, form bertahap, scanner, sticky action |
| Android besar | 412×915 | Tabel responsive detail dan modal fullscreen |
| iPhone Safari | 390×844 | Safe-area bottom nav, input nominal, download laporan |
| Browser | Chrome, Edge, Firefox | Semua alur utama dan ekspor |
| Jaringan | Fast 3G, latency 400 ms | Loading terlihat, timeout informatif, submit tidak ganda |
| Session | Hapus cookie saat halaman terbuka | AJAX menerima 401 dan diarahkan login tanpa menampilkan HTML di tabel |
| Idempotency | Double-click / kirim request dua kali | Hanya satu invoice, pembayaran, stok, atau jurnal terbentuk |
Perintah standar:
```powershell
C:\xampp\php\php.exe vendor\bin\phpunit
C:\xampp\php\php.exe index.php stage17check
```
+47
View File
@@ -0,0 +1,47 @@
<?php
use PHPUnit\Framework\TestCase;
final class FormulaServiceTest extends TestCase
{
private $formula;
protected function setUp(): void
{
$this->formula = new FormulaService();
}
public function testPayrollFormulaHonorsPrecedenceAndVariables(): void
{
$value = $this->formula->calculate('basic_salary + overtime_hours * overtime_rate', array(
'basic_salary' => 5000000,
'overtime_hours' => 4,
'overtime_rate' => 50000,
));
self::assertSame(5200000.0, $value);
}
public function testProrataFormulaAndRounding(): void
{
self::assertSame(333333.33, $this->formula->calculate('salary / work_days', array(
'salary' => 7000000,
'work_days' => 21,
)));
}
public function testUnknownVariableIsZeroForBackwardCompatibleFormula(): void
{
self::assertSame(1000.0, $this->formula->calculate('base + optional_bonus', array('base' => 1000)));
}
public function testRejectsDivisionByZero(): void
{
$this->expectException(BusinessException::class);
$this->formula->calculate('salary / days', array('salary' => 1000, 'days' => 0));
}
public function testRejectsUnsafeTokens(): void
{
$this->expectException(BusinessException::class);
$this->formula->calculate('system("whoami")', array());
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
define('BASEPATH', dirname(__DIR__) . DIRECTORY_SEPARATOR . 'system' . DIRECTORY_SEPARATOR);
define('APPPATH', dirname(__DIR__) . DIRECTORY_SEPARATOR . 'application' . DIRECTORY_SEPARATOR);
require_once APPPATH . 'exceptions/BusinessException.php';
require_once APPPATH . 'libraries/FormulaService.php';