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)
This commit is contained in:
4
addons/saat_invest/models/__init__.py
Normal file
4
addons/saat_invest/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import saham
|
||||
from . import transaksi
|
||||
from . import portofolio
|
||||
BIN
addons/saat_invest/models/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/saat_invest/models/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
addons/saat_invest/models/__pycache__/portofolio.cpython-311.pyc
Normal file
BIN
addons/saat_invest/models/__pycache__/portofolio.cpython-311.pyc
Normal file
Binary file not shown.
BIN
addons/saat_invest/models/__pycache__/saham.cpython-311.pyc
Normal file
BIN
addons/saat_invest/models/__pycache__/saham.cpython-311.pyc
Normal file
Binary file not shown.
BIN
addons/saat_invest/models/__pycache__/transaksi.cpython-311.pyc
Normal file
BIN
addons/saat_invest/models/__pycache__/transaksi.cpython-311.pyc
Normal file
Binary file not shown.
98
addons/saat_invest/models/portofolio.py
Normal file
98
addons/saat_invest/models/portofolio.py
Normal file
@@ -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
|
||||
22
addons/saat_invest/models/saham.py
Normal file
22
addons/saat_invest/models/saham.py
Normal file
@@ -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."),
|
||||
]
|
||||
51
addons/saat_invest/models/transaksi.py
Normal file
51
addons/saat_invest/models/transaksi.py
Normal file
@@ -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.")
|
||||
Reference in New Issue
Block a user