first commit
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class CategoryModel extends Model
|
||||
{
|
||||
protected $table = 'categories';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $allowedFields = ['name', 'type', 'parent_id'];
|
||||
|
||||
protected $validationRules = [
|
||||
'name' => 'required|max_length[100]',
|
||||
'type' => 'required|in_list[income,expense]',
|
||||
'parent_id' => 'permit_empty|is_natural_no_zero',
|
||||
];
|
||||
|
||||
/**
|
||||
* Plaska lista kategorii, opcjonalnie filtrowana typem.
|
||||
*/
|
||||
public function flatList(?string $type = null): array
|
||||
{
|
||||
$builder = $this->orderBy('type', 'ASC')->orderBy('name', 'ASC');
|
||||
if ($type !== null && $type !== '') {
|
||||
$builder = $builder->where('type', $type);
|
||||
}
|
||||
|
||||
return $builder->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy kategoria jest uzywana przez jakies operacje.
|
||||
*/
|
||||
public function usedByOperations(int $id): bool
|
||||
{
|
||||
return (bool) model(OperationModel::class)->where('category_id', $id)->countAllResults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie w porzadku drzewa, posortowane po nazwie na kazdym poziomie.
|
||||
* Kazdy element dostaje 'depth' oraz 'indent_label' (nazwa z wcieciem) do list i selectow.
|
||||
*/
|
||||
public function tree(?string $type = null): array
|
||||
{
|
||||
$builder = $this->orderBy('name', 'ASC');
|
||||
if ($type !== null && $type !== '') {
|
||||
$builder->where('type', $type);
|
||||
}
|
||||
$all = $builder->findAll();
|
||||
|
||||
// Grupowanie dzieci wg rodzica (0 = korzen)
|
||||
$childrenByParent = [];
|
||||
foreach ($all as $c) {
|
||||
$childrenByParent[(int) ($c['parent_id'] ?: 0)][] = $c;
|
||||
}
|
||||
|
||||
$indent = "\u{00A0}\u{00A0}\u{00A0}\u{00A0}"; // 4x twarda spacja na poziom
|
||||
$out = [];
|
||||
|
||||
$walk = static function (int $parentId, int $depth) use (&$walk, &$out, $childrenByParent, $indent): void {
|
||||
foreach ($childrenByParent[$parentId] ?? [] as $c) {
|
||||
$c['depth'] = $depth;
|
||||
$c['indent_label'] = str_repeat($indent, $depth) . $c['name'];
|
||||
$out[] = $c;
|
||||
$walk((int) $c['id'], $depth + 1);
|
||||
}
|
||||
};
|
||||
$walk(0, 0);
|
||||
|
||||
// Sieroty (np. rodzic odfiltrowany typem) — dopnij na koncu bez wciecia
|
||||
$seen = array_column($out, null, 'id');
|
||||
foreach ($all as $c) {
|
||||
if (! isset($seen[$c['id']])) {
|
||||
$c['depth'] = 0;
|
||||
$c['indent_label'] = $c['name'];
|
||||
$out[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class InstrumentModel extends Model
|
||||
{
|
||||
protected $table = 'inv_instruments';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $allowedFields = ['name'];
|
||||
|
||||
protected $validationRules = [
|
||||
'name' => 'required|max_length[120]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Czy instrument ma powiazane operacje lub wyceny (blokada usuniecia).
|
||||
*/
|
||||
public function hasChildren(int $id): bool
|
||||
{
|
||||
$ops = (int) $this->db->table('inv_operations')->where('instrument_id', $id)->countAllResults();
|
||||
$val = (int) $this->db->table('inv_valuations')->where('instrument_id', $id)->countAllResults();
|
||||
|
||||
return ($ops + $val) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ostatnia wycena per instrument: [instrument_id => value].
|
||||
* Ostatnia = najpozniejsza data, przy remisie najwyzsze id.
|
||||
*/
|
||||
private function latestValuations(): array
|
||||
{
|
||||
$rows = $this->db->table('inv_valuations')
|
||||
->select('instrument_id, value')
|
||||
->orderBy('instrument_id', 'ASC')->orderBy('date', 'ASC')->orderBy('id', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$latest = [];
|
||||
foreach ($rows as $r) {
|
||||
$latest[(int) $r['instrument_id']] = (float) $r['value']; // ostatni wygrywa (posortowane rosnaco)
|
||||
}
|
||||
|
||||
return $latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sumy wplat/wyplat per instrument: [instrument_id => ['deposit'=>..,'withdraw'=>..]].
|
||||
*/
|
||||
private function operationSums(): array
|
||||
{
|
||||
$rows = $this->db->table('inv_operations')
|
||||
->select('instrument_id')
|
||||
->select("SUM(CASE WHEN type='deposit' THEN amount ELSE 0 END) AS deposit")
|
||||
->select("SUM(CASE WHEN type='withdraw' THEN amount ELSE 0 END) AS withdraw")
|
||||
->groupBy('instrument_id')
|
||||
->get()->getResultArray();
|
||||
|
||||
$sums = [];
|
||||
foreach ($rows as $r) {
|
||||
$sums[(int) $r['instrument_id']] = [
|
||||
'deposit' => (float) $r['deposit'],
|
||||
'withdraw' => (float) $r['withdraw'],
|
||||
];
|
||||
}
|
||||
|
||||
return $sums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instrumenty ze statystykami:
|
||||
* wplacone_netto = Sumwplat - Sumwyplat; wartosc = ostatnia wycena;
|
||||
* zarobek = wartosc - wplacone_netto; procent = zarobek / wplacone_netto * 100 (gdy netto > 0).
|
||||
*/
|
||||
public function withStats(): array
|
||||
{
|
||||
$instruments = $this->orderBy('name', 'ASC')->findAll();
|
||||
$latest = $this->latestValuations();
|
||||
$sums = $this->operationSums();
|
||||
|
||||
foreach ($instruments as &$ins) {
|
||||
$id = (int) $ins['id'];
|
||||
$deposit = $sums[$id]['deposit'] ?? 0.0;
|
||||
$withdraw = $sums[$id]['withdraw'] ?? 0.0;
|
||||
$net = $deposit - $withdraw;
|
||||
$value = $latest[$id] ?? 0.0;
|
||||
$profit = $value - $net;
|
||||
|
||||
$ins['deposit'] = round($deposit, 2);
|
||||
$ins['withdraw'] = round($withdraw, 2);
|
||||
$ins['net_invested'] = round($net, 2);
|
||||
$ins['value'] = round($value, 2);
|
||||
$ins['has_value'] = array_key_exists($id, $latest);
|
||||
$ins['profit'] = round($profit, 2);
|
||||
$ins['percent'] = $net > 0 ? round($profit / $net * 100, 2) : null;
|
||||
}
|
||||
|
||||
return $instruments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Szereg czasowy calego portfela do wykresu.
|
||||
* Dla kazdej daty wyceny: wartosc = suma ostatnich wycen (data <= d) po instrumentach;
|
||||
* wplacone_netto = Sum(deposit - withdraw) o dacie <= d; zarobek = wartosc - wplacone_netto.
|
||||
* Zwraca [ ['date'=>.., 'net'=>.., 'value'=>.., 'profit'=>..], ... ].
|
||||
*/
|
||||
public function portfolioTimeline(): array
|
||||
{
|
||||
$dates = $this->db->table('inv_valuations')
|
||||
->select('date')->distinct()->orderBy('date', 'ASC')
|
||||
->get()->getResultArray();
|
||||
if (empty($dates)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$valuations = $this->db->table('inv_valuations')
|
||||
->select('instrument_id, date, value')
|
||||
->orderBy('date', 'ASC')->orderBy('id', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$operations = $this->db->table('inv_operations')
|
||||
->select('date, amount, type')
|
||||
->get()->getResultArray();
|
||||
|
||||
$out = [];
|
||||
foreach ($dates as $d) {
|
||||
$day = $d['date'];
|
||||
|
||||
// Ostatnia wycena per instrument o dacie <= day
|
||||
$latest = [];
|
||||
foreach ($valuations as $v) {
|
||||
if ($v['date'] <= $day) {
|
||||
$latest[(int) $v['instrument_id']] = (float) $v['value'];
|
||||
}
|
||||
}
|
||||
$value = array_sum($latest);
|
||||
|
||||
// Wplacone netto o dacie <= day
|
||||
$net = 0.0;
|
||||
foreach ($operations as $o) {
|
||||
if ($o['date'] <= $day) {
|
||||
$net += $o['type'] === 'deposit' ? (float) $o['amount'] : -(float) $o['amount'];
|
||||
}
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'date' => $day,
|
||||
'net' => round($net, 2),
|
||||
'value' => round($value, 2),
|
||||
'profit' => round($value - $net, 2),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class InvOperationModel extends Model
|
||||
{
|
||||
protected $table = 'inv_operations';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $allowedFields = ['instrument_id', 'date', 'amount', 'type', 'description'];
|
||||
|
||||
protected $validationRules = [
|
||||
'instrument_id' => 'required|is_natural_no_zero',
|
||||
'date' => 'required|valid_date[Y-m-d]',
|
||||
'amount' => 'required|decimal|greater_than[0]',
|
||||
'type' => 'required|in_list[deposit,withdraw]',
|
||||
'description' => 'permit_empty|max_length[255]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista operacji inwestycyjnych z nazwa instrumentu, z filtrami.
|
||||
* $f: from, to, instrument_id, type (wszystkie opcjonalne).
|
||||
*/
|
||||
public function filtered(array $f): array
|
||||
{
|
||||
$b = $this->db->table('inv_operations o')
|
||||
->select('o.*, i.name AS instrument_name')
|
||||
->join('inv_instruments i', 'i.id = o.instrument_id', 'left');
|
||||
|
||||
if (! empty($f['from'])) {
|
||||
$b->where('o.date >=', $f['from']);
|
||||
}
|
||||
if (! empty($f['to'])) {
|
||||
$b->where('o.date <=', $f['to']);
|
||||
}
|
||||
if (! empty($f['instrument_id'])) {
|
||||
$b->where('o.instrument_id', (int) $f['instrument_id']);
|
||||
}
|
||||
if (! empty($f['type'])) {
|
||||
$b->where('o.type', $f['type']);
|
||||
}
|
||||
|
||||
return $b->orderBy('o.date', 'DESC')->orderBy('o.id', 'DESC')->get()->getResultArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class OperationModel extends Model
|
||||
{
|
||||
protected $table = 'operations';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $allowedFields = ['date', 'amount', 'type', 'category_id', 'description'];
|
||||
|
||||
protected $validationRules = [
|
||||
'date' => 'required|valid_date[Y-m-d]',
|
||||
'amount' => 'required|decimal|greater_than[0]',
|
||||
'type' => 'required|in_list[income,expense]',
|
||||
'category_id' => 'permit_empty|is_natural_no_zero',
|
||||
'description' => 'permit_empty|max_length[255]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista operacji z nazwa kategorii, z filtrami.
|
||||
* $f: from, to, category_id, type (wszystkie opcjonalne).
|
||||
*/
|
||||
public function filtered(array $f): array
|
||||
{
|
||||
$b = $this->db->table('operations o')
|
||||
->select('o.*, c.name AS category_name, p.name AS parent_name')
|
||||
->join('categories c', 'c.id = o.category_id', 'left')
|
||||
->join('categories p', 'p.id = c.parent_id', 'left');
|
||||
|
||||
if (! empty($f['from'])) {
|
||||
$b->where('o.date >=', $f['from']);
|
||||
}
|
||||
if (! empty($f['to'])) {
|
||||
$b->where('o.date <=', $f['to']);
|
||||
}
|
||||
if (! empty($f['category_id'])) {
|
||||
$b->where('o.category_id', (int) $f['category_id']);
|
||||
}
|
||||
if (! empty($f['type'])) {
|
||||
$b->where('o.type', $f['type']);
|
||||
}
|
||||
|
||||
return $b->orderBy('o.date', 'DESC')->orderBy('o.id', 'DESC')->get()->getResultArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sumy przychodow/wydatkow/salda dla zakresu (i opcjonalnych filtrow).
|
||||
*/
|
||||
public function totals(array $f): array
|
||||
{
|
||||
$b = $this->db->table('operations o')
|
||||
->select("SUM(CASE WHEN o.type='income' THEN o.amount ELSE 0 END) AS income")
|
||||
->select("SUM(CASE WHEN o.type='expense' THEN o.amount ELSE 0 END) AS expense");
|
||||
|
||||
if (! empty($f['from'])) {
|
||||
$b->where('o.date >=', $f['from']);
|
||||
}
|
||||
if (! empty($f['to'])) {
|
||||
$b->where('o.date <=', $f['to']);
|
||||
}
|
||||
if (! empty($f['category_id'])) {
|
||||
$b->where('o.category_id', (int) $f['category_id']);
|
||||
}
|
||||
if (! empty($f['type'])) {
|
||||
$b->where('o.type', $f['type']);
|
||||
}
|
||||
|
||||
$row = $b->get()->getRowArray() ?: [];
|
||||
$income = (float) ($row['income'] ?? 0);
|
||||
$expense = (float) ($row['expense'] ?? 0);
|
||||
|
||||
return [
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'balance' => $income - $expense,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilans miesieczny: [ ['ym'=>'2026-07','income'=>..,'expense'=>..], ... ].
|
||||
*/
|
||||
public function monthlyBalance(string $from, string $to): array
|
||||
{
|
||||
return $this->db->table('operations')
|
||||
->select("DATE_FORMAT(date, '%Y-%m') AS ym")
|
||||
->select("SUM(CASE WHEN type='income' THEN amount ELSE 0 END) AS income")
|
||||
->select("SUM(CASE WHEN type='expense' THEN amount ELSE 0 END) AS expense")
|
||||
->where('date >=', $from)->where('date <=', $to)
|
||||
->groupBy('ym')->orderBy('ym', 'ASC')
|
||||
->get()->getResultArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Suma wg kategorii dla danego typu: [ ['name'=>..,'total'=>..], ... ].
|
||||
*/
|
||||
public function byCategory(string $from, string $to, string $type): array
|
||||
{
|
||||
$rows = $this->db->table('operations o')
|
||||
->select('o.category_id, c.name AS cat_name, p.name AS parent_name, SUM(o.amount) AS total')
|
||||
->join('categories c', 'c.id = o.category_id', 'left')
|
||||
->join('categories p', 'p.id = c.parent_id', 'left')
|
||||
->where('o.type', $type)
|
||||
->where('o.date >=', $from)->where('o.date <=', $to)
|
||||
->groupBy('o.category_id')->groupBy('c.name')->groupBy('p.name')
|
||||
->orderBy('total', 'DESC')
|
||||
->get()->getResultArray();
|
||||
|
||||
// Etykieta = pelna sciezka kategoria -> podkategoria
|
||||
foreach ($rows as &$r) {
|
||||
if (empty($r['cat_name'])) {
|
||||
$r['name'] = '(bez kategorii)';
|
||||
} elseif (! empty($r['parent_name'])) {
|
||||
$r['name'] = $r['parent_name'] . ' → ' . $r['cat_name'];
|
||||
} else {
|
||||
$r['name'] = $r['cat_name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saldo skumulowane w czasie: [ ['date'=>..,'balance'=>..], ... ].
|
||||
*/
|
||||
public function runningBalance(string $from, string $to): array
|
||||
{
|
||||
$rows = $this->db->table('operations')
|
||||
->select('date')
|
||||
->select("SUM(CASE WHEN type='income' THEN amount ELSE -amount END) AS net")
|
||||
->where('date >=', $from)->where('date <=', $to)
|
||||
->groupBy('date')->orderBy('date', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$balance = 0.0;
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$balance += (float) $r['net'];
|
||||
$out[] = ['date' => $r['date'], 'balance' => round($balance, 2)];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ValuationModel extends Model
|
||||
{
|
||||
protected $table = 'inv_valuations';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
protected $allowedFields = ['instrument_id', 'date', 'value'];
|
||||
|
||||
protected $validationRules = [
|
||||
'instrument_id' => 'required|is_natural_no_zero',
|
||||
'date' => 'required|valid_date[Y-m-d]',
|
||||
'value' => 'required|decimal|greater_than_equal_to[0]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista wycen z nazwa instrumentu, z opcjonalnym filtrem instrumentu.
|
||||
* $f: instrument_id, from, to (wszystkie opcjonalne).
|
||||
*/
|
||||
public function filtered(array $f): array
|
||||
{
|
||||
$b = $this->db->table('inv_valuations v')
|
||||
->select('v.*, i.name AS instrument_name')
|
||||
->join('inv_instruments i', 'i.id = v.instrument_id', 'left');
|
||||
|
||||
if (! empty($f['instrument_id'])) {
|
||||
$b->where('v.instrument_id', (int) $f['instrument_id']);
|
||||
}
|
||||
if (! empty($f['from'])) {
|
||||
$b->where('v.date >=', $f['from']);
|
||||
}
|
||||
if (! empty($f['to'])) {
|
||||
$b->where('v.date <=', $f['to']);
|
||||
}
|
||||
|
||||
return $b->orderBy('v.date', 'DESC')->orderBy('v.id', 'DESC')->get()->getResultArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user