diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a29c1..1ca1665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `kude --formato {a4,ticket,ticket-resumen}` y `--ancho {80,58}` para descargar + el KuDE en cinta de papel de impresoras térmicas. Sin `--out`, el ticket se + guarda como `-ticket.pdf` o `-ticket-resumen.pdf`. `make kude` acepta + `FORMATO` y `ANCHO`. - Emisión de **notas de crédito** (`NOTA_DE_CREDITO_ELECTRONICA`) y **notas de débito** (`NOTA_DE_DEBITO_ELECTRONICA`) vía el comando `emitir`, tanto por archivo como en modo interactivo. El wizard pide `motivoEmision` y el CDC del diff --git a/Makefile b/Makefile index 91febdb..416b559 100644 --- a/Makefile +++ b/Makefile @@ -81,8 +81,8 @@ nota-debito: ## Emitir nota de débito (FILE_ND=sample_nota_debito.json por defe estado: guard-CDC ## Consultar estado — make estado CDC=xxxx $(SIFENDE) estado $(CDC) $(EXTRA) -kude: guard-CDC ## Descargar KuDE PDF — make kude CDC=xxxx [OUT=ruta.pdf] - $(SIFENDE) kude $(CDC) $(if $(OUT),--out $(OUT)) $(EXTRA) +kude: guard-CDC ## Descargar KuDE PDF — make kude CDC=xxxx [FORMATO=ticket ANCHO=58] [OUT=ruta.pdf] + $(SIFENDE) kude $(CDC) $(if $(FORMATO),--formato $(FORMATO)) $(if $(ANCHO),--ancho $(ANCHO)) $(if $(OUT),--out $(OUT)) $(EXTRA) cancelar: guard-CDC guard-MOTIVO ## Cancelar documento — make cancelar CDC=xxxx MOTIVO="..." $(SIFENDE) cancelar $(CDC) --motivo "$(MOTIVO)" $(EXTRA) diff --git a/README.md b/README.md index 83d361e..7154271 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Variables soportadas en `.env`: | Nota de débito | `make nota-debito FILE_ND=nd.json` | `python sifende.py emitir --file nd.json` | | Consultar estado | `make estado CDC=` | `python sifende.py estado ` | | Descargar KuDE | `make kude CDC= OUT=factura.pdf` | `python sifende.py kude --out factura.pdf` | +| KuDE en ticket | `make kude CDC= FORMATO=ticket ANCHO=58` | `python sifende.py kude --formato ticket --ancho 58` | | Cancelar | `make cancelar CDC= MOTIVO="..."` | `python sifende.py cancelar --motivo "..."` | | Inutilizar | `make inutilizar TIPO=1 EST=001 PE=001 TIMBRADO= DESDE=1 HASTA=3 MOTIVO="..."` | `python sifende.py inutilizar --tipo-documento 1 --establecimiento 001 --punto-expedicion 001 --numero-timbrado --desde 1 --hasta 3 --motivo "..."` | | Debug HTTP | `make debug FILE=factura.json` | `python sifende.py --debug emitir --file factura.json` | @@ -62,6 +63,14 @@ restricciones de SIFEN. Una respuesta HTTP exitosa cuyo evento termine en Al aprobar un documento, la CLI guarda automáticamente `documentos/{cdc}/` con `payload.json`, `response.json` y `kude.pdf`. +### KuDE en cinta de papel + +`kude --formato ticket` descarga el KuDE en cinta de papel para impresoras +térmicas, con `--ancho 80` (default) o `--ancho 58`. `--formato ticket-resumen` +omite el detalle de ítems e impuestos, para cuando el cliente pide el resumen. Sólo +está disponible para facturas y notas de crédito o débito; el KuDE A4 sigue siendo +el que se guarda y se envía por email. + ### Notas de crédito y débito Las notas de crédito (`NOTA_DE_CREDITO_ELECTRONICA`) y débito diff --git a/sifende/client.py b/sifende/client.py index 7db965d..8b8a953 100644 --- a/sifende/client.py +++ b/sifende/client.py @@ -134,6 +134,14 @@ def kude(self, cdc: str) -> bytes: resp = self._request("GET", f"documento-electronico/{cdc}/kude", accept="application/pdf") return resp.content + def kude_ticket(self, cdc: str, *, ancho: int = 80, resumen: bool = False) -> bytes: + resp = self._request( + "GET", + f"documento-electronico/{cdc}/kude/ticket?ancho={ancho}&resumen={'true' if resumen else 'false'}", + accept="application/pdf", + ) + return resp.content + def cancelar(self, cdc: str, motivo: str) -> dict: resp = self._request( "POST", diff --git a/sifende/commands/kude.py b/sifende/commands/kude.py index f9ec7d6..a573c35 100644 --- a/sifende/commands/kude.py +++ b/sifende/commands/kude.py @@ -1,4 +1,4 @@ -"""`kude`: download the KuDE PDF for a given CDC.""" +"""`kude`: download the KuDE PDF for a given CDC, in A4 or as a paper-roll ticket.""" from __future__ import annotations @@ -6,30 +6,56 @@ import sys from ..client import SifendeClient +from ..errors import ValidationError from ..utils.io import save_pdf from ..utils.validation import validate_cdc +FORMATOS = ("a4", "ticket", "ticket-resumen") + def register(subparsers, *, parents=()) -> None: p = subparsers.add_parser( "kude", parents=list(parents), help="Descargar el KuDE (PDF) de un documento.", - description="Descarga el PDF representativo (KuDE) generado por SIFEN.", + description=( + "Descarga el PDF representativo (KuDE). Con --formato ticket se genera en cinta de papel " + "para impresoras térmicas (sólo facturas y notas de crédito o débito)." + ), ) p.add_argument("cdc", help="CDC de 44 dígitos.") - p.add_argument("--out", "-o", help="Ruta destino. Default: .pdf en el directorio actual.") + p.add_argument( + "--formato", + choices=FORMATOS, + default="a4", + help="a4 (default), ticket o ticket-resumen (sin detalle de ítems).", + ) + p.add_argument( + "--ancho", + type=int, + choices=(80, 58), + help="Ancho del papel del ticket en mm. Default: 80.", + ) + p.add_argument("--out", "-o", help="Ruta destino. Default: .pdf o -.pdf.") def run(args, client: SifendeClient) -> int: cdc = validate_cdc(args.cdc) - out_path = args.out or f"{cdc}.pdf" - pdf = client.kude(cdc) + formato = args.formato + if formato == "a4": + if args.ancho is not None: + raise ValidationError("ancho", "sólo aplica a --formato ticket o ticket-resumen") + out_path = args.out or f"{cdc}.pdf" + pdf = client.kude(cdc) + else: + ancho = args.ancho or 80 + out_path = args.out or f"{cdc}-{formato}.pdf" + pdf = client.kude_ticket(cdc, ancho=ancho, resumen=formato == "ticket-resumen") saved = save_pdf(pdf, out_path) bytes_written = len(pdf) if args.json: - print(json.dumps({"cdc": cdc, "path": saved, "bytes": bytes_written}, ensure_ascii=False)) + print(json.dumps({"cdc": cdc, "formato": formato, "path": saved, "bytes": bytes_written}, ensure_ascii=False)) elif args.quiet: print(saved) else: diff --git a/tests/test_kude.py b/tests/test_kude.py new file mode 100644 index 0000000..f426e40 --- /dev/null +++ b/tests/test_kude.py @@ -0,0 +1,65 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from sifende.client import SifendeClient +from sifende.commands.kude import run +from sifende.config import Config +from sifende.errors import ValidationError + +CDC = "01800123451001001000000122026042710000000006" +PDF = b"%PDF-1.4 synthetic" + + +def _make_args(tmp_path, *, formato="a4", ancho=None, out=None): + return SimpleNamespace( + cdc=CDC, + formato=formato, + ancho=ancho, + out=str(tmp_path / out) if out else None, + json=False, + quiet=True, + ) + + +def test_a4_uses_existing_endpoint(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + client = MagicMock() + client.kude.return_value = PDF + assert run(_make_args(tmp_path), client) == 0 + client.kude.assert_called_once_with(CDC) + assert not client.kude_ticket.called + assert (tmp_path / f"{CDC}.pdf").read_bytes() == PDF + + +@pytest.mark.parametrize( + "formato,ancho,resumen,ancho_esperado", + [("ticket", None, False, 80), ("ticket", 58, False, 58), ("ticket-resumen", 80, True, 80)], +) +def test_ticket_formats(tmp_path, monkeypatch, formato, ancho, resumen, ancho_esperado): + monkeypatch.chdir(tmp_path) + client = MagicMock() + client.kude_ticket.return_value = PDF + assert run(_make_args(tmp_path, formato=formato, ancho=ancho), client) == 0 + client.kude_ticket.assert_called_once_with(CDC, ancho=ancho_esperado, resumen=resumen) + assert (tmp_path / f"{CDC}-{formato}.pdf").read_bytes() == PDF + + +def test_ancho_with_a4_is_rejected_locally(tmp_path): + client = MagicMock() + with pytest.raises(ValidationError) as exc: + run(_make_args(tmp_path, ancho=58), client) + assert exc.value.field == "ancho" + assert not client.kude.called + + +def test_client_builds_ticket_url(): + client = SifendeClient(Config(api_key="k", base_url="https://api.example.com/api/v1/", timeout_s=5)) + response = MagicMock(status_code=200, content=PDF, ok=True) + client._session.request = MagicMock(return_value=response) + assert client.kude_ticket(CDC, ancho=58, resumen=True) == PDF + method, url = client._session.request.call_args.args + assert method == "GET" + assert url == f"https://api.example.com/api/v1/documento-electronico/{CDC}/kude/ticket?ancho=58&resumen=true" + assert client._session.request.call_args.kwargs["headers"]["Accept"] == "application/pdf"