operx-cli 0.5.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (76) hide show
  1. {operx_cli-0.5.0 → operx_cli-0.7.0}/PKG-INFO +19 -7
  2. {operx_cli-0.5.0 → operx_cli-0.7.0}/README.md +18 -6
  3. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/AGENTES.md +71 -16
  4. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/__init__.py +1 -1
  5. operx_cli-0.7.0/operx_cli/api_fields.py +232 -0
  6. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/app.py +1 -0
  7. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/ambiental.py +12 -6
  8. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/articulos.py +5 -2
  9. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/avisos.py +4 -2
  10. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/common.py +22 -2
  11. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/empresas.py +6 -6
  12. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/inventario.py +47 -12
  13. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/lecturas.py +9 -5
  14. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/ordenes.py +76 -15
  15. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/personas.py +69 -5
  16. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/planta.py +47 -5
  17. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/preventivos.py +7 -3
  18. operx_cli-0.7.0/operx_cli/commands/raw.py +288 -0
  19. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/registros.py +14 -9
  20. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/sst.py +4 -2
  21. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/record_completion.py +1 -0
  22. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/shell.py +5 -0
  23. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli.egg-info/PKG-INFO +19 -7
  24. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli.egg-info/SOURCES.txt +4 -0
  25. {operx_cli-0.5.0 → operx_cli-0.7.0}/pyproject.toml +1 -1
  26. operx_cli-0.7.0/tests/test_api_campos.py +166 -0
  27. operx_cli-0.7.0/tests/test_daily_flows.py +186 -0
  28. operx_cli-0.7.0/tests/test_json_writes.py +184 -0
  29. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_logout_and_routes.py +25 -2
  30. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_ordenes.py +2 -0
  31. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_shell.py +1 -1
  32. operx_cli-0.5.0/operx_cli/commands/raw.py +0 -151
  33. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/agent_guide.py +0 -0
  34. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/browser_login.py +0 -0
  35. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/client.py +0 -0
  36. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/__init__.py +0 -0
  37. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/account.py +0 -0
  38. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/completar.py +0 -0
  39. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/reportes.py +0 -0
  40. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/search.py +0 -0
  41. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/commands/update.py +0 -0
  42. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/lookup.py +0 -0
  43. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/output.py +0 -0
  44. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/self_update.py +0 -0
  45. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/settings.py +0 -0
  46. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/spreadsheet.py +0 -0
  47. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/updates.py +0 -0
  48. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/values.py +0 -0
  49. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli/welcome.py +0 -0
  50. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli.egg-info/dependency_links.txt +0 -0
  51. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli.egg-info/entry_points.txt +0 -0
  52. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli.egg-info/requires.txt +0 -0
  53. {operx_cli-0.5.0 → operx_cli-0.7.0}/operx_cli.egg-info/top_level.txt +0 -0
  54. {operx_cli-0.5.0 → operx_cli-0.7.0}/setup.cfg +0 -0
  55. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_agentes.py +0 -0
  56. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_app.py +0 -0
  57. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_articulos.py +0 -0
  58. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_cards.py +0 -0
  59. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_client.py +0 -0
  60. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_completar.py +0 -0
  61. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_inventario_lecturas.py +0 -0
  62. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_login.py +0 -0
  63. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_lookup.py +0 -0
  64. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_more_of_the_api.py +0 -0
  65. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_output.py +0 -0
  66. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_personas.py +0 -0
  67. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_preventivos.py +0 -0
  68. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_record_completion.py +0 -0
  69. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_registros.py +0 -0
  70. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_self_update.py +0 -0
  71. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_settings.py +0 -0
  72. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_spreadsheet.py +0 -0
  73. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_sst_ambiental.py +0 -0
  74. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_updates.py +0 -0
  75. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_values.py +0 -0
  76. {operx_cli-0.5.0 → operx_cli-0.7.0}/tests/test_welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -118,6 +118,8 @@ operx ordenes asignar 1234 "Pedro Pérez"
118
118
  operx ordenes comentar 1234 "Llegó el repuesto"
119
119
  operx ordenes editar 1234 --prioridad critica --vence 05/11/2026
120
120
  operx ordenes adjuntar 1234 foto.jpg --momento despues
121
+ operx ordenes repuestos 1234 # lo planeado, lo usado y su costo
122
+ operx ordenes repuesto 1234 --articulo FIL-010 --lugar "Bodega central" --cantidad 2
121
123
 
122
124
  operx reportes indicadores --dias 30 # MTTR, MTBF, cumplimiento de preventivos
123
125
  operx reportes vencidas
@@ -153,10 +155,12 @@ operx inventario traslado --articulo FIL-010 --cantidad 2 --de "Bodega central"
153
155
  operx inventario ajuste --articulo FIL-010 --lugar "Bodega central" --cantidad -1 --motivo Conteo
154
156
  operx inventario movimientos --articulo FIL-010
155
157
  operx inventario revertir 4512 # deshace un movimiento equivocado
