This commit is contained in:
2026-07-12 19:21:35 +02:00
parent f2d944b117
commit 41cb412cb0
49 changed files with 3170 additions and 52 deletions
+6 -3
View File
@@ -60,15 +60,17 @@ class CategoryModel extends Model
$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 {
$walk = static function (int $parentId, int $depth, string $parentPath) use (&$walk, &$out, $childrenByParent, $indent): void {
foreach ($childrenByParent[$parentId] ?? [] as $c) {
$c['depth'] = $depth;
$c['indent_label'] = str_repeat($indent, $depth) . $c['name'];
// Pelna sciezka "Nadrzedna → Podrzedna" — jednoznaczna nawet gdy JS uciasnie wciecia.
$c['path_label'] = $parentPath === '' ? $c['name'] : $parentPath . ' → ' . $c['name'];
$out[] = $c;
$walk((int) $c['id'], $depth + 1);
$walk((int) $c['id'], $depth + 1, $c['path_label']);
}
};
$walk(0, 0);
$walk(0, 0, '');
// Sieroty (np. rodzic odfiltrowany typem) — dopnij na koncu bez wciecia
$seen = array_column($out, null, 'id');
@@ -76,6 +78,7 @@ class CategoryModel extends Model
if (! isset($seen[$c['id']])) {
$c['depth'] = 0;
$c['indent_label'] = $c['name'];
$c['path_label'] = $c['name'];
$out[] = $c;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
* Uczona pamiec dopasowan: znormalizowana nazwa pozycji -> kategoria.
* Sluzy WYLACZNIE do podpowiedzi; nic nie jest ksiegowane automatycznie.
*/
class ReceiptCategoryMapModel extends Model
{
protected $table = 'receipt_category_map';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $dateFormat = 'datetime';
protected $createdField = ''; // tabela ma tylko updated_at
protected $allowedFields = ['item_name', 'category_id', 'updated_at'];
/**
* Normalizacja nazwy pozycji jako klucz uczenia:
* trim + lowercase + redukcja wielokrotnych bialych znakow.
* ponytail: bez usuwania gramatur/liczb — zaczynamy od dokladnego dopasowania nazwy.
*/
public function normalize(string $name): string
{
$n = mb_strtolower(trim($name));
return preg_replace('/\s+/u', ' ', $n) ?? $n;
}
/**
* Podpowiedziana kategoria dla nazwy pozycji lub null.
*/
public function suggest(string $name): ?int
{
$row = $this->where('item_name', $this->normalize($name))->first();
return $row ? (int) $row['category_id'] : null;
}
/**
* Zapamietaj wybor uzytkownika (upsert po znormalizowanej nazwie).
*/
public function remember(string $name, int $categoryId): void
{
$key = $this->normalize($name);
if ($key === '') {
return;
}
$existing = $this->where('item_name', $key)->first();
if ($existing) {
$this->update($existing['id'], ['category_id' => $categoryId, 'updated_at' => date('Y-m-d H:i:s')]);
} else {
$this->insert(['item_name' => $key, 'category_id' => $categoryId, 'updated_at' => date('Y-m-d H:i:s')]);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ReceiptItemModel extends Model
{
protected $table = 'receipt_items';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'receipt_id', 'name', 'display_name', 'qty', 'amount',
'suggested_category_id', 'chosen_category_id',
];
/**
* Pozycje danego paragonu w kolejnosci wprowadzenia.
*/
public function forReceipt(int $receiptId): array
{
return $this->where('receipt_id', $receiptId)
->orderBy('id', 'ASC')
->findAll();
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ReceiptModel extends Model
{
protected $table = 'receipts';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'file_path', 'original_name', 'mime', 'merchant',
'receipt_date', 'total', 'status', 'raw_json', 'error_msg',
];
public function hasOriginalName(string $name): bool
{
return $this->where('original_name', $name)->countAllResults() > 0;
}
/**
* Lista paragonow do historii, z liczba pozycji i utworzonych operacji.
*/
public function withCounts(): array
{
return $this->db->table('receipts r')
->select('r.*')
->select('(SELECT COUNT(*) FROM receipt_items ri WHERE ri.receipt_id = r.id) AS items_count', false)
->select('(SELECT COUNT(*) FROM operations o WHERE o.receipt_id = r.id) AS operations_count', false)
->orderBy('r.id', 'DESC')
->get()->getResultArray();
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class RememberTokenModel extends Model
{
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
protected $table = 'remember_tokens';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = ['selector', 'validator_hash', 'expires_at'];
public function issueToken(): string
{
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));
$this->insert([
'selector' => $selector,
'validator_hash' => hash('sha256', $validator),
'expires_at' => date('Y-m-d H:i:s', time() + self::LIFETIME_SECONDS),
]);
return $selector . ':' . $validator;
}
public function verifyToken(string $token): bool
{
[$selector, $validator] = $this->splitToken($token);
if ($selector === null) {
return false;
}
$row = $this->where('selector', $selector)->first();
if (!$row || strtotime($row['expires_at']) <= time()) {
if ($row) {
$this->delete($row['id']);
}
return false;
}
return hash_equals($row['validator_hash'], hash('sha256', $validator));
}
public function deleteToken(string $token): void
{
[$selector] = $this->splitToken($token);
if ($selector !== null) {
$this->where('selector', $selector)->delete();
}
}
public function deleteExpired(): void
{
$this->where('expires_at <=', date('Y-m-d H:i:s'))->delete();
}
private function splitToken(string $token): array
{
$parts = explode(':', $token, 2);
if (count($parts) !== 2 || !ctype_xdigit($parts[0]) || strlen($parts[0]) !== 32
|| !ctype_xdigit($parts[1]) || strlen($parts[1]) !== 64) {
return [null, null];
}
return $parts;
}
}