From 131ef74232e75b0149b6fb9d242839a11d7b493b Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 30 Sep 2026 18:52:24 +0200 Subject: [PATCH 1/2] Add missing apis --- src/dualentry_cli/client.py | 10 +- .../commands/statistical_journals.py | 202 ++++++++++++++++++ src/dualentry_cli/main.py | 8 + src/dualentry_cli/output.py | 174 +++++++++++++++ tests/test_commands.py | 62 ++++++ tests/test_filter_gating.py | 3 + tests/test_integration.py | 4 + tests/test_stale_commands.py | 4 + 8 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 src/dualentry_cli/commands/statistical_journals.py diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index da16788..88a8dba 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -249,7 +249,15 @@ def paginate( result["next_offset"] = start_offset + len(all_items) return result - def post(self, path: str, json: dict[str, Any] | None = None) -> dict: + def post( + self, + path: str, + json: dict[str, Any] | None = None, + *, + files: list[tuple[str, tuple[str, bytes]]] | None = None, + ) -> dict: + if files is not None: + return self._request("POST", path, files=files) return self._request("POST", path, json=json) def put(self, path: str, json: dict[str, Any] | None = None) -> dict: diff --git a/src/dualentry_cli/commands/statistical_journals.py b/src/dualentry_cli/commands/statistical_journals.py new file mode 100644 index 0000000..27ae6e4 --- /dev/null +++ b/src/dualentry_cli/commands/statistical_journals.py @@ -0,0 +1,202 @@ +"""Statistical journal commands (custom list filters and attachment upload).""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.commands import ( + AllPages, + Format, + Limit, + Offset, + Search, + Status, + _do_list, + _load_json_file, + _strip_record_prefix, + _supplied, +) +from dualentry_cli.output import format_output + +app = typer.Typer(help="Manage statistical journals", no_args_is_help=True, cls=HelpfulGroup) + + +def _csv_ints(value: str | None) -> list[int] | None: + if not value: + return None + return [int(part.strip()) for part in value.split(",") if part.strip()] + + +def _csv_strings(value: str | None) -> list[str] | None: + if not value: + return None + return [part.strip() for part in value.split(",") if part.strip()] + + +def _build_list_filters( + *, + search: str | None, + status: str | None, + company: str | None, + date_start: str | None, + date_end: str | None, + period_start: str | None, + period_end: str | None, + number: str | None, + account_id: str | None, + account_number: str | None, + line_account_id: str | None, + line_account_number: str | None, + updated_after: str | None, + updated_before: str | None, + ordering: str | None, +) -> dict: + params: dict = {} + if search: + params["search"] = search + if status: + params["record_status"] = status + if company: + params["company_id"] = company + if date_start: + params["date_start"] = date_start + if date_end: + params["date_end"] = date_end + if period_start: + params["period_start"] = period_start + if period_end: + params["period_end"] = period_end + if number: + params["number"] = _csv_ints(number) + if account_id: + params["account_id"] = _csv_ints(account_id) + if account_number: + params["account_number"] = _csv_strings(account_number) + if line_account_id: + params["line_account_id"] = _csv_ints(line_account_id) + if line_account_number: + params["line_account_number"] = _csv_strings(line_account_number) + if updated_after: + params["updated_after"] = updated_after + if updated_before: + params["updated_before"] = updated_before + if ordering: + params["ordering"] = ordering + return params + + +@app.command("list") +def list_cmd( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + search: str | None = Search, + status: str | None = Status, + company: str | None = typer.Option(None, "--company", "-c", help="Filter by company ID"), + date_start: str | None = typer.Option(None, "--date-start", help="Filter from journal date (YYYY-MM-DD)"), + date_end: str | None = typer.Option(None, "--date-end", help="Filter to journal date (YYYY-MM-DD)"), + period_start: str | None = typer.Option(None, "--period-start", help="Filter from period start (YYYY-MM-DD)"), + period_end: str | None = typer.Option(None, "--period-end", help="Filter to period end (YYYY-MM-DD)"), + number: str | None = typer.Option(None, "--number", help="Filter by record number(s), comma-separated"), + account_id: str | None = typer.Option(None, "--account-id", help="Filter by account ID(s), comma-separated"), + account_number: str | None = typer.Option(None, "--account-number", help="Filter by account number(s), comma-separated"), + line_account_id: str | None = typer.Option(None, "--line-account-id", help="Filter by line account ID(s), comma-separated"), + line_account_number: str | None = typer.Option(None, "--line-account-number", help="Filter by line account number(s), comma-separated"), + updated_after: str | None = typer.Option(None, "--updated-after", help="Updated after timestamp (ISO 8601)"), + updated_before: str | None = typer.Option(None, "--updated-before", help="Updated before timestamp (ISO 8601)"), + ordering: str | None = typer.Option(None, "--ordering", help="Sort order (API field name, optional leading -)"), + output: str = Format, +): + """List statistical journals.""" + from dualentry_cli.main import get_client + + client = get_client() + filters = _build_list_filters( + search=_supplied(search), + status=_supplied(status), + company=_supplied(company), + date_start=_supplied(date_start), + date_end=_supplied(date_end), + period_start=_supplied(period_start), + period_end=_supplied(period_end), + number=_supplied(number), + account_id=_supplied(account_id), + account_number=_supplied(account_number), + line_account_id=_supplied(line_account_id), + line_account_number=_supplied(line_account_number), + updated_after=_supplied(updated_after), + updated_before=_supplied(updated_before), + ordering=_supplied(ordering), + ) + _do_list( + client, + "statistical-journals", + "statistical-journal", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + status_param="record_status", + **filters, + ) + + +@app.command("get") +def get_cmd( + value: str = typer.Argument(help="Record number (#) or prefixed number (e.g. SJ-3)"), + output: str = Format, +): + """Get a statistical journal by number.""" + from dualentry_cli.main import get_client + + client = get_client() + data = client.get(f"/statistical-journals/{_strip_record_prefix(value)}/") + format_output(data, resource="statistical-journal", fmt=output) + + +@app.command("create") +def create_cmd( + file: Path = typer.Option(..., "--file", "-f", help="JSON file with record data"), + output: str = Format, +): + """Create a statistical journal from a JSON file.""" + from dualentry_cli.main import get_client + + payload = _load_json_file(file) + client = get_client() + data = client.post("/statistical-journals/", json=payload) + format_output(data, resource="statistical-journal", fmt=output) + + +@app.command("update") +def update_cmd( + number: str = typer.Argument(help="Record number of the journal to update"), + file: Path = typer.Option(..., "--file", "-f", help="JSON file with update data"), + output: str = Format, +): + """Update a statistical journal.""" + from dualentry_cli.main import get_client + + payload = _load_json_file(file) + client = get_client() + data = client.put(f"/statistical-journals/{_strip_record_prefix(number)}/", json=payload) + format_output(data, resource="statistical-journal", fmt=output) + + +@app.command("add-attachments") +def add_attachments_cmd( + number: str = typer.Argument(help="Record number of the journal"), + file: list[Path] = typer.Option(..., "--file", "-f", help="Attachment file(s) to upload", exists=True, readable=True), + output: str = Format, +): + """Upload attachment file(s) to a statistical journal.""" + from dualentry_cli.main import get_client + + client = get_client() + stripped = _strip_record_prefix(number) + multipart = [("files", (path.name, path.read_bytes())) for path in file] + data = client.post(f"/statistical-journals/{stripped}/attachments/", files=multipart) + format_output(data, resource="statistical-journal", fmt=output) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 6ef4842..69923b0 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -11,6 +11,7 @@ from dualentry_cli.commands.bank_connections import app as bank_connections_app from dualentry_cli.commands.bank_match import app as bank_match_app from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE +from dualentry_cli.commands.statistical_journals import app as statistical_journals_app from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) @@ -58,6 +59,7 @@ # Accounting app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True, filters=TXN_ALL_PARTIES), name="journal-entries") +app.add_typer(statistical_journals_app, name="statistical-journals") app.add_typer(make_resource_app("bank transfers", "bank-transfer", "bank-transfers", has_number=True, filters=TXN), name="bank-transfers") app.add_typer( make_resource_app("fixed assets", "fixed-asset", "fixed-assets", has_number=True, filters={"search", "status", "company", "customer", "vendor"}), @@ -71,6 +73,12 @@ app.add_typer(make_resource_app("items", "item", "items", filters={"search", "status"}), name="items") app.add_typer(make_resource_app("companies", "company", "companies", has_create=False, has_update=False, filters={"search"}), name="companies") app.add_typer(make_resource_app("classifications", "classification", "classifications", filters={"search"}), name="classifications") +app.add_typer(make_resource_app("custom fields", "custom-field", "custom-fields", has_delete=True, filters={"search"}), name="custom-fields") +app.add_typer(make_resource_app("product tax codes", "product-tax-code", "product-tax-codes", has_delete=True, filters={"search"}), name="product-tax-codes") +app.add_typer( + make_resource_app("VAT rates", "vat-rate", "vat-rates", has_create=False, has_update=False, filters=set()), + name="vat-rates", +) # Recurring recurring_app = typer.Typer(help="Manage recurring records", no_args_is_help=True, cls=HelpfulGroup) diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index c9c254d..6f99438 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -34,6 +34,7 @@ "intercompany-journal-entry": "IJE", "bank-transfer": "BT", "fixed-asset": "FA", + "statistical-journal": "SJ", } @@ -1192,6 +1193,179 @@ def _workflow_detail(r): _register("workflow", _workflow_list, _workflow_detail) +# ── Custom field ───────────────────────────────────────────────────── + + +def _custom_field_list(items): + table = Table(title="Custom Fields", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("Name", min_width=20, style="bold") + table.add_column("Type") + table.add_column("Active") + + for r in items: + table.add_row( + str(r.get("id", "")), + r.get("name", "-"), + str(r.get("field_type", "-")), + "yes" if r.get("is_active") else "no", + ) + + console.print(table) + + +def _custom_field_detail(r): + header = Text() + header.append("CUSTOM FIELD", style="bold") + header.append(f" {r.get('name', '')}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("ID:", str(r.get("id", "-"))) + details.add_row("Name:", r.get("name", "-")) + details.add_row("Type:", str(r.get("field_type", "-"))) + details.add_row("Active:", "yes" if r.get("is_active") else "no") + console.print(details) + + +_register("custom-field", _custom_field_list, _custom_field_detail) + + +# ── Product tax code ───────────────────────────────────────────────── + + +def _product_tax_code_list(items): + table = Table(title="Product Tax Codes", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("Code", style="bold") + table.add_column("Description", min_width=24) + table.add_column("Active") + + for r in items: + table.add_row( + str(r.get("id", "")), + r.get("code", "-"), + (r.get("description") or "-")[:40], + "yes" if r.get("is_active") else "no", + ) + + console.print(table) + + +def _product_tax_code_detail(r): + header = Text() + header.append("PRODUCT TAX CODE", style="bold") + header.append(f" {r.get('code', '')}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("ID:", str(r.get("id", "-"))) + details.add_row("Code:", r.get("code", "-")) + details.add_row("Description:", r.get("description", "-")) + details.add_row("Source:", str(r.get("source", "-"))) + details.add_row("Active:", "yes" if r.get("is_active") else "no") + console.print(details) + + +_register("product-tax-code", _product_tax_code_list, _product_tax_code_detail) + + +# ── VAT rate ───────────────────────────────────────────────────────── + + +def _vat_rate_list(items): + table = Table(title="VAT Rates", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("Country") + table.add_column("Name", min_width=20, style="bold") + table.add_column("Rate", justify="right") + table.add_column("Active") + + for r in items: + table.add_row( + str(r.get("id", "")), + r.get("country_code", "-"), + r.get("name", "-"), + str(r.get("rate", "-")), + "yes" if r.get("is_active") else "no", + ) + + console.print(table) + + +def _vat_rate_detail(r): + header = Text() + header.append("VAT RATE", style="bold") + header.append(f" {r.get('name', '')}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("ID:", str(r.get("id", "-"))) + details.add_row("Country:", r.get("country_code", "-")) + details.add_row("Name:", r.get("name", "-")) + details.add_row("Rate:", str(r.get("rate", "-"))) + details.add_row("Type:", str(r.get("rate_type", "-"))) + details.add_row("Tax type:", str(r.get("tax_type", "-"))) + details.add_row("Valid from:", str(r.get("valid_from", "-"))) + details.add_row("Valid to:", str(r.get("valid_to", "-"))) + console.print(details) + + +_register("vat-rate", _vat_rate_list, _vat_rate_detail) + + +# ── Statistical journal ────────────────────────────────────────────── + + +def _statistical_journal_list(items): + table = Table(title="Statistical Journals", show_lines=False) + table.add_column("#", style="dim", justify="right") + table.add_column("Date") + table.add_column("Period") + table.add_column("Account") + table.add_column("Status") + table.add_column("Memo", min_width=20) + + for r in items: + table.add_row( + _fmt_id(r.get("number"), "statistical-journal"), + str(r.get("date", "-")), + str(r.get("period", "-")), + r.get("account_number", "-"), + _status_badge(r.get("record_status", "")), + (r.get("memo") or "-")[:40], + ) + + console.print(table) + + +def _statistical_journal_detail(r): + header = Text() + header.append("STATISTICAL JOURNAL", style="bold") + header.append(f" {_fmt_id(r.get('number'), 'statistical-journal')}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("Number:", _fmt_id(r.get("number"), "statistical-journal")) + details.add_row("Date:", str(r.get("date", "-"))) + details.add_row("Period:", str(r.get("period", "-"))) + details.add_row("Status:", _status_badge(r.get("record_status", ""))) + details.add_row("Account:", f"{r.get('account_number', '-')} {r.get('account_name', '')}".strip()) + details.add_row("Memo:", r.get("memo", "-")) + console.print(details) + + +_register("statistical-journal", _statistical_journal_list, _statistical_journal_detail) + + # ── Recurring records (use generic transaction pattern) ────────────── for _prefix in ("recurring-invoice", "recurring-bill", "recurring-journal-entry"): diff --git a/tests/test_commands.py b/tests/test_commands.py index e7abf22..771b9b1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -184,6 +184,68 @@ def test_create_with_missing_file(self, tmp_path): assert "not found" in result.output.lower() or "Error" in result.output +class TestParityResourceCommands: + def test_product_tax_codes_crud_paths(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["product-tax-codes", "list"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_with("/product-tax-codes/", params={"limit": 20, "offset": 0}) + + def test_custom_fields_delete(self, mock_get_client): + result = runner.invoke(app, ["custom-fields", "delete", "9"]) + assert result.exit_code == 0 + mock_get_client.delete.assert_called_once_with("/custom-fields/9/") + + def test_vat_rates_read_only(self): + from dualentry_cli.main import app as cli_app + + group = next(g for g in cli_app.registered_groups if g.name == "vat-rates") + commands = {c.name for c in group.typer_instance.registered_commands} + assert commands == {"list", "get"} + + def test_statistical_journal_date_filters_use_api_names(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke( + app, + [ + "statistical-journals", + "list", + "--date-start", + "2025-01-01", + "--date-end", + "2025-03-31", + "--period-start", + "2025-01-01", + "--period-end", + "2025-03-31", + ], + ) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with( + "/statistical-journals/", + params={ + "date_start": "2025-01-01", + "date_end": "2025-03-31", + "period_start": "2025-01-01", + "period_end": "2025-03-31", + "limit": 20, + "offset": 0, + }, + ) + + def test_statistical_journal_add_attachments(self, mock_get_client, tmp_path): + mock_get_client.post.return_value = {"number": 1} + upload = tmp_path / "note.txt" + upload.write_text("hello") + result = runner.invoke(app, ["statistical-journals", "add-attachments", "3", "--file", str(upload)]) + assert result.exit_code == 0 + mock_get_client.post.assert_called_once() + args, kwargs = mock_get_client.post.call_args + assert args[0] == "/statistical-journals/3/attachments/" + assert kwargs["files"][0][0] == "files" + assert kwargs["files"][0][1][0] == "note.txt" + + class TestUnknownCommandSuggestions: """HelpfulGroup must catch typer's UsageError, not click's (see typer >= 0.26).""" diff --git a/tests/test_filter_gating.py b/tests/test_filter_gating.py index d3973c7..9aaa82e 100644 --- a/tests/test_filter_gating.py +++ b/tests/test_filter_gating.py @@ -43,6 +43,9 @@ "items": {"search", "status"}, "companies": {"search"}, "classifications": {"search"}, + "custom-fields": {"search"}, + "product-tax-codes": {"search"}, + "vat-rates": set(), "recurring/invoices": {"search", "company"}, "recurring/bills": {"search", "company"}, "recurring/journal-entries": {"search", "company"}, diff --git a/tests/test_integration.py b/tests/test_integration.py index ca602a1..a829cf7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -71,6 +71,7 @@ def assert_record(data: dict, required_keys: list[str]): ("journal-entries", ["number", "date", "currency_iso_4217_code"]), ("bank-transfers", ["number", "date"]), ("fixed-assets", ["number", "name"]), + ("statistical-journals", ["number", "date"]), ] @@ -126,6 +127,9 @@ def test_numbered_resource_offset(client: DualEntryClient, path: str, detail_key ("contracts", "id", ["id", "name"]), ("budgets", "id", ["id", "name"]), ("workflows", "id", ["id", "name"]), + ("custom-fields", "id", ["id", "name"]), + ("product-tax-codes", "id", ["id", "code"]), + ("vat-rates", "id", ["id", "name"]), ] diff --git a/tests/test_stale_commands.py b/tests/test_stale_commands.py index f3fbf39..9493fa0 100644 --- a/tests/test_stale_commands.py +++ b/tests/test_stale_commands.py @@ -32,6 +32,10 @@ def test_read_only_resource_keeps_its_read_commands(resource: str): assert _commands(resource) == {"list", "get"} +def test_vat_rates_is_read_only(): + assert _commands("vat-rates") == {"list", "get"} + + def test_inbox_keeps_only_list(): assert _commands("inbox") == {"list"} From 4c51d2c85a5435f3d5b062f1d4bc1d9a0b856ce7 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 30 Sep 2026 21:40:06 +0200 Subject: [PATCH 2/2] Fix comments --- .../commands/statistical_journals.py | 134 +++++++----------- tests/test_commands.py | 6 + 2 files changed, 55 insertions(+), 85 deletions(-) diff --git a/src/dualentry_cli/commands/statistical_journals.py b/src/dualentry_cli/commands/statistical_journals.py index 27ae6e4..664c4d0 100644 --- a/src/dualentry_cli/commands/statistical_journals.py +++ b/src/dualentry_cli/commands/statistical_journals.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import Annotated import typer @@ -14,20 +15,34 @@ Offset, Search, Status, - _do_list, _load_json_file, _strip_record_prefix, - _supplied, ) +from dualentry_cli.commands.actions import run_list from dualentry_cli.output import format_output app = typer.Typer(help="Manage statistical journals", no_args_is_help=True, cls=HelpfulGroup) -def _csv_ints(value: str | None) -> list[int] | None: - if not value: +def _parse_csv_ints(value: str) -> list[int] | None: + """Parse a comma-separated list of integers for list filters.""" + raw = value.strip() + if not raw: return None - return [int(part.strip()) for part in value.split(",") if part.strip()] + result: list[int] = [] + for part in raw.split(","): + token = part.strip() + if not token: + continue + try: + result.append(int(token)) + except ValueError: + raise typer.BadParameter(f"expected comma-separated integers, got {token!r}") from None + return result or None + + +def _csv_ints_option(flag: str, *, help: str) -> typer.Option: + return typer.Option(flag, parser=_parse_csv_ints, help=help) def _csv_strings(value: str | None) -> list[str] | None: @@ -36,58 +51,6 @@ def _csv_strings(value: str | None) -> list[str] | None: return [part.strip() for part in value.split(",") if part.strip()] -def _build_list_filters( - *, - search: str | None, - status: str | None, - company: str | None, - date_start: str | None, - date_end: str | None, - period_start: str | None, - period_end: str | None, - number: str | None, - account_id: str | None, - account_number: str | None, - line_account_id: str | None, - line_account_number: str | None, - updated_after: str | None, - updated_before: str | None, - ordering: str | None, -) -> dict: - params: dict = {} - if search: - params["search"] = search - if status: - params["record_status"] = status - if company: - params["company_id"] = company - if date_start: - params["date_start"] = date_start - if date_end: - params["date_end"] = date_end - if period_start: - params["period_start"] = period_start - if period_end: - params["period_end"] = period_end - if number: - params["number"] = _csv_ints(number) - if account_id: - params["account_id"] = _csv_ints(account_id) - if account_number: - params["account_number"] = _csv_strings(account_number) - if line_account_id: - params["line_account_id"] = _csv_ints(line_account_id) - if line_account_number: - params["line_account_number"] = _csv_strings(line_account_number) - if updated_after: - params["updated_after"] = updated_after - if updated_before: - params["updated_before"] = updated_before - if ordering: - params["ordering"] = ordering - return params - - @app.command("list") def list_cmd( limit: int = Limit, @@ -100,10 +63,19 @@ def list_cmd( date_end: str | None = typer.Option(None, "--date-end", help="Filter to journal date (YYYY-MM-DD)"), period_start: str | None = typer.Option(None, "--period-start", help="Filter from period start (YYYY-MM-DD)"), period_end: str | None = typer.Option(None, "--period-end", help="Filter to period end (YYYY-MM-DD)"), - number: str | None = typer.Option(None, "--number", help="Filter by record number(s), comma-separated"), - account_id: str | None = typer.Option(None, "--account-id", help="Filter by account ID(s), comma-separated"), + number: Annotated[ + list[int] | None, + _csv_ints_option("--number", help="Filter by record number(s), comma-separated"), + ] = None, + account_id: Annotated[ + list[int] | None, + _csv_ints_option("--account-id", help="Filter by account ID(s), comma-separated"), + ] = None, account_number: str | None = typer.Option(None, "--account-number", help="Filter by account number(s), comma-separated"), - line_account_id: str | None = typer.Option(None, "--line-account-id", help="Filter by line account ID(s), comma-separated"), + line_account_id: Annotated[ + list[int] | None, + _csv_ints_option("--line-account-id", help="Filter by line account ID(s), comma-separated"), + ] = None, line_account_number: str | None = typer.Option(None, "--line-account-number", help="Filter by line account number(s), comma-separated"), updated_after: str | None = typer.Option(None, "--updated-after", help="Updated after timestamp (ISO 8601)"), updated_before: str | None = typer.Option(None, "--updated-before", help="Updated before timestamp (ISO 8601)"), @@ -111,36 +83,28 @@ def list_cmd( output: str = Format, ): """List statistical journals.""" - from dualentry_cli.main import get_client - - client = get_client() - filters = _build_list_filters( - search=_supplied(search), - status=_supplied(status), - company=_supplied(company), - date_start=_supplied(date_start), - date_end=_supplied(date_end), - period_start=_supplied(period_start), - period_end=_supplied(period_end), - number=_supplied(number), - account_id=_supplied(account_id), - account_number=_supplied(account_number), - line_account_id=_supplied(line_account_id), - line_account_number=_supplied(line_account_number), - updated_after=_supplied(updated_after), - updated_before=_supplied(updated_before), - ordering=_supplied(ordering), - ) - _do_list( - client, + run_list( "statistical-journals", - "statistical-journal", + resource="statistical-journal", limit=limit, offset=offset, all_pages=all_pages, output=output, - status_param="record_status", - **filters, + search=search, + status=status, + company_id=company or None, + date_start=date_start or None, + date_end=date_end or None, + period_start=period_start or None, + period_end=period_end or None, + number=number, + account_id=account_id, + account_number=_csv_strings(account_number), + line_account_id=line_account_id, + line_account_number=_csv_strings(line_account_number), + updated_after=updated_after or None, + updated_before=updated_before or None, + ordering=ordering or None, ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 771b9b1..df51c56 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -245,6 +245,12 @@ def test_statistical_journal_add_attachments(self, mock_get_client, tmp_path): assert kwargs["files"][0][0] == "files" assert kwargs["files"][0][1][0] == "note.txt" + def test_statistical_journal_list_rejects_non_integer_csv(self): + result = runner.invoke(app, ["statistical-journals", "list", "--number", "1,abc"]) + assert result.exit_code == 2 + assert "comma-separated integers" in result.output + assert "abc" in result.output + class TestUnknownCommandSuggestions: """HelpfulGroup must catch typer's UsageError, not click's (see typer >= 0.26)."""