158
+ operx inventario por-comprar # lo bajo el mínimo y las solicitudes aprobadas
156
159
 
157
160
  operx equipos parar BOM-04 --motivo Falla --minutos 90
158
161
  operx equipos reanudar 21
159
162
  operx equipos hallazgo BOM-04 --titulo "Fuga en el sello" --severidad alta
163
+ operx equipos hallazgo-a-orden 8 --prioridad alta # la orden para ese hallazgo
160
164
  operx lecturas anular 31 --motivo "Lectura de otro equipo"
161
165
 
162
166
  operx lecturas puntos BOM-04
@@ -165,6 +169,8 @@ operx lecturas registrar --punto 12 --valor 73,4
165
169
  operx personas listar --buscar pérez
166
170
  operx personas importar empleados.xlsx # revisa el archivo, no guarda
167
171
  operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila tiene errores
172
+ operx personas ausencia "Ana Ruiz" --tipo vacaciones --desde 02/11/2026 --hasta 06/11/2026
173
+ operx personas ausencia "Ana Ruiz" --tipo permiso --desde 09/11/2026 --horas 08:00-10:00
168
174
 
169
175
  operx sst acciones --estado abiertas
170
176
  operx sst nueva-accion --titulo "Señalizar la salida" --para 15/11/2026 --responsable "Ana Ruiz"
@@ -196,7 +202,9 @@ llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
196
202
  ### Antes de escribir
197
203
 
198
204
  Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
199
- programada no hay a quién preguntar: agrega `--si`. Cada escritura queda en
205
+ programada no hay a quién preguntar: agrega `--si`. Con `--json`, lo que
206
+ escribe responde el registro creado o cambiado (con su `id`) en vez de una
207
+ frase. Cada escritura queda en
200
208
  el historial del token (Acceso por API › el token › Actividad) marcada como
201
209
  hecha desde la CLI.
202
210
 
@@ -211,13 +219,17 @@ Usuarios y acceso.
211
219
 
212
220
  ### Cualquier otra llamada
213
221
 
214
- Lo que no tiene comando propio se hace con `operx api`. `operx api rutas`
215
- lista todas las direcciones que tu token puede llamar (`operx api rutas
216
- compresor` deja las que lo mencionan); también están en Acceso por API ›
217
- Documentación:
222
+ Lo que no tiene comando propio se hace con `operx api`: todo lo que tu
223
+ token puede hacer, más de 400 llamadas. `operx api rutas` las lista
224
+ (`operx api rutas sst inspections` deja las que tienen esas palabras), y
225
+ `operx api campos RUTA` dice qué lleva una: sus filtros, cada campo con su
226
+ tipo, si es obligatorio y los valores permitidos. También están en Acceso
227
+ por API › Documentación:
218
228
 
219
229
  ```sh
220
- operx api rutas schedules
230
+ operx api rutas purchase requests
231
+ operx api campos inventory/purchase-requests/ --metodo POST
232
+ operx api POST inventory/purchase-requests/ -d '{"item": 3, "quantity": "10", "reason": "Bajo el mínimo"}'
221
233
  operx api GET pm/schedules/ -q search=compresor
222
234
  operx api POST contacts/ -d @contacto.json
223
235
  operx api GET work-orders/export/ -o ordenes.csv
@@ -104,6 +104,8 @@ operx ordenes asignar 1234 "Pedro Pérez"
104
104
  operx ordenes comentar 1234 "Llegó el repuesto"
105
105
  operx ordenes editar 1234 --prioridad critica --vence 05/11/2026
106
106
  operx ordenes adjuntar 1234 foto.jpg --momento despues
107
+ operx ordenes repuestos 1234 # lo planeado, lo usado y su costo
108
+ operx ordenes repuesto 1234 --articulo FIL-010 --lugar "Bodega central" --cantidad 2
107
109
 
108
110
  operx reportes indicadores --dias 30 # MTTR, MTBF, cumplimiento de preventivos
109
111
  operx reportes vencidas
@@ -139,10 +141,12 @@ operx inventario traslado --articulo FIL-010 --cantidad 2 --de "Bodega central"
139
141
  operx inventario ajuste --articulo FIL-010 --lugar "Bodega central" --cantidad -1 --motivo Conteo
140
142
  operx inventario movimientos --articulo FIL-010
141
143
  operx inventario revertir 4512 # deshace un movimiento equivocado
144
+ operx inventario por-comprar # lo bajo el mínimo y las solicitudes aprobadas
142
145
 
143
146
  operx equipos parar BOM-04 --motivo Falla --minutos 90
144
147
  operx equipos reanudar 21
145
148
  operx equipos hallazgo BOM-04 --titulo "Fuga en el sello" --severidad alta
149
+ operx equipos hallazgo-a-orden 8 --prioridad alta # la orden para ese hallazgo
146
150
  operx lecturas anular 31 --motivo "Lectura de otro equipo"
147
151
 
148
152
  operx lecturas puntos BOM-04
@@ -151,6 +155,8 @@ operx lecturas registrar --punto 12 --valor 73,4
151
155
  operx personas listar --buscar pérez
152
156
  operx personas importar empleados.xlsx # revisa el archivo, no guarda
153
157
  operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila tiene errores
158
+ operx personas ausencia "Ana Ruiz" --tipo vacaciones --desde 02/11/2026 --hasta 06/11/2026
159
+ operx personas ausencia "Ana Ruiz" --tipo permiso --desde 09/11/2026 --horas 08:00-10:00
154
160
 
155
161
  operx sst acciones --estado abiertas
156
162
  operx sst nueva-accion --titulo "Señalizar la salida" --para 15/11/2026 --responsable "Ana Ruiz"
@@ -182,7 +188,9 @@ llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
182
188
  ### Antes de escribir
183
189
 
184
190
  Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
185
- programada no hay a quién preguntar: agrega `--si`. Cada escritura queda en
191
+ programada no hay a quién preguntar: agrega `--si`. Con `--json`, lo que
192
+ escribe responde el registro creado o cambiado (con su `id`) en vez de una
193
+ frase. Cada escritura queda en
186
194
  el historial del token (Acceso por API › el token › Actividad) marcada como
187
195
  hecha desde la CLI.
188
196
 
@@ -197,13 +205,17 @@ Usuarios y acceso.
197
205
 
198
206
  ### Cualquier otra llamada
199
207
 
200
- Lo que no tiene comando propio se hace con `operx api`. `operx api rutas`
201
- lista todas las direcciones que tu token puede llamar (`operx api rutas
202
- compresor` deja las que lo mencionan); también están en Acceso por API ›
203
- Documentación:
208
+ Lo que no tiene comando propio se hace con `operx api`: todo lo que tu
209
+ token puede hacer, más de 400 llamadas. `operx api rutas` las lista
210
+ (`operx api rutas sst inspections` deja las que tienen esas palabras), y
211
+ `operx api campos RUTA` dice qué lleva una: sus filtros, cada campo con su
212
+ tipo, si es obligatorio y los valores permitidos. También están en Acceso
213
+ por API › Documentación:
204
214
 
205
215
  ```sh
