diff --git a/.env.example b/.env.example index fb6baa2c..25665a2d 100644 --- a/.env.example +++ b/.env.example @@ -52,10 +52,3 @@ MYKAD_READER_ENABLED_CAWANGAN='' INTERNAL_NOTIFICATION_SECRET= LOGVIEWER_PASSWORD= - -# Same master database as api_arrahn mysql7; required for financing records. -SHORAKA_MASTER_DB_HOST= -SHORAKA_MASTER_DB_PORT=3306 -SHORAKA_MASTER_DB_DATABASE= -SHORAKA_MASTER_DB_USERNAME= -SHORAKA_MASTER_DB_PASSWORD= diff --git a/app/Console/Commands/MoveShorakaFinancings.php b/app/Console/Commands/MoveShorakaFinancings.php deleted file mode 100644 index 549d7491..00000000 --- a/app/Console/Commands/MoveShorakaFinancings.php +++ /dev/null @@ -1,78 +0,0 @@ -option('source'); - $targetName = 'shoraka_master'; - $source = DB::connection($sourceName); - $target = DB::connection($targetName); - if ($sourceName === $targetName || ($source->getConfig('host') === $target->getConfig('host') - && $source->getDatabaseName() === $target->getDatabaseName() - && $source->getConfig('port') === $target->getConfig('port'))) { - $this->error('Source and target must be different databases.'); - return 1; - } - if (!Schema::connection($sourceName)->hasTable('shoraka_financings') - || !Schema::connection($targetName)->hasTable('shoraka_financings')) { - $this->error('Create the target table using the API master migration first. Source and target tables are required.'); - return 1; - } - if ($this->option('apply') && !app()->isDownForMaintenance()) { - $this->error('Stop traffic on every counter instance and run artisan down before applying the transfer.'); - return 1; - } - if ($source->table('shoraka_financings')->where('state', 'PROCESSING')->exists()) { - $this->error('PROCESSING records require reconciliation before transfer.'); - return 1; - } - $count = $source->table('shoraka_financings')->count(); - if (!$this->option('apply')) { - $this->info($count . ' source records. Check only; no data was copied.'); - return 0; - } - try { - $target->transaction(function () use ($source, $target) { - $source->table('shoraka_financings')->orderBy('id')->chunkById(200, function ($rows) use ($target) { - foreach ($rows as $row) { - $data = (array) $row; - unset($data['id']); // IDs may overlap across counter databases. - $key = ['kodcaw' => $row->kodcaw, 'norujukan' => $row->norujukan]; - $existing = $target->table('shoraka_financings')->where($key)->first(); - if (!$existing) { - $target->table('shoraka_financings')->insert($data); - $existing = $target->table('shoraka_financings')->where($key)->first(); - } - foreach ($data as $field => $value) { - $actual = $existing->$field; - if ($field === 'context') { - if (json_decode($actual, true) != json_decode($value, true)) { - throw new RuntimeException('Conflicting context: ' . $row->kodcaw . '/' . $row->norujukan); - } - } elseif ((string) $actual !== (string) $value) { - throw new RuntimeException('Conflicting field ' . $field . ': ' . $row->kodcaw . '/' . $row->norujukan); - } - } - } - }); - }); - } catch (\Throwable $exception) { - $this->error('Transfer rolled back. ' . $exception->getMessage()); - return 1; - } - $this->info($count . ' source records copied and verified. Source retained.'); - $this->info('Ensure shoraka.financing_connection is shoraka_master and reload configuration before reopening traffic.'); - return 0; - } -} diff --git a/app/Http/Controllers/GadaianController.php b/app/Http/Controllers/GadaianController.php index d1e71bda..a9bd60ff 100644 --- a/app/Http/Controllers/GadaianController.php +++ b/app/Http/Controllers/GadaianController.php @@ -180,15 +180,12 @@ class GadaianController extends Controller Session::put('nopelanggan', $customer_info['nopelanggan']); Session::put('page_reload', true); Session::forget('borang_online_printed'); - $gadai = Http::withOptions(['verify' => config('app.api_verify')])->post(config('api.url') . '/api/gadai/' . $auth_user['cawangan'], [ + Http::withOptions(['verify' => config('app.api_verify')])->post(config('api.url') . '/api/gadai/' . $auth_user['cawangan'], [ 'norujukan' => $norujukan, 'nopelanggan' => $customer_info['nopelanggan'], 'nokp' => $noic, 'sirig' => $new_sirig, - 'shoraka_new' => true, - ])->throw()->json(); - $norujukan = $gadai['norujukan']; - Session::put('norujukan', $norujukan); + ])->throw(); } else { $norujukan = Session::get('norujukan'); $nokp = Session::get('nokp'); @@ -208,15 +205,13 @@ class GadaianController extends Controller Session::put('nopelanggan', $nopelanggan); Session::put('page_reload', true); Session::forget('borang_online_printed'); - $gadai_baru = Http::withOptions(['verify' => config('app.api_verify')])->post(config('api.url') . '/api/gadai/' . $auth_user['cawangan'], [ + Http::withOptions(['verify' => config('app.api_verify')])->post(config('api.url') . '/api/gadai/' . $auth_user['cawangan'], [ 'norujukan' => $norujukan_baru, 'nopelanggan' => $nopelanggan, 'nokp' => $nokp, 'sirig' => $new_sirig, - 'shoraka_new' => true, - ])->throw()->json(); - $norujukan = $gadai_baru['norujukan']; - Session::put('norujukan', $norujukan); + ])->throw(); + $norujukan = $norujukan_baru; } } diff --git a/app/Http/Controllers/ShorakaController.php b/app/Http/Controllers/ShorakaController.php index 0dea9b76..b53bbf95 100644 --- a/app/Http/Controllers/ShorakaController.php +++ b/app/Http/Controllers/ShorakaController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers; -use App\ShorakaFinancing; use App\Services\ShorakaFinancingService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -12,8 +11,7 @@ class ShorakaController extends Controller { public function index() { - $records = ShorakaFinancing::where('kodcaw', Auth::user()->cawangan) - ->orderByDesc('id')->limit(100)->get(); + $records = app(ShorakaFinancingService::class)->listForBranch(Auth::user()->cawangan); $cawangan_info = ['details_caw' => Auth::user()->cawangan, 'kodcaw' => Auth::user()->cawangan]; return view('shoraka.index', compact('records', 'cawangan_info')); } @@ -29,28 +27,10 @@ class ShorakaController extends Controller 'date_from' => 'nullable|date_format:Y-m-d', 'date_to' => 'nullable|date_format:Y-m-d' . ($request->filled('date_from') ? '|after_or_equal:date_from' : ''), ]); - $branchQuery = ShorakaFinancing::select('kodcaw'); - if (!$isOperasi) { - $branchQuery->where('kodcaw', Auth::user()->cawangan); - } - $branches = $branchQuery->distinct()->orderBy('kodcaw')->pluck('kodcaw'); - $query = ShorakaFinancing::select('id', 'kodcaw', 'norujukan', 'teller', 'action', 'state', 'created_at', 'updated_at'); - if (!$isOperasi) { - $query->where('kodcaw', Auth::user()->cawangan); - } - foreach (['kodcaw', 'norujukan', 'state'] as $field) { - if (!empty($filters[$field])) { - $query->where($field, $filters[$field]); - } - } - if (!empty($filters['date_from'])) { - $query->where('created_at', '>=', $filters['date_from'] . ' 00:00:00'); - } - if (!empty($filters['date_to'])) { - $query->where('created_at', '<', \Carbon\Carbon::parse($filters['date_to'])->addDay()->format('Y-m-d') . ' 00:00:00'); - } - $records = $query->orderByDesc('created_at')->orderByDesc('id') - ->paginate(25)->appends($filters); + [$records, $branches] = app(ShorakaFinancingService::class)->search( + $filters, + $isOperasi ? null : Auth::user()->cawangan + ); $cawangan_info = ['details_caw' => Auth::user()->cawangan, 'kodcaw' => Auth::user()->cawangan]; return view('shoraka.operasi', compact('records', 'branches', 'filters', 'isOperasi', 'cawangan_info')); } @@ -78,15 +58,15 @@ class ShorakaController extends Controller public function poll(Request $request, string $norujukan, ShorakaFinancingService $service) { $record = $service->local($norujukan); - abort_unless($record && $record->teller === Auth::user()->name && $record->action === 'gadai', 403); - if ($record->state === 'DONE') { + abort_unless($record && ($record['teller'] ?? null) === Auth::user()->name && ($record['action'] ?? null) === 'gadai', 403); + if (($record['state'] ?? null) === 'DONE') { return response()->json(['state' => 'done', 'redirect' => route('customer_info')]); } - abort_unless(!empty($record->context['temporary_gadai']), 409); - if ($record->state === 'REVIEW') { + abort_unless(!empty($record['context']['temporary_gadai']), 409); + if (($record['state'] ?? null) === 'REVIEW') { return response()->json(['state' => 'failed', 'message' => 'Penyelesaian memerlukan semakan operasi. Jangan ulang bayaran.']); } - if ($record->state === 'PROCESSING') { + if (($record['state'] ?? null) === 'PROCESSING') { return response()->json(['state' => 'pending']); } try { @@ -115,7 +95,8 @@ class ShorakaController extends Controller } return response()->json(['state' => 'pending']); } catch (Throwable $exception) { - if ($record->fresh()->state === 'REVIEW') { + $fresh = $service->local($norujukan); + if ($fresh && ($fresh['state'] ?? null) === 'REVIEW') { return response()->json(['state' => 'failed', 'message' => 'Penyelesaian memerlukan semakan operasi. Jangan ulang bayaran.']); } if ($exception instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface @@ -134,7 +115,7 @@ class ShorakaController extends Controller public function submit(string $norujukan, ShorakaFinancingService $service) { $record = $service->local($norujukan); - abort_unless($record && $record->teller === Auth::user()->name, 403); + abort_unless($record && ($record['teller'] ?? null) === Auth::user()->name, 403); $response = $service->api()->post($service->url($norujukan, '/submit')); return redirect()->route('shoraka.show', compact('norujukan')) ->with('shoraka_message', $response->successful() diff --git a/app/Services/ShorakaFinancingService.php b/app/Services/ShorakaFinancingService.php index 240fe879..ca537ed5 100644 --- a/app/Services/ShorakaFinancingService.php +++ b/app/Services/ShorakaFinancingService.php @@ -2,11 +2,11 @@ namespace App\Services; -use App\ShorakaFinancing; use App\Http\Controllers\GadaianController; use App\Http\Controllers\AliranTunaiController; use App\Events\Dashboard\GadaiNotify; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Http; use Throwable; @@ -16,8 +16,6 @@ class ShorakaFinancingService { public function ensureReady(): void { - // Check the configured financing database before any registration side effect. - ShorakaFinancing::where('kodcaw', Auth::user()->cawangan)->exists(); $response = $this->api()->get(rtrim(config('api.url'), '/') . '/api/shoraka/readiness')->throw()->json(); abort_unless(!empty($response['ready']), 503, 'Shoraka belum dikonfigurasi. Pembiayaan baharu ditahan.'); } @@ -34,10 +32,66 @@ class ShorakaFinancingService . rawurlencode(Auth::user()->cawangan) . '/' . rawurlencode($reference) . $suffix; } - public function local(string $reference) + public function financingUrl(string $reference): string { - return ShorakaFinancing::where('kodcaw', Auth::user()->cawangan) - ->where('norujukan', $reference)->first(); + return rtrim(config('api.url'), '/') . '/api/shoraka/financings/' + . rawurlencode(Auth::user()->cawangan) . '/' . rawurlencode($reference); + } + + public function local(string $reference): ?array + { + $response = $this->api()->get($this->financingUrl($reference)); + if ($response->status() === 404) { + return null; + } + if ($response->serverError()) { + abort(503, 'Shoraka belum dikonfigurasi. Pembiayaan baharu ditahan.'); + } + $response->throw(); + return $this->record($response->json()['data'] ?? []); + } + + public function listForBranch(string $branch) + { + $response = $this->api()->get(rtrim(config('api.url'), '/') . '/api/shoraka/financings', [ + 'kodcaw' => $branch, + 'per_page' => 100, + ])->throw()->json(); + return collect($response['data'] ?? [])->map(function ($row) { + return $this->record($row); + }); + } + + public function search(array $filters, ?string $onlyBranch): array + { + $query = [ + 'per_page' => 25, + 'page' => max(1, (int) request()->input('page', 1)), + 'with_branches' => 1, + ]; + if ($onlyBranch) { + $query['kodcaw'] = $onlyBranch; + $query['branch_scope'] = $onlyBranch; + } elseif (!empty($filters['kodcaw'])) { + $query['kodcaw'] = $filters['kodcaw']; + } + foreach (['norujukan', 'state', 'date_from', 'date_to'] as $field) { + if (!empty($filters[$field])) { + $query[$field] = $filters[$field]; + } + } + $response = $this->api()->get(rtrim(config('api.url'), '/') . '/api/shoraka/financings', $query)->throw()->json(); + $items = collect($response['data'] ?? [])->map(function ($row) { + return $this->record($row); + }); + $records = new LengthAwarePaginator( + $items, + (int) ($response['total'] ?? 0), + (int) ($response['per_page'] ?? 25), + (int) ($response['current_page'] ?? 1), + ['path' => request()->url(), 'query' => request()->query()] + ); + return [$records, collect($response['branches'] ?? [])]; } public function hold(Request $request, string $reference, string $action, array $extra = []) @@ -49,17 +103,14 @@ class ShorakaFinancingService $temporaryContext = $context; $context = ['temporary_gadai' => true, 'pinjaman' => $extra['pinjaman']]; } - $record = ShorakaFinancing::firstOrCreate( - ['kodcaw' => $user->cawangan, 'norujukan' => $reference], - [ - 'teller' => $user->name, 'action' => $action, 'state' => 'WAITING', - 'context' => $context, - ] - ); - // A duplicate request only opens the saved workflow; it never replays registration. - if ($record->wasRecentlyCreated) { + $response = $this->api()->post($this->financingUrl($reference), [ + 'teller' => $user->name, + 'action' => $action, + 'context' => $context, + ])->throw()->json(); + if (!empty($response['created'])) { if (isset($temporaryContext) && !Cache::store('file')->put($key, $temporaryContext, 604800)) { - $record->update(['state' => 'REVIEW']); + $this->api()->put($this->financingUrl($reference), ['state' => 'REVIEW'])->throw(); abort(503, 'Maklumat pembiayaan sementara tidak dapat disimpan.'); } try { @@ -83,12 +134,12 @@ class ShorakaFinancingService public function finish(Request $request, string $reference) { $record = $this->local($reference); - abort_unless($record && $record->teller === Auth::user()->name, 403, + abort_unless($record && ($record['teller'] ?? null) === Auth::user()->name, 403, 'Pembiayaan perlu diselesaikan oleh teller asal.'); - if ($record->state === 'DONE') { + if (($record['state'] ?? null) === 'DONE') { return redirect()->route('shoraka.show', ['norujukan' => $reference]); } - abort_unless($record->state === 'WAITING', 409, + abort_unless(($record['state'] ?? null) === 'WAITING', 409, 'Penyelesaian telah diproses atau memerlukan semakan operasi. Jangan ulang bayaran.'); $this->api()->post($this->url($reference, '/refresh'))->throw(); @@ -97,52 +148,59 @@ class ShorakaFinancingService 'Bayaran ditahan sehingga Shoraka Completed dan amaun sepadan.'); abort_unless( - number_format((float) ($record->context['pinjaman'] ?? 0), 2, '.', '') + number_format((float) ($record['context']['pinjaman'] ?? 0), 2, '.', '') === number_format((float) ($snapshot['order_amount'] ?? 0), 2, '.', ''), 409, 'Amaun pembiayaan berubah. Semakan operasi diperlukan sebelum bayaran.' ); - $context = $record->context; + $context = $record['context'] ?? []; if (!empty($context['temporary_gadai'])) { - $context = Cache::store('file')->get('shoraka:payout:' . hash('sha256', $record->kodcaw . ':' . $reference)); + $context = Cache::store('file')->get('shoraka:payout:' . hash('sha256', $record['kodcaw'] . ':' . $reference)); abort_unless(is_array($context), 409, 'Maklumat sementara tamat. Semakan operasi diperlukan sebelum bayaran.'); } - $claimed = ShorakaFinancing::where('id', $record->id)->where('state', 'WAITING') - ->update(['state' => 'PROCESSING', 'updated_at' => now()]); - abort_unless($claimed === 1, 409, 'Penyelesaian sedang diproses.'); + $claim = $this->api()->put($this->financingUrl($reference), [ + 'state' => 'PROCESSING', + 'expected_state' => 'WAITING', + ])->throw()->json(); + abort_unless(!empty($claim['claimed']), 409, 'Penyelesaian sedang diproses.'); $saved = new Request($context); $saved->setLaravelSession($request->session()); try { - if ($record->action === 'gadai') { + if (($record['action'] ?? null) === 'gadai') { $result = app(GadaianController::class)->completeShorakaFinancing($saved); } else { - if (!empty($record->context['update_history'])) { + if (!empty($record['context']['update_history'])) { $this->api()->put(rtrim(config('api.url'), '/') . '/api/gadai/updatehistory/' - . rawurlencode($record->kodcaw) . '/' . rawurlencode($reference), [ + . rawurlencode($record['kodcaw']) . '/' . rawurlencode($reference), [ 'historygadaian' => $saved->norujukan, 'jenistebus' => $saved->jenistebus, ])->throw(); } $cash = app(AliranTunaiController::class); - if ($record->action === 'autotawarruq') { + if (($record['action'] ?? null) === 'autotawarruq') { $cash->alirantunaiTebusAutoTawarruq($saved); } else { $cash->alirantunaiTebus($saved); } - event(new \App\Events\Dashboard\TebusNotify('lulus', $record->kodcaw)); - event(new GadaiNotify('lulus', $record->kodcaw)); + event(new \App\Events\Dashboard\TebusNotify('lulus', $record['kodcaw'])); + event(new GadaiNotify('lulus', $record['kodcaw'])); $result = redirect()->route('shoraka.show', ['norujukan' => $reference]); } - $record->update(['state' => 'DONE', 'context' => []]); + $this->api()->put($this->financingUrl($reference), [ + 'state' => 'DONE', + 'context' => [], + ])->throw(); if (!empty($context['temporary_gadai'])) { - Cache::store('file')->forget('shoraka:payout:' . hash('sha256', $record->kodcaw . ':' . $reference)); + Cache::store('file')->forget('shoraka:payout:' . hash('sha256', $record['kodcaw'] . ':' . $reference)); } return $result; } catch (Throwable $exception) { - // A failed remote payment response can be ambiguous. Never retry automatically. - $record->update(['state' => 'REVIEW']); + try { + $this->api()->put($this->financingUrl($reference), ['state' => 'REVIEW'])->throw(); + } catch (Throwable $updateError) { + } throw $exception; } } @@ -175,4 +233,10 @@ class ShorakaFinancingService 'kuantiti' => $details['volume'], ]; } + + private function record(array $data): array + { + $data['context'] = $data['context'] ?? []; + return $data; + } } diff --git a/app/ShorakaFinancing.php b/app/ShorakaFinancing.php deleted file mode 100644 index e86b3983..00000000 --- a/app/ShorakaFinancing.php +++ /dev/null @@ -1,16 +0,0 @@ -connection ?: config('shoraka.financing_connection'); - } - - protected $guarded = []; - protected $casts = ['context' => 'array']; -} diff --git a/config/database.php b/config/database.php index a108dd5d..25be54e9 100644 --- a/config/database.php +++ b/config/database.php @@ -63,22 +63,6 @@ return [ ]) : [], ], - // Point these settings at the same database as api_arrahn's mysql7. - 'shoraka_master' => [ - 'driver' => 'mysql', - 'host' => env('SHORAKA_MASTER_DB_HOST'), - 'port' => env('SHORAKA_MASTER_DB_PORT', '3306'), - 'database' => env('SHORAKA_MASTER_DB_DATABASE'), - 'username' => env('SHORAKA_MASTER_DB_USERNAME'), - 'password' => env('SHORAKA_MASTER_DB_PASSWORD'), - 'unix_socket' => env('SHORAKA_MASTER_DB_SOCKET', ''), - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - ], - 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), diff --git a/config/shoraka.php b/config/shoraka.php deleted file mode 100644 index 7c7bcd57..00000000 --- a/config/shoraka.php +++ /dev/null @@ -1,7 +0,0 @@ - 'shoraka_master', -]; diff --git a/database/migrations/2026_10_05_120000_create_shoraka_financings_table.php b/database/migrations/2026_10_05_120000_create_shoraka_financings_table.php deleted file mode 100644 index 1e4090bd..00000000 --- a/database/migrations/2026_10_05_120000_create_shoraka_financings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -bigIncrements('id'); - $table->string('kodcaw', 20); - $table->string('norujukan'); - $table->string('teller'); - $table->string('action', 30); - $table->string('state', 30)->default('WAITING'); - $table->json('context'); - $table->timestamps(); - $table->unique(['kodcaw', 'norujukan']); - }); - } - - public function down() - { - Schema::dropIfExists('shoraka_financings'); - } -} diff --git a/resources/views/shoraka/index.blade.php b/resources/views/shoraka/index.blade.php index 31adf1b1..c3e074a5 100644 --- a/resources/views/shoraka/index.blade.php +++ b/resources/views/shoraka/index.blade.php @@ -50,13 +50,13 @@ $statusColors = ['WAITING' => 'warning', 'PROCESSING' => 'info', 'REVIEW' => 'danger', 'DONE' => 'success']; @endphp