diff --git a/.changeset/three-apples-post.md b/.changeset/three-apples-post.md new file mode 100644 index 0000000..aaa96d9 --- /dev/null +++ b/.changeset/three-apples-post.md @@ -0,0 +1,5 @@ +--- +'@ssecd/jkn': minor +--- + +Implement Surkon V2 API diff --git a/README.md b/README.md index 544dc61..f56d26b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -JKN (BPJS) Bridging API untuk NodeJS +# JKN -Mudahnya JKN API dari SSEC +JKN (BPJS) Bridging API untuk NodeJS ## Fitur @@ -9,15 +9,26 @@ JKN (BPJS) Bridging API untuk NodeJS - ✅ Antrean - ✅ Apotek - ✅ i-Care -- ✅ Rekam Medis +- ✅ Rekam Medis _(Experimental types)_ - 🧩 PCare _([partial](https://github.com/ssecd/jkn/pull/26))_ +## Demo + +https://github.com/user-attachments/assets/02809ccd-30ea-48a8-bbb3-1e0df687175a + ## Instalasi Instalasi paket dapat dilakukan dengan perintah berikut: ```bash +# Node npm install @ssecd/jkn + +# Bun +bun install @ssecd/jkn + +# Deno +deno install npm:@ssecd/jkn ``` Untuk dukungan _type_ pada API Rekam Medis, perlu menambahkan development dependensi `@types/fhir` dengan perintah: @@ -26,7 +37,7 @@ Untuk dukungan _type_ pada API Rekam Medis, perlu menambahkan development depend npm install --save-dev @types/fhir ``` -Instalasi juga dapat dilakukan menggunakan `PNPM` atau `YARN` +> Instalasi paket NodeJS juga dapat dilakukan menggunakan `PNPM` atau `YARN` ## Penggunaan @@ -94,7 +105,7 @@ console.log(result); faskes: [ { kode: "0089S002", - nama: "Klinik Utama Mata Silampari Sriwijaya Eye Centre" + nama: "Silampari Sriwijaya Eye Centre" } ] }; @@ -148,7 +159,7 @@ onResponse: ((info: SendOption & { duration: number; type - `onError` ```ts -onError: ((error: unknown) => MaybePromise) | undefined = undefined; +onError: ((error: Error) => MaybePromise) | undefined = undefined; ``` Contoh penggunaan event: diff --git a/assets/demo.gif b/assets/demo.gif deleted file mode 100644 index 2ec919a..0000000 Binary files a/assets/demo.gif and /dev/null differ diff --git a/src/apotek/index.ts b/src/apotek/index.ts index 6611684..914786e 100644 --- a/src/apotek/index.ts +++ b/src/apotek/index.ts @@ -2,6 +2,7 @@ import { CachedApi } from '../base.js'; import { Monitoring } from './monitoring.js'; import { Obat } from './obat.js'; import { PelayananObat } from './pelayanan-obat.js'; +import { PRB } from './prb.js'; import { Referensi } from './referensi.js'; import { Resep } from './resep.js'; import { SEP } from './sep.js'; @@ -41,4 +42,8 @@ export class Apotek { get monitoring() { return this.cache.get('apotek_monitoring', Monitoring); } + + get prb() { + return this.cache.get('apotek_prb', PRB); + } } diff --git a/src/apotek/obat.ts b/src/apotek/obat.ts index dadc7f5..4ac3ec7 100644 --- a/src/apotek/obat.ts +++ b/src/apotek/obat.ts @@ -48,4 +48,17 @@ export class Obat extends ApotekBaseApi { data }); } + + /** + * Update data stok obat dari sistem informasi apotek ke + * aplikasi Apotek Online + */ + async updateStok(data: { KDOBAT: string; STOK: number }) { + return this.send({ + name: this.name + 'Update Stok Obat', + path: '/UpdateStokObat/updatestok', + method: 'POST', + data + }); + } } diff --git a/src/apotek/prb.ts b/src/apotek/prb.ts new file mode 100644 index 0000000..a95d464 --- /dev/null +++ b/src/apotek/prb.ts @@ -0,0 +1,32 @@ +import { ApotekBaseApi } from './base.js'; + +export class PRB extends ApotekBaseApi { + /** + * Data rekap peserta PRB yang baru dilakukan PRB oleh Faskes + */ + async rekapPeserta(params: { + /** tahun */ + tahun: number; + + /** angka bulan 1 sampai 12 */ + bulan: number; + }) { + return this.send<{ + list: { + No: 1; + NamaPeserta: string; + NomorKaPst: string; + Alamat: string; + TglSRB: string; + Diagnosa: string; + Obat: string; + DPJP: string; + AsalFaskes: string; + }[]; + }>({ + name: this.name + 'Data Klaim', + path: `/Prb/rekappeserta/tahun/${params.tahun}/bulan/${params.bulan}`, + method: 'GET' + }); + } +} diff --git a/src/apotek/referensi.ts b/src/apotek/referensi.ts index 338d066..b6b2b94 100644 --- a/src/apotek/referensi.ts +++ b/src/apotek/referensi.ts @@ -16,6 +16,8 @@ export class Referensi extends ApotekBaseApi { restriksi: string; generik: string; aktif: string | null; + sedia: string; + stok: string; }[]; }>({ name: this.name + 'DPHO', diff --git a/src/fetcher.ts b/src/fetcher.ts index 9e556cd..a662314 100644 --- a/src/fetcher.ts +++ b/src/fetcher.ts @@ -215,7 +215,7 @@ export class Fetcher { ) => MaybePromise) | undefined = undefined; - public onError: ((error: unknown) => MaybePromise) | undefined = undefined; + public onError: ((error: Error) => MaybePromise) | undefined = undefined; private configured = false; @@ -319,9 +319,9 @@ export class Fetcher { option: SendOption ): Promise[T]> { await this.applyConfig(); - if (!option.path.startsWith('/')) throw new Error(`path must be starts with "/"`); + if (!option.path.startsWith('/')) throw new Error(`Path must be starts with "/"`); - let response = ''; + let result = ''; try { const baseUrl = this.config.baseUrls[type]; if (!baseUrl) throw new Error(`base url of type "${type}" is invalid`); @@ -332,7 +332,7 @@ export class Fetcher { init.headers = headers; if (option.data) { - if (option.method === 'GET') throw new Error(`can not pass data with "GET" method`); + if (option.method === 'GET') throw new Error(`Can not pass data with "GET" method`); init.body = JSON.stringify(option.data); // default fetch content type in request header is json @@ -350,9 +350,11 @@ export class Fetcher { this.onRequest?.({ ...option, type }); const startedAt = performance.now(); - response = await fetch(url, init).then((r) => r.text()); - const json: SendResponse[T] = JSON.parse(response); + const response = await fetch(url, init); + result = await response.text(); + if (!result) throw new Error(`The response body is empty (${response.status})`); + const json: SendResponse[T] = JSON.parse(result); if (json.response && !option.skipDecrypt) { const decrypted = this.decrypt(String(json.response), headers['X-timestamp']); json.response = JSON.parse(this.decompress(decrypted)); @@ -362,23 +364,22 @@ export class Fetcher { this.onResponse?.({ ...option, duration, type }, json); return json; } catch (error: unknown) { - this.onError?.(error); - if (this.config.throw) { - if (error instanceof Error) { - error.message += `. \nResponse: ${response}`; - } - throw error; - } - let message = + const customError = new Error( error instanceof SyntaxError - ? 'Received response from the JKN API appears to be in an unexpected format' - : 'An error occurred while requesting information from the JKN API'; - if (error instanceof Error) message += `. ` + error.message; - message += '. ' + response; - console.error(error); + ? `The response is not JSON (${parseHtml(result)})` + : error instanceof Error + ? error.message + : JSON.stringify(error), + { cause: error } + ); + + this.onError?.(customError); + if (this.config.throw) throw customError; + console.error(customError); // TODO: find better way to infer generic response type const code = type === 'icare' ? 500 : '500'; + const message = `An error occurred: "${customError.message}"`; return { metadata: { code: +code, message }, metaData: { code, message }, @@ -397,3 +398,19 @@ export class Fetcher { return this.config; } } + +/** + * A simple HTML parser so ugly HTML error messages + * don't hurt your eyes anymore. + */ +function parseHtml(html?: string) { + if (!html) return '[empty]'; + return html + .replace(/]*>[\s\S]*?<\/head>/gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]*>/g, '') + .trim() + .replace(/\r?\n+/g, ' - ') // newlines to dash + .replace(/\s+/g, ' '); // normalize whitespace +} diff --git a/src/vclaim/prb.ts b/src/vclaim/prb.ts index 67d08d4..0be0c91 100644 --- a/src/vclaim/prb.ts +++ b/src/vclaim/prb.ts @@ -233,4 +233,26 @@ export class PRB extends VClaimBaseApi { method: 'GET' }); } + + /** + * Menyediakan data rekap klaim yang diajukan oleh Faskes 2, di mana + * dalam klaim tersebut terdapat peserta dengan flagging Potensi PRB. + */ + async rekapPotensi(params: { tahun: number; bulan: number }) { + // TODO: Response returning empty string + return this.send<{ + list: { + NamaPeserta: string; + NomorKartu: string; + NOSEP: string; + TanggalPelayanan: string; + DiagnosaPotensiPRB: string; + KategoriPenyakitPRB: string; + }[]; + }>({ + name: this.name + 'Rekap Potensi PRB', + path: `/prbpotensi/tahun/${params.tahun}/bulan/${params.bulan}`, + method: 'GET' + }); + } } diff --git a/src/vclaim/rencana-kontrol.ts b/src/vclaim/rencana-kontrol.ts index a24f475..32ef37f 100644 --- a/src/vclaim/rencana-kontrol.ts +++ b/src/vclaim/rencana-kontrol.ts @@ -1,40 +1,100 @@ import { VClaimBaseApi } from './base.js'; -// TODO: make generic request and response data type as possible +const formPRBFieldsMap = { + // key: [codes[], min, max] + HBA1C: [['01'], 0.1, 15], + GDP: [['01', '07'], 10, 500], + GD2JPP: [['01'], 10, 500], + eGFR: [['01', '02'], 5, 150], + TD_Sistolik: [['01', '07'], 20, 200], + TD_Diastolik: [['01', '07'], 20, 200], + LDL: [['01', '07'], 20, 500], + Rata_TD_Sistolik: [['02', '04'], 20, 200], + Rata_TD_Diastolik: [['02', '04'], 20, 200], + JantungKoroner: [['02'], 0, 1], + Stroke: [['02'], 0, 1], + VaskularPerifer: [['02'], 0, 1], + Aritmia: [['02', '04'], 0, 1], + AtrialFibrilasi: [['02'], 0, 1], + NadiIstirahat: [['04'], 20, 200], + SesakNapas3Bulan: [['04'], 0, 1], + NyeriDada3Bulan: [['04'], 0, 1], + SesakNapasAktivitas: [['04'], 0, 1], + NyeriDadaAktivitas: [['04'], 0, 1], + Terkontrol: [['03'], 0, 1], + Gejala2xMinggu: [['03'], 0, 1], + BangunMalam: [['03'], 0, 1], + KeterbatasanFisik: [['03'], 0, 1], + FungsiParu: [['03'], 0, 100], + SkorMMRC: [['05'], 0, 40], + Eksaserbasi1Tahun: [['05'], 0, 1], + MampuAktivitas: [['05'], 0, 1], + Epileptik6Bulan: [['08'], 0, 1], + EfekSampingOAB: [['08'], 0, 1], + HamilMenyusui: [['08'], 0, 1], + Remisi: [['06'], 0, 100], + TerapiRumatan: [['06'], 0, 1], + Usia: [['06'], 1, 100], + AsamUrat: [['07'], 0.1, 20], + RemisiSLE: [['09'], 0, 100], + Hamil: [['09'], 0, 1] +} as const; + export class RencanaKontrol extends VClaimBaseApi { + get listPenyakitPRB(): { kode: string; nama: string }[] { + return [ + ['01', 'Diabetes Mellitus'], + ['02', 'Hipertensi'], + ['03', 'Asma'], + ['04', 'Penyakit Jantung'], + ['05', 'PPOK'], + ['06', 'Skizofrenia'], + ['07', 'Stroke'], + ['08', 'Epilepsi'], + ['09', 'SLE'] + ].map(([kode, nama]) => ({ kode, nama })); + } + /** - * Insert rencana kontrol + * Mengambil semua form fields penyakit PRB yang dapat dimanfaatkan + * untuk membentuk form input penyakit PRB dengan melakukan filter + * berdasarkan `kode` dari {@link listPenyakitPRB}. */ - async insert(data: { - /** nomor SEP */ - noSEP: string; - - /** kode dokter JKN */ - kodeDokter: string; - - /** kode poli JKN */ - poliKontrol: string; + get allFieldsPenyakitPRB() { + const fieldsMap = formPRBFieldsMap as unknown as Record; + return Object.entries(fieldsMap).map( + ([namaField, [listKode, nilaiMinimum, nilaiMaksimum]]) => ({ + namaField, + listKode, + nilaiMinimum, + nilaiMaksimum + }) + ); + } - /** - * - Rawat Jalan: diisi tanggal rencana kontrol - * - Rawat Inap: diisi tanggal SPRI - * - * format tanggal YYYY-MM-DD - */ - tglRencanaKontrol: string; + /** + * Mengambil semua form fields penyakit PRB berdasarkan kode + * penyakit PRB. Berguna untuk membentuk form input di sisi + * client atau UI secara dinamis berdasarkan Penyakit PRB. + * + * @param kode Kode penyakit PRB. Lihat {@link listPenyakitPRB} + */ + getFieldsPenyakitPRB(kode: string) { + const fieldsMap = formPRBFieldsMap as unknown as Record; + return Object.entries(fieldsMap) + .filter(([, [codes]]) => codes.includes(kode)) + .map(([namaField, [, nilaiMinimum, nilaiMaksimum]]) => ({ + namaField, + nilaiMinimum, + nilaiMaksimum + })); + } - /** user pembuat surat kontrol */ - user: string; - }) { - return this.send<{ - noSuratKontrol: string; - tglRencanaKontrol: string; - namaDokter: string; - noKartu: string; - nama: string; - kelamin: string; - tglLahir: string; - }>({ + /** + * Insert rencana kontrol + */ + async insert(data: RencanaKontrolInsert) { + return this.send({ name: this.name + 'Insert', path: `/RencanaKontrol/insert`, method: 'POST', @@ -43,41 +103,27 @@ export class RencanaKontrol extends VClaimBaseApi { } /** - * Update rencana kontrol + * Insert rencana kontrol dengan API v2 */ - async update(data: { - /** nomor surat kontrol yang akan di-update */ - noSuratKontrol: string; - - /** nomor SEP */ - noSEP: string; - - /** kode dokter JKN */ - kodeDokter: string; - - /** kode poli JKN */ - poliKontrol: string; - - /** - * - Rawat Jalan: diisi tanggal rencana kontrol - * - Rawat Inap: diisi tanggal SPRI - * - * format tanggal YYYY-MM-DD - */ - tglRencanaKontrol: string; + async insertV2(data: RencanaKontrolInsertV2) { + return this.send({ + name: this.name + 'InsertV2', + path: '/RencanaKontrol/v2/Insert', + method: 'POST', + data: { request: data } + }); + } - /** user pembuat surat kontrol */ - user: string; - }) { - return this.send<{ + /** + * Update rencana kontrol + */ + async update( + data: { + /** nomor surat kontrol yang akan di-update */ noSuratKontrol: string; - tglRencanaKontrol: string; - namaDokter: string; - noKartu: string; - nama: string; - kelamin: string; - tglLahir: string; - }>({ + } & RencanaKontrolInsert + ) { + return this.send({ name: this.name + 'Update', path: `/RencanaKontrol/Update`, method: 'PUT', @@ -85,6 +131,23 @@ export class RencanaKontrol extends VClaimBaseApi { }); } + /** + * Update rencana kontrol dengan API v2 + */ + async updateV2( + data: { + /** nomor surat kontrol yang akan di-update */ + noSuratKontrol: string; + } & RencanaKontrolInsertV2 + ) { + return this.send({ + name: this.name + 'UpdateV2', + path: '/RencanaKontrol/v2/Update', + method: 'PUT', + data: { request: data } + }); + } + /** * Delete atau hapus rencana kontrol */ @@ -217,54 +280,19 @@ export class RencanaKontrol extends VClaimBaseApi { * Melihat detail surat kontrol berdasarkan nomor surat kontrol * * Catatan: - * Ketika pembuatan SPRI atau jenis kontrol 1 tidak ada referensi nomor SEP asalnya, + * Ketika pembuatan SPRI atau jenis kontrol = 1 tidak ada referensi nomor SEP asalnya, * jadi field response SEP kosong atau null. Sedangkan jika pembuatan surat kontrol - * atau jenis kontrol 2, akan ter-isi field response SEP karena terdapat referensi + * atau jenis kontrol = 2, akan ter-isi field response SEP karena terdapat referensi * nomor SEP asal ketika pembuatan surat kontrol tersebut. */ async cari(params: { /** nomor surat kontrol */ nomor: string; }) { - return this.send<{ - noSuratKontrol: string; - tglRencanaKontrol: string; - tglTerbit: string; - jnsKontrol: string; - poliTujuan: string; - namaPoliTujuan: string; - kodeDokter: string; - namaDokter: string; - flagKontrol: 'True' | 'False' | string; - kodeDokterPembuat: string | null; - namaDokterPembuat: string | null; - namaJnsKontrol: string; - sep: { - noSep: string; - tglSep: string; - jnsPelayanan: string; - poli: string; - diagnosa: string; - peserta: { - noKartu: string; - nama: string; - tglLahir: string; - kelamin: string; - hakKelas: string; - }; - provUmum: { - kdProvider: string; - nmProvider: string; - }; - provPerujuk: { - kdProviderPerujuk: string; - nmProviderPerujuk: string; - asalRujukan: '1' | '2'; - noRujukan: string; - tglRujukan: string; - }; - }; - }>({ + return this.send< + Omit & + ({ jnsKontrol: '1'; sep: null } | { jnsKontrol: '2'; sep: SuratKontrolDetail['sep'] }) + >({ name: this.name + 'Cari Surat Kontrol', path: `/RencanaKontrol/noSuratKontrol/${encodeURIComponent(params.nomor)}`, method: 'GET' @@ -308,7 +336,7 @@ export class RencanaKontrol extends VClaimBaseApi { /** jenis filter (1 = tanggal entri) (2 = tanggal rencana kontrol) */ filter: number; }) { - return this.send<{ list: RencanaKontrolListItem[] }>({ + return this.send<{ list: Omit[] }>({ name: this.name + 'Data Berdasarkan Tanggal', path: `/RencanaKontrol/ListRencanaKontrol/tglAwal/${params.awal}/tglAkhir/${params.akhir}/filter/${params.filter}`, method: 'GET' @@ -371,10 +399,53 @@ export class RencanaKontrol extends VClaimBaseApi { } } +interface SuratKontrolDetail { + noSuratKontrol: string; + tglRencanaKontrol: string; + tglTerbit: string; + /** 1 = SPRI/Rawat Inap | 2 = Surat Kontrol/Rawat Jalan */ + jnsKontrol: '1' | '2'; + poliTujuan: string; + namaPoliTujuan: string; + kodeDokter: string; + namaDokter: string; + flagKontrol: 'True' | 'False' | string; + kodeDokterPembuat: string | null; + namaDokterPembuat: string | null; + namaJnsKontrol: string; + sep: { + noSep: string; + tglSep: string; + jnsPelayanan: string; + poli: string; + diagnosa: string; + peserta: { + noKartu: string; + nama: string; + tglLahir: string; + kelamin: string; + hakKelas: string; + }; + provUmum: { + kdProvider: string; + nmProvider: string; + }; + provPerujuk: { + kdProviderPerujuk: string; + nmProviderPerujuk: string; + asalRujukan: '1' | '2'; + noRujukan: string; + tglRujukan: string; + }; + }; + formPRB: RencanaKontrolPRB; +} + interface RencanaKontrolListItem { noSuratKontrol: string; jnsPelayanan: string; - jnsKontrol: string; + /** 1 = SPRI/Rawat Inap | 2 = Surat Kontrol/Rawat Jalan */ + jnsKontrol: '1' | '2'; namaJnsKontrol: string; tglRencanaKontrol: string; tglTerbitKontrol: string; @@ -388,7 +459,67 @@ interface RencanaKontrolListItem { namaDokter: string; noKartu: string; nama: string; + terbitSEP: 'Belum' | 'Sudah'; +} + +interface RencanaKontrolInsert { + /** nomor SEP */ + noSEP: string; + + /** kode dokter JKN */ + kodeDokter: string; + + /** kode poli JKN */ + poliKontrol: string; + + /** + * - Rawat Jalan: diisi tanggal rencana kontrol + * - Rawat Inap: diisi tanggal SPRI + * + * format tanggal YYYY-MM-DD + */ + tglRencanaKontrol: string; + + /** user pembuat surat kontrol */ + user: string; +} + +interface RencanaKontrolWriteResult { + noSuratKontrol: string; + tglRencanaKontrol: string; + namaDokter: string; + noKartu: string; + nama: string; + kelamin: string; + tglLahir: string; +} - /** 'Belum' | 'Sudah' */ - terbitSEP: 'Belum' | 'Sudah' | string; +interface RencanaKontrolInsertV2 extends RencanaKontrolInsert { + formPRB: Omit & { + data: Partial; + }; +} + +interface RencanaKontrolWriteResultV2 extends RencanaKontrolWriteResult { + namaDiagnosa: string; + formPRB: RencanaKontrolPRB; +} + +interface RencanaKontrolPRB { + /** Kode Penyakit PRB + * + * - 01 = Diabetes Mellitus + * - 02 = Hipertensi + * - 03 = Asma + * - 04 = Penyakit Jantung + * - 05 = PPOK + * - 06 = Skizofrenia + * - 07 = Stroke + * - 08 = Epilepsi + * - 09 = SLE + * + * Lihat {@link RencanaKontrol.listPenyakitPRB} + */ + kdStatusPRB: string | null; + data: Record; } diff --git a/src/vclaim/sep.ts b/src/vclaim/sep.ts index 9cdf71e..5243549 100644 --- a/src/vclaim/sep.ts +++ b/src/vclaim/sep.ts @@ -532,7 +532,7 @@ export class SEP extends VClaimBaseApi { flagProcedure: string; informasi: { dinsos: string | null; - eSEP: string; + eSEP: 'True' | 'False' | null; noSKTM: string | null; prolanisPRB: string | null; }; diff --git a/test/vclaim/prb.test.ts b/test/vclaim/prb.test.ts index 0144732..aa765e6 100644 --- a/test/vclaim/prb.test.ts +++ b/test/vclaim/prb.test.ts @@ -5,7 +5,8 @@ import { generateDateRanges } from '../utils'; describe('VClaim - PRB', { timeout: 25_000 }, () => { it.concurrent('cariByTanggal() - not 200 no data', async () => { const results: string[] = []; - for (const [awal, akhir] of generateDateRanges(2023)) { + // max 31 days + for (const [awal, akhir] of generateDateRanges(2023, 40)) { const result = await jkn.vclaim.prb.cariByTanggal({ awal, akhir @@ -14,4 +15,12 @@ describe('VClaim - PRB', { timeout: 25_000 }, () => { } expect(results).not.toContain('200'); }); + + it.concurrent('rekapPotensi() - should return data', async () => { + const result = await jkn.vclaim.prb.rekapPotensi({ + tahun: 2025, + bulan: 11 + }); + expect(result.metaData.code).toBe('200'); + }); }); diff --git a/words.txt b/words.txt index 2ef5b0f..4d94b59 100644 --- a/words.txt +++ b/words.txt @@ -24,6 +24,7 @@ dinsos dpho dpjp eclaim +Eksaserbasi enkripsi estimasidilayani faskes @@ -72,6 +73,7 @@ katarak kddokter kdjenispeserta KDJNSOBAT +KDOBAT KDOBT kdpenunjang kdpoli @@ -101,6 +103,7 @@ lastupdate LINGGAU listobat listsep +MMRC namaapoteker namabu namadokter @@ -164,7 +167,9 @@ pesertasep pisat POLIRSP ppkpelsep +PPOK Praktek +prbpotensi prolanis propinsi Psikososial @@ -175,9 +180,11 @@ randomquestion REFASALSJP rekammedis rekap +rekappeserta riwayatobat rownumber ruangrawat +Rumatan settingppk signa Silampari @@ -222,6 +229,7 @@ totalbiayasetuju totalitems tujuanrujuk updatejadwaldokter +updatestok updatewaktu updtglplg vclaim