206
- operx api rutas schedules
216
+ operx api rutas purchase requests
217
+ operx api campos inventory/purchase-requests/ --metodo POST
218
+ operx api POST inventory/purchase-requests/ -d '{"item": 3, "quantity": "10", "reason": "Bajo el mínimo"}'
207
219
  operx api GET pm/schedules/ -q search=compresor
208
220
  operx api POST contacts/ -d @contacto.json
209
221
  operx api GET work-orders/export/ -o ordenes.csv
@@ -9,8 +9,9 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
9
9
 
10
10
  1. Escribe siempre el comando completo (`operx ordenes listar …`). No abras el
11
11
  modo interactivo: `operx` solo, fuera de una terminal, solo muestra la ayuda.
12
- 2. Lee con `--json`: es lo que responde la API, completo. Las tablas son para
13
- personas.
12
+ 2. Usa `--json` siempre: al leer es lo que responde la API, completo; al
13
+ escribir es el registro creado o cambiado, con su `id`. Las tablas y las
14
+ frases son para personas.
14
15
  3. Antes de crear o cambiar algo, dile a la persona qué vas a hacer y espera
15
16
  su sí. Con su sí, agrega `--si`: sin él, toda escritura falla fuera de una
16
17
  terminal.
@@ -20,7 +21,9 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
20
21
  consulta primero si quedó hecha.
21
22
  6. Si un nombre coincide con varios registros, `operx` lo dice con sus ids:
22
23
  usa el id, no adivines.
23
- 7. Usa un comando propio cuando exista; `operx api` es para lo demás.
24
+ 7. Usa un comando propio cuando exista: acepta nombres y fechas como los
25
+ escribe la persona. Para todo lo demás está `operx api` (abajo): con él
26
+ puedes hacer todo lo que el token permite.
24
27
  8. `operx update` y `operx completar` cambian el computador de la persona, no
25
28
  los datos de su empresa: son para ella, no los ejecutes.
26
29
 
@@ -54,13 +57,16 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
54
57
 
55
58
  ## Escribir
56
59
 
60
+ - Todos los comandos que escriben aceptan `--json`: la salida es solo la
61
+ respuesta de la API (el registro, con su `id`). Úsalo para encadenar pasos.
57
62
  - Escriben: `ordenes crear`, `ordenes editar`, `ordenes estado`,
58
- `ordenes asignar`, `ordenes comentar`, `ordenes adjuntar`,
63
+ `ordenes asignar`, `ordenes comentar`, `ordenes adjuntar`, `ordenes repuesto`,
59
64
  `preventivos crear`, `preventivos nota`, `equipos crear`, `equipos editar`,
60
- `equipos parar`, `equipos reanudar`, `equipos hallazgo`, `clientes crear`,
65
+ `equipos parar`, `equipos reanudar`, `equipos hallazgo`,
66
+ `equipos hallazgo-a-orden`, `clientes crear`,
61
67
  `clientes editar`, `lugares crear`, `articulos crear`, `inventario entrada`, `inventario salida`, `inventario ajuste`,
