DONE: remove cache, and remove dto financing

This commit is contained in:
2026-10-26 12:50:02 +08:00
parent cdc28fb010
commit 9aee720bd0
13 changed files with 132 additions and 482 deletions
-7
View File
@@ -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=
@@ -1,78 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class MoveShorakaFinancings extends Command
{
protected $signature = 'shoraka:move-financings {--source=mysql} {--apply : Copy records; default only checks}';
protected $description = 'Copy and verify financing records to shoraka_master without deleting the source';
public function handle()
{
$sourceName = $this->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;
}
}
+5 -10
View File
@@ -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;
}
}
+13 -32
View File
@@ -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()
+99 -35
View File
@@ -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;
}
}
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class ShorakaFinancing extends Model
{
public function getConnectionName()
{
return $this->connection ?: config('shoraka.financing_connection');
}
protected $guarded = [];
protected $casts = ['context' => 'array'];
}
-16
View File
@@ -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'),
-7
View File
@@ -1,7 +0,0 @@
<?php
return [
// Financing workflow records are centralized in API mysql7's master database.
// Never fall back to the counter database when the master is unavailable.
'financing_connection' => 'shoraka_master',
];
@@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateShorakaFinancingsTable extends Migration
{
public function up()
{
Schema::create('shoraka_financings', function (Blueprint $table) {
$table->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');
}
}
+4 -4
View File
@@ -50,13 +50,13 @@
$statusColors = ['WAITING' => 'warning', 'PROCESSING' => 'info', 'REVIEW' => 'danger', 'DONE' => 'success'];
@endphp
<tr>
<td class="text-nowrap">{{ $record->norujukan }}</td>
<td>{{ $record->teller }}</td>
<td class="text-nowrap">{{ $record['norujukan'] }}</td>
<td>{{ $record['teller'] }}</td>
<td>
<span class="badge badge-{{ $statusColors[$record->state] ?? 'secondary' }} status-badge">{{ $statusLabels[$record->state] ?? $record->state }}</span>
<span class="badge badge-{{ $statusColors[$record['state']] ?? 'secondary' }} status-badge">{{ $statusLabels[$record['state']] ?? $record['state'] }}</span>
</td>
<td class="text-center">
<a class="btn btn-info btn-sm" href="{{ route('shoraka.show', $record->norujukan) }}" aria-label="Buka pembiayaan {{ $record->norujukan }}">Buka</a>
<a class="btn btn-info btn-sm" href="{{ route('shoraka.show', $record['norujukan']) }}" aria-label="Buka pembiayaan {{ $record['norujukan'] }}">Buka</a>
</td>
</tr>
@empty
+6 -6
View File
@@ -77,12 +77,12 @@
<tbody>
@forelse($records as $record)
<tr>
<td>{{ $record->kodcaw }}</td>
<td class="text-nowrap">{{ $record->norujukan }}</td>
<td>{{ $record->teller }}</td>
<td><span class="badge badge-{{ $statusColors[$record->state] ?? 'secondary' }} p-2">{{ $statusLabels[$record->state] ?? $record->state }}</span></td>
<td class="text-nowrap">{{ $record->created_at ? $record->created_at->format('d/m/Y H:i') : '—' }}</td>
<td class="text-nowrap">{{ $record->updated_at ? $record->updated_at->format('d/m/Y H:i') : '—' }}</td>
<td>{{ $record['kodcaw'] }}</td>
<td class="text-nowrap">{{ $record['norujukan'] }}</td>
<td>{{ $record['teller'] }}</td>
<td><span class="badge badge-{{ $statusColors[$record['state']] ?? 'secondary' }} p-2">{{ $statusLabels[$record['state']] ?? $record['state'] }}</span></td>
<td class="text-nowrap">{{ !empty($record['created_at']) ? \Carbon\Carbon::parse($record['created_at'])->format('d/m/Y H:i') : '—' }}</td>
<td class="text-nowrap">{{ !empty($record['updated_at']) ? \Carbon\Carbon::parse($record['updated_at'])->format('d/m/Y H:i') : '—' }}</td>
</tr>
@empty
<tr><td colspan="6" class="text-center text-muted py-4">Tiada transaksi untuk tapisan ini.</td></tr>
+5 -5
View File
@@ -23,7 +23,7 @@
@if(!empty($data['simulation']))
<div class="alert alert-warning">SIMULASI LOCAL — tiada transaksi sebenar dihantar ke Shoraka.</div>
@endif
@if($record && !empty($record->context['temporary_gadai']) && $record->teller === Auth::user()->name && $record->state === 'WAITING')
@if($record && !empty($record['context']['temporary_gadai']) && $record['teller'] === Auth::user()->name && $record['state'] === 'WAITING')
<div id="shoraka-progress" class="alert alert-info" role="status">Sedang memproses pembiayaan dan menyemak komoditi Shoraka secara automatik. Sila tunggu.</div>
<script>
(function () {
@@ -128,9 +128,9 @@
@if(in_array($status, ['UNKNOWN', 'FAILED', 'PENDING']))
<p>Jangan hantar order sekali lagi. Hubungi operasi untuk semakan dengan Shoraka.</p>
@endif
@if($record && in_array($record->state, ['REVIEW', 'PROCESSING']))
@if($record && in_array($record['state'], ['REVIEW', 'PROCESSING']))
<div class="alert alert-warning">Penyelesaian sedang diproses atau memerlukan semakan operasi. Jangan ulang bayaran.</div>
@elseif($record && $record->state === 'DONE')
@elseif($record && $record['state'] === 'DONE')
<div class="alert alert-success">Pembiayaan telah diselesaikan.</div>
@endif
<div class="d-flex flex-wrap pt-3 mt-4 border-top">
@@ -138,12 +138,12 @@
<form class="mr-2 mb-2" method="post" action="{{ route('shoraka.refresh', $norujukan) }}">@csrf
<button class="btn btn-info" type="submit">Semak Status</button>
</form>
@elseif($data && !$order && $record && $record->teller === Auth::user()->name)
@elseif($data && !$order && $record && $record['teller'] === Auth::user()->name)
<form class="mr-2 mb-2" method="post" action="{{ route('shoraka.submit', $norujukan) }}">@csrf
<button class="btn btn-primary" type="submit">Hantar Order</button>
</form>
@endif
@if($ready && $record && $record->state === 'WAITING' && $record->teller === Auth::user()->name)
@if($ready && $record && $record['state'] === 'WAITING' && $record['teller'] === Auth::user()->name)
<form class="mr-2 mb-2" method="post" action="{{ route('shoraka.finish', $norujukan) }}">@csrf
<button class="btn btn-success" type="submit">Selesaikan Pembiayaan</button>
</form>
-238
View File
@@ -1,238 +0,0 @@
<?php
namespace Tests\Feature;
use App\ShorakaFinancing;
use App\User;
use App\Services\ShorakaFinancingService;
use App\Http\Controllers\GadaianController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Factory;
use Mockery;
use Tests\TestCase;
class ShorakaFinancingTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'shoraka_test',
'database.connections.shoraka_test' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
'api.url' => 'https://api.test', 'session.driver' => 'array',
]);
DB::purge('shoraka_test');
require_once database_path('migrations/2026_10_05_120000_create_shoraka_financings_table.php');
(new \CreateShorakaFinancingsTable())->up();
$this->actingAs(new User(['name' => 'teller1', 'cawangan' => 'KB2', 'roles' => 'teller']));
Http::swap(new Factory());
}
protected function record(): ShorakaFinancing
{
return ShorakaFinancing::create([
'kodcaw' => 'KB2', 'norujukan' => 'SAG-001', 'teller' => 'teller1',
'action' => 'gadai', 'state' => 'WAITING',
'context' => ['norujukan_gadai' => 'SAG-001', 'pinjaman' => 1000, 'cara_terima' => 'TUNAI'],
]);
}
protected function request(): Request
{
$request = new Request(['norujukan_gadai' => 'SAG-001', 'pinjaman' => 1000, '_token' => 'private']);
$request->setLaravelSession(app('session.store'));
return $request;
}
public function testHoldPersistsAndNeverSubmitsTwice(): void
{
Http::fake(function ($request) {
$this->assertSame('https://api.test/api/shoraka/gadai/KB2/SAG-001/submit', $request->url());
return Http::response(['success' => true, 'data' => ['status' => 'Active']]);
});
$service = new ShorakaFinancingService();
$service->hold($this->request(), 'SAG-001', 'gadai');
$service->hold($this->request(), 'SAG-001', 'gadai');
$this->assertSame(1, ShorakaFinancing::count());
$this->assertSame('WAITING', ShorakaFinancing::first()->state);
$this->assertArrayNotHasKey('_token', ShorakaFinancing::first()->context);
Http::assertSentCount(1);
}
public function testPendingStatusCannotTriggerPayment(): void
{
$record = $this->record();
Http::fake(function () { return Http::response(['data' => ['can_complete' => false]]); });
$controller = Mockery::mock(GadaianController::class);
$controller->shouldNotReceive('completeShorakaFinancing');
$this->app->instance(GadaianController::class, $controller);
try {
(new ShorakaFinancingService())->finish($this->request(), 'SAG-001');
$this->fail('Pending trade must block payment.');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
$this->assertSame(409, $exception->getStatusCode());
$this->assertSame('WAITING', $record->fresh()->state);
}
}
public function testCompletedTradePaysOnlyOnce(): void
{
$record = $this->record();
Http::fake(function () { return Http::response(['data' => ['can_complete' => true, 'order_amount' => 1000]]); });
$controller = Mockery::mock(GadaianController::class);
$controller->shouldReceive('completeShorakaFinancing')->once()->andReturn(redirect('/'));
$this->app->instance(GadaianController::class, $controller);
$service = new ShorakaFinancingService();
$service->finish($this->request(), 'SAG-001');
$service->finish($this->request(), 'SAG-001');
$this->assertSame('DONE', $record->fresh()->state);
$this->assertSame([], $record->fresh()->context);
Http::assertSentCount(2);
}
public function testAmbiguousPaymentFailureRequiresReview(): void
{
$record = $this->record();
Http::fake(function () { return Http::response(['data' => ['can_complete' => true, 'order_amount' => 1000]]); });
$controller = Mockery::mock(GadaianController::class);
$controller->shouldReceive('completeShorakaFinancing')->once()->andThrow(new \RuntimeException('Timeout'));
$this->app->instance(GadaianController::class, $controller);
try {
(new ShorakaFinancingService())->finish($this->request(), 'SAG-001');
$this->fail('Expected payment failure.');
} catch (\RuntimeException $exception) {
$this->assertSame('REVIEW', $record->fresh()->state);
}
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
(new ShorakaFinancingService())->finish($this->request(), 'SAG-001');
}
public function testDifferentTellerCannotCompletePayment(): void
{
$record = $this->record();
$record->update(['teller' => 'someone-else']);
Http::fake();
try {
(new ShorakaFinancingService())->finish($this->request(), 'SAG-001');
$this->fail('Expected teller restriction.');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
$this->assertSame(403, $exception->getStatusCode());
}
Http::assertNothingSent();
}
public function testPendingTradeCannotPrintSag(): void
{
$this->record();
Http::fake(function () { return Http::response(['data' => [
'can_complete' => false, 'order' => ['status' => 'Active'],
]]); });
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
(new ShorakaFinancingService())->printData('SAG-001');
}
public function testPrintUsesOrderCertificateAndVolume(): void
{
Http::fake(function () { return Http::response(['data' => [
'can_complete' => true, 'order' => ['status' => 'Completed'],
'details' => ['certificateDetails1' => 'CERT-01|timestamp|seller', 'unit' => 'Tonnes',
'volume' => '1.25', 'commodityType' => '000-COPPER'],
]]); });
$data = (new ShorakaFinancingService())->printData('SAG-001');
$this->assertSame('CERT-01', $data['komoditi']['no_sijil']);
$this->assertSame('1.25', $data['kuantiti']);
}
public function testBranchComesFromAuthenticatedUser(): void
{
$service = new ShorakaFinancingService();
$this->assertSame('https://api.test/api/shoraka/gadai/KB2/SAG-001', $service->url('SAG-001'));
$record = $this->record();
$record->update(['kodcaw' => 'KB3']);
$this->assertNull($service->local('SAG-001'));
}
public function testRegistrationDefersCashAndOnlinePayment(): void
{
\Illuminate\Support\Facades\Event::fake();
Http::fake(function ($request) {
if (strpos($request->url(), '/shoraka/readiness') !== false) {
return Http::response(['ready' => true]);
}
if (strpos($request->url(), '/marhun/gadai/') !== false) {
return Http::response('2000.00');
}
if (strpos($request->url(), '/gadai/norujukan/') !== false) {
return Http::response('true');
}
if (strpos($request->url(), '/shoraka/gadai/') !== false) {
return Http::response(['success' => true, 'data' => ['status' => 'Active']]);
}
$this->fail('Unexpected payment request: ' . $request->url());
});
$request = $this->request();
$request->merge([
'cara_terima' => 'TUNAI', 'keuntungan_sebulan' => 10,
'kadarkeuntungan' => 1, 'total_berat' => 5, 'nokp' => 'IC-1',
]);
$result = app(GadaianController::class)->store($request);
$this->assertStringContainsString('/shoraka/SAG-001', $result->getTargetUrl());
$this->assertSame('WAITING', ShorakaFinancing::first()->state);
app(GadaianController::class)->store($request);
Http::assertSentCount(4);
\Illuminate\Support\Facades\Event::assertNotDispatched(\App\Events\Dashboard\GadaiNotify::class);
}
public function testReadinessFailureBlocksRegistrationBeforeSave(): void
{
Http::fake(function () { return Http::response(['ready' => false]); });
try {
app(GadaianController::class)->store($this->request());
$this->fail('Registration should be blocked.');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
$this->assertSame(503, $exception->getStatusCode());
}
Http::assertSentCount(1);
$this->assertSame(0, ShorakaFinancing::count());
}
public function testRefinancingUsesNewReferenceAndDefersCash(): void
{
\Illuminate\Support\Facades\Event::fake();
Http::fake(function ($request) {
if (strpos($request->url(), '/shoraka/readiness') !== false) {
return Http::response(['ready' => true]);
}
if (strpos($request->url(), '/updatetambahpinjaman/') !== false) {
return Http::response(['result' => 'success', 'norujukan_baru' => 'NEW-SAG']);
}
if (strpos($request->url(), '/shoraka/gadai/KB2/NEW-SAG/submit') !== false) {
return Http::response(['success' => true]);
}
$this->fail('Unexpected cash/legacy commodity request: ' . $request->url());
});
$request = $this->request();
$request->merge(['norujukan' => 'OLD-SAG']);
$request->headers->set('X-Requested-With', 'XMLHttpRequest');
$result = app(\App\Http\Controllers\PenebusanController::class)->tambahpinjaman_latest($request);
$this->assertSame(202, $result->getStatusCode());
$this->assertSame('NEW-SAG', ShorakaFinancing::first()->norujukan);
$this->assertSame('refinance', ShorakaFinancing::first()->action);
\Illuminate\Support\Facades\Event::assertNotDispatched(\App\Events\Dashboard\GadaiNotify::class);
}
public function testChangedSavedPayoutAmountCannotBePaid(): void
{
$this->record();
Http::fake(function () { return Http::response(['data' => [
'can_complete' => true, 'order_amount' => 2000,
]]); });
$controller = Mockery::mock(GadaianController::class);
$controller->shouldNotReceive('completeShorakaFinancing');
$this->app->instance(GadaianController::class, $controller);
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
(new ShorakaFinancingService())->finish($this->request(), 'SAG-001');
}
}