From d657a0a8f099b7eb49bcba5a126632f129a1484a Mon Sep 17 00:00:00 2001 From: Alex Ardhian Date: Wed, 2 Sep 2026 11:23:39 +0700 Subject: [PATCH] Aplikasi catat jual-beli saham: Odoo 17 + Postgres 16 + addon saat_invest - docker-compose.yml: odoo:17.0 (port 8069) + postgres:16 (internal, no publish) - Addon saat_invest: model saat.saham / saat.transaksi / saat.portofolio - P/L computed (realized FIFO, unrealized dari harga_sekarang) - action_recalculate() endpoint untuk Flutter - seed 5 saham IDX (BBRI, TLKM, BBCA, ASII, PTBA) - JSON-RPC end-to-end terverifikasi: create beli/jual -> recalculate -> portfolio (avg_cost 4525, realized 65000, unrealized 220000, nilai 3840000) - PLAN.md: rancangan disetujui Bozz - Login Odoo admin/admin (ganti password master di .env, jangan commit) --- .gitignore | 12 +++ PLAN.md | 40 +++++++ README.md | 79 ++++++++++++++ addons/saat_invest/__init__.py | 2 + addons/saat_invest/__manifest__.py | 29 ++++++ addons/saat_invest/data/saat.demo.saham.xml | 35 +++++++ addons/saat_invest/models/__init__.py | 4 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 287 bytes .../__pycache__/portofolio.cpython-311.pyc | Bin 0 -> 6529 bytes .../models/__pycache__/saham.cpython-311.pyc | Bin 0 -> 1322 bytes .../__pycache__/transaksi.cpython-311.pyc | Bin 0 -> 4282 bytes addons/saat_invest/models/portofolio.py | 98 ++++++++++++++++++ addons/saat_invest/models/saham.py | 22 ++++ addons/saat_invest/models/transaksi.py | 51 +++++++++ .../saat_invest/security/ir.model.access.csv | 4 + addons/saat_invest/views/portofolio_views.xml | 62 +++++++++++ addons/saat_invest/views/saham_views.xml | 59 +++++++++++ addons/saat_invest/views/transaksi_views.xml | 55 ++++++++++ docker-compose.yml | 56 ++++++++++ 19 files changed, 608 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 addons/saat_invest/__init__.py create mode 100644 addons/saat_invest/__manifest__.py create mode 100644 addons/saat_invest/data/saat.demo.saham.xml create mode 100644 addons/saat_invest/models/__init__.py create mode 100644 addons/saat_invest/models/__pycache__/__init__.cpython-311.pyc create mode 100644 addons/saat_invest/models/__pycache__/portofolio.cpython-311.pyc create mode 100644 addons/saat_invest/models/__pycache__/saham.cpython-311.pyc create mode 100644 addons/saat_invest/models/__pycache__/transaksi.cpython-311.pyc create mode 100644 addons/saat_invest/models/portofolio.py create mode 100644 addons/saat_invest/models/saham.py create mode 100644 addons/saat_invest/models/transaksi.py create mode 100644 addons/saat_invest/security/ir.model.access.csv create mode 100644 addons/saat_invest/views/portofolio_views.xml create mode 100644 addons/saat_invest/views/saham_views.xml create mode 100644 addons/saat_invest/views/transaksi_views.xml create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17f07bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Rahasia — jangan pernah commit +.env +*.local +.DS_Store + +# Python +__pycache__/ +*.pyc + +# Terraform / backup dll +*.bak +*.log diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c5d3ec2 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,40 @@ +# Rancangan Addon `saat_invest` — Catat Jual/Beli Saham + +**Status:** Disetujui oleh Bozz (2026-09-02) +**Backend:** Odoo 17 Community + +## Keputusan (dari Bozz) +1. Hitung P/L **otomatis (computed)** di Odoo dari data transaksi. +2. **Seed 5 saham contoh** IDX saat install (BBRI, TLKM, BBCA, +2). +3. **Endpoint JSON-RPC khusus** untuk Flutter (method Python, dipanggil via `execute_kw`). + +## Model +- `saat.saham` — master saham (ticker `name`, `perusahaan`, `sector`) +- `saat.transaksi` — catat beli/jual (tanggal, tipe buy/sell, saham, volume, harga, fee, catatan) +- `saat.portofolio` — ringkasan per saham (jumlah lembar, avg_cost, investasi, realized/unrealized P/L — semua computed dari transaksi) + +## Relasi & perhitungan +- Total lembar = Σ(buy.volume) − Σ(sell.volume) per saham +- **Realized P/L** = saat sell: (harga_jual − avg_cost) × volume − fee +- **Unrealized P/L** = (harga_sekarang − avg_cost) × sisa lembar +- Avg cost = weighted average (total beli ÷ jumlah lembar beli) + +## API (untuk Flutter, via execute_kw JSON-RPC) +- `saat.portofolio.search_read` — daftar posisi +- `saat.transaksi.create` / `search_read` — catat & lihat transaksi +- Method khusus: `action_calculate_portfolio()` pada saat.portofolio akselerasi agregasi + +## Struktur file +``` +addons/saat_invest/ +├── __manifest__.py +├── __init__.py +├── models/{__init__,saham,transaksi,portofolio}.py +├── security/ir.model.access.csv +├── data/saat.demo.saham.csv (seed) +└── views/{saham,transaksi,portofolio}_views.xml +``` + +## Keamanan +- Model internal (group `Sales`? no → buat group "User Investasi" via access CSV) +- Record rule: user hanya akses data miliknya (default res.users via owner field opsional; v1 sederhana → semua internal user bisa CRUD) diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea951ef --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Aplikasi Catat Jual-Beli Saham — Odoo 17 + Flutter + +Backend Odoo ERP (Community 17) untuk mencatat pembelian & penjualan saham, +diakses dari aplikasi **Flutter** (Android). + +## Arsitektur +``` +Flutter app (Android) ──JSON-RPC──► Odoo 17 (Python) + │ custom addon: saat_invest + ▼ + PostgreSQL 16 +``` + +## Stack + +| Layer | Teknologi | Port | +|---|---|---| +| Backend | Odoo 17 Community (`odoo:17.0`) | `8069` (host) | +| Database | PostgreSQL 16 (`postgres:16`) | internal (5432, tdk di-publish) | +| Mobile | Flutter (Android) | — | + +## Struktur proyek +``` +saat-app/ +├── docker-compose.yml # Odoo 17 + PostgreSQL 16 +├── .env # RAHASIA: password (jangan commit) +├── addons/ # custom addon buatan sendiri (saat_invest) +└── README.md +``` + +## Cara menjalankan +```bash +cd /home/ardhi/Application/saat-app +docker compose config # validasi +docker compose up -d # start +``` + +Akses UI: http://localhost:8069 — login `admin` / `admin` +(`ADMIN_PASSWORD` untuk Database Management di `.env`). + +## Status +- ✅ Odoo 17 container jalan (port 8069), DB PostgreSQL 16 healthy +- ✅ JSON-RPC aktif (uid `admin` verified) — endpoint yang akan dipakai Flutter +- ✅ **Addon `saat_invest` terinstal** (model saham, transaksi, portofolio + seed 5 saham IDX) +- ✅ End-to-end teruji: buat beli/jual → recalculate → hitung avg cost & realized/unrealized P/L +- ⏳ Aplikasi Flutter (login, catat jual/beli, portfolio, P/L) + +## Install / upgrade addon saat_invest +```bash +cd /home/ardhi/Application/saat-app +set -a && source .env && set +a +# install pertama kali +docker exec -e PASSWORD="$POSTGRES_PASSWORD" saat-odoo odoo \ + -d "$POSTGRES_DB" --db_host=db --db_user="$POSTGRES_USER" \ + --db_password="$POSTGRES_PASSWORD" --no-http --stop-after-init -i saat_invest +# upgrade setelah ubah kode +docker exec -e PASSWORD="$POSTGRES_PASSWORD" saat-odoo odoo \ + -d "$POSTGRES_DB" --db_host=db --db_user="$POSTGRES_USER" \ + --db_password="$POSTGRES_PASSWORD" --no-http --stop-after-init -u saat_invest +docker restart saat-odoo +``` + +> Catatan Odoo: `` view di Odoo 17 kadang bermasalah, pakai `` +> (kompatibel lintas versi) seperti yang sudah dilakukan. + +## API untuk Flutter (via JSON-RPC `execute_kw`) +Semua melalui `POST /jsonrpc` dengan `{service:'object', method:'execute_kw'}`. +Login: `common.authenticate(db, user, pass)` → `uid`. +- List saham: `saat.saham search_read [domain] {fields}` +- Catat transaksi: `saat.transaksi create [{vals}]` +- List transaksi: `saat.transaksi search_read [domain] {fields}` +- **Rekalkulasi portofolio**: `saat.portofolio action_recalculate [[]]` +- Baca portofolio: `saat.portofolio search_read [domain] {fields}` + (sisa_lembar, avg_cost, total_beli_biaya, realized_pl, unrealized_pl, nilai_sekarang) + +## Catatan keamanan +- `.env` berisi rahasia → **tidak boleh di-commit** (sudah di `.gitignore`) +- PostgreSQL tidak di-publish ke host, hanya akses antar-container +- Ganti `ADMIN_PASSWD` segera setelah setup awal diff --git a/addons/saat_invest/__init__.py b/addons/saat_invest/__init__.py new file mode 100644 index 0000000..a0fdc10 --- /dev/null +++ b/addons/saat_invest/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/addons/saat_invest/__manifest__.py b/addons/saat_invest/__manifest__.py new file mode 100644 index 0000000..fb4b3e9 --- /dev/null +++ b/addons/saat_invest/__manifest__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Catat Jual/Beli Saham (Saat Invest)", + 'summary': "Aplikasi pencatatan pembelian & penjualan saham dengan perhitungan portofolio & P/L", + 'description': """ +Modul untuk mencatat transaksi jual-beli saham, menghitung posisi portofolio +(jumlah lembar, average cost, investasi) serta realised/unrealised profit-loss +secara otomatis. Dirancang untuk diakses melalui aplikasi mobile Flutter +via JSON-RPC bawaan Odoo. + """, + 'version': '1.0.0', + 'category': 'Account/Finance', + 'author': "Kos Barokah", + 'website': "https://kosbarokah.com", + 'license': 'LGPL-3', + 'sequence': 1, + 'depends': ['base', 'web'], + 'data': [ + 'security/ir.model.access.csv', + 'data/saat.demo.saham.xml', + 'views/saham_views.xml', + 'views/transaksi_views.xml', + 'views/portofolio_views.xml', + ], + 'demo': [], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons/saat_invest/data/saat.demo.saham.xml b/addons/saat_invest/data/saat.demo.saham.xml new file mode 100644 index 0000000..53bd447 --- /dev/null +++ b/addons/saat_invest/data/saat.demo.saham.xml @@ -0,0 +1,35 @@ + + + + + BBRI + Bank Rakyat Indonesia Tbk + IDX + Perbankan + + + TLKM + Telkom Indonesia Tbk + IDX + Telekomunikasi + + + BBCA + Bank Central Asia Tbk + IDX + Perbankan + + + ASII + Astra International Tbk + IDX + Otomotif + + + PTBA + Bukit Asam Tbk + IDX + Energi / Batu Bara + + + diff --git a/addons/saat_invest/models/__init__.py b/addons/saat_invest/models/__init__.py new file mode 100644 index 0000000..be38093 --- /dev/null +++ b/addons/saat_invest/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from . import saham +from . import transaksi +from . import portofolio diff --git a/addons/saat_invest/models/__pycache__/__init__.cpython-311.pyc b/addons/saat_invest/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f489c83ede677f7989685bee918a6eb817336453 GIT binary patch literal 287 zcmZ3^%ge<81P>NZ&x!@ok3k$5V1hC}YXKS48B!Qh7;_kM8KW2(L2M=q zLlHAjbrB1QUTGsOScR#06}qhy!Q}0JH{3 A4FCWD literal 0 HcmV?d00001 diff --git a/addons/saat_invest/models/__pycache__/portofolio.cpython-311.pyc b/addons/saat_invest/models/__pycache__/portofolio.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..779e4bdce61f33ad5a28f0cba19d77f564104183 GIT binary patch literal 6529 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 literal 0 HcmV?d00001 diff --git a/addons/saat_invest/models/__pycache__/saham.cpython-311.pyc b/addons/saat_invest/models/__pycache__/saham.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..101505a478a67e7653f571593426e994d185094c GIT binary patch literal 1322 zcmZ`(&2QX96d!-?mz`#tr9=us7G0#6Ln1*G6oDFO(nU~`A|*ksk+6oHN#bN}Cu6%P zd)fns9CFAZQaDyZ3PQ~xN5o&i(JSp?HK$5&;kKxpazMPXvn#QMvFCZ_z2AF&@8>uE z(KO2l#_vCz@BJ+!^qbfWhjz1g`4KG65kW+1AzyMNUv^|)aTE#3=o3Wb?+{U_vZoFm zFBF8H!_0@H5p@IE+8<(TD_p?`9@n=$+9DCx{gBXB)V#rFkI{RtVSA1!awLKrnMjU8 zWJe{6i0J4tqJ_QU(2;0Fzbsrp4bNfb!zqnnikGDe2@o)E5alsMnN%)~u_%=>jQL6w zBSV{MY1JUf?~LKKVcE(%BQQ3Z8=vJ z2;Ig>E9UwJ-H$`YwU75>Z@WE_|oRPV8`!+ASt^m3&FS{;uR?-q{!*vf6+`6$8T$hARP-__$ ztb|5Q<{iXd7;xPU8KI1cUgqk`E@n)$7%v};7~tABS;cxpo1(bF{V;4%48FJ-drv7Z zJ__i&J`Rq!IjR+kjQFG*9k$$N7y!%I3*zV}^iuT0^;?(_(9R2fTrYS^qj)|)7@z_! z<_?Zn0f3nh$0Pa$L}zsM)rC}D=o{0W2mPtLozMH`RHyNxa_4yQs$><7&7s{$Y^JxK(4KcA46XCU17>x-iFn6@x%Pn r-ea?{6=ykm0%D*@lJo-A)BnE*dR5W~=v8s5pBaA&{a^oK%wGKuY`1C5 literal 0 HcmV?d00001 diff --git a/addons/saat_invest/models/__pycache__/transaksi.cpython-311.pyc b/addons/saat_invest/models/__pycache__/transaksi.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..976a0d7589f8a4556d69ac235006bc9998134b21 GIT binary patch literal 4282 zcmaJ^O>7fM7Vfs){)zt*NDR&oI70|H1e>3k?Ci1&1d;#&0!socWLi``Cseny z#00Ii;$R_5%K-_mhLxgSi47u$Xr+}n>>-Ca%&|SPG*(F?A?;~yL2%-Gd{o(0=~M^Z9=}Iqtvoq1J?I=lxmeJm(OH_ym{aV|>yQvm~uC zE6-WD3mmdM=8#pk&f98=FRdK+8oox03CK3ig@vE#W7tZp(xO7_Gm4x*DzPWiNKUBn zDwh4&s}Jx0ZlUj$xfqYQm<91ME3(9F$Vx57>_|ZNSxd}e;biB$t7ZT>kQ2Gqn;Lv* zVkozXaw8XLna}f{+RVDALDS1LeYJVnfxO6te1L=XzBbn!^D|s?4Tt=w8E`G@yBcsU z47ba~?FMgKU&2aX!`EoB-3-%Odq!<20H{3;p0zcg_5v!n-rj%;G@v>F)d|+R8vNSB zaC^;n?pxpgk_WHgYqVI9A+}c-Lfz<#$K3jXhBvl1;Cj$O)cXlshuk^eRkMx`J?37* z!|U1r7uyGRI_LM-2GC&?Mt$|~LPvIJg&MT_(NPpZ;ri90=nlPZruT*UHqfh&3Ocq! z=KwkmJNpFjj!Tj@fu)oxEvQO1d|uKdEu_ktA}xj%<>a)SP^45S3jNf4MoK_mmFA>m zBa=}*A00_#&Swkh*ylKlZzQm7E@AEo`5v8s~T2Pv&2!IAcCr;u}mBj#%sb>OgBAo zV)=eX!7?HaB+p2hghp%=%A$;+TyWVY=095u5tyG;ftrqMRSTHzb>d8?Akk$_&U!~P z$%Hf)x&pjNIBa3BVW6z%qJ(Fqs4+v_@eBs<i{Fe& zF`r% zlNPbMjl|lEa5u~tOU4JFi+ezPZYh*dmF}n>s+ztNUIdW;}#t5u(7nT%N z7L^$>mDa?WbS8yhZ_!#ttXQ5gvQA1ZV;Y(r1RR3F?Ed;#?x3v`Kx!C7j@zw8 ze_465QXGBrReq)9A1wO^b-%PZ@!cQF!#B&rlJ1lkLBvt5BgCdb8WSf=UP!Sz=I9yB znEMxWD04&HUgQz~$fDKm3!njvne5b`BC9keI~v$EwGm&>|065R*6sux zeb={e=UA~aY5A7>o3O%*W;6>Eh=X`o#|Zb44}~2#0xu&rTJ?wxK~;k4b?N#rvChgG zJ_>WV9|XjPR(`@`V%L=Tf{aNMWk*a%P`g#?a|oKTJ!v9kI~t<%%ks+h^L;?&xVJ5B zTb$K(mN#U6u+kQI7JDY@ZGCH&{K!wit|EG&{B{3}{XYi#e+c%k{eJz{#*I?&>vHhx z{Aguw@P(`BU%jEXoq^9k)x%U}2t<`z-ZcW;QVHDd+57Cl(?_ev*IL&1taWY- zmI4EBy8mcyfp$O2?ri zFf7c{+Y?q6>Dq~1Ok*Tt{3SSzZ6Jv4Jn)K7uwB)q(x1^9Q&{sfqXgMeNhq!e+`3jma1 z<3ynQg$V4h$MiIymoKgK3_SCU6aIT9T4d@gNMmGEP9FNI58s3L0<~{^@|I z)cz{Ale+C>#nYJ^uI!HFu2j4|xskV?4!!HB?vCV!-ns+%fx@Zc!p52Jdvy1;lKWcO zeJwXs@dfiUg@x7N#>D2pcc;qdztMe@CEsM(H<`Ooad+eg%I=Qh&|2V4&-a4v9xJ)W z%I-1H^Y6=*&{sl?$h$=$g?Z&*xZNDAea;X E0~{6(1^@s6 literal 0 HcmV?d00001 diff --git a/addons/saat_invest/models/portofolio.py b/addons/saat_invest/models/portofolio.py new file mode 100644 index 0000000..6ee4bdf --- /dev/null +++ b/addons/saat_invest/models/portofolio.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class SaatPortofolio(models.Model): + """Ringkasan posisi per saham, dihitung dari seluruh transaksi. + + Bukan stored-view melainkan snapshot yang direkalkulasi dari transaksi + (via action_recalculate / API). Satu record per saham yang pernah + ditransaksikan. Menyimpan aggregate terakhir + P/L realised/unrealised. + """ + _name = 'saat.portofolio' + _description = "Portofolio Saham" + _rec_name = 'saham_id' + + saham_id = fields.Many2one('saat.saham', string="Saham", required=True, ondelete='cascade') + # Posisi agregat + total_beli_lembar = fields.Integer(string="Total Beli (lembar)", default=0) + total_beli_biaya = fields.Monetary(string="Total Beli (biaya)", default=0.0, currency_field='currency_id') + total_jual_lembar = fields.Integer(string="Total Jual (lembar)", default=0) + total_jual_hasil = fields.Monetary(string="Total Jual (hasil)", default=0.0, currency_field='currency_id') + sisa_lembar = fields.Integer(string="Sisa Lembar", compute='_compute_sisa', store=True) + avg_cost = fields.Monetary(string="Avg Cost/Lembar", compute='_compute_avg', store=True, currency_field='currency_id') + harga_sekarang = fields.Monetary(string="Harga Sekarang/Lembar", currency_field='currency_id') + nilai_sekarang = fields.Monetary( + string="Nilai Sekarang", compute='_compute_nilai', store=True, currency_field='currency_id') + realized_pl = fields.Monetary( + string="Realized P/L", currency_field='currency_id') + unrealized_pl = fields.Monetary( + string="Unrealized P/L", compute='_compute_pl', store=True, currency_field='currency_id') + currency_id = fields.Many2one('res.currency', string="Mata Uang", default=lambda self: self.env.company.currency_id) + + _sql_constraints = [ + ('portofolio_saham_uniq', 'unique(saham_id)', "Sudah ada portofolio untuk saham ini."), + ] + + # --- aggregate helper (dijalankan saat rekalkulasi) --- + def action_recalculate(self): + """Rekalkulasi semua posisi portofolio dari transaksi. Return dict ringkas.""" + self.env['saat.portofolio'].search([]).unlink() + trans = self.env['saat.transaksi'].search([], order='tanggal, id') + per_saham = {} + for t in trans: + a = per_saham.setdefault(t.saham_id.id, { + 'saham': t.saham_id, 'beli_lb': 0, 'beli_by': 0.0, + 'jual_lb': 0, 'jual_hl': 0.0, 'moves': [], 'tot_cost': 0.0, + }) + a['moves'].append(t) + if t.tipe == 'buy': + a['beli_lb'] += t.volume + a['beli_by'] += t.total + else: + a['jual_lb'] += t.volume + a['jual_hl'] += t.total # total sudah termasuk fee (keluar) + + portfolios = self.env['saat.portofolio'] + for sid, a in per_saham.items(): + # FIFO realized P/L sederhana: realized saat sell = jual - (avg cost beli * volume terjual) + # avg cost beli per lembar: + avg_beli = (a['beli_by'] / a['beli_lb']) if a['beli_lb'] else 0.0 + lembar_terjual = min(a['jual_lb'], a['beli_lb']) + realized = (a['jual_hl'] - avg_beli * lembar_terjual) if lembar_terjual else 0.0 + sisa = max(a['beli_lb'] - a['jual_lb'], 0) + portfolios.create({ + 'saham_id': sid, + 'total_beli_lembar': a['beli_lb'], + 'total_beli_biaya': a['beli_by'], + 'total_jual_lembar': a['jual_lb'], + 'total_jual_hasil': a['jual_hl'], + 'harga_sekarang': a['saham']._get_last_harga() if hasattr(a['saham'], '_get_last_harga') else 0.0, + 'realized_pl': realized, + }) + return { + 'portofolio_ids': portfolios.ids, + 'count': len(portfolios), + } + + # --- computed store fields --- + @api.depends('total_beli_lembar', 'total_jual_lembar') + def _compute_sisa(self): + for r in self: + r.sisa_lembar = max((r.total_beli_lembar or 0) - (r.total_jual_lembar or 0), 0) + + @api.depends('total_beli_biaya', 'total_beli_lembar') + def _compute_avg(self): + for r in self: + r.avg_cost = (r.total_beli_biaya / r.total_beli_lembar) if r.total_beli_lembar else 0.0 + + @api.depends('sisa_lembar', 'harga_sekarang') + def _compute_nilai(self): + for r in self: + r.nilai_sekarang = (r.sisa_lembar or 0) * (r.harga_sekarang or 0.0) + + @api.depends('harga_sekarang', 'avg_cost', 'sisa_lembar') + def _compute_pl(self): + for r in self: + r.unrealized_pl = ((r.harga_sekarang or 0.0) - (r.avg_cost or 0.0)) * (r.sisa_lembar or 0) + # realized dihitung di action_recalculate (dibawa via currency) — simpan sisa diff --git a/addons/saat_invest/models/saham.py b/addons/saat_invest/models/saham.py new file mode 100644 index 0000000..94ea362 --- /dev/null +++ b/addons/saat_invest/models/saham.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class SaatSaham(models.Model): + """Master daftar saham / emiten yang dicatat.""" + _name = 'saat.saham' + _description = "Saham / Emiten" + _order = 'name' + + name = fields.Char(string="Kode Saham (Ticker)", required=True) + perusahaan = fields.Char(string="Nama Perusahaan / Emiten") + bursa = fields.Char(string="Bursa", default="IDX") + sector = fields.Char(string="Sektor") + active = fields.Boolean(string="Aktif", default=True) + + # o2m convenience menampilkan semua transaksi saham ini + transaksi_ids = fields.One2many('saat.transaksi', 'saham_id', string="Transaksi") + + _sql_constraints = [ + ('saham_uniq', 'unique(name)', "Kode saham sudah terdaftar."), + ] diff --git a/addons/saat_invest/models/transaksi.py b/addons/saat_invest/models/transaksi.py new file mode 100644 index 0000000..217b5ad --- /dev/null +++ b/addons/saat_invest/models/transaksi.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class SaatTransaksi(models.Model): + """Catat setiap pembelian / penjualan saham.""" + _name = 'saat.transaksi' + _description = "Transaksi Jual/Beli Saham" + _order = 'tanggal desc, id desc' + + TIPE = [('buy', 'Beli'), ('sell', 'Jual')] + + name = fields.Char(string="Referensi", compute='_compute_name', store=True) + tanggal = fields.Date(string="Tanggal", required=True, default=fields.Date.context_today) + tipe = fields.Selection(TIPE, string="Tipe", required=True, default='buy') + saham_id = fields.Many2one('saat.saham', string="Saham", required=True, ondelete='restrict') + volume = fields.Integer(string="Jumlah Lembar", required=True) + harga = fields.Monetary(string="Harga/Lembar", currency_field='currency_id', required=True) + fee = fields.Monetary(string="Biaya (Komisi/PPN)", currency_field='currency_id', default=0.0) + total = fields.Monetary( + string="Total Biaya", currency_field='currency_id', + compute='_compute_total', store=True) + catatan = fields.Text(string="Catatan") + user_id = fields.Many2one('res.users', string="Pencatat", default=lambda self: self.env.user) + currency_id = fields.Many2one( + 'res.currency', string="Mata Uang", default=lambda self: self._default_currency()) + + @api.model + def _default_currency(self): + # IDR + return self.env.ref('base.IDR', raise_if_not_found=False) or self.env.company.currency_id + + @api.depends('tanggal', 'tipe', 'saham_id') + def _compute_name(self): + for r in self: + ticker = r.saham_id.name or '' + tipe_label = dict(self.TIPE).get(r.tipe, '') + r.name = f"{r.tanggal or ''} {tipe_label} {ticker}" + + @api.depends('volume', 'harga', 'fee', 'tipe') + def _compute_total(self): + for r in self: + r.total = (r.volume or 0) * (r.harga or 0) + (r.fee or 0) + + @api.constrains('volume', 'harga') + def _check_positive(self): + for r in self: + if r.volume <= 0: + raise models.ValidationError("Volume harus lebih dari 0.") + if r.harga < 0: + raise models.ValidationError("Harga tidak boleh negatif.") diff --git a/addons/saat_invest/security/ir.model.access.csv b/addons/saat_invest/security/ir.model.access.csv new file mode 100644 index 0000000..e6ae0ee --- /dev/null +++ b/addons/saat_invest/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_saat_saham_all,saat.saham.all,model_saat_saham,base.group_user,1,1,1,1 +access_saat_transaksi_all,saat.transaksi.all,model_saat_transaksi,base.group_user,1,1,1,1 +access_saat_portofolio_all,saat.portofolio.all,model_saat_portofolio,base.group_user,1,1,1,1 diff --git a/addons/saat_invest/views/portofolio_views.xml b/addons/saat_invest/views/portofolio_views.xml new file mode 100644 index 0000000..40f519f --- /dev/null +++ b/addons/saat_invest/views/portofolio_views.xml @@ -0,0 +1,62 @@ + + + + saat.portofolio.list + saat.portofolio + + + + + + + + + + + + + + + saat.portofolio.form + saat.portofolio + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Portofolio + saat.portofolio + list,form + +