62
68
  `inventario traslado`, `inventario revertir`, `lecturas registrar`,
63
- `lecturas anular`, `personas importar --confirmar`, `sst nueva-accion`,
69
+ `lecturas anular`, `personas importar --confirmar`, `personas ausencia`, `sst nueva-accion`,
64
70
  `sst accion-hecha`, `ambiental nueva-accion`, `ambiental accion-hecha`,
65
71
  `ambiental residuo`, `ambiental lectura`, `avisos --marcar-leidos`, y
66
72
  `operx api` con POST, PUT, PATCH o DELETE. Todos llevan `--si`.
@@ -110,13 +116,46 @@ Lo que responde la API:
110
116
  - **429**: demasiadas llamadas (120 por minuto por token, 600 por empresa).
111
117
  `operx` espera y reintenta solo, hasta 3 veces.
112
118
 
113
- ## Cualquier otra llamada: `operx api`
114
-
115
- `operx api MÉTODO RUTA` hace cualquier llamada que el token pueda hacer y
116
- responde el JSON de la API. `operx api rutas --json` lista todas las rutas
117
- que el token puede llamar, con lo que hace cada una; `operx api rutas TEXTO`
118
- deja solo las que lo mencionan. Escríbelas como salen ahí: sin `/api/v1/` y
119
- sin barra inicial.
119
+ ## Todo lo demás: `operx api`
120
+
121
+ El token puede hacer más de 400 llamadas; los comandos propios cubren las
122
+ del día a día. Todas las demás se hacen con `operx api MÉTODO RUTA`, en
123
+ cinco pasos:
124
+
125
+ 1. **Busca la ruta.** `operx api rutas TEXTO --json` deja las que tienen
126
+ cada palabra del texto en la ruta, en su `producto` (SG-SST, Inventario…)
127
+ o en lo que hace. Las rutas están en inglés: busca también así
128
+ (`operx api rutas sst inspections`, `operx api rutas purchase requests`).
129
+ 2. **Lee lo que lleva.** `operx api campos RUTA --json` (con `--metodo POST`
130
+ para uno solo) da, por método:
131
+ - `en_la_ruta`: los valores de la dirección, como `{pk}`.
132
+ - `filtros`: lo que va en `-q nombre=valor`.
133
+ - `datos`: cada campo del cuerpo, con su `tipo`, su `formato`, si es
134
+ `obligatorio`, y en `valores` los permitidos con su etiqueta en español
135
+ (para entender lo que dice la persona). `lines[].item` es un campo de
136
+ cada elemento de la lista `lines`.
137
+ - `envio`: `json` (todo en `-d`) o `archivos` (el archivo con
138
+ `-a campo=ruta`, lo demás en `-d`).
139
+ - `responde`: los campos de la respuesta.
140
+ 3. **Busca los ids.** La API toma ids, no nombres: búscalos con un comando
141
+ (`operx equipos listar --buscar bomba --json`) o con
142
+ `operx api GET RUTA -q search=TEXTO`.
143
+ 4. **Escribe**, después del sí de la persona: `-d '{"campo": valor}'` y `--si`.
144
+ 5. **Lee la respuesta**: es el registro creado o cambiado.
145
+
146
+ En `-d` los datos van como los toma la API, no como en los comandos propios:
147
+
148
+ - Decimales como texto con punto: `"2.5"`, nunca `2,5`.
149
+ - Fechas `"2026-10-30"`; fecha y hora con su zona:
150
+ `"2026-10-30T08:00:00-05:00"`.
151
+ - Ids, no nombres: `"item": 3`, no `"item": "FIL-010"`.
152
+ - Los valores de una lista, en inglés, como salen en `valores`:
153
+ `"priority": "high"`.
154
+
155
+ Si la API responde 400, el mensaje dice qué campo falló y por qué; corrige
156
+ eso, no lo demás.
157
+
158
+ Más opciones de `operx api`:
120
159
 
121
160
  - `-q nombre=valor`: un filtro. Se repite.
122
161
  - `-d '{"campo": "valor"}'` o `-d @archivo.json`: el cuerpo de la llamada.
@@ -125,9 +164,9 @@ sin barra inicial.
125
164
  - `-o archivo`: guarda una respuesta que es un archivo (CSV, PDF).
126
165
 
127
166
  ```sh
128
- operx api rutas schedules --json
129
- operx api GET pm/schedules/ -q search=compresor
130
- operx api POST contacts/ -d @contacto.json --si
167
+ operx api rutas purchase requests --json
168
+ operx api campos inventory/purchase-requests/ --metodo POST --json
169
+ operx api POST inventory/purchase-requests/ -d '{"item": 3, "quantity": "10", "reason": "Bajo el mínimo"}' --si
131
170
  operx api GET work-orders/export/ -o ordenes.csv
132
171
  ```
133
172
 
