operx-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
operx_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """OperX's command line (PRODUCT_ROADMAP.md › Platform, step 3): the public
2
+ API (backend/users/api_allowlist.py) from a terminal, with a personal API
3
+ token."""
4
+
5
+ __version__ = "0.1.0"
operx_cli/app.py ADDED
@@ -0,0 +1,87 @@
1
+ """`operx`: the root of the commands, and what turns a refused call into
2
+ one line instead of a traceback."""
3
+
4
+ import sys
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+ from typer.core import TyperGroup
9
+
10
+ from . import __version__, settings
11
+ from .client import ApiError
12
+ from .commands import account, inventario, lecturas, ordenes, personas, raw, registros, search
13
+ from .commands.common import CliError, Session
14
+ from .lookup import NoSingleMatch
15
+ from .spreadsheet import SpreadsheetError
16
+
17
+ # What a person can fix: shown as "Error: …", exit code 1.
18
+ EXPECTED_ERRORS = (ApiError, CliError, NoSingleMatch, SpreadsheetError, settings.SettingsError)
19
+
20
+
21
+ class ReportedErrors(TyperGroup):
22
+ def invoke(self, ctx):
23
+ try:
24
+ return super().invoke(ctx)
25
+ except EXPECTED_ERRORS as error:
26
+ typer.echo(f"Error: {error}", err=True)
27
+ raise typer.Exit(1) from None
28
+ except typer.Abort:
29
+ # Ctrl+C, or a question whose answer never came.
30
+ typer.echo("Se canceló.", err=True)
31
+ raise typer.Exit(1) from None
32
+
33
+
34
+ app = typer.Typer(
35
+ cls=ReportedErrors,
36
+ help="OperX desde la terminal: tu empresa con tu token de API.",
37
+ no_args_is_help=True,
38
+ add_completion=False,
39
+ context_settings={"help_option_names": ["-h", "--help"]},
40
+ # A crash must never print the token, which lives in the client's locals.
41
+ pretty_exceptions_show_locals=False,
42
+ )
43
+
44
+
45
+ def _show_version(value):
46
+ if value:
47
+ typer.echo(f"operx {__version__}")
48
+ raise typer.Exit()
49
+
50
+
51
+ @app.callback()
52
+ def root(
53
+ ctx: typer.Context,
54
+ perfil: Annotated[
55
+ Optional[str],
56
+ typer.Option("--perfil", "-p", help="El perfil: otra API u otra empresa. Sin él, «principal» (o OPERX_PERFIL)."),
57
+ ] = None,
58
+ version: Annotated[
59
+ bool, typer.Option("--version", callback=_show_version, is_eager=True, help="La versión instalada.")
60
+ ] = False,
61
+ ):
62
+ if ctx.obj is None:
63
+ ctx.obj = Session()
64
+ ctx.obj.profile = settings.profile_name(perfil)
65
+
66
+
67
+ app.command()(account.entrar)
68
+ app.command()(account.salir)
69
+ app.command()(account.cuenta)
70
+ app.command()(search.buscar)
71
+ app.command()(raw.api)
72
+ app.add_typer(ordenes.app, name="ordenes")
73
+ app.add_typer(registros.equipos, name="equipos")
74
+ app.add_typer(registros.clientes, name="clientes")
75
+ app.add_typer(registros.lugares, name="lugares")
76
+ app.add_typer(inventario.app, name="inventario")
77
+ app.add_typer(lecturas.app, name="lecturas")
78
+ app.add_typer(personas.app, name="personas")
79
+
80
+
81
+ def main():
82
+ # Redirected to a file, Windows would write the results in its own code
83
+ # page; the CSV and the JSON are UTF-8.
84
+ for stream in (sys.stdout, sys.stderr):
85
+ if hasattr(stream, "reconfigure"):
86
+ stream.reconfigure(encoding="utf-8")
87
+ app()
operx_cli/client.py ADDED
@@ -0,0 +1,134 @@
1
+ """The calls to OperX's API, with a personal API token: the only module that
2
+ talks to the network. The MCP server can use it as it is.
3
+
4
+ A write carries an Idempotency-Key, so the views that honour it (stock
5
+ moves, a work order's status, Ambiental's records) never do it twice. Only a
6
+ 429 is repeated by itself: it changed nothing. A write that timed out or
7
+ failed on the server is never repeated, because on a view without the key a
8
+ second try could create the record twice.
9
+ """
10
+
11
+ import time
12
+ import uuid
13
+
14
+ import httpx
15
+
16
+ from . import __version__
17
+
18
+ USER_AGENT = f"operx-cli/{__version__}"
19
+ WRITES = ("POST", "PUT", "PATCH", "DELETE")
20
+ # A list asks for this many rows a page: the API's cap (utils/pagination.py).
21
+ PAGE_SIZE = 200
22
+ # How many times a 429 is waited out before giving up, and the longest wait.
23
+ RETRIES_ON_429 = 3
24
+ LONGEST_WAIT = 60
25
+
26
+ SIGN_IN_AGAIN = "Entra de nuevo con `operx entrar`."
27
+ TIMED_OUT_WRITE = (
28
+ "La API no respondió a tiempo. Revisa en la app si el cambio quedó hecho antes de repetirlo."
29
+ )
30
+
31
+
32
+ class ApiError(Exception):
33
+ """An answer the API refused, or no answer at all (status 0)."""
34
+
35
+ def __init__(self, status, message):
36
+ super().__init__(message)
37
+ self.status = status
38
+
39
+
40
+ def api_path(path):
41
+ """'/api/v1/work-orders/12' → 'work-orders/12/': the part after /api/v1/,
42
+ with the trailing slash Django's addresses need."""
43
+ path = path.strip().lstrip("/")
44
+ if path.startswith("api/v1/"):
45
+ path = path[len("api/v1/"):]
46
+ address, separator, query = path.partition("?")
47
+ if not address.endswith("/"):
48
+ address += "/"
49
+ return address + separator + query
50
+
51
+
52
+ def error_message(response):
53
+ """The API's own words: its "detail", or each field with its errors."""
54
+ try:
55
+ body = response.json()
56
+ except ValueError:
57
+ return f"La API respondió {response.status_code}."
58
+ if isinstance(body, dict) and isinstance(body.get("detail"), str):
59
+ return body["detail"]
60
+ if isinstance(body, dict) and body:
61
+ lines = []
62
+ for field, errors in body.items():
63
+ text = " ".join(str(error) for error in errors) if isinstance(errors, list) else str(errors)
64
+ lines.append(f"{field}: {text}")
65
+ return "\n".join(lines)
66
+ return f"La API respondió {response.status_code}."
67
+
68
+
69
+ def seconds_to_wait(response):
70
+ try:
71
+ return min(int(response.headers.get("Retry-After", "5")), LONGEST_WAIT)
72
+ except ValueError:
73
+ return 5
74
+
75
+
76
+ class ApiClient:
77
+ def __init__(self, address, token, transport=None, sleep=time.sleep):
78
+ self.address = address.rstrip("/")
79
+ self.sleep = sleep
80
+ self.http = httpx.Client(
81
+ base_url=self.address + "/api/v1/",
82
+ headers={"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT, "Accept": "application/json"},
83
+ timeout=60,
84
+ transport=transport,
85
+ )
86
+
87
+ def send(self, method, path, params=None, json=None, files=None, data=None):
88
+ """One call; its answer, or ApiError when the API refused it."""
89
+ method = method.upper()
90
+ headers = {"Idempotency-Key": str(uuid.uuid4())} if method in WRITES else {}
91
+ waits = 0
92
+ while True:
93
+ response = self._request(method, path, params, json, files, data, headers)
94
+ if response.status_code != 429 or waits == RETRIES_ON_429:
95
+ break
96
+ self.sleep(seconds_to_wait(response))
97
+ waits += 1
98
+ if response.status_code >= 400:
99
+ message = error_message(response)
100
+ if response.status_code == 401:
101
+ message = f"{message}\n{SIGN_IN_AGAIN}"
102
+ raise ApiError(response.status_code, message)
103
+ return response
104
+
105
+ def _request(self, method, path, params, json, files, data, headers):
106
+ try:
107
+ return self.http.request(
108
+ method, api_path(path), params=params, json=json, files=files, data=data, headers=headers,
109
+ )
110
+ except httpx.TimeoutException:
111
+ if method in WRITES:
112
+ raise ApiError(0, TIMED_OUT_WRITE)
113
+ raise ApiError(0, "La API no respondió a tiempo. Intenta de nuevo.")
114
+ except httpx.HTTPError as error:
115
+ raise ApiError(0, f"No se pudo conectar con {self.address}: {error}")
116
+
117
+ def get(self, path, params=None):
118
+ """A read's answer, as JSON."""
119
+ return self.send("GET", path, params=params).json()
120
+
121
+ def all_pages(self, path, params):
122
+ """Every row of a list, page by page. An address that isn't paginated
123
+ answers a plain list, taken as it comes. The pages are asked by number:
124
+ the "next" the API sends can name the wrong host behind Cloudflare."""
125
+ page = 1
126
+ rows = []
127
+ while True:
128
+ answer = self.get(path, {**params, "page": page, "page_size": PAGE_SIZE})
129
+ if isinstance(answer, list):
130
+ return answer
131
+ rows.extend(answer.get("results", []))
132
+ if not answer.get("next"):
133
+ return rows
134
+ page += 1
@@ -0,0 +1 @@
1
+ """One module per group of commands: `operx ordenes …`, `operx equipos …`."""
@@ -0,0 +1,68 @@
1
+ """`operx entrar`, `operx salir` and `operx cuenta`: the token of a profile,
2
+ and who it acts as."""
3
+
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+
8
+ from .. import settings
9
+ from ..client import ApiClient
10
+ from ..output import print_record
11
+ from .common import CliError, JsonOption, Session, client_of
12
+
13
+ TOKEN_PREFIX = "opx_"
14
+ PRODUCT_LABELS = {
15
+ "maintenance": "Trabajos", "people": "Personas", "inventory": "Inventario",
16
+ "equipment": "Equipos", "sst": "SG-SST", "environment": "Ambiental",
17
+ }
18
+
19
+
20
+ def full_name(me):
21
+ return " ".join(f"{me.get('first_name', '')} {me.get('last_name', '')}".split()) or me.get("email", "")
22
+
23
+
24
+ def entrar(
25
+ ctx: typer.Context,
26
+ direccion: Annotated[
27
+ Optional[str], typer.Option("--direccion", help="La dirección de la API. Sin ella, la de producción.")
28
+ ] = None,
29
+ ):
30
+ """Guarda tu token de API en el llavero del sistema. Créalo en la app: menú de tu cuenta › Acceso por API."""
31
+ session = ctx.find_object(Session)
32
+ token = typer.prompt("Pega tu token (no se mostrará)", hide_input=True).strip()
33
+ if not token.startswith(TOKEN_PREFIX):
34
+ raise CliError("Eso no es un token de OperX: empieza por «opx_». Créalo en la app, en Acceso por API.")
35
+ address = direccion.rstrip("/") if direccion else session.address()
36
+ # Checked before it's kept: a token the API refuses is never saved.
37
+ me = ApiClient(address, token, transport=session.transport).get("auth/me")
38
+ settings.save_token(session.profile, token)
39
+ if direccion:
40
+ settings.save_address(session.profile, address)
41
+ typer.echo(f"Listo: entraste como {full_name(me)} en {me.get('client_name')}.")
42
+ typer.echo(f"Perfil «{session.profile}», {address}.")
43
+
44
+
45
+ def salir(ctx: typer.Context):
46
+ """Borra el token de este perfil del llavero. El token sigue vivo: revócalo en Acceso por API si ya no lo usas."""
47
+ session = ctx.find_object(Session)
48
+ settings.forget_token(session.profile)
49
+ typer.echo(f"Se borró el token del perfil «{session.profile}».")
50
+
51
+
52
+ def cuenta(ctx: typer.Context, as_json: JsonOption = False):
53
+ """Quién eres para la API: tu nombre, tu empresa y tu rol."""
54
+ session = ctx.find_object(Session)
55
+ me = client_of(ctx).get("auth/me")
56
+ if as_json:
57
+ print_record(me, "json")
58
+ return
59
+ roles = [me.get("role")] + list(me.get("extra_roles") or [])
60
+ print_record({
61
+ "Nombre": full_name(me),
62
+ "Correo": me.get("email"),
63
+ "Empresa": me.get("client_name"),
64
+ "Rol": [role for role in roles if role],
65
+ "Productos": [PRODUCT_LABELS.get(product, product) for product in me.get("products") or []],
66
+ "Perfil": session.profile,
67
+ "Dirección": session.address(),
68
+ }, "tabla")
@@ -0,0 +1,116 @@
1
+ """What every command shares: the session (which profile, which API), the
2
+ options that choose how results are shown, and the question before a
3
+ write."""
4
+
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from typing import Annotated, Optional
8
+
9
+ import typer
10
+
11
+ from .. import settings
12
+ from ..client import ApiClient
13
+ from ..output import note
14
+ from ..values import decimal_text, iso_date, iso_datetime
15
+
16
+ NOT_SIGNED_IN = "No has entrado en este perfil. Usa `operx entrar` con un token de Acceso por API."
17
+ NEEDS_SI = "Esto cambia datos de tu empresa. Fuera de una terminal agrega --si para confirmarlo."
18
+
19
+
20
+ class CliError(Exception):
21
+ """Something the person typed that can't be done: shown as one line."""
22
+
23
+
24
+ @dataclass
25
+ class Session:
26
+ profile: str = settings.DEFAULT_PROFILE
27
+ # httpx's transport: None goes to the network; the tests pass a fake API.
28
+ transport: object = None
29
+
30
+ def address(self):
31
+ return settings.address_of(self.profile)
32
+
33
+ def client(self):
34
+ token = settings.token_of(self.profile)
35
+ if not token:
36
+ raise CliError(NOT_SIGNED_IN)
37
+ return ApiClient(self.address(), token, transport=self.transport)
38
+
39
+
40
+ def client_of(ctx):
41
+ return ctx.find_object(Session).client()
42
+
43
+
44
+ JsonOption = Annotated[bool, typer.Option("--json", help="Todo lo que responde la API, en JSON.")]
45
+ CsvOption = Annotated[bool, typer.Option("--csv", help="En CSV para Excel (UTF-8, separado por punto y coma).")]
46
+ AllOption = Annotated[bool, typer.Option("--todas", help="Todas las páginas, no solo la primera.")]
47
+ YesOption = Annotated[bool, typer.Option("--si", help="Escribe sin preguntar (para scripts y tareas programadas).")]
48
+ SearchOption = Annotated[Optional[str], typer.Option("--buscar", help="Solo lo que contenga este texto.")]
49
+
50
+
51
+ def output_format(json_flag, csv_flag):
52
+ if json_flag and csv_flag:
53
+ raise CliError("Usa --json o --csv, no los dos.")
54
+ if json_flag:
55
+ return "json"
56
+ return "csv" if csv_flag else "tabla"
57
+
58
+
59
+ def list_rows(client, path, params, all_pages):
60
+ """The first page of a list, or every page with --todas."""
61
+ params = {key: value for key, value in params.items() if value not in (None, "")}
62
+ if all_pages:
63
+ return client.all_pages(path, params)
64
+ answer = client.get(path, params)
65
+ if isinstance(answer, list):
66
+ return answer
67
+ rows = answer.get("results", [])
68
+ if answer.get("next"):
69
+ note(f"Mostrando {len(rows)} de {answer.get('count')}. Usa --todas para verlos todos.")
70
+ return rows
71
+
72
+
73
+ YES_ANSWERS = ("s", "si", "sí", "y", "yes")
74
+
75
+
76
+ def can_ask():
77
+ return sys.stdin.isatty()
78
+
79
+
80
+ def confirm_write(what, yes):
81
+ """Ask before changing anything, unless --si. A script can't answer, so
82
+ it needs --si: without a terminal, or when the answer never comes
83
+ (Windows' NUL device passes for a terminal)."""
84
+ if yes:
85
+ return
86
+ if not can_ask():
87
+ raise CliError(NEEDS_SI)
88
+ try:
89
+ answer = typer.prompt(f"{what} ¿Confirmas? (s/n)", default="n", show_default=False)
90
+ except typer.Abort:
91
+ raise CliError(NEEDS_SI) from None
92
+ if answer.strip().casefold() not in YES_ANSWERS:
93
+ raise CliError("No se cambió nada.")
94
+
95
+
96
+ def number(text):
97
+ try:
98
+ return decimal_text(text)
99
+ except ValueError as error:
100
+ raise CliError(str(error)) from None
101
+
102
+
103
+ def moment(text):
104
+ try:
105
+ return iso_datetime(text)
106
+ except ValueError as error:
107
+ raise CliError(str(error)) from None
108
+
109
+
110
+ def day(text):
111
+ return iso_date(text) if text else None
112
+
113
+
114
+ def without_empty(fields):
115
+ """The fields someone gave: an option left out isn't sent."""
116
+ return {key: value for key, value in fields.items() if value not in (None, "")}
@@ -0,0 +1,105 @@
1
+ """`operx inventario`: stock per place, and stock in and out. Each move is
2
+ a line in the ledger; a mistake is undone in the app with "Revertir"."""
3
+
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+
8
+ from ..lookup import find_in, find_one
9
+ from ..output import print_rows
10
+ from .common import (
11
+ AllOption, CsvOption, JsonOption, SearchOption, YesOption, client_of, confirm_write, list_rows, moment, number,
12
+ output_format, without_empty,
13
+ )
14
+
15
+ app = typer.Typer(help="Inventario: existencias, entradas y salidas.", no_args_is_help=True)
16
+
17
+ STOCK_COLUMNS = [
18
+ ("item_code", "Código"), ("item_name", "Artículo"), ("location_name", "Lugar"), ("on_hand", "Existencias"),
19
+ ("unit", "Unidad"), ("min_qty", "Mínimo"), ("is_low", "Bajo el mínimo"),
20
+ ]
21
+ # inventory.Reason.used_for: the reasons for taking stock out.
22
+ FOR_ISSUE = "issue"
23
+
24
+ ItemOption = Annotated[str, typer.Option("--articulo", help="El código o nombre del artículo.")]
25
+ PlaceOption = Annotated[str, typer.Option("--lugar", help="El nombre de la bodega o el vehículo.")]
26
+ QuantityOption = Annotated[str, typer.Option("--cantidad", help="Cuánto: 10 o 2,5.")]
27
+ NoteOption = Annotated[Optional[str], typer.Option("--nota")]
28
+ WhenOption = Annotated[Optional[str], typer.Option("--fecha", help="Cuándo pasó, si no fue ahora: 22/10/2026 08:00.")]
29
+
30
+
31
+ def _item_and_place(client, articulo, lugar):
32
+ item = find_one(client, "inventory/items", articulo, ["code", "name"], "artículo")
33
+ place = find_one(client, "locations", lugar, ["name"], "lugar")
34
+ return item, place
35
+
36
+
37
+ @app.command()
38
+ def existencias(
39
+ ctx: typer.Context,
40
+ buscar: SearchOption = None,
41
+ todas: AllOption = False,
42
+ as_json: JsonOption = False,
43
+ as_csv: CsvOption = False,
44
+ ):
45
+ """Cuánto hay de cada artículo en cada lugar."""
46
+ rows = list_rows(client_of(ctx), "inventory/stock", {"search": buscar}, todas)
47
+ print_rows(STOCK_COLUMNS, rows, output_format(as_json, as_csv))
48
+
49
+
50
+ @app.command()
51
+ def entrada(
52
+ ctx: typer.Context,
53
+ articulo: ItemOption,
54
+ lugar: PlaceOption,
55
+ cantidad: QuantityOption,
56
+ costo_unitario: Annotated[str, typer.Option("--costo-unitario", help="Lo que costó cada unidad: 1.500,50.")],
57
+ proveedor: Annotated[Optional[str], typer.Option("--proveedor", help="El nombre o NIT del proveedor.")] = None,
58
+ nota: NoteOption = None,
59
+ fecha: WhenOption = None,
60
+ si: YesOption = False,
61
+ ):
62
+ """Registra una entrada de stock."""
63
+ client = client_of(ctx)
64
+ item, place = _item_and_place(client, articulo, lugar)
65
+ move = without_empty({
66
+ "item": item["id"],
67
+ "location": place["id"],
68
+ "quantity": number(cantidad),
69
+ "unit_cost": number(costo_unitario),
70
+ "supplier": find_one(client, "customers", proveedor, ["name", "tax_id"], "proveedor")["id"] if proveedor else None,
71
+ "note": nota,
72
+ "occurred_at": moment(fecha) if fecha else None,
73
+ })
74
+ confirm_write(f"Entrada de {cantidad} de «{item['name']}» en {place['name']}.", si)
75
+ client.send("POST", "inventory/actions/entrada", json=move)
76
+ typer.echo(f"Entrada registrada: {cantidad} de {item['name']} en {place['name']}.")
77
+
78
+
79
+ @app.command()
80
+ def salida(
81
+ ctx: typer.Context,
82
+ articulo: ItemOption,
83
+ lugar: PlaceOption,
84
+ cantidad: QuantityOption,
85
+ motivo: Annotated[str, typer.Option("--motivo", help="El motivo de salida, como en Ajustes de Inventario.")],
86
+ nota: NoteOption = None,
87
+ fecha: WhenOption = None,
88
+ si: YesOption = False,
89
+ ):
90
+ """Registra una salida de stock."""
91
+ client = client_of(ctx)
92
+ item, place = _item_and_place(client, articulo, lugar)
93
+ reasons = [reason for reason in client.get("inventory/reasons") if reason.get("used_for") == FOR_ISSUE]
94
+ reason = find_in(reasons, motivo, ["name"], "motivo de salida")
95
+ move = without_empty({
96
+ "item": item["id"],
97
+ "location": place["id"],
98
+ "quantity": number(cantidad),
99
+ "reason": reason["id"],
100
+ "note": nota,
101
+ "occurred_at": moment(fecha) if fecha else None,
102
+ })
103
+ confirm_write(f"Salida de {cantidad} de «{item['name']}» de {place['name']} ({reason['name']}).", si)
104
+ client.send("POST", "inventory/actions/salida", json=move)
105
+ typer.echo(f"Salida registrada: {cantidad} de {item['name']} de {place['name']}.")
@@ -0,0 +1,57 @@
1
+ """`operx lecturas`: Equipos' measurement points and their readings, what a
2
+ sensor, a SCADA or a scheduled job sends. A wrong reading is voided in the
3
+ app, never edited."""
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+
9
+ from ..lookup import find_one
10
+ from ..output import print_rows
11
+ from ..values import now_iso
12
+ from .common import CsvOption, JsonOption, YesOption, client_of, confirm_write, moment, number, output_format
13
+
14
+ app = typer.Typer(help="Lecturas de Equipos: los puntos de medición y sus lecturas.", no_args_is_help=True)
15
+
16
+ POINT_COLUMNS = [
17
+ ("id", "Punto"), ("name", "Nombre"), ("unit", "Unidad"), ("min_value", "Mínimo"), ("max_value", "Máximo"),
18
+ ("last_value", "Última lectura"), ("last_at", "Tomada"),
19
+ ]
20
+
21
+
22
+ def _with_last_reading(point):
23
+ last = point.get("last") or {}
24
+ return {**point, "last_value": last.get("value"), "last_at": (last.get("taken_at") or "")[:16].replace("T", " ")}
25
+
26
+
27
+ @app.command()
28
+ def puntos(
29
+ ctx: typer.Context,
30
+ equipo: Annotated[str, typer.Argument(help="El código, nombre o id del equipo.")],
31
+ as_json: JsonOption = False,
32
+ as_csv: CsvOption = False,
33
+ ):
34
+ """Los puntos de medición de un equipo, con su última lectura."""
35
+ client = client_of(ctx)
36
+ asset = find_one(client, "assets", equipo, ["code", "name"], "equipo")
37
+ points = client.get("measurements/points", {"asset": asset["id"]})
38
+ output = output_format(as_json, as_csv)
39
+ print_rows(POINT_COLUMNS, points if output == "json" else [_with_last_reading(point) for point in points], output)
40
+
41
+
42
+ @app.command()
43
+ def registrar(
44
+ ctx: typer.Context,
45
+ punto: Annotated[int, typer.Option("--punto", help="El número del punto (operx lecturas puntos EQUIPO).")],
46
+ valor: Annotated[str, typer.Option("--valor", help="La lectura: 73,4.")],
47
+ fecha: Annotated[Optional[str], typer.Option("--fecha", help="Cuándo se tomó, si no fue ahora: 22/10/2026 08:00.")] = None,
48
+ reinicio: Annotated[bool, typer.Option("--reinicio", help="El contador volvió a cero.")] = False,
49
+ si: YesOption = False,
50
+ ):
51
+ """Registra una lectura."""
52
+ reading = {"point": punto, "value": number(valor), "taken_at": moment(fecha) if fecha else now_iso()}
53
+ if reinicio:
54
+ reading["is_reset"] = True
55
+ confirm_write(f"Lectura {valor} en el punto {punto}.", si)
56
+ client_of(ctx).send("POST", "measurements/readings", json=reading)
57
+ typer.echo(f"Lectura registrada: {valor} en el punto {punto}.")