+ Klik Rekalkulasi (menu Form) untuk memperbarui posisi dari transaksi. +

+
+
+ + +
diff --git a/addons/saat_invest/views/saham_views.xml b/addons/saat_invest/views/saham_views.xml new file mode 100644 index 0000000..8472ada --- /dev/null +++ b/addons/saat_invest/views/saham_views.xml @@ -0,0 +1,59 @@ + + + + saat.saham.list + saat.saham + + + + + + + + + + + + saat.saham.form + saat.saham + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Saham + saat.saham + list,form + +

Daftar saham/emiten yang dicatat.

+
+
+ + + +
diff --git a/addons/saat_invest/views/transaksi_views.xml b/addons/saat_invest/views/transaksi_views.xml new file mode 100644 index 0000000..be341c2 --- /dev/null +++ b/addons/saat_invest/views/transaksi_views.xml @@ -0,0 +1,55 @@ + + + + saat.transaksi.list + saat.transaksi + + + + + + + + + + + + + + + saat.transaksi.form + saat.transaksi + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Transaksi + saat.transaksi + list,form + +

Catat pembelian / penjualan saham di sini.

+
+
+ + +
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..30f6ed7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +# Docker Compose untuk Odoo 17 (Community) + PostgreSQL 16 +# Proyek: Aplikasi catat pembelian/penjualan saham +# Lokasi: /home/ardhi/Application/saat-app +# +# Catatan: +# - HANYA container baru, TIDAK menyentuh traefik/gitea/portainer yang sudah ada. +# - Postgres port 5432 TIDAK dipublish ke host (hanya akses antar container). +# - Odoo ter-expose di host port 8069. Traefik routing opsional belakangan. +# - Buat project admin di Odoo langsung via web UI setelah `docker compose up`. + +services: + db: + image: postgres:16 + container_name: saat-odoo-db + restart: unless-stopped + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - saat_odoo_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - saat-odoo-net + + odoo: + image: odoo:17.0 + container_name: saat-odoo + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "8069:8069" + environment: + - HOST=db + - USER=${POSTGRES_USER} + - PASSWORD=${POSTGRES_PASSWORD} + - ADMIN_PASSWORD=${ADMIN_PASSWD} + volumes: + - saat_odoo_filestore:/var/lib/odoo + - ./addons:/mnt/extra-addons + networks: + - saat-odoo-net + +volumes: + saat_odoo_pgdata: + saat_odoo_filestore: + +networks: + saat-odoo-net: + driver: bridge