@@ -155,6 +194,22 @@ operx inventario entrada --articulo CON-0001 --lugar "Bodega central" --cantidad
155
194
  # Un equipo que entró a mantenimiento
156
195
  operx equipos editar BOM-04 --estado en-mantenimiento --si
157
196
 
197
+ # Un repuesto usado en una orden, y lo que ya lleva
198
+ operx ordenes repuesto 1234 --articulo FIL-010 --lugar "Bodega central" --cantidad 2 --si --json
199
+ operx ordenes repuestos 1234 --json
200
+
201
+ # Lo que hay que comprar, y una solicitud de compra (no tiene comando propio)
202
+ operx inventario por-comprar --json
203
+ operx api campos inventory/purchase-requests/ --metodo POST --json
204
+ operx api POST inventory/purchase-requests/ -d '{"item": 3, "quantity": "10", "reason": "Bajo el mínimo"}' --si
205
+
206
+ # La orden para un hallazgo, y una ausencia (queda por aprobar)
207
+ operx equipos hallazgo-a-orden 8 --prioridad alta --si --json
208
+ operx personas ausencia "Ana Ruiz" --tipo permiso --desde 09/11/2026 --horas 08:00-10:00 --si --json
209
+
210
+ # Crear y seguir con el id que responde
211
+ operx ordenes crear --titulo "Cambio de rodamiento" --equipo BOM-04 --si --json
212
+
158
213
  # Los equipos en un CSV para Excel
159
214
  operx equipos listar --todas --csv > equipos.csv
160
215
 
@@ -2,4 +2,4 @@
2
2
  API (backend/users/api_allowlist.py) from a terminal, with a personal API
