05 · Arsitektur Teknis

Satu Fondasi, Banyak Pintu

untuk orang awam

Bayangkan sebuah gudang pusat dengan banyak loket. Gudangnya satu: semua data produk, stok, harga, dan transaksi tersimpan di satu tempat yang tertib. Di depannya ada satu meja pelayanan yang melayani semua loket — staf internal, pembeli, vendor, dan aplikasi HP staf gudang. Loketnya bertambah seiring waktu, tapi meja pelayanan dan gudangnya tidak pernah dibangun ulang. Itu sebabnya menambah loket baru cepat, dan kenapa angka yang dilihat semua orang selalu sama.

untuk teknisi

Monorepo (pnpm + Turborepo): satu API Express 5 (TypeScript) berdiri sendiri (apps/api — satu proses, satu port, folder + route per modul) melayani tiga aplikasi web per audiens plus satu aplikasi React Native, ditambah satu worker Go (apps/worker) untuk kerja CPU-berat — impor Excel, ekspor pembukuan, render PDF, foto produk — lewat antrean job berbasis Postgres, sehingga operasi panjang tidak pernah memblokir jalur request. Semua berbagi packages/db (schema Drizzle), packages/core (aturan domain + otorisasi), dan packages/contracts (skema Zod → OpenAPI → klien bertipe keempat aplikasi; → JSON Schema → validasi payload di Go). Satu PostgreSQL. Antar modul memanggil lewat packages/core in-process — tidak pernah lewat HTTP — sehingga checkout menulis sales_orders dan stock_movements dalam satu transaksi atomik. Kontrak URL {modul}/api/v1/* identik di sisi publik dan upstream (tanpa rewrite), sehingga klien yang digenerate tidak pernah berbohong. Deploy: VPS + Docker Compose, edge Caddy, SeaweedFS (container) untuk artefak file. Schema database penuh untuk semua modul sejak migrasi pertama.

Keputusan yang mengikat ada di ADR-001, ADR-002, dan ADR-003. Halaman ini adalah penjelasan visualnya; kalau keduanya bertentangan, berkas ADR yang benar. Bagian identitas (principals, external_accounts, account_grants) berasal dari adr-003-identitas-dan-pemberian-akses.md. Tim: 2 web developer + 1 mobile developer (React Native).

Struktur Monorepo & Alur Dependensi

satu API Express 5 + satu worker Go melayani lima klien — tiga web, dua HP · garis putus-putus = belum dibangun, tempatnya sudah disediakan

Ctrl/Cmd + scroll untuk zoom. Drag untuk geser. Klik dua kali untuk fit.

Memuat...
Dibangun di Gelombang A
Garis putus-putus = menyusul di akhir Gelombang A
Di luar sistem (keputusan hybrid)

Kontrak URL — identik publik dan upstream

konsisten dengan konvensi mount /<modul>/api/v1 yang sudah dipakai EDUNUSA

api.kmtxpumit.my.id/vendor/api/v1/listings (domain sementara — final menunggu nama PT resmi) di sisi publik, dan /vendor/api/v1/listings juga di sisi upstream. Tanpa rewrite. Empat hal yang dibeli keputusan sederhana ini: nomor versi terlihat oleh klien (aplikasi mobile terpasang tidak bisa dipaksa update, jadi v1 harus tetap hidup saat v2 lahir); klien yang digenerate dari OpenAPI tidak pernah berbohong soal path; developer mobile bisa menembak proses API langsung di localhost tanpa gateway; dan gateway bisa disisipkan kapan saja tanpa satu pun URL berubah.

Channel (web/mobile) TIDAK masuk path. Aturannya: route mengikuti upstream, bukan channel — selama upstream cuma satu, prefix apa pun di depannya hanyalah alias. Perbedaan web dan mobile ditangani lewat kredensial (cookie vs Bearer), header X-Client, dan kebijakan per consumer di edge. Ada alasan khusus proyek ini: sales_orders.channel sudah berarti channel bisnis (internal | marketplace); kalau URL juga punya "channel", cepat atau lambat seseorang menurunkan kolom itu dari prefix URL — dan pesanan marketplace dari HP tercatat dengan channel salah. Itu bug pembukuan lintas dua badan hukum, bukan bug tampilan.

Mengapa dipecah per audiens, bukan per modul? Tool internal, etalase publik, dan portal vendor punya kebutuhan keamanan, model login, dan irama rilis yang berbeda total. Sementara modul bisnis cukup jadi bagian di dalam aplikasi yang tepat, karena semuanya membaca data inti yang sama.

Satu penggabungan bersifat wajib, dan buktinya ada di kriteria selesai kita sendiri: "pencarian part bisa difilter berdasarkan unit milik pelanggan." Satu layar itu butuh katalog + cross-reference (SPAREPART) dan customer_units + sesi login (CUSTOMER) sekaligus. Batas modul bisnis tidak memotong bersih di lapisan UI — jadi SPAREPART dan CUSTOMER harus satu aplikasi.

Kepemilikan diatur lewat CODEOWNERS, bukan lewat URL. Tiap aplikasi punya pemilik; dan yang terpenting, packages/contracts dimiliki bertiga — perubahan kontrak API tidak bisa di-merge tanpa persetujuan developer mobile. Itu perlindungan yang sebenarnya ia butuhkan: hak veto atas hal yang bisa merusak aplikasi di tangan pengguna.

Model Data Inti (ERD)

master dibagi bersama — transaksi selalu membawa entity_id — stok adalah ledger append-only

untuk orang awam

Tiga keputusan penting dalam bahasa sehari-hari:

  • Satu katalog untuk semua. Daftar produk adalah milik bersama (aset PT); tapi setiap transaksi jelas tercatat milik CV yang mana — penting karena keduanya beda aturan pajak.
  • Stok seperti buku tabungan. Angka stok tidak pernah "ditimpa" — setiap barang masuk/keluar adalah catatan baru. Kalau angka aneh, riwayatnya selalu bisa ditelusuri.
  • Orang dalam dan orang luar dipisah. Staf punya akun buatan admin (nama pengguna + sandi); pembeli, vendor, dan mitra masuk pakai Google atau tautan sekali-pakai lewat email — tanpa sandi sama sekali. Keduanya di daftar terpisah, jadi pendaftaran publik tidak mungkin melahirkan akun staf. Siapa boleh membuka portal apa ditentukan lewat pemberian akses yang bisa dicabut kapan saja — dan pencabutannya langsung berlaku. Penjelasan lengkapnya, tanpa istilah teknis, ada di 07 · Aplikasi & Cara Masuk.
  • Nomor part alternatif dicatat. Part yang sama sering punya banyak nomor dari brand berbeda — database kami tahu mana yang saling cocok. Inilah bekal fitur pencarian marketplace kelak.
untuk teknisi
  • stock_movements append-only, ditegakkan engine: REVOKE UPDATE, DELETE pada app role. Koreksi lewat reversing entry, bukan UPDATE. Stok = agregat ledger; reorder point & fast/slow/dead stock = query.
  • Idempotency wajib: client_ref unique + tabel idempotency_keys yang menyimpan RESPONS. Retry setelah sukses harus replay 200, bukan 409.
  • Kolom aktor: created_by + device_id. DoD menuntut "alasan DAN jejak" — ledger dua badan hukum butuh tahu siapa.
  • Semua tabel transaksi membawa entity_id (KMT non-PPN / PUM PPN); master data tanpa entity_id. Scoping-nya dipaksakan di lapisan akses data lewat packages/core, bukan per handler.
  • Dua kolam identitas di atas satu tulang punggung (ADR-003): principalsusers (staf, kredensial) dan external_accounts (pembeli/vendor/mitra, Google atau magic link). Staf gudang sengaja TIDAK memakai Google — alur Google butuh jaringan, dan gudang tidak selalu punya sinyal.
  • Akses modul = baris account_grants, bukan kolom di akun. Ia dibaca authorize() per permintaan, tidak pernah dibekukan ke dalam token — itulah yang membuat revoked_at menggigit di request berikutnya. Grant buta-entitas: yang dibawa scope_ref (atas nama perusahaan mana), bukan entity_id.
  • Memberi lebih ketat daripada mencabut (ADR-003 kep. 7, keputusan pemilik 7 Agu 2026): grant.create hanya admin; grant.revoke boleh admin atau sales. Asimetri ini mengikuti asimetri biayanya — salah beri tidak bisa ditarik setelah datanya dilihat, salah cabut bisa diperbaiki dalam semenit. Konsekuensinya kolom revoked_by: begitu pencabut tidak lagi bisa disimpulkan sebagai admin, hanya kolom itu yang menjawabnya.
  • Direktori pelanggan terbuka untuk akun yang sudah masuk (ADR-005, keputusan pemilik butir A2). Ia membalik pembatasan awal: daftar kini bisa digulir tanpa ambang huruf. Yang dilonggarkan cara menelusuri, bukan apa yang terlihat — barisnya tetap name + city, dan tiga pagar tetap berdiri: wajib sudah masuk, batas laju per akun, setiap akses dicatat.
  • Sesi: sessions + refresh_tokens + devices, semuanya menunjuk principal_id. Cookie (web), Bearer (mobile), Google, dan magic link menghasilkan satu objek Principal kanonik; cek otorisasi hanya membaca Principal.
  • supplier_prices menyimpan histori harga per supplier per produk, multi-currency (IDR/USD/RMB) — fondasi fitur "vendor darurat" Level 3.
Kenapa idempotency masuk migrasi pertama, bukan nanti. Komponen edge apa pun — Caddy, nginx, Kong — memasukkan semantik timeout dan retry. Klien yang mengulang request setelah 504 menghasilkan dua baris ledger untuk satu pesanan, dan di ledger append-only baris itu tidak bisa dihapus. Koreksinya jurnal balik yang mengotori rekonsiliasi opname — persis DoD yang paling ingin dicapai. Ditambal sekarang: ~1,5–2 hari. Ditambal setelah aplikasi opname beredar: berhari-hari plus rekonsiliasi manual per kejadian.

Ctrl/Cmd + scroll untuk zoom. Drag untuk geser. Klik dua kali untuk fit.

Memuat...

Perluasan Skema: Semua Modul Diwadahi Sejak Awal

tabel modul CUSTOMER · VENDOR · MEKANIK · RENTAL — schema dibuat sekarang, frontend menyusul sesuai prioritas

Diagram berikut melengkapi ERD inti di atas. Tabel-tabel ini dibuat di migrasi awal supaya tidak ada perombakan struktur di tengah jalan: modul prioritas (SPAREPART, CUSTOMER, VENDOR, INTERNAL) langsung memakainya, sedangkan tabel mekanik dan rental menunggu frontend-nya tanpa mengganggu apa pun. Titik sambungnya ke ERD inti: customers, products, dan entities.

Ctrl/Cmd + scroll untuk zoom. Drag untuk geser. Klik dua kali untuk fit.

Memuat...
Keputusan yang sudah dikunci (ringkasan ADR-001):
← 04 · Status Modul Lanjut: 06 · Pipeline Pengembangan →