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 +5 -0
- operx_cli/app.py +87 -0
- operx_cli/client.py +134 -0
- operx_cli/commands/__init__.py +1 -0
- operx_cli/commands/account.py +68 -0
- operx_cli/commands/common.py +116 -0
- operx_cli/commands/inventario.py +105 -0
- operx_cli/commands/lecturas.py +57 -0
- operx_cli/commands/ordenes.py +187 -0
- operx_cli/commands/personas.py +107 -0
- operx_cli/commands/raw.py +114 -0
- operx_cli/commands/registros.py +192 -0
- operx_cli/commands/search.py +21 -0
- operx_cli/lookup.py +42 -0
- operx_cli/output.py +102 -0
- operx_cli/settings.py +90 -0
- operx_cli/spreadsheet.py +179 -0
- operx_cli/values.py +59 -0
- operx_cli-0.1.0.dist-info/METADATA +136 -0
- operx_cli-0.1.0.dist-info/RECORD +23 -0
- operx_cli-0.1.0.dist-info/WHEEL +5 -0
- operx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- operx_cli-0.1.0.dist-info/top_level.txt +1 -0
operx_cli/__init__.py
ADDED
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}.")
|