3
3
  token."""
4
4
 
5
- __version__ = "0.5.0"
5
+ __version__ = "0.7.0"
@@ -0,0 +1,232 @@
1
+ """What a route takes, read from the OpenAPI schema the API serves a token
2
+ (auth/api-tokens/schema/, drf-spectacular): the values in its address, its
3
+ filters, the fields of its body and what it answers. `operx api campos`
4
+ prints it, so an AI agent can call any route with `operx api` without
5
+ guessing; no network here, so the MCP server can use it as it is.
6
+
7
+ The schema's shapes this follows: a field's type behind a $ref; an allOf
8
+ of one $ref (a nested record); a oneOf of an enum with BlankEnum and
9
+ NullEnum (a choice that may be "" or null); each enum's labels in its
10
+ description, one "* `value` - Label" a line.
11
+ """
12
+
13
+ import re
14
+
15
+ METHODS = ("get", "post", "put", "patch", "delete")
16
+ # A field inside a list inside a list… is shown this many levels deep.
17
+ MAX_DEPTH = 3
18
+ LABEL_LINE = re.compile(r"^\* `([^`]*)` - (.+)$", re.MULTILINE)
19
+ DECIMALS = re.compile(r"\\\.\\d\{0,(\d+)\}")
20
+ # Django's integer field bounds: every integer has them, they say nothing.
21
+ INT32 = (-2147483648, 2147483647)
22
+ PAGE_FIELDS = {"count", "next", "previous", "results"}
23
+
24
+
25
+ def find_route(schema, route):
26
+ """The schema's path for a route as a person or an agent types it: with
27
+ real ids (work-orders/1234/) or the placeholders `operx api rutas` prints
28
+ ({pk}). A fixed word beats a placeholder, so items/siguiente-codigo/
29
+ isn't items/{pk}/. LookupError when no route fits."""
30
+ wanted = _segments(route)
31
+ best = None
32
+ for path in schema.get("paths", {}):
33
+ segments = _segments(path)
34
+ if len(segments) != len(wanted):
35
+ continue
36
+ fixed = 0
37
+ for template, given in zip(segments, wanted):
38
+ if template.startswith("{"):
39
+ continue
40
+ if template != given:
41
+ break
42
+ fixed += 1
43
+ else:
44
+ if best is None or fixed > best[0]:
45
+ best = (fixed, path)
46
+ if best is None:
47
+ raise LookupError(route)
48
+ return best[1]
49
+
50
+
51
+ def _segments(route):
52
+ route = route.strip().split("?")[0].strip("/")
53
+ route = route.removeprefix("api/v1/")
54
+ return route.split("/")
55
+
56
+
57
+ def describe(schema, path, method=None):
58
+ """Each operation of the route (or only `method`): a dict an agent reads
59
+ as JSON and `operx api campos` prints as tables."""
60
+ components = schema.get("components", {}).get("schemas", {})
61
+ operations = schema["paths"][path]
62
+ described = []
63
+ for name in METHODS:
64
+ if name not in operations or (method and method.lower() != name):
65
+ continue
66
+ operation = operations[name]
67
+ parameters = operation.get("parameters", [])
68
+ body_schema, sending = _body(operation)
69
+ described.append({
70
+ "metodo": name.upper(),
71
+ "ruta": path.removeprefix("/api/v1/"),
72
+ "descripcion": (operation.get("description") or "").strip(),
73
+ "en_la_ruta": [_parameter(components, parameter) for parameter in parameters if parameter.get("in") == "path"],
74
+ "filtros": [_parameter(components, parameter) for parameter in parameters if parameter.get("in") == "query"],
75
+ "datos": _fields(components, body_schema) if body_schema else [],
76
+ "envio": sending if body_schema else None,
77
+ "responde": _answer(components, operation),
78
+ })
79
+ return described
80
+
81
+
82
+ def methods_of(schema, path):
83
+ return [name.upper() for name in METHODS if name in schema["paths"][path]]
84
+
85
+
86
+ def _body(operation):
87
+ """The body's schema and how it goes: JSON (-d), or a form with a file
88
+ (-a for the file, -d for the rest)."""
89
+ content = operation.get("requestBody", {}).get("content", {})
90
+ if "application/json" in content:
91
+ return content["application/json"].get("schema"), "json"
92
+ for kind in ("multipart/form-data", "application/x-www-form-urlencoded"):
93
+ if kind in content:
94
+ return content[kind].get("schema"), "archivos"
95
+ return None, None
96
+
97
+
98
+ def _resolve(components, node):
99
+ """A field's schema with its $ref, its one-ref allOf and its
100
+ enum-or-blank-or-null oneOf followed. What the wrapper says (nullable,
101
+ readOnly, its own description) stays; an enum's description becomes
102
+ its labels."""
103
+ node = dict(node or {})
104
+ if "$ref" in node:
105
+ name = node.pop("$ref").split("/")[-1]
106
+ target = _labelled(components.get(name, {}))
107
+ node = {**target, **node, "_name": name}
108
+ if len(node.get("allOf", [])) == 1:
109
+ inner = _resolve(components, node.pop("allOf")[0])
110
+ node = {**inner, **node}
111
+ if "oneOf" in node:
112
+ choices = [_resolve(components, choice) for choice in node.pop("oneOf")]
113
+ kept = []
114
+ for choice in choices:
115
+ if choice.get("enum") == [""] or choice.get("maxLength") == 0:
116
+ node["_blank"] = True
117
+ elif choice.get("enum") == [None]:
118
+ node["nullable"] = True
119
+ else:
120
+ kept.append(choice)
121
+ if kept:
122
+ node = {**kept[0], **node}
123
+ if node.get("type") == "array" and "items" in node:
124
+ # One level only: a record's own fields are followed by _fields.
125
+ node["items"] = _resolve(components, node["items"]) if "$ref" in node["items"] else node["items"]
126
+ return node
127
+
128
+
129
+ def _labelled(node):
130
+ """An enum's "* `low` - Baja" description as its labels."""
131
+ if "enum" not in node:
132
+ return node
133
+ node = dict(node)
134
+ labels = dict(LABEL_LINE.findall(node.pop("description", "") or ""))
135
+ node["_labels"] = labels
136
+ return node
137
+
138
+
139
+ def _fields(components, body_schema, prefix="", depth=0, seen=frozenset()):
140
+ record = _resolve(components, body_schema)
141
+ required = set(record.get("required", []))
142
+ rows = []
143
+ for name, value in record.get("properties", {}).items():
144
+ field = _resolve(components, value)
145
+ if field.get("readOnly"):
146
+ continue
147
+ path = prefix + name
148
+ rows.append(_row(path, field, name in required))
149
+ if depth >= MAX_DEPTH:
150
+ continue
151
+ if field.get("type") == "array":
152
+ items = _resolve(components, field.get("items"))
153
+ if "properties" in items and items.get("_name") not in seen:
154
+ rows.extend(_fields(components, items, f"{path}[].", depth + 1, seen | {items.get("_name")}))
155
+ elif "properties" in field and field.get("_name") not in seen:
156
+ rows.extend(_fields(components, field, f"{path}.", depth + 1, seen | {field.get("_name")}))
157
+ return rows
158
+
159
+
160
+ def _row(name, field, required):
161
+ """One field: its JSON type and format, and only what applies to it."""
162
+ row = {"campo": name, "tipo": field.get("type") or ("string" if "enum" in field else None), "obligatorio": required}
163
+ if field.get("format"):
164
+ row["formato"] = field["format"]
165
+ if field.get("type") == "array":
166
+ items = field.get("items") or {}
167
+ row["de"] = "object" if "properties" in items else items.get("type") or ("string" if "enum" in items else None)
168
+ values = _values(field)
169
+ if field.get("type") == "array" and not values:
170
+ values = _values(field.get("items") or {})
171
+ if values:
172
+ row["valores"] = values
173
+ if field.get("nullable"):
174
+ row["nulo"] = True
175
+ if field.get("_blank"):
176
+ row["vacio"] = True
177
+ if "default" in field:
178
+ row["por_defecto"] = field["default"]
179
+ if field.get("maxLength"):
180
+ row["maximo_largo"] = field["maxLength"]
181
+ for key, label in (("minimum", "minimo"), ("maximum", "maximo")):
182
+ if key in field and field[key] not in INT32:
183
+ row[label] = field[key]
184
+ decimals = DECIMALS.search(field.get("pattern", "")) if field.get("format") == "decimal" else None
185
+ if decimals:
186
+ row["decimales"] = int(decimals.group(1))
187
+ if field.get("description"):
188
+ row["nota"] = field["description"].strip()
189
+ return row
190
+
191
+
192
+ def _values(field):
193
+ labels = field.get("_labels") or {}
194
+ values = [value for value in field.get("enum", []) if value not in (None, "")]
195
+ return [{"valor": value, "etiqueta": labels.get(value, value)} for value in values]
196
+
197
+
198
+ def _parameter(components, parameter):
199
+ """A filter or a value in the address. A filter's labels come in its own
200
+ description, not in its schema's."""
201
+ field = _resolve(components, parameter.get("schema"))
202
+ description = (parameter.get("description") or "").strip()
203
+ labels = dict(LABEL_LINE.findall(description))
204
+ if "enum" in field and labels:
205
+ field["_labels"] = labels
206
+ description = ""
207
+ if description:
208
+ field["description"] = description
209
+ row = _row(parameter["name"], field, bool(parameter.get("required")))
210
+ if parameter.get("in") == "path":
211
+ # Only the name and type: it's written in the address itself.
212
+ return {key: row[key] for key in ("campo", "tipo", "obligatorio")}
213
+ return row
214
+
215
+
216
+ def _answer(components, operation):
217
+ """What a call answers: its status, whether it comes by pages, and the
218
+ names of a record's fields."""
219
+ for status, response in operation.get("responses", {}).items():
220
+ if not status.startswith("2"):
221
+ continue
222
+ schema = (response.get("content", {}).get("application/json") or {}).get("schema")
223
+ if schema is None:
224
+ return {"estado": int(status), "pagina": False, "campos": []}
225
+ record = _resolve(components, schema)
226
+ paged = PAGE_FIELDS <= set(record.get("properties", {}))
227
+ if paged:
228
+ record = _resolve(components, record["properties"]["results"])
229
+ if record.get("type") == "array":
230
+ record = _resolve(components, record.get("items"))
231
+ return {"estado": int(status), "pagina": paged, "campos": list(record.get("properties", {}))}
232
+ return {"estado": None, "pagina": False, "campos": []}
@@ -147,6 +147,7 @@ registros.equipos.command("parar")(planta.parar)
147
147
  registros.equipos.command("reanudar")(planta.reanudar)
