first commit

This commit is contained in:
2026-07-06 20:16:00 +02:00
commit 352e6d9e22
147 changed files with 12242 additions and 0 deletions
View File
+85
View File
@@ -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;
}
}
+158
View File
@@ -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;
}
}
+48
View File
@@ -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();
}
}
+147
View File
@@ -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;
}
}
+43
View File
@@ -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();
}
}