diff --git a/.env-example b/.env-example index c5dda0a5..ab87d54c 100644 --- a/.env-example +++ b/.env-example @@ -80,4 +80,9 @@ DUITKU_MERCHANT_CODE= DUITKU_IS_SANDBOX=TRUE WKHTML_PDF_BINARY=/var/www/aso/vendor/h4cc/wkhtmltopdf-amd64/bin/wkhtmltopdf-amd64 -WKHTML_IMG_BINARY= \ No newline at end of file +WKHTML_IMG_BINARY= + +EMAIL = helpdesk@linksehat.com +PW_EMAIL = vsfcvwxcgldhhkdm +NAME_EMAIL = "LinkSehat" +PROVIDER_ONLINE_NOTIFICATION_EMAIL=helpdesk@linksehat.com \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..dde841fb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,182 @@ +# AGENTS.md + +File ini berisi instruksi untuk AI coding agent yang bekerja di repository ini. + +## Project Overview + +Project name: ASO (Laravel + Modules) +Purpose: Platform operasional health insurance/managed care untuk pengelolaan member, corporate policy, claim, monitoring, livechat, dan pelaporan. +Main users: Tim internal operasional, portal client corporate, portal hospital. +Business domain: Healthtech / asuransi kesehatan / manajemen klaim dan layanan kesehatan. + +## Tech Stack + +Backend: PHP 8+, Laravel 9, `nwidart/laravel-modules`. +Frontend: React + TypeScript + Vite (utama di `frontend/dashboard` dan `frontend/client-portal`), plus aset root Laravel (`resources/`, Vite/Mix). +Database: MySQL/MariaDB (via Eloquent ORM + Laravel migrations). +Queue/background jobs: Laravel Queue (`jobs` table tersedia; default testing `sync`). +Cache: Laravel cache (driver mengikuti `.env`, testing pakai `array`). +Auth: Laravel Sanctum, Spatie Permission (`role`/`permission` middleware), sebagian flow JWT untuk integrasi tertentu. +Deployment: Build frontend ke `public/dashboard` dan `public/client-portal`; backend Laravel standar. +Package manager: Composer (PHP), npm/yarn/pnpm (frontend, mixed antar subproject). + +## Local Setup + +Install dependencies: + +```bash +composer install +npm install +cd frontend/dashboard && yarn install +cd ../client-portal && yarn install +``` + +Run app: + +```bash +php artisan serve +npm run dev +cd frontend/dashboard && yarn start +cd ../client-portal && yarn start +``` + +Run tests: + +```bash +php artisan test +``` + +Run lint: + +```bash +cd frontend/dashboard && yarn lint +cd ../client-portal && yarn lint +``` + +Run typecheck: + +```bash +# Belum ada script typecheck standar di root/subproject. +# Jika diperlukan, gunakan tsc manual per frontend setelah konfirmasi tim. +``` + +Run build: + +```bash +npm run production +cd frontend/dashboard && yarn build +cd ../client-portal && yarn build +``` + +Run migrations: + +```bash +php artisan migrate +``` + +## Repository Structure + +```text +app/ # Core Laravel app (models, services, middleware, controllers) +routes/ # Root web/api routes +Modules/ # Domain/module-based backend (Client, Internal, Linksehat, Primaya, HospitalPortal) +Modules/*/Routes/ # Route entry point tiap module +Modules/*/Http/Controllers/Api/ +database/migrations/ # Root migrations +frontend/dashboard/ # React TS admin/internal dashboard +frontend/client-portal/ # React TS client portal +resources/ # Blade/views/assets untuk app Laravel utama +public/ # Public assets + output build frontend +tests/ # PHPUnit tests (Feature/Unit) +``` + +## Architecture Rules + +- Tempatkan business logic di layer service/domain yang sudah ada (`app/Services`, `Modules/*/Services`) dan jaga controller tetap tipis. +- API layer mengikuti pemisahan module: endpoint module didefinisikan di `Modules//Routes/api.php` dengan controller module terkait. +- Untuk perubahan schema, gunakan migration baru; jangan ubah migration lama kecuali ada instruksi eksplisit. +- Untuk frontend, ikuti pattern existing per app (`src/pages`, `src/components`, `src/hooks`, `src/sections`) dan hindari cross-import antar app dashboard/client-portal. +- Testing backend utama menggunakan PHPUnit (`tests/Feature`, `tests/Unit`); tambah regression test untuk perubahan behavior penting. + +## Coding Standards + +- Ikuti pattern existing project sebelum membuat pattern baru. +- Buat perubahan kecil dan fokus. +- Jangan lakukan refactor yang tidak berhubungan. +- Jangan hardcode secrets. +- Jangan bypass validation. +- Jangan bypass auth/permission checks. +- Pilih code yang eksplisit dan mudah dibaca dibanding code yang terlalu clever. +- Tambahkan atau update test jika behavior berubah. + +## Git Rules + +- Jangan commit kecuali user secara eksplisit meminta. +- Jangan push kecuali user secara eksplisit meminta. +- Jangan rewrite git history kecuali user secara eksplisit meminta. +- Tampilkan file yang berubah di final response. + +## Security Rules + +- Jangan pernah print secrets. +- Jangan ubah nilai `.env` asli kecuali diminta secara eksplisit. +- Gunakan `.env-example`/`.env.example` untuk dokumentasi environment variables. +- Validasi auth, permissions, dan scoping corporate/member untuk protected data. +- Untuk endpoint API, pertahankan middleware auth yang ada (`auth:sanctum`, role/permission, middleware khusus module). +- Minta konfirmasi sebelum destructive data changes. + +## Database Rules + +- Gunakan migration system project ini. +- Jangan edit historical migrations kecuali project ini memang mengizinkan. +- Sertakan rollback/safety notes untuk schema changes. +- Pertimbangkan existing production data. +- Perhatikan migrasi dapat berasal dari root `database/migrations` dan modul tertentu. + +## Testing Rules + +Sebelum selesai, jalankan command yang relevan: + +```bash +php artisan test +cd frontend/dashboard && yarn lint +cd frontend/client-portal && yarn lint +``` + +Jika command tidak bisa dijalankan, jelaskan alasannya dan command apa yang harus dijalankan manual. + +## AI Agent Workflow + +Untuk feature work: + +1. Baca file ini. +2. Inspect pattern yang sudah ada. +3. Tulis plan singkat untuk pekerjaan non-trivial. +4. Tanya klarifikasi jika requirement kurang jelas. +5. Implement perubahan kecil dan fokus. +6. Jalankan test/lint/typecheck/build yang relevan. +7. Rangkum file yang berubah, command yang dijalankan, dan risiko. + +Untuk debugging: + +1. Reproduce atau inspect issue. +2. Identifikasi root cause sebelum mengubah code. +3. Buat fix minimal yang aman. +4. Tambahkan regression test jika memungkinkan. +5. Jalankan verification. + +## Final Response Format + +```text +Summary: +- ... + +Files changed: +- ... + +Commands run: +- ... + +Risks / notes: +- ... +``` diff --git a/Modules/HospitalPortal/Routes/api.php b/Modules/HospitalPortal/Routes/api.php index f1cd4b58..3ccbe08f 100755 --- a/Modules/HospitalPortal/Routes/api.php +++ b/Modules/HospitalPortal/Routes/api.php @@ -135,4 +135,4 @@ Route::prefix('v1')->group(function() { }); -}); \ No newline at end of file +}); diff --git a/Modules/HospitalPortal/Routes/web.php b/Modules/HospitalPortal/Routes/web.php index f7912f59..2fa0af13 100755 --- a/Modules/HospitalPortal/Routes/web.php +++ b/Modules/HospitalPortal/Routes/web.php @@ -13,4 +13,5 @@ Route::prefix('hospitalportal')->group(function() { Route::get('/', 'HospitalPortalController@index'); + Route::get('download-log/{id}', 'HospitalPortalController@downloadLog'); }); diff --git a/Modules/ProviderIntegrations/Config/config.php b/Modules/ProviderIntegrations/Config/config.php new file mode 100644 index 00000000..235a520b --- /dev/null +++ b/Modules/ProviderIntegrations/Config/config.php @@ -0,0 +1,5 @@ + 'ProviderIntegrations', +]; diff --git a/Modules/ProviderIntegrations/Http/Controllers/Api/ProviderOnlineController.php b/Modules/ProviderIntegrations/Http/Controllers/Api/ProviderOnlineController.php new file mode 100644 index 00000000..9b7ba0c8 --- /dev/null +++ b/Modules/ProviderIntegrations/Http/Controllers/Api/ProviderOnlineController.php @@ -0,0 +1,723 @@ +validate([ + 'username' => 'required|string', + 'password' => 'required|string', + 'kodeprovider' => 'required|string', + ]); + + $organization = Organization::query()->where('code', $request->kodeprovider)->first(); + if (!$organization) { + return $this->headerError('Kode provider tidak ditemukan'); + } + + $provider = Provider::query()->firstOrNew([ + 'organization_id' => $organization->id, + 'username' => $request->username, + ]); + + if ($provider->exists) { + $isPasswordValid = Hash::check($request->password, $provider->password) || $provider->password === $request->password; + if (!$isPasswordValid) { + return $this->headerError('Username atau password tidak valid'); + } + } else { + $provider->password = Hash::make($request->password); + } + + $provider->code = $request->kodeprovider; + $provider->status = 'active'; + $provider->header_token = Str::random(64); + $provider->token = Str::random(64); + $provider->save(); + + return response()->json([ + 'header-token' => $provider->header_token, + 'userid' => $provider->id, + 'usertoken' => $provider->token, + 'kodeprovider' => $organization->code, + 'namaprovider' => $organization->name, + 'errornumber' => 0, + 'messagestring' => 'Success', + ]); + } + + public function checkEligibilitasPeserta(Request $request) + { + $request->validate([ + 'nokartu' => 'required|string', + 'kodeprovider' => 'required|string', + 'p_user_no' => 'required', + 'p_token' => 'required|string', + ]); + + [, $organization, $authError] = $this->resolveProvider($request->kodeprovider, $request->p_user_no, $request->p_token); + if ($authError) { + return $authError; + } + + $member = Member::query() + ->with(['person', 'currentCorporate', 'currentPolicy', 'memberPlans.plan']) + ->where('member_id', $request->nokartu) + ->first(); + + if (!$member) { + return $this->statusError('Peserta tidak ditemukan'); + } + + $benefits = $member->memberPlans + ->map(function (MemberPlan $memberPlan) { + $plan = $memberPlan->plan; + if (!$plan || empty($plan->code)) { + return null; + } + + return [ + 'kodebenefit' => $plan->code, + 'namabenefit' => $plan->corporate_plan_id, + 'planid' => $plan->code, + ]; + }) + ->filter() + ->unique('kodebenefit') + ->values(); + + return response()->json([ + 'Status' => $this->okStatus(), + 'Data' => [ + 'nokartu' => $member->member_id, + 'memberid' => (string) $member->id, + 'namapeserta' => $member->full_name, + 'nomorbpjs' => $member->bpjs_id, + 'jeniskelamin' => $member->gender_code, + 'tanggallahir' => $this->isoDate($member->birth_date), + 'hubungankeluarga' => $member->relation_with_principal, + 'namaperusahaan' => optional($member->currentCorporate)->name, + 'pesertavip' => 'N', + 'namapenjamin' => optional($organization)->name, + 'nomorpolis' => optional($member->currentPolicy)->code, + 'tglmulaipolis' => $this->isoDate(optional($member->currentPolicy)->start), + 'tglberakhirpolis' => $this->isoDate(optional($member->currentPolicy)->end), + 'phone' => optional($member->person)->phone, + 'email' => $member->email, + ], + 'Benefit' => $benefits, + ]); + } + + public function createPendaftaran(Request $request) + { + $request->validate([ + 'kodeprovider' => 'required|string', + 'kodebenefit' => 'required|string', + 'statusrujukan' => 'nullable|string', + 'nomorrujukan' => 'nullable|string', + 'keterangan' => 'nullable|string', + 'nomorsep' => 'nullable|string', + 'nokartu' => 'required|string', + 'kelaskamar' => 'nullable|string', + 'cobbpjs' => 'nullable|numeric', + 'notransaksiprovider' => 'nullable|string', + 'inacbgscode' => 'nullable|string', + 'inacbgsamount' => 'nullable|numeric', + 'p_user_no' => 'required', + 'p_token' => 'required|string', + ]); + + [, $organization, $authError] = $this->resolveProvider($request->kodeprovider, $request->p_user_no, $request->p_token); + if ($authError) { + return $authError; + } + + $member = Member::query() + ->with(['person', 'currentCorporate', 'currentPolicy']) + ->where('member_id', $request->nokartu) + ->first(); + + if (!$member) { + return $this->statusError('Peserta tidak ditemukan'); + } + + $plan = $this->resolvePlan($member, $request->kodebenefit); + $generatedLogCode = $this->generateNextRequestLogCode($organization, $member); + + $requestLog = RequestLog::query()->create([ + 'code' => $generatedLogCode, + 'organization_id' => $organization->id, + 'member_id' => $member->id, + 'plan_id' => optional($plan)->id, + 'policy_id' => optional($member->currentPolicy)->id ?? 0, + 'payment_type' => 'cashless', + 'service_code' => $request->kodebenefit, + 'type_of_member' => 'member', + 'status' => 'approved', + 'source' => 'api', + 'keterangan' => $request->keterangan, + 'hak_kamar_pasien' => $request->kelaskamar ?? '', + 'penempatan_kamar' => $request->kelaskamar, + 'total_cob' => $request->cobbpjs, + 'nominal' => 0, + 'import_system' => 0, + 'nomor_sep' => $request->nomorsep, + 'inacbgs_code' => $request->inacbgscode, + 'inacbgs_amount' => $request->inacbgsamount, + 'no_transaksi_provider' => $request->notransaksiprovider, + 'diagnosis' => '', + 'reason' => '', + 'reason_final' => '', + 'catatan' => '', + 'nomor_rujukan' => $request->nomorrujukan, + 'status_rujukan' => $request->statusrujukan, + 'submission_date' => now(), + 'admission_date' => now(), + ]); + + $limitSubBenefit = collect(); + if ($plan) { + $limitSubBenefit = CorporateBenefit::query() + ->with('benefit') + ->where('plan_id', $plan->id) + ->get() + ->map(function (CorporateBenefit $corporateBenefit) { + return [ + 'kodesubbenefit' => optional($corporateBenefit->benefit)->code, + 'namasubbenefit' => optional($corporateBenefit->benefit)->description, + 'batasan' => (string) ($corporateBenefit->limit_amount ?? 0), + ]; + }) + ->filter(function (array $item) { + return !empty($item['kodesubbenefit']); + }); + } + + $this->sendProviderOnlineEmail('pendaftaran baru', $requestLog, $organization); + + return response()->json([ + 'Status' => $this->okStatus(), + 'Data' => [$this->buildClaimHeader($requestLog, $organization)], + 'LimitSubBenefit' => $limitSubBenefit->values(), + ]); + } + + public function createPengesahan(Request $request) + { + $request->validate([ + 'noklaim' => 'required|string', + 'kodeprovider' => 'required|string', + 'tanggalkeluar' => 'nullable|date', + 'kodediagnosa' => 'nullable|string', + 'inacbgscode' => 'nullable|string', + 'inacbgsamount' => 'nullable|numeric', + 'daftarbiaya' => 'nullable|array', + 'daftarbiaya.*.kodesubbenefit' => 'required_with:daftarbiaya|string', + 'daftarbiaya.*.biayaaju' => 'required_with:daftarbiaya|numeric', + 'p_user_no' => 'required', + 'p_token' => 'required|string', + ]); + + [, $organization, $authError] = $this->resolveProvider($request->kodeprovider, $request->p_user_no, $request->p_token); + if ($authError) { + return $authError; + } + + $requestLog = RequestLog::query() + ->with(['member.person', 'member.currentCorporate', 'member.currentPolicy', 'plan']) + ->where('code', $request->noklaim) + ->where('organization_id', $organization->id) + ->first(); + + if (!$requestLog) { + return $this->statusError('Nomor klaim tidak ditemukan'); + } + + $benefitsByCode = $this->resolveBenefitCodes($request->daftarbiaya ?? []); + if (isset($benefitsByCode['error'])) { + return $this->statusError($benefitsByCode['error']); + } + + $requestLog->update([ + 'status_final_log' => 'approve', + 'final_log' => true, + 'discharge_date' => $request->tanggalkeluar, + 'diagnosis' => $request->kodediagnosa, + 'inacbgs_code' => $request->inacbgscode ?? $requestLog->inacbgs_code, + 'inacbgs_amount' => $request->inacbgsamount ?? $requestLog->inacbgs_amount, + ]); + + foreach (($request->daftarbiaya ?? []) as $biaya) { + $benefit = $benefitsByCode[$biaya['kodesubbenefit']]; + + RequestLogBenefit::query()->updateOrCreate( + [ + 'request_log_id' => $requestLog->id, + 'benefit_id' => $benefit->id, + ], + [ + 'amount_incurred' => $biaya['biayaaju'], + 'amount_approved' => 0, + 'amount_not_approved' => 0, + 'excess_paid' => 0, + 'keterangan' => null, + ] + ); + } + + $requestLog->load(['requestLogBenefits.benefit']); + + $biayaResponse = $requestLog->requestLogBenefits->map(function (RequestLogBenefit $requestLogBenefit) use ($requestLog) { + return [ + 'noklaim' => $requestLog->code, + 'kodesubbenefit' => optional($requestLogBenefit->benefit)->code, + 'namasubbenefit' => optional($requestLogBenefit->benefit)->description, + 'kodebenefit' => optional($requestLog->plan)->code, + 'namabenefit' => optional($requestLog->plan)->corporate_plan_id, + 'biayaaju' => (float) ($requestLogBenefit->amount_incurred ?? 0), + 'jaminanasuransi' => (float) ($requestLogBenefit->amount_approved ?? 0), + 'jaminanpeserta' => (float) ($requestLogBenefit->excess_paid ?? 0), + 'keterangan' => $requestLogBenefit->keterangan, + ]; + })->values(); + + $this->sendProviderOnlineEmail('pengesahan', $requestLog, $organization); + + return response()->json([ + 'Status' => $this->okStatus(), + 'Data' => [$this->buildClaimHeader($requestLog, $organization)], + 'Biaya' => $biayaResponse, + ]); + } + + public function upsertBillingSementara(Request $request) + { + $request->validate([ + 'noklaim' => 'required|string', + 'kodeprovider' => 'required|string', + 'tanggalkeluar' => 'nullable|date', + 'kodediagnosa' => 'nullable|string', + 'daftarbiaya' => 'nullable|array', + 'daftarbiaya.*.kodesubbenefit' => 'required_with:daftarbiaya|string', + 'daftarbiaya.*.biayaaju' => 'required_with:daftarbiaya|numeric', + 'p_user_no' => 'required', + 'p_token' => 'required|string', + ]); + + [, $organization, $authError] = $this->resolveProvider($request->kodeprovider, $request->p_user_no, $request->p_token); + if ($authError) { + return $authError; + } + + $requestLog = RequestLog::query() + ->where('code', $request->noklaim) + ->where('organization_id', $organization->id) + ->first(); + + if (!$requestLog) { + return $this->statusError('Nomor klaim tidak ditemukan'); + } + + $benefitsByCode = $this->resolveBenefitCodes($request->daftarbiaya ?? []); + if (isset($benefitsByCode['error'])) { + return $this->statusError($benefitsByCode['error']); + } + + $requestLog->update([ + 'discharge_date' => $request->tanggalkeluar, + 'diagnosis' => $request->kodediagnosa, + ]); + + foreach (($request->daftarbiaya ?? []) as $biaya) { + $benefit = $benefitsByCode[$biaya['kodesubbenefit']]; + + RequestLogBenefit::query()->updateOrCreate( + [ + 'request_log_id' => $requestLog->id, + 'benefit_id' => $benefit->id, + ], + [ + 'amount_incurred' => $biaya['biayaaju'], + 'amount_approved' => 0, + 'amount_not_approved' => 0, + 'excess_paid' => 0, + 'keterangan' => null, + ] + ); + } + + $this->sendProviderOnlineEmail('upsert billing sementara', $requestLog, $organization); + + return response()->json([ + 'Status' => $this->okStatus(), + ]); + } + + public function getRincianBiayaKlaim(Request $request) + { + $request->validate([ + 'noklaim' => 'required|string', + 'kodeprovider' => 'required|string', + 'p_user_no' => 'required', + 'p_token' => 'required|string', + ]); + + [, $organization, $authError] = $this->resolveProvider($request->kodeprovider, $request->p_user_no, $request->p_token); + if ($authError) { + return $authError; + } + + $requestLog = RequestLog::query() + ->with(['member.person', 'member.currentCorporate', 'member.currentPolicy', 'plan', 'requestLogBenefits.benefit']) + ->where('code', $request->noklaim) + ->where('organization_id', $organization->id) + ->first(); + + if (!$requestLog) { + return $this->statusError('Nomor klaim tidak ditemukan'); + } + + $biaya = $requestLog->requestLogBenefits->map(function (RequestLogBenefit $requestLogBenefit) use ($requestLog) { + return [ + 'noklaim' => $requestLog->code, + 'kodesubbenefit' => optional($requestLogBenefit->benefit)->code, + 'namasubbenefit' => optional($requestLogBenefit->benefit)->description, + 'kodebenefit' => optional($requestLog->plan)->code, + 'namabenefit' => optional($requestLog->plan)->corporate_plan_id, + 'biayaaju' => (float) ($requestLogBenefit->amount_incurred ?? 0), + 'jaminanasuransi' => (float) ($requestLogBenefit->amount_approved ?? 0), + 'jaminanpeserta' => (float) ($requestLogBenefit->excess_paid ?? 0), + 'keterangan' => $requestLogBenefit->keterangan, + ]; + })->values(); + + return response()->json([ + 'Status' => $this->okStatus(), + 'Data' => [$this->buildClaimHeader($requestLog, $organization)], + 'Biaya' => $biaya, + ]); + } + + public function downloadStrukPendaftaran(Request $request) + { + $request->validate([ + 'noklaim' => 'required|string', + 'kodeprovider' => 'required|string', + 'p_user_no' => 'required', + 'p_token' => 'required|string', + ]); + + [, $organization, $authError] = $this->resolveProvider($request->kodeprovider, $request->p_user_no, $request->p_token); + if ($authError) { + return $authError; + } + + $requestLog = RequestLog::query() + ->where('code', $request->noklaim) + ->where('organization_id', $organization->id) + ->first(); + + if (!$requestLog) { + return $this->statusError('Nomor klaim tidak ditemukan'); + } + + try { + return (new RequestLogController())->downlodLog($requestLog->id); + } catch (Throwable $e) { + $fallbackUrl = url('api/v1/hospitalportal/download-log/' . $requestLog->id); + + return $this->statusError( + 'Gagal generate PDF struk pendaftaran: ' . $e->getMessage() . '. request_log_id=' . $requestLog->id . ', fallback_url=' . $fallbackUrl, + 500 + ); + } + } + + public function downloadStrukPengesahan(Request $request) + { + $request->validate([ + 'noklaim' => 'required|string', + 'kodeprovider' => 'required|string', + 'p_user_no' => 'required', + 'p_token' => 'required|string', + ]); + + [, $organization, $authError] = $this->resolveProvider($request->kodeprovider, $request->p_user_no, $request->p_token); + if ($authError) { + return $authError; + } + + $requestLog = RequestLog::query() + ->where('code', $request->noklaim) + ->where('organization_id', $organization->id) + ->first(); + + if (!$requestLog) { + return $this->statusError('Nomor klaim tidak ditemukan'); + } + + try { + return (new RequestLogController())->downlodFinalLog($requestLog->id); + } catch (Throwable $e) { + $fallbackUrl = url('api/v1/hospitalportal/download-final-log/' . $requestLog->id); + + return $this->statusError( + 'Gagal generate PDF struk pengesahan: ' . $e->getMessage() . '. request_log_id=' . $requestLog->id . ', fallback_url=' . $fallbackUrl, + 500 + ); + } + } + + private function resolveProvider(string $kodeProvider, $userId, string $token): array + { + $organization = Organization::query()->where('code', $kodeProvider)->first(); + if (!$organization) { + return [null, null, $this->statusError('Kode provider tidak ditemukan')]; + } + + $provider = Provider::query() + ->where('id', $userId) + ->where('organization_id', $organization->id) + ->where('code', $kodeProvider) + ->where('status', 'active') + ->first(); + + if (!$provider) { + return [null, null, $this->statusError('User provider tidak ditemukan')]; + } + + if ((string) $provider->token !== (string) $token) { + return [null, null, $this->statusError('Token tidak valid')]; + } + + return [$provider, $organization, null]; + } + + private function resolvePlan(Member $member, string $serviceCode): ?Plan + { + $memberPlan = $member->memberPlans() + ->with('plan') + ->where('status', 'active') + ->orderByDesc('id') + ->get() + ->first(function (MemberPlan $item) use ($serviceCode) { + return optional($item->plan)->service_code === $serviceCode; + }); + + if ($memberPlan && $memberPlan->plan) { + return $memberPlan->plan; + } + + return optional($member->memberPlans()->with('plan')->orderByDesc('id')->first())->plan; + } + + private function resolveBenefitCodes(array $daftarBiaya): array + { + $codes = collect($daftarBiaya) + ->pluck('kodesubbenefit') + ->filter() + ->unique() + ->values(); + + if ($codes->isEmpty()) { + return []; + } + + $benefits = Benefit::query() + ->whereIn('code', $codes) + ->get() + ->keyBy('code'); + + $missing = $codes->filter(function (string $code) use ($benefits) { + return !$benefits->has($code); + }); + + if ($missing->isNotEmpty()) { + return [ + 'error' => 'Kode sub benefit tidak valid: ' . $missing->implode(', '), + ]; + } + + return $benefits->all(); + } + + private function buildClaimHeader(RequestLog $requestLog, Organization $organization): array + { + $requestLog->loadMissing(['member.currentCorporate', 'member.currentPolicy', 'plan']); + + return [ + 'noklaim' => $requestLog->code, + 'namapeserta' => optional($requestLog->member)->full_name, + 'tanggallahir' => $this->isoDate(optional($requestLog->member)->birth_date), + 'nokartu' => optional($requestLog->member)->member_id, + 'nopolis' => optional(optional($requestLog->member)->currentPolicy)->code, + 'nobpjs' => optional($requestLog->member)->bpjs_id, + 'nosep' => $requestLog->nomor_sep, + 'nomorrujukan' => $requestLog->nomor_rujukan, + 'planid' => optional($requestLog->plan)->code, + 'masapolis' => optional(optional($requestLog->member)->currentPolicy)->end + ? Carbon::parse($requestLog->member->currentPolicy->end)->format('d/m/Y') + : null, + 'namaperusahaan' => optional(optional($requestLog->member)->currentCorporate)->name, + 'namapenjamin' => $organization->name, + 'tanggalmasuk' => $this->isoDate($requestLog->admission_date), + 'tanggalkeluar' => $this->isoDate($requestLog->discharge_date), + 'hakkamar' => $requestLog->penempatan_kamar, + 'hakicu' => null, + 'nosuratjaminan' => $requestLog->code, + 'namapegawai' => null, + 'namabenefit' => optional($requestLog->plan)->corporate_plan_id, + 'kodediagnosa' => $requestLog->diagnosis, + 'keterangan' => $requestLog->keterangan, + 'catatanTC1' => null, + 'catatanTC2' => null, + 'catatanTC3' => null, + 'catatanTC4' => null, + 'catatanTC5' => null, + 'catatanTC6' => null, + 'catatanTC7' => null, + 'catatanTC8' => null, + 'catatanTC9' => null, + 'catatanTC10' => null, + 'statusrujukan' => $requestLog->status_rujukan, + 'statusklaim' => 0, + 'notransaksiprovider' => $requestLog->no_transaksi_provider, + 'inacbgscode' => $requestLog->inacbgs_code, + 'inacbgsamount' => (float) ($requestLog->inacbgs_amount ?? 0), + ]; + } + + private function sendProviderOnlineEmail(string $eventName, RequestLog $requestLog, Organization $organization): void + { + $email = trim((string) ($organization->email ?: env('PROVIDER_ONLINE_NOTIFICATION_EMAIL', 'helpdesk@linksehat.com'))); + if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + return; + } + + $name = $organization->name ?: 'Provider'; + $subject = sprintf('[ProviderOnline] %s - %s', ucfirst($eventName), $requestLog->code); + $body = sprintf( + '

