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.
@@ -0,0 +1,187 @@
1
+ """`operx ordenes`: Trabajos' work orders."""
2
+
3
+ from enum import Enum
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+
8
+ from ..lookup import find_one
9
+ from ..output import print_record, print_rows
10
+ from .common import (
11
+ AllOption, CsvOption, JsonOption, SearchOption, YesOption, client_of, confirm_write, day, list_rows,
12
+ output_format, without_empty,
13
+ )
14
+
15
+ app = typer.Typer(help="Órdenes de trabajo: verlas, crearlas, moverlas de estado, asignarlas y comentarlas.", no_args_is_help=True)
16
+
17
+
18
+ class Estado(str, Enum):
19
+ abierta = "abierta"
20
+ en_progreso = "en-progreso"
21
+ en_espera = "en-espera"
22
+ completada = "completada"
23
+ cancelada = "cancelada"
24
+
25
+
26
+ class Prioridad(str, Enum):
27
+ baja = "baja"
28
+ media = "media"
29
+ alta = "alta"
30
+ critica = "critica"
31
+
32
+
33
+ class Tipo(str, Enum):
34
+ correctivo = "correctivo"
35
+ preventivo = "preventivo"
36
+ predictivo = "predictivo"
37
+ inspeccion = "inspeccion"
38
+ infraestructura = "infraestructura"
39
+
40
+
41
+ # Verifying an order is a person's sign-off in the app: the API refuses it
42
+ # to a token, so it isn't offered here.
43
+ STATUS_KEYS = {
44
+ "abierta": "open", "en-progreso": "in_progress", "en-espera": "on_hold",
45
+ "completada": "completed", "cancelada": "cancelled",
46
+ }
47
+ STATUS_LABELS = {
48
+ "open": "Abierta", "in_progress": "En progreso", "on_hold": "En espera",
49
+ "completed": "Completada", "verified": "Verificada", "cancelled": "Cancelada",
50
+ }
51
+ PRIORITY_KEYS = {"baja": "low", "media": "medium", "alta": "high", "critica": "critical"}
52
+ PRIORITY_LABELS = {"low": "Baja", "medium": "Media", "high": "Alta", "critical": "Crítica"}
53
+ TYPE_KEYS = {
54
+ "correctivo": "corrective", "preventivo": "preventive", "predictivo": "predictive",
55
+ "inspeccion": "inspection", "infraestructura": "infrastructure",
56
+ }
57
+ COLUMNS = [
58
+ ("id", "ID"), ("title", "Título"), ("status", "Estado"), ("priority", "Prioridad"),
59
+ ("assigned_to_name", "Asignada a"), ("asset_name", "Equipo"), ("site_name", "Lugar"), ("due_date", "Vence"),
60
+ ]
61
+
62
+ PersonOption = Annotated[
63
+ Optional[str], typer.Option("--asignada-a", help="«yo», o el nombre o código de la persona.")
64
+ ]
65
+
66
+
67
+ def _in_spanish(order):
68
+ return {
69
+ **order,
70
+ "status": STATUS_LABELS.get(order.get("status"), order.get("status")),
71
+ "priority": PRIORITY_LABELS.get(order.get("priority"), order.get("priority")),
72
+ "due_date": (order.get("due_date") or "")[:10],
73
+ }
74
+
75
+
76
+ def account_id(client, person):
77
+ """The account a work order is assigned to: «yo», or a person found by
78
+ name or code among the maintenance staff (their user_id, not their
79
+ record's id)."""
80
+ if person.strip().casefold() == "yo":
81
+ return client.get("auth/me")["id"]
82
+ return find_one(client, "employees", person, ["user_name", "employee_code"], "persona")["user_id"]
83
+
84
+
85
+ @app.command()
86
+ def listar(
87
+ ctx: typer.Context,
88
+ estado: Annotated[Optional[Estado], typer.Option("--estado", help="Solo las de este estado.")] = None,
89
+ prioridad: Annotated[Optional[Prioridad], typer.Option("--prioridad", help="Solo las de esta prioridad.")] = None,
90
+ asignada_a: PersonOption = None,
91
+ vencidas: Annotated[bool, typer.Option("--vencidas", help="Solo las vencidas.")] = False,
92
+ buscar: SearchOption = None,
93
+ todas: AllOption = False,
94
+ as_json: JsonOption = False,
95
+ as_csv: CsvOption = False,
96
+ ):
97
+ """Las órdenes de trabajo que puedes ver."""
98
+ output = output_format(as_json, as_csv)
99
+ client = client_of(ctx)
100
+ params = {
101
+ "status": STATUS_KEYS[estado.value] if estado else None,
102
+ "priority": PRIORITY_KEYS[prioridad.value] if prioridad else None,
103
+ "assigned_to_id": account_id(client, asignada_a) if asignada_a else None,
104
+ "overdue": "true" if vencidas else None,
105
+ "search": buscar,
106
+ }
107
+ orders = list_rows(client, "work-orders", params, todas)
108
+ print_rows(COLUMNS, orders if output == "json" else [_in_spanish(order) for order in orders], output)
109
+
110
+
111
+ @app.command()
112
+ def ver(ctx: typer.Context, orden: Annotated[int, typer.Argument(help="El número de la orden.")], as_json: JsonOption = False):
113
+ """Una orden de trabajo, con todos sus datos."""
114
+ print_record(client_of(ctx).get(f"work-orders/{orden}"), output_format(as_json, False))
115
+
116
+
117
+ @app.command()
118
+ def crear(
119
+ ctx: typer.Context,
120
+ titulo: Annotated[str, typer.Option("--titulo", help="Qué hay que hacer.")],
121
+ descripcion: Annotated[Optional[str], typer.Option("--descripcion")] = None,
122
+ prioridad: Annotated[Optional[Prioridad], typer.Option("--prioridad")] = None,
123
+ tipo: Annotated[Optional[Tipo], typer.Option("--tipo")] = None,
124
+ equipo: Annotated[Optional[str], typer.Option("--equipo", help="El código o nombre del equipo.")] = None,
125
+ lugar: Annotated[Optional[str], typer.Option("--lugar", help="El nombre del lugar.")] = None,
126
+ cliente: Annotated[Optional[str], typer.Option("--cliente", help="El nombre o NIT del cliente.")] = None,
127
+ asignar_a: Annotated[Optional[str], typer.Option("--asignar-a", help="«yo», o el nombre o código de la persona.")] = None,
128
+ vence: Annotated[Optional[str], typer.Option("--vence", help="La fecha límite: 30/10/2026.")] = None,
129
+ si: YesOption = False,
130
+ ):
131
+ """Crea una orden de trabajo."""
132
+ client = client_of(ctx)
133
+ order = without_empty({
134
+ "title": titulo,
135
+ "description": descripcion,
136
+ "priority": PRIORITY_KEYS[prioridad.value] if prioridad else None,
137
+ "work_type": TYPE_KEYS[tipo.value] if tipo else None,
138
+ "asset_id": find_one(client, "assets", equipo, ["code", "name"], "equipo")["id"] if equipo else None,
139
+ "site_id": find_one(client, "locations", lugar, ["name"], "lugar")["id"] if lugar else None,
140
+ "customer_id": find_one(client, "customers", cliente, ["name", "tax_id"], "cliente")["id"] if cliente else None,
141
+ "assigned_to_id": account_id(client, asignar_a) if asignar_a else None,
142
+ "due_date": day(vence),
143
+ })
144
+ confirm_write(f"Crear la orden «{titulo}».", si)
145
+ created = client.send("POST", "work-orders", json=order).json()
146
+ typer.echo(f"Orden {created['id']} creada: {created['title']}.")
147
+
148
+
149
+ @app.command()
150
+ def estado(
151
+ ctx: typer.Context,
152
+ orden: Annotated[int, typer.Argument(help="El número de la orden.")],
153
+ nuevo: Annotated[Estado, typer.Argument(help="El estado al que pasa.")],
154
+ si: YesOption = False,
155
+ ):
156
+ """Cambia el estado de una orden. Verificarla se hace en la app."""
157
+ confirm_write(f"Pasar la orden {orden} a «{nuevo.value}».", si)
158
+ changed = client_of(ctx).send("POST", f"work-orders/{orden}/transition", json={"status": STATUS_KEYS[nuevo.value]}).json()
159
+ typer.echo(f"Orden {orden}: {STATUS_LABELS.get(changed.get('status'), changed.get('status'))}.")
160
+
161
+
162
+ @app.command()
163
+ def asignar(
164
+ ctx: typer.Context,
165
+ orden: Annotated[int, typer.Argument(help="El número de la orden.")],
166
+ persona: Annotated[str, typer.Argument(help="«yo», «nadie», o el nombre o código de la persona.")],
167
+ si: YesOption = False,
168
+ ):
169
+ """Asigna una orden a una persona, que recibe el aviso."""
170
+ client = client_of(ctx)
171
+ account = None if persona.strip().casefold() == "nadie" else account_id(client, persona)
172
+ confirm_write(f"Asignar la orden {orden} a {persona}.", si)
173
+ client.send("POST", f"work-orders/{orden}/assign", json={"assigned_to_id": account})
174
+ typer.echo(f"Orden {orden} asignada a {persona}.")
175
+
176
+
177
+ @app.command()
178
+ def comentar(
179
+ ctx: typer.Context,
180
+ orden: Annotated[int, typer.Argument(help="El número de la orden.")],
181
+ texto: Annotated[str, typer.Argument(help="El comentario.")],
182
+ si: YesOption = False,
183
+ ):
184
+ """Agrega un comentario a una orden."""
185
+ confirm_write(f"Comentar en la orden {orden}.", si)
186
+ client_of(ctx).send("POST", f"work-orders/{orden}/comments", json={"body": texto})
187
+ typer.echo(f"Comentario agregado a la orden {orden}.")
@@ -0,0 +1,107 @@
1
+ """`operx personas importar`: a company's people from its own Excel, into
2
+ Personas (backend/people/import_views.py).
3
+
4
+ Every run checks the whole file first and shows each row's errors; with
5
+ --confirmar, a clean file is saved all at once, or nothing is saved. Nobody
6
+ gets access to the app here: invitations are sent from Usuarios y acceso.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Annotated, Optional
11
+
12
+ import typer
13
+
14
+ from ..lookup import NoSingleMatch, find_one
15
+ from ..output import note, print_rows
16
+ from ..spreadsheet import DATE_FIELDS, people_rows
17
+ from .common import CliError, YesOption, client_of, confirm_write
18
+
19
+ app = typer.Typer(help="Personas: la gente de tu empresa.", no_args_is_help=True)
20
+
21
+ # The API takes at most this many rows a call (people/import_views.py).
22
+ MAX_ROWS = 1000
23
+ BAD_DATE = "no se entiende la fecha; escríbela como 31/12/2026."
24
+ REPORT_COLUMNS = [("line", "Fila"), ("name", "Nombre"), ("result", "Resultado")]
25
+
26
+
27
+ def _with_place_ids(client, people):
28
+ """A "Sede" column holds a place's name; the API takes its id."""
29
+ found = {}
30
+ rows = []
31
+ for line, person in zip(people.lines, people.rows):
32
+ person = dict(person)
33
+ place_name = person.pop("sede", None)
34
+ if place_name:
35
+ if place_name not in found:
36
+ try:
37
+ found[place_name] = find_one(client, "locations", place_name, ["name"], "sede")["id"]
38
+ except NoSingleMatch as error:
39
+ raise CliError(f"Fila {line}: {error}") from None
40
+ person["branch_id"] = found[place_name]
41
+ rows.append(person)
42
+ return rows
43
+
44
+
45
+ def _errors_text(errors, columns):
46
+ parts = []
47
+ for field, messages in errors.items():
48
+ text = " ".join(messages) if isinstance(messages, list) else str(messages)
49
+ if field in DATE_FIELDS:
50
+ # The API names its own format (YYYY-MM-DD); the file is read day first.
51
+ text = BAD_DATE
52
+ column = columns.get(field)
53
+ parts.append(f"{column}: {text}" if column else text)
54
+ return "; ".join(parts)
55
+
56
+
57
+ def _show_report(answer, people):
58
+ columns = {field: column for column, field in people.mapping.items()}
59
+ columns["branch_id"] = columns.get("sede")
60
+ report = [
61
+ {
62
+ "line": people.lines[row["row"] - 1],
63
+ "name": row["name"],
64
+ "result": "Bien" if row["ok"] else _errors_text(row["errors"], columns),
65
+ }
66
+ for row in answer.get("report", [])
67
+ ]
68
+ print_rows(REPORT_COLUMNS, report, "tabla")
69
+
70
+
71
+ @app.command()
72
+ def importar(
73
+ ctx: typer.Context,
74
+ archivo: Annotated[Path, typer.Argument(help="El Excel (.xlsx) o CSV con una persona por fila.")],
75
+ hoja: Annotated[Optional[str], typer.Option("--hoja", help="La hoja del Excel; sin ella, la primera.")] = None,
76
+ mapa: Annotated[
77
+ Optional[list[str]],
78
+ typer.Option("--mapa", help="Una columna que no se reconoce: \"Tu columna=campo\". Se repite."),
79
+ ] = None,
80
+ confirmar: Annotated[bool, typer.Option("--confirmar", help="Guarda las personas si ninguna fila tiene errores.")] = False,
81
+ si: YesOption = False,
82
+ ):
83
+ """Importa personas desde un Excel. Sin --confirmar solo revisa el archivo y no guarda nada."""
84
+ people = people_rows(archivo, mapa, hoja)
85
+ if not people.rows:
86
+ raise CliError("El archivo no tiene filas con datos.")
87
+ if len(people.rows) > MAX_ROWS:
88
+ raise CliError(f"El archivo tiene {len(people.rows)} filas y se importan hasta {MAX_ROWS} por vez: divídelo.")
89
+ note("Columnas: " + ", ".join(f"{column} → {field}" for column, field in people.mapping.items()))
90
+ if people.ignored:
91
+ note("Sin usar: " + ", ".join(people.ignored))
92
+
93
+ client = client_of(ctx)
94
+ rows = _with_place_ids(client, people)
95
+ preview = client.send("POST", "people/import", json={"rows": rows, "dry_run": True}).json()
96
+ _show_report(preview, people)
97
+ if preview["with_errors"]:
98
+ raise CliError(
99
+ f"{preview['with_errors']} de {preview['rows']} filas tienen errores: corrígelas en el archivo. No se guardó nada."
100
+ )
101
+ if not confirmar:
102
+ note("Es una prueba: no se guardó nada. Para guardar, repite con --confirmar.")
103
+ return
104
+
105
+ confirm_write(f"Guardar {len(rows)} personas en Personas.", si)
106
+ saved = client.send("POST", "people/import", json={"rows": rows, "dry_run": False}).json()
107
+ typer.echo(f"Se guardaron {saved['saved']} personas. Nadie recibió acceso a la app: invítalos en Usuarios y acceso.")
@@ -0,0 +1,114 @@
1
+ """`operx api`: any call a token may make (Acceso por API › Documentación
2
+ lists them), for what has no command of its own yet."""
3
+
4
+ import json
5
+ from contextlib import ExitStack
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional
8
+
9
+ import typer
10
+
11
+ from ..client import ApiError, api_path
12
+ from ..output import json_text, note
13
+ from .common import AllOption, CliError, YesOption, client_of, confirm_write
14
+
15
+ METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
16
+
17
+
18
+ def _pairs(texts, what):
19
+ """['search=acme', 'page=2'] → {'search': 'acme', 'page': '2'}."""
20
+ pairs = {}
21
+ for text in texts or []:
22
+ key, equals, value = text.partition("=")
23
+ if not equals or not key.strip():
24
+ raise CliError(f"«{text}» no es {what}: escríbelo como nombre=valor.")
25
+ pairs[key.strip()] = value
26
+ return pairs
27
+
28
+
29
+ def _body(text):
30
+ """The JSON to send: written out, or read from a file with @archivo.json."""
31
+ if text is None:
32
+ return None
33
+ if text.startswith("@"):
34
+ path = Path(text[1:])
35
+ if not path.exists():
36
+ raise CliError(f"No existe el archivo {path}.")
37
+ text = path.read_text(encoding="utf-8-sig")
38
+ try:
39
+ return json.loads(text)
40
+ except ValueError as error:
41
+ raise CliError(f"Los datos no son JSON válido: {error}") from None
42
+
43
+
44
+ def _form_fields(body):
45
+ """With files, the other fields go as a form: text for each one."""
46
+ if body is None:
47
+ return None
48
+ if not isinstance(body, dict):
49
+ raise CliError("Con --adjunto, los datos deben ser un objeto JSON: {\"campo\": \"valor\"}.")
50
+ return {key: value if isinstance(value, str) else json.dumps(value) for key, value in body.items()}
51
+
52
+
53
+ def _show(response, salida):
54
+ if salida:
55
+ salida.write_bytes(response.content)
56
+ note(f"Guardado en {salida}.")
57
+ return
58
+ content_type = response.headers.get("Content-Type", "")
59
+ if not response.content:
60
+ note("Hecho.")
61
+ elif "json" in content_type:
62
+ typer.echo(json_text(response.json()))
63
+ elif content_type.startswith("text/"):
64
+ typer.echo(response.text, nl=False)
65
+ else:
66
+ raise CliError(f"La respuesta es un archivo ({content_type}): guárdala con --salida archivo.")
67
+
68
+
69
+ def api(
70
+ ctx: typer.Context,
71
+ metodo: Annotated[str, typer.Argument(help="GET, POST, PATCH, PUT o DELETE.")],
72
+ ruta: Annotated[str, typer.Argument(help="La dirección, como en la documentación: /work-orders/ o /customers/12/.")],
73
+ consulta: Annotated[Optional[list[str]], typer.Option("--consulta", "-q", help="Un filtro, nombre=valor. Se repite.")] = None,
74
+ datos: Annotated[Optional[str], typer.Option("--datos", "-d", help="El JSON a enviar, o @archivo.json.")] = None,
75
+ adjunto: Annotated[Optional[list[str]], typer.Option("--adjunto", "-a", help="Un archivo a subir, campo=ruta. Se repite.")] = None,
76
+ todas: AllOption = False,
77
+ salida: Annotated[Optional[Path], typer.Option("--salida", "-o", help="Guarda la respuesta en este archivo (CSV, PDF…).")] = None,
78
+ si: YesOption = False,
79
+ ):
80
+ """Cualquier llamada que tu token pueda hacer. Responde el JSON de la API."""
81
+ method = metodo.upper()
82
+ if method not in METHODS:
83
+ raise CliError(f"«{metodo}» no es un método: usa {', '.join(METHODS)}.")
84
+ params = _pairs(consulta, "un filtro")
85
+ body = _body(datos)
86
+ files = _pairs(adjunto, "un adjunto")
87
+ if todas and method != "GET":
88
+ raise CliError("--todas es solo para GET.")
89
+ if method != "GET":
90
+ confirm_write(f"{method} {ruta}", si)
91
+
92
+ client = client_of(ctx)
93
+ try:
94
+ if todas:
95
+ typer.echo(json_text(client.all_pages(ruta, params)))
96
+ return
97
+ with ExitStack() as stack:
98
+ opened = {}
99
+ for field, path in files.items():
100
+ if not Path(path).exists():
101
+ raise CliError(f"No existe el archivo {path}.")
102
+ opened[field] = (Path(path).name, stack.enter_context(open(path, "rb")))
103
+ response = client.send(
104
+ method, ruta, params=params or None,
105
+ json=None if opened else body,
106
+ data=_form_fields(body) if opened else None,
107
+ files=opened or None,
108
+ )
109
+ except ApiError as error:
110
+ if error.status != 404:
111
+ raise
112
+ # Git Bash rewrites "/work-orders/" into a Windows path: show what was asked.
113
+ raise ApiError(404, f"{error} ({method} {api_path(ruta)})") from None
114
+ _show(response, salida)
@@ -0,0 +1,192 @@
1
+ """Registros, the records every product shares: `operx equipos`, `operx
2
+ clientes` and `operx lugares`."""
3
+
4
+ from enum import Enum
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+
9
+ from ..lookup import find_one
10
+ from ..output import print_record, print_rows
11
+ from .common import (
12
+ AllOption, CsvOption, JsonOption, SearchOption, YesOption, client_of, confirm_write, list_rows, output_format,
13
+ without_empty,
14
+ )
15
+
16
+ equipos = typer.Typer(help="Equipos (Registros): las máquinas y activos de tu empresa.", no_args_is_help=True)
17
+ clientes = typer.Typer(help="Clientes y proveedores (Registros).", no_args_is_help=True)
18
+ lugares = typer.Typer(help="Lugares (Registros): sedes, plantas, bodegas y vehículos.", no_args_is_help=True)
19
+
20
+ ASSET_COLUMNS = [
21
+ ("id", "ID"), ("code", "Código"), ("name", "Nombre"), ("status", "Estado"), ("category_name", "Categoría"),
22
+ ("site_name", "Lugar"), ("customer_name", "Cliente"),
23
+ ]
24
+ ASSET_STATUS_LABELS = {
25
+ "active": "Activo", "inactive": "Inactivo", "under_maintenance": "En mantenimiento", "decommissioned": "Dado de baja",
26
+ }
27
+ CUSTOMER_COLUMNS = [
28
+ ("id", "ID"), ("name", "Nombre"), ("tax_id", "NIT"), ("contract_status", "Contrato"), ("is_active", "Activo"),
29
+ ]
30
+ CONTRACT_LABELS = {"active": "Activo", "pending": "Pendiente", "expired": "Vencido", "cancelled": "Cancelado"}
31
+ PLACE_COLUMNS = [
32
+ ("id", "ID"), ("name", "Nombre"), ("kind_label", "Tipo"), ("path", "Dentro de"), ("city", "Ciudad"),
33
+ ("customer_name", "Cliente"), ("is_active", "Activo"),
34
+ ]
35
+
36
+
37
+ class Rol(str, Enum):
38
+ cliente = "cliente"
39
+ proveedor = "proveedor"
40
+ gestor = "gestor"
41
+ laboratorio = "laboratorio"
42
+ consultor = "consultor"
43
+
44
+
45
+ # customers/models.py ROLES: Ambiental's outside firms are the last three.
46
+ ROLE_KEYS = {
47
+ "cliente": "client", "proveedor": "supplier", "gestor": "waste_manager",
48
+ "laboratorio": "lab", "consultor": "consultant",
49
+ }
50
+
51
+
52
+ def _labelled(rows, field, labels, output):
53
+ if output == "json":
54
+ return rows
55
+ return [{**row, field: labels.get(row.get(field), row.get(field))} for row in rows]
56
+
57
+
58
+ # ── Equipos ────────────────────────────────────────────────────────────────
59
+
60
+
61
+ @equipos.command("listar")
62
+ def listar_equipos(
63
+ ctx: typer.Context,
64
+ buscar: SearchOption = None,
65
+ todas: AllOption = False,
66
+ as_json: JsonOption = False,
67
+ as_csv: CsvOption = False,
68
+ ):
69
+ """Los equipos que puedes ver."""
70
+ output = output_format(as_json, as_csv)
71
+ rows = list_rows(client_of(ctx), "assets", {"search": buscar}, todas)
72
+ print_rows(ASSET_COLUMNS, _labelled(rows, "status", ASSET_STATUS_LABELS, output), output)
73
+
74
+
75
+ @equipos.command("ver")
76
+ def ver_equipo(
77
+ ctx: typer.Context,
78
+ equipo: Annotated[str, typer.Argument(help="El código, nombre o id del equipo.")],
79
+ as_json: JsonOption = False,
80
+ ):
81
+ """Un equipo, con todos sus datos."""
82
+ client = client_of(ctx)
83
+ found = find_one(client, "assets", equipo, ["code", "name"], "equipo")
84
+ print_record(client.get(f"assets/{found['id']}"), output_format(as_json, False))
85
+
86
+
87
+ @equipos.command("crear")
88
+ def crear_equipo(
89
+ ctx: typer.Context,
90
+ nombre: Annotated[str, typer.Option("--nombre")],
91
+ codigo: Annotated[Optional[str], typer.Option("--codigo")] = None,
92
+ lugar: Annotated[Optional[str], typer.Option("--lugar", help="El nombre del lugar donde está.")] = None,
93
+ categoria: Annotated[Optional[str], typer.Option("--categoria", help="El nombre de su categoría.")] = None,
94
+ serie: Annotated[Optional[str], typer.Option("--serie", help="El número de serie.")] = None,
95
+ fabricante: Annotated[Optional[str], typer.Option("--fabricante")] = None,
96
+ modelo: Annotated[Optional[str], typer.Option("--modelo")] = None,
97
+ etiqueta: Annotated[Optional[str], typer.Option("--etiqueta", help="Su etiqueta en otro sistema (SCADA, ERP).")] = None,
98
+ si: YesOption = False,
99
+ ):
100
+ """Crea un equipo."""
101
+ client = client_of(ctx)
102
+ asset = without_empty({
103
+ "name": nombre,
104
+ "code": codigo,
105
+ "site_id": find_one(client, "locations", lugar, ["name"], "lugar")["id"] if lugar else None,
106
+ "category_id": find_one(client, "assets/categories", categoria, ["name"], "categoría")["id"] if categoria else None,
107
+ "serial_number": serie,
108
+ "manufacturer": fabricante,
109
+ "model_number": modelo,
110
+ "external_tag": etiqueta,
111
+ })
112
+ confirm_write(f"Crear el equipo «{nombre}».", si)
113
+ created = client.send("POST", "assets", json=asset).json()
114
+ typer.echo(f"Equipo {created['id']} creado: {created['name']}.")
115
+
116
+
117
+ # ── Clientes ───────────────────────────────────────────────────────────────
118
+
119
+
120
+ @clientes.command("listar")
121
+ def listar_clientes(
122
+ ctx: typer.Context,
123
+ buscar: SearchOption = None,
124
+ todas: AllOption = False,
125
+ as_json: JsonOption = False,
126
+ as_csv: CsvOption = False,
127
+ ):
128
+ """Los clientes y proveedores que puedes ver."""
129
+ output = output_format(as_json, as_csv)
130
+ rows = list_rows(client_of(ctx), "customers", {"search": buscar}, todas)
131
+ print_rows(CUSTOMER_COLUMNS, _labelled(rows, "contract_status", CONTRACT_LABELS, output), output)
132
+
133
+
134
+ @clientes.command("ver")
135
+ def ver_cliente(
136
+ ctx: typer.Context,
137
+ cliente: Annotated[str, typer.Argument(help="El nombre, NIT o id.")],
138
+ as_json: JsonOption = False,
139
+ ):
140
+ """Un cliente o proveedor, con todos sus datos."""
141
+ client = client_of(ctx)
142
+ found = find_one(client, "customers", cliente, ["name", "tax_id", "legal_name"], "cliente")
143
+ print_record(client.get(f"customers/{found['id']}"), output_format(as_json, False))
144
+
145
+
146
+ @clientes.command("crear")
147
+ def crear_cliente(
148
+ ctx: typer.Context,
149
+ nombre: Annotated[str, typer.Option("--nombre")],
150
+ nit: Annotated[Optional[str], typer.Option("--nit")] = None,
151
+ razon_social: Annotated[Optional[str], typer.Option("--razon-social")] = None,
152
+ telefono: Annotated[Optional[str], typer.Option("--telefono")] = None,
153
+ ciudad: Annotated[Optional[str], typer.Option("--ciudad")] = None,
154
+ correo_facturacion: Annotated[Optional[str], typer.Option("--correo-facturacion")] = None,
155
+ direccion_facturacion: Annotated[Optional[str], typer.Option("--direccion-facturacion")] = None,
156
+ notas: Annotated[Optional[str], typer.Option("--notas")] = None,
157
+ rol: Annotated[
158
+ Optional[list[Rol]], typer.Option("--rol", help="cliente (sin --rol), proveedor, gestor, laboratorio o consultor. Se repite.")
159
+ ] = None,
160
+ si: YesOption = False,
161
+ ):
162
+ """Crea un cliente o proveedor."""
163
+ customer = without_empty({
164
+ "name": nombre,
165
+ "tax_id": nit,
166
+ "legal_name": razon_social,
167
+ "phone": telefono,
168
+ "city": ciudad,
169
+ "billing_email": correo_facturacion,
170
+ "billing_address": direccion_facturacion,
171
+ "notes": notas,
172
+ })
173
+ customer["roles"] = [ROLE_KEYS[role.value] for role in rol] if rol else ["client"]
174
+ confirm_write(f"Crear «{nombre}».", si)
175
+ created = client_of(ctx).send("POST", "customers", json=customer).json()
176
+ typer.echo(f"«{created['name']}» creado con el id {created['id']}.")
177
+
178
+
179
+ # ── Lugares ────────────────────────────────────────────────────────────────
180
+
181
+
182
+ @lugares.command("listar")
183
+ def listar_lugares(
184
+ ctx: typer.Context,
185
+ buscar: SearchOption = None,
186
+ todas: AllOption = False,
187
+ as_json: JsonOption = False,
188
+ as_csv: CsvOption = False,
189
+ ):
190
+ """Los lugares de tu empresa y de tus clientes."""
191
+ rows = list_rows(client_of(ctx), "locations", {"search": buscar}, todas)
192
+ print_rows(PLACE_COLUMNS, rows, output_format(as_json, as_csv))
@@ -0,0 +1,21 @@
1
+ """`operx buscar`: the app's top bar search, across everything you may open."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from ..output import print_rows
8
+ from .common import CsvOption, JsonOption, client_of, output_format
9
+
10
+ COLUMNS = [("kind", "Tipo"), ("id", "ID"), ("title", "Nombre"), ("subtitle", "Detalle")]
11
+
12
+
13
+ def buscar(
14
+ ctx: typer.Context,
15
+ texto: Annotated[str, typer.Argument(help="Lo que buscas; desde 2 letras.")],
16
+ as_json: JsonOption = False,
17
+ as_csv: CsvOption = False,
18
+ ):
19
+ """Busca órdenes, equipos, clientes, lugares y personas, como la barra de arriba de la app."""
20
+ answer = client_of(ctx).get("search", {"q": texto})
21
+ print_rows(COLUMNS, answer.get("results", []), output_format(as_json, as_csv))
operx_cli/lookup.py ADDED
@@ -0,0 +1,42 @@
1
+ """Finding a record by what people type (a code, a name) instead of its id.
2
+
3
+ Only an exact match counts, ignoring case and extra spaces: a search's first
4
+ hit is never taken, so "Bodega" can't move stock into "Bodega central norte".
5
+ A number nobody is called is taken as the record's id.
6
+ """
7
+
8
+
9
+ class NoSingleMatch(Exception):
10
+ pass
11
+
12
+
13
+ def plural(word):
14
+ return word + ("s" if word[-1] in "aeiouáéíóú" else "es")
15
+
16
+
17
+ def _normalized(text):
18
+ return " ".join(str(text).split()).casefold()
19
+
20
+
21
+ def find_in(rows, text, fields, what):
22
+ """The one row whose `fields` hold exactly `text`."""
23
+ wanted = _normalized(text)
24
+ matches = [row for row in rows if any(_normalized(row.get(field) or "") == wanted for field in fields)]
25
+ if len(matches) == 1:
26
+ return matches[0]
27
+ if matches:
28
+ ids = ", ".join(str(row["id"]) for row in matches)
29
+ raise NoSingleMatch(f"Hay {len(matches)} {plural(what)} con «{text}» (ids {ids}): usa el id.")
30
+ raise NoSingleMatch(f"No se encontró «{text}» entre los {plural(what)}.")
31
+
32
+
33
+ def find_one(client, path, text, fields, what, params=None):
34
+ """The record at `path` (a list address) called `text`: searched in the
35
+ API, matched exactly here, or read by id when `text` is a number."""
36
+ rows = client.all_pages(path, {**(params or {}), "search": text})
37
+ try:
38
+ return find_in(rows, text, fields, what)
39
+ except NoSingleMatch:
40
+ if str(text).strip().isdigit():
41
+ return client.get(f"{path.rstrip('/')}/{str(text).strip()}/")
42
+ raise