148
148
  registros.equipos.command("hallazgos")(planta.hallazgos)
149
149
  registros.equipos.command("hallazgo")(planta.hallazgo)
150
+ registros.equipos.command("hallazgo-a-orden")(planta.hallazgo_a_orden)
150
151
  app.add_typer(registros.equipos, name="equipos")
151
152
  app.add_typer(registros.clientes, name="clientes")
152
153
  app.add_typer(registros.lugares, name="lugares")
@@ -15,12 +15,14 @@ from .common import (
15
15
  CsvOption,
16
16
  JsonOption,
17
17
  YesOption,
18
+ answer_of,
18
19
  client_of,
19
20
  confirm_write,
20
21
  day,
21
22
  month,
22
23
  number,
23
24
  output_format,
25
+ print_result,
24
26
  )
25
27
  from .empresas import (
26
28
  ENVIRONMENT,
@@ -71,9 +73,10 @@ def nueva_accion(
71
73
  tipo: Annotated[TipoAccion, typer.Option("--tipo")] = TipoAccion.correctiva,
72
74
  empresa: EmpresaOption = None,
73
75
  si: YesOption = False,
76
+ as_json: JsonOption = False,
74
77
  ):
75
78
  """Agrega una acción al plan de acción ambiental."""
76
- create_action(ctx, ENVIRONMENT, titulo, para, responsable, tipo, empresa, si)
79
+ create_action(ctx, ENVIRONMENT, titulo, para, responsable, tipo, empresa, si, as_json)
77
80
 
78
81
 
79
82
  @app.command("accion-hecha")
@@ -83,9 +86,10 @@ def accion_hecha(
83
86
  evidencia: Annotated[str, typer.Option("--evidencia", help="Qué se hizo.")],
84
87
  empresa: EmpresaOption = None,
85
88
  si: YesOption = False,
89
+ as_json: JsonOption = False,
86
90
  ):
87
91
  """Marca hecha una acción, con lo que se hizo. Verificarla se hace en la app."""
88
- mark_done(ctx, ENVIRONMENT, accion, evidencia, empresa, si)
92
+ mark_done(ctx, ENVIRONMENT, accion, evidencia, empresa, si, as_json)
89
93
 
90
94
 
91
95
  @app.command()
@@ -126,6 +130,7 @@ def residuo(
126
130
  sede: Annotated[Optional[str], typer.Option("--sede", help="La sede, si dos tienen una corriente con ese nombre.")] = None,
127
131
  empresa: EmpresaOption = None,
128
132
  si: YesOption = False,
133
+ as_json: JsonOption = False,
129
134
  ):
130
135
  """Registra los kilos de residuos generados (pesados)."""
131
136
  client = client_of(ctx)
@@ -133,8 +138,8 @@ def residuo(
133
138
  stream = _stream(client, path, corriente, sede)
134
139
  entry = {"stream": stream["id"], "date": day(fecha) or date.today().isoformat(), "kg": number(kg)}
135
140
  confirm_write(f"Registrar {kg} kg de «{stream['name']}» el {shown_moment(entry['date'])}.", si)
136
- client.send("POST", f"{path}/waste/entries", json=entry)
137
- typer.echo(f"Registrados {kg} kg de {stream['name']}.")
141
+ recorded = answer_of(client.send("POST", f"{path}/waste/entries", json=entry))
142
+ print_result(recorded, as_json, f"Registrados {kg} kg de {stream['name']}.")
138
143
 
139
144
 
140
145
  def _stream(client, path, corriente, sede):
@@ -163,6 +168,7 @@ def lectura(
163
168
  fecha: FechaOption = None,
164
169
  empresa: EmpresaOption = None,
165
170
  si: YesOption = False,
171
+ as_json: JsonOption = False,
166
172
  ):
167
173
  """Registra la lectura de un medidor (agua, energía, gas)."""
168
174
  client = client_of(ctx)
@@ -170,5 +176,5 @@ def lectura(
170
176
  meter = find_in(client.get(f"{path}/consumption/meters"), medidor, ["name", "meter_name"], "medidor")
171
177
  reading = {"source": meter["id"], "read_on": day(fecha) or date.today().isoformat(), "value": number(valor)}
172
178
  confirm_write(f"Lectura {valor} en «{meter['name']}» el {shown_moment(reading['read_on'])}.", si)
173
- client.send("POST", f"{path}/consumption/readings", json=reading)
174
- typer.echo(f"Lectura registrada: {valor} en {meter['name']}.")
179
+ recorded = answer_of(client.send("POST", f"{path}/consumption/readings", json=reading))
180
+ print_result(recorded, as_json, f"Lectura registrada: {valor} en {meter['name']}.")
@@ -10,7 +10,7 @@ from ..lookup import NoSingleMatch, find_in, find_one
10
10
  from ..output import cell_text, print_card, print_record, print_rows
11
11
  from .common import (
12
12
  AllOption, CliError, CsvOption, JsonOption, SearchOption, YesOption, client_of, confirm_write, list_rows,
13
- output_format, without_empty,
13
+ output_format, print_result, without_empty,
14
14
  )
15
15
 
16
16
  app = typer.Typer(help="Artículos (Inventario): los repuestos, consumibles y herramientas que manejas.", no_args_is_help=True)
@@ -98,6 +98,7 @@ def crear(
98
98
  fabricante: Annotated[Optional[str], typer.Option("--fabricante")] = None,
99
99
  referencia: Annotated[Optional[str], typer.Option("--referencia", help="La referencia del fabricante.")] = None,
100
100
  si: YesOption = False,
101
+ as_json: JsonOption = False,
101
102
  ):
102
103
  """Crea un artículo en el catálogo. Lo que hay se carga después con `inventario entrada`."""
103
104
  client = client_of(ctx)
@@ -119,7 +120,9 @@ def crear(
119
120
  })
120
121
  confirm_write(f"Crear el artículo «{nombre}».", si)
121
122
  created = client.send("POST", "inventory/items", json=item).json()
122
- typer.echo(f"Artículo {created['code']} creado: {created['name']}.")
123
+ print_result(created, as_json, f"Artículo {created['code']} creado: {created['name']}.")
124
+ if as_json:
125
+ return
123
126
  typer.echo(
124
127
  f"Para cargar lo que hay: operx inventario entrada --articulo {created['code']} --lugar … --cantidad … --costo-unitario …"
125
128
  )
@@ -14,11 +14,13 @@ from .common import (
14
14
  CsvOption,
15
15
  JsonOption,
16
16
  YesOption,
17
+ answer_of,
17
18
  client_of,
18
19
  confirm_write,
19
20
  day,
20
21
  list_rows,
21
22
  output_format,
23
+ print_result,
22
24
  )
23
25
 
24
26
  NOTICE_COLUMNS = [("when", "Cuándo"), ("title", "Aviso"), ("body", "Detalle"), ("read", "Leído")]
@@ -40,8 +42,8 @@ def avisos(
40
42
  client = client_of(ctx)
41
43
  if marcar_leidos:
42
44
  confirm_write("Marcar todos tus avisos como leídos.", si)
43
- client.send("POST", "notifications/mark-read")
44
- typer.echo("Listo: no te quedan avisos sin leer.")
45
+ marked = answer_of(client.send("POST", "notifications/mark-read"))
46
+ print_result(marked, as_json, "Listo: no te quedan avisos sin leer.")
45
47
  return
46
48
  output = output_format(as_json, as_csv)
47
49
  rows = list_rows(client, "notifications", {"is_read": None if todos else "false"}, todas)