Halo %s,

Event %s berhasil diproses untuk nomor klaim %s.

Waktu: %s

', + e($name), + e($eventName), + e($requestLog->code), + now()->format('Y-m-d H:i:s') + ); + + try { + Helper::sendEmail([ + 'email' => $email, + 'name' => $name, + 'subject' => $subject, + 'body' => $body, + ]); + } catch (Throwable $throwable) { + Log::warning('ProviderOnline notification email failed', [ + 'event' => $eventName, + 'request_log_id' => $requestLog->id, + 'request_log_code' => $requestLog->code, + 'organization_id' => $organization->id, + 'error' => $throwable->getMessage(), + ]); + } + } + + private function okStatus(): array + { + return [ + 'errornumber' => 0, + 'messagestring' => 'Success', + ]; + } + + private function statusError(string $message, int $statusCode = 400) + { + return response()->json([ + 'Status' => [ + 'errornumber' => 1, + 'messagestring' => $message, + ], + 'Data' => null, + ], $statusCode); + } + + private function headerError(string $message, int $statusCode = 400) + { + return response()->json([ + 'header-token' => null, + 'userid' => 0, + 'usertoken' => null, + 'kodeprovider' => null, + 'namaprovider' => null, + 'errornumber' => 1, + 'messagestring' => $message, + ], $statusCode); + } + + private function isoDate($date): ?string + { + if (empty($date)) { + return null; + } + + return Carbon::parse($date)->toISOString(); + } + + private function generateNextRequestLogCode(Organization $organization, Member $member): string + { + $data = [ + 'source' => 'H', + 'provideCode' => $organization->code ?? '', + 'date' => date('ymd'), + 'policy' => optional($member->currentPolicy)->code ?? '-', + 'member_code' => $member->member_id ?? '-', + ]; + + $lastNumericCode = RequestLog::query() + ->select(DB::raw('MAX(CAST(SUBSTRING_INDEX(code, ".", -1) AS SIGNED)) as max_numeric_code')) + ->whereRaw('SUBSTRING_INDEX(code, ".", -1) REGEXP "^[0-9]+$"') + ->value('max_numeric_code'); + + $nextNumber = $lastNumericCode ? ((int) $lastNumericCode + 1) : 1; + + return $this->makeRequestLogCode($nextNumber, $data); + } + + private function makeRequestLogCode(int $nextNumber, array $data): string + { + $nextNumber = max(1, $nextNumber); + + return implode('.', [ + 'LOG', + $data['source'], + $data['provideCode'], + $data['date'], + $data['policy'], + $data['member_code'], + str_pad((string) $nextNumber, 5, '0', STR_PAD_LEFT), + ]); + } +} diff --git a/Modules/ProviderIntegrations/Providers/ProviderIntegrationsServiceProvider.php b/Modules/ProviderIntegrations/Providers/ProviderIntegrationsServiceProvider.php new file mode 100644 index 00000000..e5c10f0a --- /dev/null +++ b/Modules/ProviderIntegrations/Providers/ProviderIntegrationsServiceProvider.php @@ -0,0 +1,82 @@ +registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->loadMigrationsFrom(module_path($this->moduleName, 'Database/Migrations')); + } + + public function register() + { + $this->app->register(RouteServiceProvider::class); + } + + protected function registerConfig() + { + $this->publishes([ + module_path($this->moduleName, 'Config/config.php') => config_path($this->moduleNameLower . '.php'), + ], 'config'); + + $this->mergeConfigFrom( + module_path($this->moduleName, 'Config/config.php'), + $this->moduleNameLower + ); + } + + public function registerViews() + { + $viewPath = resource_path('views/modules/' . $this->moduleNameLower); + $sourcePath = module_path($this->moduleName, 'Resources/views'); + + $this->publishes([ + $sourcePath => $viewPath, + ], ['views', $this->moduleNameLower . '-module-views']); + + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->moduleNameLower); + } + + public function registerTranslations() + { + $langPath = resource_path('lang/modules/' . $this->moduleNameLower); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, $this->moduleNameLower); + $this->loadJsonTranslationsFrom($langPath, $this->moduleNameLower); + + return; + } + + $this->loadTranslationsFrom(module_path($this->moduleName, 'Resources/lang'), $this->moduleNameLower); + $this->loadJsonTranslationsFrom(module_path($this->moduleName, 'Resources/lang'), $this->moduleNameLower); + } + + public function provides() + { + return []; + } + + private function getPublishableViewPaths(): array + { + $paths = []; + + foreach (config('view.paths') as $path) { + if (is_dir($path . '/modules/' . $this->moduleNameLower)) { + $paths[] = $path . '/modules/' . $this->moduleNameLower; + } + } + + return $paths; + } +} diff --git a/Modules/ProviderIntegrations/Providers/RouteServiceProvider.php b/Modules/ProviderIntegrations/Providers/RouteServiceProvider.php new file mode 100644 index 00000000..cf39318c --- /dev/null +++ b/Modules/ProviderIntegrations/Providers/RouteServiceProvider.php @@ -0,0 +1,37 @@ +mapApiRoutes(); + $this->mapWebRoutes(); + } + + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->moduleNamespace) + ->group(module_path('ProviderIntegrations', '/Routes/web.php')); + } + + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('ProviderIntegrations', '/Routes/api.php')); + } +} diff --git a/Modules/ProviderIntegrations/Routes/api.php b/Modules/ProviderIntegrations/Routes/api.php new file mode 100644 index 00000000..a7e1cb3a --- /dev/null +++ b/Modules/ProviderIntegrations/Routes/api.php @@ -0,0 +1,15 @@ +group(function () { + Route::post('AddHeaderKey', [ProviderOnlineController::class, 'getHeaderKey']); + Route::post('HeaderKey', [ProviderOnlineController::class, 'getHeaderKey']); + Route::post('EligibilitasPeserta', [ProviderOnlineController::class, 'checkEligibilitasPeserta']); + Route::post('Pendaftaran', [ProviderOnlineController::class, 'createPendaftaran']); + Route::post('Pengesahan', [ProviderOnlineController::class, 'createPengesahan']); + Route::post('BillingSementara', [ProviderOnlineController::class, 'upsertBillingSementara']); + Route::post('RincianBiayaKlaim', [ProviderOnlineController::class, 'getRincianBiayaKlaim']); + Route::post('StrukPendaftaran', [ProviderOnlineController::class, 'downloadStrukPendaftaran']); + Route::post('StrukPengesahan', [ProviderOnlineController::class, 'downloadStrukPengesahan']); +}); diff --git a/Modules/ProviderIntegrations/Routes/web.php b/Modules/ProviderIntegrations/Routes/web.php new file mode 100644 index 00000000..760ec8c3 --- /dev/null +++ b/Modules/ProviderIntegrations/Routes/web.php @@ -0,0 +1,7 @@ +group(function () { + Route::get('/', function () { + return response()->json(['module' => 'ProviderIntegrations']); + }); +}); diff --git a/Modules/ProviderIntegrations/composer.json b/Modules/ProviderIntegrations/composer.json new file mode 100644 index 00000000..a7120408 --- /dev/null +++ b/Modules/ProviderIntegrations/composer.json @@ -0,0 +1,21 @@ +{ + "name": "nwidart/providerintegrations", + "description": "", + "authors": [ + { + "name": "Nicolas Widart", + "email": "n.widart@gmail.com" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": {} + } + }, + "autoload": { + "psr-4": { + "Modules\\ProviderIntegrations\\": "" + } + } +} diff --git a/Modules/ProviderIntegrations/module.json b/Modules/ProviderIntegrations/module.json new file mode 100644 index 00000000..9c8b0520 --- /dev/null +++ b/Modules/ProviderIntegrations/module.json @@ -0,0 +1,11 @@ +{ + "name": "ProviderIntegrations", + "alias": "providerintegrations", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\ProviderIntegrations\\Providers\\ProviderIntegrationsServiceProvider" + ], + "files": [] +} diff --git a/app/Models/Provider.php b/app/Models/Provider.php new file mode 100644 index 00000000..20c8c207 --- /dev/null +++ b/app/Models/Provider.php @@ -0,0 +1,32 @@ +belongsTo(Organization::class, 'organization_id', 'id'); + } +} diff --git a/app/Models/RequestLog.php b/app/Models/RequestLog.php index a0b25c53..1f247b02 100644 --- a/app/Models/RequestLog.php +++ b/app/Models/RequestLog.php @@ -23,8 +23,10 @@ class RequestLog extends Model 'invoice_no', 'billing_no', 'submission_date', + 'admission_date', 'discharge_date', 'member_id', + 'plan_id', 'payment_type', 'service_code', 'type_of_member', @@ -38,6 +40,12 @@ class RequestLog extends Model 'claim_id', 'organization_id', 'keterangan', + 'nomor_sep', + 'status_rujukan', + 'nomor_rujukan', + 'no_transaksi_provider', + 'inacbgs_code', + 'inacbgs_amount', 'hak_kamar_pasien', 'penempatan_kamar', 'catatan', @@ -272,6 +280,11 @@ class RequestLog extends Model return $this->belongsTo(Service::class, 'service_code', 'code'); } + public function plan() + { + return $this->belongsTo(Plan::class, 'plan_id'); + } + public function requestLogBenefits() { return $this->hasMany(RequestLogBenefit::class, 'request_log_id'); diff --git a/database/migrations/2026_05_14_170000_create_providers_table.php b/database/migrations/2026_05_14_170000_create_providers_table.php new file mode 100644 index 00000000..4a99371b --- /dev/null +++ b/database/migrations/2026_05_14_170000_create_providers_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('organization_id')->constrained('organizations')->cascadeOnDelete(); + $table->string('username'); + $table->string('password'); + $table->string('code')->nullable(); + $table->string('status')->default('active'); + $table->string('header_token', 255)->nullable(); + $table->string('token', 255)->nullable(); + $table->timestamps(); + + $table->unique(['organization_id', 'username']); + $table->index(['organization_id', 'code', 'status']); + $table->index('token'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('providers'); + } +}; diff --git a/database/migrations/2026_05_14_171000_add_bridging_columns_to_request_logs_table.php b/database/migrations/2026_05_14_171000_add_bridging_columns_to_request_logs_table.php new file mode 100644 index 00000000..b7d45c20 --- /dev/null +++ b/database/migrations/2026_05_14_171000_add_bridging_columns_to_request_logs_table.php @@ -0,0 +1,94 @@ +unsignedBigInteger('plan_id')->nullable()->after('member_id'); + $table->index('plan_id'); + } + + if (!Schema::hasColumn('request_logs', 'admission_date')) { + $table->dateTime('admission_date')->nullable()->after('submission_date'); + } + + if (!Schema::hasColumn('request_logs', 'nomor_sep')) { + $table->string('nomor_sep')->nullable()->after('keterangan'); + } + + if (!Schema::hasColumn('request_logs', 'status_rujukan')) { + $table->string('status_rujukan')->nullable()->after('nomor_sep'); + } + + if (!Schema::hasColumn('request_logs', 'nomor_rujukan')) { + $table->string('nomor_rujukan')->nullable()->after('status_rujukan'); + } + + if (!Schema::hasColumn('request_logs', 'no_transaksi_provider')) { + $table->string('no_transaksi_provider')->nullable()->after('nomor_rujukan'); + } + + if (!Schema::hasColumn('request_logs', 'inacbgs_code')) { + $table->string('inacbgs_code')->nullable()->after('no_transaksi_provider'); + } + + if (!Schema::hasColumn('request_logs', 'inacbgs_amount')) { + $table->decimal('inacbgs_amount', 18, 2)->nullable()->after('inacbgs_code'); + } + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('request_logs', function (Blueprint $table) { + if (Schema::hasColumn('request_logs', 'inacbgs_amount')) { + $table->dropColumn('inacbgs_amount'); + } + + if (Schema::hasColumn('request_logs', 'inacbgs_code')) { + $table->dropColumn('inacbgs_code'); + } + + if (Schema::hasColumn('request_logs', 'no_transaksi_provider')) { + $table->dropColumn('no_transaksi_provider'); + } + + if (Schema::hasColumn('request_logs', 'nomor_rujukan')) { + $table->dropColumn('nomor_rujukan'); + } + + if (Schema::hasColumn('request_logs', 'status_rujukan')) { + $table->dropColumn('status_rujukan'); + } + + if (Schema::hasColumn('request_logs', 'nomor_sep')) { + $table->dropColumn('nomor_sep'); + } + + if (Schema::hasColumn('request_logs', 'admission_date')) { + $table->dropColumn('admission_date'); + } + + if (Schema::hasColumn('request_logs', 'plan_id')) { + $table->dropIndex(['plan_id']); + $table->dropColumn('plan_id'); + } + }); + } +}; diff --git a/docs/ai-agent-guide.id.md b/docs/ai-agent-guide.id.md new file mode 100644 index 00000000..fa296147 --- /dev/null +++ b/docs/ai-agent-guide.id.md @@ -0,0 +1,242 @@ +# Panduan Penggunaan AI Agent (ASO) + +Panduan ini menjelaskan cara tim ASO menyiapkan dan memakai AI coding agent secara aman di repository ini. + +Tujuan utamanya adalah memberi AI konteks project, command yang benar, dan batasan safety agar AI membantu lebih cepat tanpa merusak codebase. + +## Prinsip Utama + +1. Human tetap pemilik keputusan final. +2. AI wajib mengikuti konvensi project ASO. +3. AI tidak boleh commit/push kecuali diminta eksplisit. +4. AI harus membuat rencana singkat sebelum perubahan besar/berisiko. +5. AI harus menjalankan verifikasi relevan sebelum menyatakan selesai. +6. Human wajib review area kritikal: security, auth/permission, scoping corporate/member, migration, payments, data deletion, production config. +7. Repository ini wajib memiliki `AGENTS.md` di root. + +## Konteks Project Ini + +ASO adalah platform operasional health insurance/managed care untuk pengelolaan corporate, member, claim, monitoring, livechat, dan pelaporan. + +Stack utama saat ini: + +- Backend: Laravel 9 (`app/`) + modular backend `nwidart/laravel-modules` (`Modules/`) +- Frontend: `frontend/dashboard` dan `frontend/client-portal` (React + TS + Vite), plus assets root Laravel (`resources/`, Vite/Mix) +- Auth/Permission: Sanctum + Spatie Permission (`role`, `permission`) + beberapa flow JWT untuk integrasi tertentu +- Testing backend: PHPUnit (`tests/Feature`, `tests/Unit`) + +## AI Agent Ready untuk Repo ASO + +Repository ini dianggap AI Agent Ready jika AI bisa cepat menjawab: + +- Struktur backend modular dan batas tiap module +- Command setup/run/test/lint/build yang benar untuk root + dua frontend app +- Aturan peletakan business logic +- Aturan auth/permission/scoping corporate-member pada endpoint terlindungi +- Aturan migration aman +- Aturan git (tidak commit/push tanpa instruksi) + +## File Minimum yang Harus Ada + +```text +. +├── AGENTS.md +├── README.md +├── .env-example (dan/atau .env.example) +└── docs/ + └── ai-agent-guide.id.md +``` + +Disarankan ditambah: + +```text +docs/architecture.md +docs/testing.md +docs/database.md +docs/api-contracts.md +``` + +## Command Operasional (Aktual Repo Ini) + +Install dependencies: + +```bash +composer install +npm install +cd frontend/dashboard && yarn install +cd ../client-portal && yarn install +``` + +Menjalankan aplikasi lokal: + +```bash +php artisan serve +npm run dev +cd frontend/dashboard && yarn start +cd ../client-portal && yarn start +``` + +Testing backend: + +```bash +php artisan test +``` + +Lint frontend: + +```bash +cd frontend/dashboard && yarn lint +cd ../client-portal && yarn lint +``` + +Build frontend: + +```bash +npm run production +cd frontend/dashboard && yarn build +cd ../client-portal && yarn build +``` + +Migration: + +```bash +php artisan migrate +``` + +Catatan: + +- Script `typecheck` standar belum tersedia konsisten di root/subproject; jika perlu, jalankan `tsc` manual sesuai kebutuhan task. +- Repo ini memiliki lockfile campuran (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`). Ikuti lockfile dan package manager yang sudah dipakai pada subproject yang disentuh. + +## Struktur Repository Penting + +```text +app/ # Core Laravel app +routes/ # Root routes (web/api) +Modules/ # Module domain (Client, Internal, Linksehat, Primaya, HospitalPortal) +Modules/*/Routes/api.php # API route per module +database/migrations/ # Root migrations +frontend/dashboard/ # Internal/admin frontend +frontend/client-portal/ # Client portal frontend +resources/ # View/assets Laravel utama +public/ # Public assets + output build frontend +tests/ # PHPUnit tests +``` + +## Aturan Arsitektur untuk Agent + +- Gunakan pattern yang sudah ada sebelum menambah pattern baru. +- Jaga controller tetap tipis; letakkan logic di service/domain yang sudah ada (`app/Services`, `Modules/*/Services`). +- Untuk API module, tambahkan route dan controller dalam module yang sesuai; hindari menaruh logic module di tempat acak. +- Jangan cross-import antar frontend app (`dashboard` vs `client-portal`) kecuali memang sudah ada kontrak eksplisit. +- Jika mengubah perilaku endpoint protected, review middleware terkait (`auth:sanctum`, role/permission, middleware custom module). + +## Workflow AI Harian + +### 1. Mulai Task + +- Baca `AGENTS.md`. +- Pahami pattern existing di area yang akan diubah. +- Buat plan singkat untuk task non-trivial. +- Klarifikasi requirement jika masih ambigu. + +### 2. Feature Work + +- Implement perubahan kecil dan fokus. +- Hindari refactor unrelated. +- Tambah/update test saat behavior berubah. +- Jalankan verifikasi relevan. + +### 3. Bug Fixing + +- Reproduce/inspeksi issue terlebih dahulu. +- Temukan root cause. +- Terapkan fix minimal dan aman. +- Tambah regression test jika memungkinkan. + +### 4. Review Sebelum PR + +Periksa: + +- correctness +- security +- auth/permission/scoping +- migration safety +- missing tests +- lint/type/build impact +- perubahan unrelated + +## Definition of Done untuk Task AI + +Task AI belum selesai sampai: + +- Perubahan mengikuti pattern existing. +- Tidak ada perubahan unrelated. +- Test/lint/build relevan sudah dijalankan atau alasannya dijelaskan. +- Risiko migration/auth/scoping dijelaskan jika relevan. +- File berubah dan command yang dijalankan dicatat. + +## Safety Rules (Wajib) + +AI agent tidak boleh: + +- Commit/push/rewrite history tanpa instruksi eksplisit. +- Mengubah `.env` asli tanpa instruksi eksplisit. +- Print secrets ke output/chat. +- Menghapus data/folder besar tanpa konfirmasi. +- Mengubah schema DB tanpa migration baru dan catatan safety. +- Menambah dependency besar tanpa alasan jelas dan dampak. + +## Prompt Siap Pakai untuk Repo Ini + +### Setup / Sinkronisasi Dokumentasi + +```text +Baca AGENTS.md dan docs/ai-agent-guide.id.md. +Sinkronkan dokumentasi AI agent dengan kondisi codebase saat ini. +Validasi command setup/test/lint/build berdasarkan file konfigurasi aktual. +Jangan commit perubahan. +``` + +### Feature Work + +```text +Implement feature berikut: [jelaskan]. +Rules: +- Baca AGENTS.md terlebih dahulu. +- Ikuti pattern existing repo ASO. +- Jangan commit. +- Jalankan verifikasi relevan. +Output akhir wajib: Summary, Files changed, Commands run, Risks/notes. +``` + +### Bug Fixing + +```text +Fix bug berikut: [jelaskan]. +Gunakan systematic debugging: +1. Reproduce/inspeksi issue +2. Temukan root cause +3. Terapkan fix minimal aman +4. Tambah regression test jika memungkinkan +5. Jalankan verifikasi +Jangan commit. +``` + +## Format Final Response Standar + +Gunakan format ini: + +```text +Summary: +- ... + +Files changed: +- ... + +Commands run: +- ... + +Risks / notes: +- ... +``` diff --git a/docs/ai-agent-guide.md b/docs/ai-agent-guide.md new file mode 100644 index 00000000..291a8fc7 --- /dev/null +++ b/docs/ai-agent-guide.md @@ -0,0 +1,242 @@ +# AI Agent Usage Guide (ASO) + +This guide explains how the ASO team should prepare and use AI coding agents safely in this repository. + +The main goal is to provide the AI with correct project context, commands, and safety boundaries so it can help faster without damaging the codebase. + +## Core Principles + +1. Humans make the final decisions. +2. AI must follow ASO project conventions. +3. AI must not commit/push unless explicitly asked. +4. AI should create a short plan before large/risky changes. +5. AI must run relevant verification checks before saying work is done. +6. Humans must review critical areas: security, auth/permission, corporate/member scoping, migrations, payments, data deletion, and production config. +7. This repository must have `AGENTS.md` at the root. + +## Project Context + +ASO is an operational health insurance/managed-care platform for managing corporates, members, claims, monitoring, livechat, and reporting. + +Current main stack: + +- Backend: Laravel 9 (`app/`) + modular backend with `nwidart/laravel-modules` (`Modules/`) +- Frontend: `frontend/dashboard` and `frontend/client-portal` (React + TypeScript + Vite), plus Laravel root assets (`resources/`, Vite/Mix) +- Auth/Permission: Sanctum + Spatie Permission (`role`, `permission`) + some JWT flows for specific integrations +- Backend testing: PHPUnit (`tests/Feature`, `tests/Unit`) + +## AI Agent Ready in ASO Repo + +This repository is considered AI Agent Ready when an AI can quickly answer: + +- Modular backend structure and module boundaries +- Correct setup/run/test/lint/build commands for root + both frontend apps +- Where business logic should live +- Auth/permission/corporate-member scoping rules on protected endpoints +- Safe migration rules +- Git rules (no commit/push without instruction) + +## Minimum Required Files + +```text +. +├── AGENTS.md +├── README.md +├── .env-example (and/or .env.example) +└── docs/ + └── ai-agent-guide.id.md +``` + +Recommended additions: + +```text +docs/architecture.md +docs/testing.md +docs/database.md +docs/api-contracts.md +``` + +## Operational Commands (Current Repo) + +Install dependencies: + +```bash +composer install +npm install +cd frontend/dashboard && yarn install +cd ../client-portal && yarn install +``` + +Run locally: + +```bash +php artisan serve +npm run dev +cd frontend/dashboard && yarn start +cd ../client-portal && yarn start +``` + +Backend tests: + +```bash +php artisan test +``` + +Frontend lint: + +```bash +cd frontend/dashboard && yarn lint +cd ../client-portal && yarn lint +``` + +Frontend build: + +```bash +npm run production +cd frontend/dashboard && yarn build +cd ../client-portal && yarn build +``` + +Migrations: + +```bash +php artisan migrate +``` + +Notes: + +- A standard `typecheck` script is not consistently available across root/subprojects; run manual `tsc` only when needed for the task. +- This repo has mixed lockfiles (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`). Follow the lockfile and package manager already used in the touched subproject. + +## Key Repository Structure + +```text +app/ # Core Laravel app +routes/ # Root routes (web/api) +Modules/ # Domain modules (Client, Internal, Linksehat, Primaya, HospitalPortal) +Modules/*/Routes/api.php # API route entry per module +database/migrations/ # Root migrations +frontend/dashboard/ # Internal/admin frontend +frontend/client-portal/ # Client portal frontend +resources/ # Main Laravel views/assets +public/ # Public assets + frontend build output +tests/ # PHPUnit tests +``` + +## Architecture Rules for Agents + +- Follow existing patterns before introducing new ones. +- Keep controllers thin; place logic in existing service/domain layers (`app/Services`, `Modules/*/Services`). +- For module APIs, add routes/controllers within the correct module; avoid placing module logic in random locations. +- Do not cross-import between frontend apps (`dashboard` vs `client-portal`) unless there is an explicit existing contract. +- When changing protected endpoint behavior, review related middleware (`auth:sanctum`, role/permission, module-specific middleware). + +## Daily AI Workflow + +### 1. Start a Task + +- Read `AGENTS.md`. +- Understand existing patterns in the area you will change. +- Create a short plan for non-trivial work. +- Clarify ambiguous requirements. + +### 2. Feature Work + +- Keep changes small and focused. +- Avoid unrelated refactors. +- Add/update tests when behavior changes. +- Run relevant verification commands. + +### 3. Bug Fixing + +- Reproduce/inspect the issue first. +- Find the root cause. +- Apply a minimal, safe fix. +- Add a regression test when possible. + +### 4. Pre-PR Review + +Check: + +- correctness +- security +- auth/permission/scoping +- migration safety +- missing tests +- lint/type/build impact +- unrelated changes + +## Definition of Done for AI Tasks + +AI work is not done until: + +- Changes follow existing patterns. +- No unrelated files are changed. +- Relevant test/lint/build checks are run or failures are explained. +- Migration/auth/scoping risks are explained when relevant. +- Changed files and commands run are documented. + +## Safety Rules (Mandatory) + +AI agents must not: + +- Commit/push/rewrite history without explicit instruction. +- Modify real `.env` values without explicit instruction. +- Print secrets in chat/output. +- Delete large data/folders without confirmation. +- Change DB schema without new migrations and safety notes. +- Add major dependencies without clear justification and impact. + +## Ready-to-Use Prompts for This Repo + +### Setup / Documentation Sync + +```text +Read AGENTS.md and docs/ai-agent-guide.id.md. +Sync AI-agent documentation with the current codebase state. +Validate setup/test/lint/build commands against actual config files. +Do not commit changes. +``` + +### Feature Work + +```text +Implement this feature: [describe]. +Rules: +- Read AGENTS.md first. +- Follow existing ASO repository patterns. +- Do not commit. +- Run relevant verification commands. +Final output must include: Summary, Files changed, Commands run, Risks/notes. +``` + +### Bug Fixing + +```text +Fix this bug: [describe]. +Use systematic debugging: +1. Reproduce/inspect issue +2. Find root cause +3. Apply minimal safe fix +4. Add regression test if possible +5. Run verification +Do not commit. +``` + +## Standard Final Response Format + +Use this format: + +```text +Summary: +- ... + +Files changed: +- ... + +Commands run: +- ... + +Risks / notes: +- ... +``` diff --git a/docs/postman/ProviderOnline-v1.postman_collection.json b/docs/postman/ProviderOnline-v1.postman_collection.json new file mode 100644 index 00000000..b763beed --- /dev/null +++ b/docs/postman/ProviderOnline-v1.postman_collection.json @@ -0,0 +1,165 @@ +{ + "info": { + "name": "ProviderOnline v1 Flow", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "base_url", "value": "http://127.0.0.1:8000" }, + { "key": "username", "value": "provtest" }, + { "key": "password", "value": "provpass" }, + { "key": "kodeprovider", "value": "MAN000000000Q" }, + { "key": "nokartu", "value": "WS2026021" }, + { "key": "kodebenefit", "value": "OP-GPSPHG001" }, + { "key": "p_user_no", "value": "1" }, + { "key": "p_token", "value": "" }, + { "key": "noklaim", "value": "" } + ], + "item": [ + { + "name": "1. HeaderKey", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": "{{base_url}}/api/v1/bridging-service/ProviderOnline/HeaderKey", + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"kodeprovider\": \"{{kodeprovider}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200', () => pm.response.to.have.status(200));", + "const j = pm.response.json();", + "pm.environment.set('p_user_no', String(j.userid));", + "pm.environment.set('p_token', j.usertoken);" + ] + } + } + ] + }, + { + "name": "2. EligibilitasPeserta", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": "{{base_url}}/api/v1/bridging-service/ProviderOnline/EligibilitasPeserta", + "body": { + "mode": "raw", + "raw": "{\n \"nokartu\": \"{{nokartu}}\",\n \"kodeprovider\": \"{{kodeprovider}}\",\n \"p_user_no\": {{p_user_no}},\n \"p_token\": \"{{p_token}}\"\n}" + } + } + }, + { + "name": "3. Pendaftaran", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": "{{base_url}}/api/v1/bridging-service/ProviderOnline/Pendaftaran", + "body": { + "mode": "raw", + "raw": "{\n \"kodeprovider\": \"{{kodeprovider}}\",\n \"kodebenefit\": \"{{kodebenefit}}\",\n \"statusrujukan\": \"Y\",\n \"nomorrujukan\": \"RJ-20260515-0001\",\n \"keterangan\": \"Rawat jalan kontrol\",\n \"nomorsep\": \"SEP-20260515-0001\",\n \"nokartu\": \"{{nokartu}}\",\n \"kelaskamar\": \"KELAS-1\",\n \"cobbpjs\": 0,\n \"notransaksiprovider\": \"TRX-RS-20260515-0001\",\n \"inacbgscode\": \"E-4-10-I\",\n \"inacbgsamount\": 3200000,\n \"p_user_no\": {{p_user_no}},\n \"p_token\": \"{{p_token}}\"\n}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const j = pm.response.json();", + "if (j && j.Data && j.Data[0] && j.Data[0].noklaim) pm.environment.set('noklaim', j.Data[0].noklaim);" + ] + } + } + ] + }, + { + "name": "4. BillingSementara", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": "{{base_url}}/api/v1/bridging-service/ProviderOnline/BillingSementara", + "body": { + "mode": "raw", + "raw": "{\n \"noklaim\": \"{{noklaim}}\",\n \"kodeprovider\": \"{{kodeprovider}}\",\n \"tanggalkeluar\": \"2026-05-15T10:00:00Z\",\n \"kodediagnosa\": \"I10\",\n \"daftarbiaya\": [\n { \"kodesubbenefit\": \"OPCONS1\", \"biayaaju\": 1500000 },\n { \"kodesubbenefit\": \"OPDIAG1\", \"biayaaju\": 1700000 }\n ],\n \"p_user_no\": {{p_user_no}},\n \"p_token\": \"{{p_token}}\"\n}" + } + } + }, + { + "name": "5. Pengesahan", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": "{{base_url}}/api/v1/bridging-service/ProviderOnline/Pengesahan", + "body": { + "mode": "raw", + "raw": "{\n \"noklaim\": \"{{noklaim}}\",\n \"kodeprovider\": \"{{kodeprovider}}\",\n \"tanggalkeluar\": \"2026-05-15T10:00:00Z\",\n \"kodediagnosa\": \"I10\",\n \"inacbgscode\": \"E-4-10-I\",\n \"inacbgsamount\": 3200000,\n \"daftarbiaya\": [\n { \"kodesubbenefit\": \"OPCONS1\", \"biayaaju\": 1500000 },\n { \"kodesubbenefit\": \"OPDIAG1\", \"biayaaju\": 1700000 }\n ],\n \"p_user_no\": {{p_user_no}},\n \"p_token\": \"{{p_token}}\"\n}" + } + } + }, + { + "name": "6. RincianBiayaKlaim", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": "{{base_url}}/api/v1/bridging-service/ProviderOnline/RincianBiayaKlaim", + "body": { + "mode": "raw", + "raw": "{\n \"noklaim\": \"{{noklaim}}\",\n \"kodeprovider\": \"{{kodeprovider}}\",\n \"p_user_no\": {{p_user_no}},\n \"p_token\": \"{{p_token}}\"\n}" + } + } + }, + { + "name": "7. StrukPendaftaran", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": "{{base_url}}/api/v1/bridging-service/ProviderOnline/StrukPendaftaran", + "body": { + "mode": "raw", + "raw": "{\n \"noklaim\": \"{{noklaim}}\",\n \"kodeprovider\": \"{{kodeprovider}}\",\n \"p_user_no\": {{p_user_no}},\n \"p_token\": \"{{p_token}}\"\n}" + } + } + }, + { + "name": "8. StrukPengesahan", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": "{{base_url}}/api/v1/bridging-service/ProviderOnline/StrukPengesahan", + "body": { + "mode": "raw", + "raw": "{\n \"noklaim\": \"{{noklaim}}\",\n \"kodeprovider\": \"{{kodeprovider}}\",\n \"p_user_no\": {{p_user_no}},\n \"p_token\": \"{{p_token}}\"\n}" + } + } + } + ] +} diff --git a/modules_statuses.json b/modules_statuses.json index 1ddeefbe..3afe33b5 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -3,5 +3,6 @@ "Client": true, "Linksehat": true, "HospitalPortal": true, - "Primaya": true -} \ No newline at end of file + "Primaya": true, + "ProviderIntegrations": true +} diff --git a/resources/views/pdf/final_log_page_1.blade.php b/resources/views/pdf/final_log_page_1.blade.php index 486b4e2e..f0b51f8b 100644 --- a/resources/views/pdf/final_log_page_1.blade.php +++ b/resources/views/pdf/final_log_page_1.blade.php @@ -216,8 +216,9 @@
@php - if(!empty($logoPerusahaan->path)) { - $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents(storage_path('app/public/' . $logoPerusahaan->path))); + $logoPath = !empty($logoPerusahaan->path) ? storage_path('app/public/' . $logoPerusahaan->path) : null; + if (!empty($logoPath) && is_file($logoPath)) { + $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath)); echo ''; } @endphp diff --git a/resources/views/pdf/final_log_page_1_primayan.blade.php b/resources/views/pdf/final_log_page_1_primayan.blade.php index f5a98f47..1d46220d 100644 --- a/resources/views/pdf/final_log_page_1_primayan.blade.php +++ b/resources/views/pdf/final_log_page_1_primayan.blade.php @@ -216,8 +216,9 @@
@php - if(!empty($logoPerusahaan->path)) { - $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents(storage_path('app/public/' . $logoPerusahaan->path))); + $logoPath = !empty($logoPerusahaan->path) ? storage_path('app/public/' . $logoPerusahaan->path) : null; + if (!empty($logoPath) && is_file($logoPath)) { + $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath)); echo ''; } @endphp diff --git a/resources/views/pdf/final_log_page_2.blade.php b/resources/views/pdf/final_log_page_2.blade.php index fc847e9d..64151938 100644 --- a/resources/views/pdf/final_log_page_2.blade.php +++ b/resources/views/pdf/final_log_page_2.blade.php @@ -210,8 +210,9 @@
@php - if(!empty($logoPerusahaan->path)) { - $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents(storage_path('app/public/' . $logoPerusahaan->path))); + $logoPath = !empty($logoPerusahaan->path) ? storage_path('app/public/' . $logoPerusahaan->path) : null; + if (!empty($logoPath) && is_file($logoPath)) { + $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath)); echo ''; } @endphp diff --git a/resources/views/pdf/final_log_page_2_primayan.blade.php b/resources/views/pdf/final_log_page_2_primayan.blade.php index d1eae975..279ff93c 100644 --- a/resources/views/pdf/final_log_page_2_primayan.blade.php +++ b/resources/views/pdf/final_log_page_2_primayan.blade.php @@ -210,8 +210,9 @@
@php - if(!empty($logoPerusahaan->path)) { - $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents(storage_path('app/public/' . $logoPerusahaan->path))); + $logoPath = !empty($logoPerusahaan->path) ? storage_path('app/public/' . $logoPerusahaan->path) : null; + if (!empty($logoPath) && is_file($logoPath)) { + $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath)); echo ''; } @endphp diff --git a/resources/views/pdf/req_log_page_1.blade.php b/resources/views/pdf/req_log_page_1.blade.php index 3442824b..7b749c20 100644 --- a/resources/views/pdf/req_log_page_1.blade.php +++ b/resources/views/pdf/req_log_page_1.blade.php @@ -224,8 +224,9 @@
@php - if(!empty($logoPerusahaan->path)) { - $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents(storage_path('app/public/' . $logoPerusahaan->path))); + $logoPath = !empty($logoPerusahaan->path) ? storage_path('app/public/' . $logoPerusahaan->path) : null; + if (!empty($logoPath) && is_file($logoPath)) { + $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath)); echo ''; } @endphp diff --git a/resources/views/pdf/req_log_page_1_primayan.blade.php b/resources/views/pdf/req_log_page_1_primayan.blade.php index c1a157a9..47896c40 100644 --- a/resources/views/pdf/req_log_page_1_primayan.blade.php +++ b/resources/views/pdf/req_log_page_1_primayan.blade.php @@ -222,8 +222,9 @@
@php - if(!empty($logoPerusahaan->path)) { - $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents(storage_path('app/public/' . $logoPerusahaan->path))); + $logoPath = !empty($logoPerusahaan->path) ? storage_path('app/public/' . $logoPerusahaan->path) : null; + if (!empty($logoPath) && is_file($logoPath)) { + $imgSrc = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath)); echo ''; } @endphp diff --git a/tests/Feature/ProviderOnlineRouteTest.php b/tests/Feature/ProviderOnlineRouteTest.php new file mode 100644 index 00000000..afd0f206 --- /dev/null +++ b/tests/Feature/ProviderOnlineRouteTest.php @@ -0,0 +1,34 @@ + 'getHeaderKey', + '/api/v1/bridging-service/ProviderOnline/HeaderKey' => 'getHeaderKey', + '/api/v1/bridging-service/ProviderOnline/EligibilitasPeserta' => 'checkEligibilitasPeserta', + '/api/v1/bridging-service/ProviderOnline/Pendaftaran' => 'createPendaftaran', + '/api/v1/bridging-service/ProviderOnline/Pengesahan' => 'createPengesahan', + '/api/v1/bridging-service/ProviderOnline/BillingSementara' => 'upsertBillingSementara', + '/api/v1/bridging-service/ProviderOnline/RincianBiayaKlaim' => 'getRincianBiayaKlaim', + '/api/v1/bridging-service/ProviderOnline/StrukPendaftaran' => 'downloadStrukPendaftaran', + '/api/v1/bridging-service/ProviderOnline/StrukPengesahan' => 'downloadStrukPengesahan', + ]; + + foreach ($routes as $uri => $method) { + $route = app('router')->getRoutes()->match(Request::create($uri, 'POST')); + + $this->assertSame( + 'Modules\\ProviderIntegrations\\Http\\Controllers\\Api\\ProviderOnlineController@' . $method, + $route->getActionName(), + 'Route mismatch for ' . $uri + ); + } + } +}