diff --git a/config.json b/config.json index bacbf4b..14ae0dc 100644 --- a/config.json +++ b/config.json @@ -114,5 +114,10 @@ "memory": "memory/", "logs": "logs/", "sessions": "sessions/" + }, + "roa2web": { + "base_url": "https://roa2web.romfast.ro/roa2web/api", + "default_company": "114", + "default_company_name": "ROMFAST SRL" } } diff --git a/cron/jobs.json b/cron/jobs.json index 713262f..86f05fc 100644 --- a/cron/jobs.json +++ b/cron/jobs.json @@ -11,9 +11,9 @@ "report_on": "changes", "timeout": 120, "enabled": true, - "last_run": "2026-07-09T10:00:00.001988+00:00", + "last_run": "2026-07-10T10:00:00.001575+00:00", "last_status": "ok", - "next_run": "2026-07-09T16:00:00+00:00" + "next_run": "2026-07-10T16:00:00+00:00" }, { "name": "security-audit-daily", @@ -43,9 +43,9 @@ "report_on": "never", "timeout": 120, "enabled": true, - "last_run": "2026-07-09T03:30:00.002154+00:00", + "last_run": "2026-07-10T03:30:00.002026+00:00", "last_status": "ok", - "next_run": "2026-07-10T03:30:00+00:00" + "next_run": "2026-07-11T03:30:00+00:00" }, { "name": "archive-tasks-daily", @@ -59,9 +59,9 @@ "report_on": "changes", "timeout": 60, "enabled": true, - "last_run": "2026-07-09T03:00:00.001431+00:00", + "last_run": "2026-07-10T03:00:00.002135+00:00", "last_status": "ok", - "next_run": "2026-07-10T03:00:00+00:00" + "next_run": "2026-07-11T03:00:00+00:00" }, { "name": "backup-config", @@ -75,9 +75,9 @@ "report_on": "never", "timeout": 120, "enabled": true, - "last_run": "2026-07-09T02:00:00.001391+00:00", + "last_run": "2026-07-10T02:00:00.002082+00:00", "last_status": "ok", - "next_run": "2026-07-10T02:00:00+00:00" + "next_run": "2026-07-11T02:00:00+00:00" }, { "name": "insights-extract", @@ -243,9 +243,9 @@ "prompt": "Heartbeat check. Rulează src/heartbeat.py printr-un scurt raport de status.\nDacă nu e nimic de raportat (email=0, calendar nu are evenimente <2h, kb ok), răspunde doar cu HEARTBEAT_OK și oprește-te — nu trimite mesaj.\nDacă e ceva: raport scurt pe Discord #echo-work.", "allowed_tools": [], "enabled": true, - "last_run": "2026-07-09T14:00:00.001126+00:00", + "last_run": "2026-07-10T14:00:00.002633+00:00", "last_status": "ok", - "next_run": "2026-07-09T18:00:00+00:00" + "next_run": "2026-07-10T18:00:00+00:00" }, { "name": "night-execute", diff --git a/cron/newsletter-cercetasi-state.json b/cron/newsletter-cercetasi-state.json index 1afb5bc..67ecd01 100644 --- a/cron/newsletter-cercetasi-state.json +++ b/cron/newsletter-cercetasi-state.json @@ -1,5 +1,5 @@ { - "last_sent": 25, + "last_sent": 26, "year": 2026, - "last_sent_at": "2026-07-02T19:00:58.783724+00:00" + "last_sent_at": "2026-07-09T15:01:09.525808+00:00" } diff --git a/memory/kb/index.json b/memory/kb/index.json index f8a1b3d..3c4dff4 100644 --- a/memory/kb/index.json +++ b/memory/kb/index.json @@ -1,5 +1,21 @@ { "notes": [ + { + "file": "notes-data/youtube/2026-07-10_opencode-token-usage-96-percent.md", + "title": "I Cut My OpenCode Token Usage by 96% - Here's How", + "date": "2026-07-10", + "tags": [], + "domains": [ + "work", + "growth" + ], + "types": [], + "category": "youtube", + "project": null, + "subdir": null, + "video": "", + "tldr": "Autorul folosește un proxy man-in-the-middle pentru a vedea exact ce trimite OpenCode (agent de coding AI) către LLM la un simplu \"hello\" — și găsește 8.000 de tokeni: system prompt de 9.500 caractere..." + }, { "file": "notes-data/youtube/2026-07-08_fine-tune-modele-mari-kimi-k27.md", "title": "Fine-Tune the biggest open-source models (even with a bad PC)", @@ -10192,11 +10208,11 @@ } ], "stats": { - "total": 589, + "total": 590, "by_domain": { - "work": 201, + "work": 202, "health": 104, - "growth": 274, + "growth": 275, "sprijin": 39, "scout": 9 }, @@ -10213,7 +10229,7 @@ "reflectii": 3, "retete": 1, "tools": 7, - "youtube": 158, + "youtube": 159, "memory": 44 } }, diff --git a/memory/kb/index.md b/memory/kb/index.md index 272b107..63498d8 100644 --- a/memory/kb/index.md +++ b/memory/kb/index.md @@ -14,7 +14,7 @@ - **[reflectii/](reflectii/index.md)** — 3 note - **[retete/](retete/index.md)** — 1 note - **[tools/](tools/index.md)** — 7 note -- **[youtube/](youtube/index.md)** — 158 note +- **[youtube/](youtube/index.md)** — 159 note ## Note la rădăcină diff --git a/memory/kb/youtube/2026-07-10_opencode-token-usage-96-percent.md b/memory/kb/youtube/2026-07-10_opencode-token-usage-96-percent.md new file mode 100644 index 0000000..cf925bf --- /dev/null +++ b/memory/kb/youtube/2026-07-10_opencode-token-usage-96-percent.md @@ -0,0 +1,48 @@ +# I Cut My OpenCode Token Usage by 96% - Here's How + +**URL:** https://youtu.be/FX7jcd3GYtI +**Durata:** 9:42 +**Data salvat:** 2026-07-10 +**Tags:** @work @growth @ai @agents @llm + +--- + +## TL;DR + +Autorul folosește un proxy man-in-the-middle pentru a vedea exact ce trimite OpenCode (agent de coding AI) către LLM la un simplu "hello" — și găsește 8.000 de tokeni: system prompt de 9.500 caractere + 11 definiții de tool-uri (bash, task, edit, read etc.) trimise la FIECARE request, plus un al doilea apel LLM ascuns doar pentru titlul conversației. Soluția: creează un agent custom minimal (fără tools, system prompt scurt) → 300-500 tokeni, reducere ~90-96%. Recomandare: pornește minimal, adaugă tools doar când chiar ai nevoie de ele. + +--- + +## Puncte cheie + +- **8.000 tokeni pentru "hello"** — 0,025% e mesajul propriu-zis, restul e scaffolding (system prompt + tool definitions) +- OpenCode face **2 apeluri LLM** per mesaj: unul ascuns doar ca să genereze titlul conversației (~2.000 tokeni, pt un titlu de 1 cuvânt) +- System prompt default: ~114 linii, spune LLM-ului tot ce poate/nu poate face, format, emoji, etc. +- 11 tool definitions trimise mereu — bash tool singur = 4.700 caractere, task tool = 3.000 caractere +- **Cache-ul providerului NU te ajută pe tine** — reduce costul $ pentru provider, dar tot trimiți full payload pe rețea, tot consumi context window și latență +- Soluție: agenți custom minimali (folder `.opencode/agents/*.md`) cu system prompt scurt și zero/puține tools, setați ca `primary` — switch cu Tab între agent complet (build) și agent lean +- Trade-off: agentul lean nu are task/sub-agents, web fetch, etc. — dar poți adăuga selectiv doar ce ai nevoie +- Filosofie: **start minimal, expand when necessary** — nu monta toate tool-urile/MCP-urile by default + +--- + +## Quote-uri relevante + +> "8,000 tokens. That's what it cost me to say hello to an AI coding agent." + +> "Cached tokens are a cost optimization for the provider, not you. For you, it's still 8,000 tokens of latency and context window usage." + +> "Start minimal and expand when necessary." + +--- + +## Idei acționabile pentru Marius + +- [ ] Relevant pentru Claude Code / echo-core: verifică dacă subagenții custom (`Agent` tool) pot fi definiți cu scope minim (fără tool-uri inutile) pentru task-uri simple, ca să reduci tokeni pe apelurile dese (heartbeat, routine checks) @work +- [ ] Ideea de "agent lean vs agent complet, switch la nevoie" se mapează pe strategia ta Haiku/Sonnet din AGENTS.md — poate merită un al treilea nivel: agent minimal fără tools pentru întrebări simple @growth + +--- + +## Surse menționate + +- Repo GitHub cu scriptul proxy + config agent (menționat, link nu apare explicit în transcript) diff --git a/memory/kb/youtube/index.md b/memory/kb/youtube/index.md index 5b7ee7b..3dd3b6d 100644 --- a/memory/kb/youtube/index.md +++ b/memory/kb/youtube/index.md @@ -1,6 +1,6 @@ # Index — youtube/ -> 158 note. Citește acest index întâi; deschide doar fișierele relevante. +> 159 note. Citește acest index întâi; deschide doar fișierele relevante. - **[Talk to Claude on 3CX Phone System Tutorial (Full Setup)](2025-02-13_talk-to-claude-3cx-phone.md)** `@work` Tutorial complet pentru a vorbi cu Claude Code prin telefon, folosind 3CX (sistem telefonic cloud gratuit) + un proiect GitHub custom. Setup @@ -316,5 +316,7 @@ Chris Koerner explică cum pornești o afacere cu $1k-$5k și ce principii l-au dus la succes cu 35+ proprietăți RV și multiple business-uri. E - **[Fine-Tune the biggest open-source models (even with a bad PC)](2026-07-08_fine-tune-modele-mari-kimi-k27.md)** `@work @growth` Tutorial complet despre cum se fine-tunează modele AI masive (ex: Kimi K2.7 — 1 trilion parametri) pe date proprii, fără hardware de sute de +- **[I Cut My OpenCode Token Usage by 96% - Here's How](2026-07-10_opencode-token-usage-96-percent.md)** `@work @growth` + Autorul folosește un proxy man-in-the-middle pentru a vedea exact ce trimite OpenCode (agent de coding AI) către LLM la un simplu "hello" — - **[thinking-on-paper](thinking-on-paper.md)** `@growth` Metoda "Thinking on Paper" — 3 principii: **Make it Wrong** (scrie repede, fără perfecționism), **Make it Shorter** (doar keywords), **Make diff --git a/src/fast_commands.py b/src/fast_commands.py index 8eabd28..35ff7d6 100644 --- a/src/fast_commands.py +++ b/src/fast_commands.py @@ -787,6 +787,11 @@ def cmd_audio(args: list[str]) -> str: if len(text) > _MAX_TTS_CHARS: text = text[:_MAX_TTS_CHARS] + "..." + # Normalizare RO pentru TTS (numere, monedă, unități, abrevieri) — + # aceeași expandare ca voice mode live, fără trunchierea la 200 cuvinte + from src.voice.normalize import expand_for_tts + text = expand_for_tts(text) + # Apel TTS (import direct din tools/tts.py) result = _tts_synthesize(text, voice) if result.get("ok"): diff --git a/src/voice/normalize.py b/src/voice/normalize.py index 4daad5b..bd41c79 100644 --- a/src/voice/normalize.py +++ b/src/voice/normalize.py @@ -201,8 +201,12 @@ _CURRENCY_SUB = { } _CURRENCY_PATTERNS = [ - # RON suffix (case-insensitive: RON, ron, lei) - (re.compile(r'(? str: return f"{word} {plural}" -def _format_currency(amount: str, code: str) -> str: +def _format_currency(amount: str, code: str, comma_frac: str | None = None) -> str: main_sg, main_pl = _CURRENCY_MAIN[code] - if '.' in amount: + if comma_frac is not None: + whole_s, frac_s = amount, comma_frac + elif '.' in amount: whole_s, frac_s = amount.split('.', 1) - # Normalize fractional part to 2 digits so "12.5 RON" reads as - # 50 bani, not 5 bani. - if len(frac_s) == 1: - frac_s = frac_s + '0' - elif len(frac_s) > 2: - frac_s = frac_s[:2] - whole = int(whole_s) - frac = int(frac_s) - whole_part = _format_currency_unit(whole, main_sg, main_pl) - if frac == 0: - return whole_part - sub_sg, sub_pl = _CURRENCY_SUB[code] - frac_part = _format_currency_unit(frac, sub_sg, sub_pl) - return f"{whole_part} și {frac_part}" - return _format_currency_unit(int(amount), main_sg, main_pl) + else: + return _format_currency_unit(int(amount), main_sg, main_pl) + # Normalize fractional part to 2 digits so "12.5 RON" reads as + # 50 bani, not 5 bani. + if len(frac_s) == 1: + frac_s = frac_s + '0' + elif len(frac_s) > 2: + frac_s = frac_s[:2] + whole = int(whole_s) + frac = int(frac_s) + whole_part = _format_currency_unit(whole, main_sg, main_pl) + if frac == 0: + return whole_part + sub_sg, sub_pl = _CURRENCY_SUB[code] + frac_part = _format_currency_unit(frac, sub_sg, sub_pl) + return f"{whole_part} și {frac_part}" def expand_currency(text: str) -> str: @@ -251,7 +258,10 @@ def expand_currency(text: str) -> str: bani / cenți / pence). """ for pattern, code in _CURRENCY_PATTERNS: - text = pattern.sub(lambda m, c=code: _format_currency(m.group(1), c), text) + def _sub(m, c=code): + comma_frac = m.group(2) if m.re.groups >= 2 else None + return _format_currency(m.group(1), c, comma_frac) + text = pattern.sub(_sub, text) return text @@ -296,12 +306,13 @@ _MAX_WORDS = 200 _TRUNCATE_SUFFIX = "Restul l-am scris în chat." -def normalize_for_tts(text: str) -> str: - """Apply the full normalization pipeline and truncate to 200 words. +def expand_for_tts(text: str) -> str: + """Apply the full normalization pipeline (markdown strip, abbreviations, + numbers, currency, units, symbols) WITHOUT the 200-word truncation. - If the text exceeds 200 words, the first 200 are kept and the suffix - "Restul l-am scris în chat." is appended so the listener knows the - response continues in the text channel mirror. + Use this for one-shot TTS generation (e.g. /audio command) where the + "Restul l-am scris în chat." suffix from normalize_for_tts() would be + misleading (no live chat mirror exists for that flow). """ text = strip_markdown(text) text = sanitize_punctuation(text) @@ -312,6 +323,17 @@ def normalize_for_tts(text: str) -> str: text = expand_units(text) text = expand_numbers_ro(text) text = expand_symbols(text) + return text.strip() + + +def normalize_for_tts(text: str) -> str: + """Apply the full normalization pipeline and truncate to 200 words. + + If the text exceeds 200 words, the first 200 are kept and the suffix + "Restul l-am scris în chat." is appended so the listener knows the + response continues in the text channel mirror. + """ + text = expand_for_tts(text) words = text.split() if len(words) > _MAX_WORDS: text = ' '.join(words[:_MAX_WORDS]) + f" {_TRUNCATE_SUFFIX}" diff --git a/tests/test_voice_normalize.py b/tests/test_voice_normalize.py index 0b6c4b9..5243fed 100644 --- a/tests/test_voice_normalize.py +++ b/tests/test_voice_normalize.py @@ -78,6 +78,28 @@ def test_expand_currency(text, expected): assert expand_currency(text) == expected +# ============================================================ +# Currency — Romanian accounting format (comma decimal, dot +# thousands already collapsed upstream by normalize_thousands) +# ============================================================ +@pytest.mark.parametrize("text,expected", [ + ("53600,00 lei", "cincizeci și trei de mii șase sute de lei"), + ("17600,50 lei", "șaptesprezece mii șase sute de lei și cincizeci de bani"), + ("100,00 lei", "o sută de lei"), + ("17600,05 lei", "șaptesprezece mii șase sute de lei și cinci bani"), +]) +def test_expand_currency_comma_decimal(text, expected): + assert expand_currency(text) == expected + + +@pytest.mark.parametrize("text,expected", [ + ("53.600,00 lei", "cincizeci și trei de mii șase sute de lei"), + ("17.600,50 lei", "șaptesprezece mii șase sute de lei și cincizeci de bani"), +]) +def test_normalize_for_tts_currency_ro_accounting_format(text, expected): + assert normalize_for_tts(text) == expected + + # ============================================================ # Symbols (4) # ============================================================ diff --git a/tools/anaf-monitor/hashes.json b/tools/anaf-monitor/hashes.json index f9b6b8a..0fc47c6 100644 --- a/tools/anaf-monitor/hashes.json +++ b/tools/anaf-monitor/hashes.json @@ -6,7 +6,7 @@ "D406": "ca6103448d663ab16fcaef0f29f8933ef526cbf5aad12c7ff5dbd61b22ca9fc6", "SIT_FIN_SEM_2025": "8164843431e6b703a38fbdedc7898ec6ae83559fe10f88663ba0b55f3091d5fe", "SIT_FIN_AN_2025": "accceef5b6585a3e901d83d23fc2e60f6562eac4a2ce00f943856232bed929d6", - "DESCARCARE_DECLARATII": "0fb1535fe97aeadc7f415431238fae2cf7bc52d9e0c17ce3c4b4a12db92cd733", + "DESCARCARE_DECLARATII": "1abf61068fc38e76963520f455a87635efdcf6c126fbf913d8d860478d3fc5ba", "D205": "d3c20a7ae70f4c18bbb7add42af035e3746d323b2e6df37a4e31ed625ddb86d9", "D390": "31d0bf3f98eda3556039cc7e186bfcaf71bdb962459a2cf54f4d03d1d57053fc", "BILANT_2024": "fbb8d66c2e530d8798362992c6983e07e1250188228c758cb6da4cde4f955950", diff --git a/tools/anaf-monitor/snapshots/DESCARCARE_DECLARATII.txt b/tools/anaf-monitor/snapshots/DESCARCARE_DECLARATII.txt index a60bc41..7ac22cf 100644 --- a/tools/anaf-monitor/snapshots/DESCARCARE_DECLARATII.txt +++ b/tools/anaf-monitor/snapshots/DESCARCARE_DECLARATII.txt @@ -507,6 +507,11 @@ L153 Se transmit prin portalul e-guvernare.ro +Formularul +S007 +Se transmit prin + portalul +e-guvernare.ro C801 - Cerere de atribuire a numărului unic de identificare din aplicaţia informatică a ANAF - publicat în data de diff --git a/tools/roa2web_client.py b/tools/roa2web_client.py new file mode 100644 index 0000000..abd162f --- /dev/null +++ b/tools/roa2web_client.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +Client generic pentru API-ul roa2web — autentificare + query-uri rapoarte. + +Autentificare: user/parolă Oracle (ca la login-ul normal ROA) + 2FA email la +prima logare. După primul login cu trust_device=True, sesiunile următoare +folosesc refresh_token din keyring — fără OTP. + +Credențiale (keyring, service "echo-core", vezi src/credential_store.py): + roa2web_user, roa2web_pass, roa2web_server, + roa2web_access_token, roa2web_refresh_token, roa2web_trusted_device_token + +Firma implicită și base_url sunt în config.json -> "roa2web". + +Usage: + python3 tools/roa2web_client.py summary [--company COD_SAU_NUME] [--luna N] [--an N] + python3 tools/roa2web_client.py companies + python3 tools/roa2web_client.py get [--company COD_SAU_NUME] [--param k=v ...] + python3 tools/roa2web_client.py verify-2fa +""" + +import argparse +import json +import sys +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import requests + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from src.credential_store import get_secret, set_secret +from src.config import Config + + +class ROA2WebError(Exception): + pass + + +class ROA2WebClient: + def __init__(self): + self.config = Config() + self.base_url = self.config.get("roa2web.base_url", "https://roa2web.romfast.ro/roa2web/api") + self.session = requests.Session() + self._token = get_secret("roa2web_access_token") + + parsed = urlsplit(self.base_url) + self._origin = f"{parsed.scheme}://{parsed.netloc}" + # IIS/ARR reverse-proxy-ul din fata backend-ului serveste totul sub /roa2web, + # dar FastAPI nu stie de acest prefix -> redirect-urile lui (307, ex. trailing + # slash pe rutele definite la "/") vin fara el si pica in 404. Il reinjectam. + self._prefix = parsed.path[: -len("/api")] if parsed.path.endswith("/api") else parsed.path + + @property + def default_company(self) -> str: + return self.config.get("roa2web.default_company", "114") + + def _store_tokens(self, data: dict) -> None: + set_secret("roa2web_access_token", data["access_token"]) + if data.get("refresh_token"): + set_secret("roa2web_refresh_token", data["refresh_token"]) + if data.get("trusted_device_token"): + set_secret("roa2web_trusted_device_token", data["trusted_device_token"]) + self._token = data["access_token"] + + def _login_password(self) -> None: + user = get_secret("roa2web_user") + pw = get_secret("roa2web_pass") + server = get_secret("roa2web_server") or "romfast" + if not user or not pw: + raise ROA2WebError("Lipsesc roa2web_user/roa2web_pass din keyring (eco secrets set ...)") + + payload = {"username": user, "password": pw, "server_id": server} + trusted = get_secret("roa2web_trusted_device_token") + if trusted: + payload["trusted_device_token"] = trusted + + r = self.session.post(f"{self.base_url}/auth/login", json=payload, timeout=15) + r.raise_for_status() + data = r.json() + if data.get("requires_2fa"): + raise ROA2WebError( + f"Necesar cod OTP nou, trimis pe {data.get('masked_email')}. " + "Cere-i lui Marius codul, apoi ruleaza verify_2fa(code, email)." + ) + self._store_tokens(data) + + def _refresh(self) -> bool: + refresh_token = get_secret("roa2web_refresh_token") + if not refresh_token: + return False + r = self.session.post( + f"{self.base_url}/auth/refresh", + json={"refresh_token": refresh_token}, + timeout=15, + ) + if r.status_code != 200: + return False + self._store_tokens(r.json()) + return True + + def verify_2fa(self, code: str, email: str, server_id: str | None = None) -> dict: + server = server_id or get_secret("roa2web_server") or "romfast" + r = self.session.post( + f"{self.base_url}/auth/verify-2fa-code", + json={"code": code, "email": email, "server_id": server, "trust_device": True}, + timeout=15, + ) + r.raise_for_status() + data = r.json() + self._store_tokens(data) + return data + + def request(self, method: str, path: str, params: dict | None = None, _retried: bool = False) -> dict: + if not self._token and not self._refresh(): + self._login_password() + + url = f"{self.base_url}{path}" if path.startswith("/") else f"{self.base_url}/{path}" + headers = {"Authorization": f"Bearer {self._token}"} + r = self.session.request(method, url, params=params, headers=headers, timeout=30, allow_redirects=False) + + if r.status_code in (307, 308) and "Location" in r.headers: + loc = urlsplit(r.headers["Location"]) + loc_path = loc.path + if self._prefix and not loc_path.startswith(self._prefix): + loc_path = self._prefix + loc_path + fixed_url = urlunsplit((urlsplit(self.base_url).scheme, urlsplit(self.base_url).netloc, loc_path, loc.query, "")) + r = self.session.request(method, fixed_url, headers=headers, timeout=30) + + if r.status_code == 401 and not _retried: + if self._refresh() or (self._login_password() or True): + return self.request(method, path, params=params, _retried=True) + r.raise_for_status() + return r.json() + + def get(self, path: str, params: dict | None = None) -> dict: + return self.request("GET", path, params=params) + + def companies(self) -> list: + return self.get("/companies").get("companies", []) + + def dashboard_summary(self, company: str | None = None, luna: int | None = None, an: int | None = None) -> dict: + params = {"company": company or self.default_company} + if luna: + params["luna"] = luna + if an: + params["an"] = an + return self.get("/reports/dashboard/summary", params) + + +def resolve_company(client: ROA2WebClient, value: str | None) -> str: + """Acceptă cod numeric sau (parte din) numele firmei; implicit firma din config.""" + if not value: + return client.default_company + if value.isdigit(): + return value + needle = value.lower() + matches = [c for c in client.companies() if needle in c["name"].lower()] + if not matches: + raise ROA2WebError(f"Firma '{value}' nu a fost gasita in lista de firme.") + if len(matches) > 1: + names = ", ".join(f"{c['id_firma']}={c['name']}" for c in matches) + raise ROA2WebError(f"'{value}' e ambiguu, se potriveste cu mai multe firme: {names}") + return str(matches[0]["id_firma"]) + + +def main(): + parser = argparse.ArgumentParser(description="Client CLI pentru roa2web API") + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("summary", help="Dashboard financiar (trezorerie, clienti, furnizori)") + p.add_argument("--company", help="Cod sau nume firma (implicit: cea din config.json)") + p.add_argument("--luna", type=int) + p.add_argument("--an", type=int) + + sub.add_parser("companies", help="Lista firmelor disponibile") + + p = sub.add_parser("get", help="Query generic pe orice endpoint /api/...") + p.add_argument("path", help='ex: "/reports/invoices" sau "/reports/treasury/bank-cash-register"') + p.add_argument("--company", help="Daca e dat, se adauga ca param 'company'") + p.add_argument("--param", action="append", default=[], metavar="k=v", help="Parametru query, repetabil") + + v = sub.add_parser("verify-2fa", help="Finalizeaza login-ul cu un cod OTP primit pe email") + v.add_argument("code") + v.add_argument("email") + + args = parser.parse_args() + client = ROA2WebClient() + + try: + if args.cmd == "summary": + company = resolve_company(client, args.company) + data = client.dashboard_summary(company=company, luna=args.luna, an=args.an) + elif args.cmd == "companies": + for c in client.companies(): + print(f"{c['id_firma']}\t{c['name']}") + return + elif args.cmd == "get": + params = dict(kv.split("=", 1) for kv in args.param) + if args.company: + params["company"] = resolve_company(client, args.company) + data = client.get(args.path, params or None) + elif args.cmd == "verify-2fa": + raw = client.verify_2fa(args.code, args.email) + data = {k: v for k, v in raw.items() if k not in ("access_token", "refresh_token", "trusted_device_token")} + except ROA2WebError as e: + print(f"Eroare: {e}", file=sys.stderr) + sys.exit(1) + + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main()