operx-cli 0.6.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 (75) hide show
  1. {operx_cli-0.6.0 → operx_cli-0.7.0}/PKG-INFO +10 -4
  2. {operx_cli-0.6.0 → operx_cli-0.7.0}/README.md +9 -3
  3. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/AGENTES.md +20 -12
  4. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/__init__.py +1 -1
  5. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/app.py +1 -0
  6. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/inventario.py +28 -0
  7. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/ordenes.py +57 -4
  8. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/personas.py +56 -1
  9. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/planta.py +33 -1
  10. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/record_completion.py +1 -0
  11. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/shell.py +5 -0
  12. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli.egg-info/PKG-INFO +10 -4
  13. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli.egg-info/SOURCES.txt +1 -0
  14. {operx_cli-0.6.0 → operx_cli-0.7.0}/pyproject.toml +1 -1
  15. operx_cli-0.7.0/tests/test_daily_flows.py +186 -0
  16. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_ordenes.py +2 -0
  17. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_shell.py +1 -1
  18. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/agent_guide.py +0 -0
  19. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/api_fields.py +0 -0
  20. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/browser_login.py +0 -0
  21. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/client.py +0 -0
  22. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/__init__.py +0 -0
  23. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/account.py +0 -0
  24. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/ambiental.py +0 -0
  25. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/articulos.py +0 -0
  26. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/avisos.py +0 -0
  27. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/common.py +0 -0
  28. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/completar.py +0 -0
  29. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/empresas.py +0 -0
  30. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/lecturas.py +0 -0
  31. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/preventivos.py +0 -0
  32. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/raw.py +0 -0
  33. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/registros.py +0 -0
  34. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/reportes.py +0 -0
  35. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/search.py +0 -0
  36. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/sst.py +0 -0
  37. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/commands/update.py +0 -0
  38. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/lookup.py +0 -0
  39. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/output.py +0 -0
  40. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/self_update.py +0 -0
  41. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/settings.py +0 -0
  42. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/spreadsheet.py +0 -0
  43. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/updates.py +0 -0
  44. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/values.py +0 -0
  45. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli/welcome.py +0 -0
  46. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli.egg-info/dependency_links.txt +0 -0
  47. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli.egg-info/entry_points.txt +0 -0
  48. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli.egg-info/requires.txt +0 -0
  49. {operx_cli-0.6.0 → operx_cli-0.7.0}/operx_cli.egg-info/top_level.txt +0 -0
  50. {operx_cli-0.6.0 → operx_cli-0.7.0}/setup.cfg +0 -0
  51. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_agentes.py +0 -0
  52. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_api_campos.py +0 -0
  53. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_app.py +0 -0
  54. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_articulos.py +0 -0
  55. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_cards.py +0 -0
  56. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_client.py +0 -0
  57. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_completar.py +0 -0
  58. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_inventario_lecturas.py +0 -0
  59. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_json_writes.py +0 -0
  60. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_login.py +0 -0
  61. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_logout_and_routes.py +0 -0
  62. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_lookup.py +0 -0
  63. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_more_of_the_api.py +0 -0
  64. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_output.py +0 -0
  65. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_personas.py +0 -0
  66. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_preventivos.py +0 -0
  67. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_record_completion.py +0 -0
  68. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_registros.py +0 -0
  69. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_self_update.py +0 -0
  70. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_settings.py +0 -0
  71. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_spreadsheet.py +0 -0
  72. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_sst_ambiental.py +0 -0
  73. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_updates.py +0 -0
  74. {operx_cli-0.6.0 → operx_cli-0.7.0}/tests/test_values.py +0 -0
  75. {operx_cli-0.6.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.6.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"
@@ -221,9 +227,9 @@ tipo, si es obligatorio y los valores permitidos. También están en Acceso
221
227
  por API › Documentación:
222
228
 
223
229
  ```sh
224
- operx api rutas repuestos
225
- operx api campos inventory/work-orders/1234/parts/ --metodo POST
226
- operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}'
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"}'
227
233
  operx api GET pm/schedules/ -q search=compresor
228
234
  operx api POST contacts/ -d @contacto.json
229
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"
@@ -207,9 +213,9 @@ tipo, si es obligatorio y los valores permitidos. También están en Acceso
207
213
  por API › Documentación:
208
214
 
209
215
  ```sh
210
- operx api rutas repuestos
211
- operx api campos inventory/work-orders/1234/parts/ --metodo POST
212
- operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}'
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"}'
213
219
  operx api GET pm/schedules/ -q search=compresor
214
220
  operx api POST contacts/ -d @contacto.json
215
221
  operx api GET work-orders/export/ -o ordenes.csv
@@ -60,12 +60,13 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
60
60
  - Todos los comandos que escriben aceptan `--json`: la salida es solo la
61
61
  respuesta de la API (el registro, con su `id`). Úsalo para encadenar pasos.
62
62
  - Escriben: `ordenes crear`, `ordenes editar`, `ordenes estado`,
63
- `ordenes asignar`, `ordenes comentar`, `ordenes adjuntar`,
63
+ `ordenes asignar`, `ordenes comentar`, `ordenes adjuntar`, `ordenes repuesto`,
64
64
  `preventivos crear`, `preventivos nota`, `equipos crear`, `equipos editar`,
65
- `equipos parar`, `equipos reanudar`, `equipos hallazgo`, `clientes crear`,
65
+ `equipos parar`, `equipos reanudar`, `equipos hallazgo`,
66
+ `equipos hallazgo-a-orden`, `clientes crear`,
66
67
  `clientes editar`, `lugares crear`, `articulos crear`, `inventario entrada`, `inventario salida`, `inventario ajuste`,
67
68
  `inventario traslado`, `inventario revertir`, `lecturas registrar`,
68
- `lecturas anular`, `personas importar --confirmar`, `sst nueva-accion`,
69
+ `lecturas anular`, `personas importar --confirmar`, `personas ausencia`, `sst nueva-accion`,
69
70
  `sst accion-hecha`, `ambiental nueva-accion`, `ambiental accion-hecha`,
70
71
  `ambiental residuo`, `ambiental lectura`, `avisos --marcar-leidos`, y
71
72
  `operx api` con POST, PUT, PATCH o DELETE. Todos llevan `--si`.
@@ -124,7 +125,7 @@ cinco pasos:
124
125
  1. **Busca la ruta.** `operx api rutas TEXTO --json` deja las que tienen
125
126
  cada palabra del texto en la ruta, en su `producto` (SG-SST, Inventario…)
126
127
  o en lo que hace. Las rutas están en inglés: busca también así
127
- (`operx api rutas sst inspections`, `operx api rutas repuestos`).
128
+ (`operx api rutas sst inspections`, `operx api rutas purchase requests`).
128
129
  2. **Lee lo que lleva.** `operx api campos RUTA --json` (con `--metodo POST`
129
130
  para uno solo) da, por método:
130
131
  - `en_la_ruta`: los valores de la dirección, como `{pk}`.
@@ -163,9 +164,9 @@ Más opciones de `operx api`:
163
164
  - `-o archivo`: guarda una respuesta que es un archivo (CSV, PDF).
164
165
 
165
166
  ```sh
166
- operx api rutas repuestos --json
167
- operx api campos inventory/work-orders/1234/parts/ --metodo POST --json
168
- operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}' --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
169
170
  operx api GET work-orders/export/ -o ordenes.csv
170
171
  ```
171
172
 
@@ -193,11 +194,18 @@ operx inventario entrada --articulo CON-0001 --lugar "Bodega central" --cantidad
193
194
  # Un equipo que entró a mantenimiento
194
195
  operx equipos editar BOM-04 --estado en-mantenimiento --si
195
196
 
196
- # Un repuesto usado en una orden (no tiene comando propio): ruta, campos, ids, escribir
197
- operx api rutas repuestos --json
198
- operx api campos inventory/work-orders/1234/parts/ --metodo POST --json
199
- operx inventario existencias --buscar filtro --json
200
- operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}' --si
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
201
209
 
202
210
  # Crear y seguir con el id que responde
203
211
  operx ordenes crear --titulo "Cambio de rodamiento" --equipo BOM-04 --si --json
@@ -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.6.0"
5
+ __version__ = "0.7.0"
@@ -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")
@@ -214,3 +214,31 @@ def salida(
214
214
  confirm_write(f"Salida de {cantidad} de «{item['name']}» de {place['name']} ({reason['name']}).", si)
215
215
  moved = answer_of(client.send("POST", "inventory/actions/salida", json=move))
216
216
  print_result(moved, as_json, f"Salida registrada: {cantidad} de {item['name']} de {place['name']}.")
217
+
218
+
219
+ TO_BUY_COLUMNS = [
220
+ ("code", "Código"), ("name", "Artículo"), ("place_name", "Lugar"), ("on_hand", "Hay"), ("min_qty", "Mínimo"),
221
+ ("quantity", "Pedir"), ("unit", "Unidad"), ("supplier_name", "Proveedor"), ("unit_price", "Último precio"),
222
+ ("request_number", "Solicitud"),
223
+ ]
224
+
225
+
226
+ @app.command("por-comprar")
227
+ def por_comprar(
228
+ ctx: typer.Context,
229
+ as_json: JsonOption = False,
230
+ as_csv: CsvOption = False,
231
+ ):
232
+ """Lo que hay que comprar: lo que está bajo el mínimo (cuánto pedir para llegar al máximo) y las solicitudes aprobadas."""
233
+ output = output_format(as_json, as_csv)
234
+ lines = client_of(ctx).get("inventory/por-comprar")
235
+ if output != "json":
236
+ lines = [
237
+ {**line, "code": (line.get("item") or {}).get("code"),
238
+ # A request may name something not in the catalog: its description.
239
+ "name": (line.get("item") or {}).get("name") or line.get("description"),
240
+ "unit": (line.get("item") or {}).get("unit"), "supplier_name": (line.get("supplier") or {}).get("name"),
241
+ "request_number": (line.get("request") or {}).get("number")}
242
+ for line in lines
243
+ ]
244
+ print_rows(TO_BUY_COLUMNS, lines, output)
@@ -7,14 +7,16 @@ from typing import Annotated, Optional
7
7
  import typer
8
8
 
9
9
  from ..lookup import find_one
10
- from ..output import print_card, print_notes, print_record, print_rows
10
+ from ..output import cell_text, json_text, print_card, print_notes, print_record, print_rows
11
11
  from ..values import shown_moment
12
12
  from .common import (
13
13
  AllOption, CliError, CsvOption, JsonOption, SearchOption, YesOption, answer_of, client_of, confirm_write, day,
14
- list_rows, number, output_format, print_result, without_empty,
14
+ list_rows, moment, number, output_format, print_result, without_empty,
15
15
  )
16
16
 
17
- app = typer.Typer(help="Órdenes de trabajo: verlas, crearlas, moverlas de estado, asignarlas y comentarlas.", no_args_is_help=True)
17
+ app = typer.Typer(
18
+ help="Órdenes de trabajo: verlas, crearlas, moverlas de estado, asignarlas, comentarlas y sus repuestos.", no_args_is_help=True,
19
+ )
18
20
 
19
21
 
20
22
  class Estado(str, Enum):
@@ -75,7 +77,7 @@ def _in_spanish(order):
75
77
  **order,
76
78
  "status": STATUS_LABELS.get(order.get("status"), order.get("status")),
77
79
  "priority": PRIORITY_LABELS.get(order.get("priority"), order.get("priority")),
78
- "due_date": (order.get("due_date") or "")[:10],
80
+ "due_date": shown_moment(order.get("due_date")),
79
81
  }
80
82
 
81
83
 
@@ -289,3 +291,54 @@ def comentar(
289
291
  confirm_write(f"Comentar en la orden {orden}.", si)
290
292
  comment = answer_of(client_of(ctx).send("POST", f"work-orders/{orden}/comments", json={"body": texto}))
291
293
  print_result(comment, as_json, f"Comentario agregado a la orden {orden}.")
294
+
295
+
296
+ # ── Parts (Inventario: inventory/stage_views.py WorkOrderPartsView) ──────────
297
+
298
+ PART_COLUMNS = [
299
+ ("item_code", "Código"), ("item_name", "Repuesto"), ("location_name", "De"), ("planned", "Planeado"),
300
+ ("used", "Usado"), ("unit", "Unidad"), ("cost", "Costo"),
301
+ ]
302
+
303
+
304
+ @app.command()
305
+ def repuestos(
306
+ ctx: typer.Context,
307
+ orden: Annotated[int, typer.Argument(help="El número de la orden.")],
308
+ as_json: JsonOption = False,
309
+ as_csv: CsvOption = False,
310
+ ):
311
+ """Los repuestos de una orden: lo planeado, lo usado y su costo."""
312
+ output = output_format(as_json, as_csv)
313
+ answer = client_of(ctx).get(f"inventory/work-orders/{orden}/parts")
314
+ if output == "json":
315
+ typer.echo(json_text(answer))
316
+ return
317
+ print_rows(PART_COLUMNS, answer.get("parts", []), output)
318
+ if output == "tabla":
319
+ typer.echo(f"Costo de repuestos: {cell_text(answer.get('cost'))}")
320
+
321
+
322
+ @app.command()
323
+ def repuesto(
324
+ ctx: typer.Context,
325
+ orden: Annotated[int, typer.Argument(help="El número de la orden.")],
326
+ articulo: Annotated[str, typer.Option("--articulo", help="El código o nombre del repuesto.")],
327
+ lugar: Annotated[str, typer.Option("--lugar", help="De qué bodega o vehículo sale.")],
328
+ cantidad: Annotated[str, typer.Option("--cantidad", help="Cuánto: 2 o 2,5. Negativa devuelve al stock lo que no se usó.")],
329
+ planeado: Annotated[bool, typer.Option("--planeado", help="Solo lo reserva para la orden; no sale del stock.")] = False,
330
+ fecha: Annotated[Optional[str], typer.Option("--fecha", help="Cuándo se usó, si no fue ahora: 22/10/2026 08:00.")] = None,
331
+ si: YesOption = False,
332
+ as_json: JsonOption = False,
333
+ ):
334
+ """Registra un repuesto usado en una orden: sale del stock y suma a su costo. Con --planeado solo lo reserva."""
335
+ client = client_of(ctx)
336
+ item = find_one(client, "inventory/items", articulo, ["code", "name"], "artículo")
337
+ place = find_one(client, "locations", lugar, ["name"], "lugar")
338
+ part = {"action": "planear" if planeado else "usar", "item": item["id"], "quantity": number(cantidad), "location": place["id"]}
339
+ if fecha and not planeado:
340
+ part["occurred_at"] = moment(fecha)
341
+ what = "Reservar" if planeado else "Usar"
342
+ confirm_write(f"{what} {cantidad} de «{item['name']}» de {place['name']} en la orden {orden}.", si)
343
+ answer = client.send("POST", f"inventory/work-orders/{orden}/parts", json=part).json()
344
+ print_result(answer, as_json, f"Listo. Costo de repuestos de la orden {orden}: {cell_text(answer.get('cost'))}.")
@@ -6,6 +6,8 @@ Every run checks the whole file first and shows each row's errors; with
6
6
  gets access to the app here: invitations are sent from Usuarios y acceso.
7
7
  """
8
8
 
9
+ import re
10
+ from enum import Enum
9
11
  from pathlib import Path
10
12
  from typing import Annotated, Optional
11
13
 
@@ -16,7 +18,7 @@ from ..output import json_text, note, print_rows
16
18
  from ..spreadsheet import DATE_FIELDS, people_rows
17
19
  from ..values import shown_moment
18
20
  from .common import (
19
- AllOption, CliError, CsvOption, JsonOption, SearchOption, YesOption, client_of, confirm_write, list_rows,
21
+ AllOption, CliError, CsvOption, JsonOption, SearchOption, YesOption, client_of, confirm_write, day, list_rows,
20
22
  output_format, print_result,
21
23
  )
22
24
 
@@ -137,3 +139,56 @@ def importar(
137
139
  print_result(
138
140
  saved, as_json, f"Se guardaron {saved['saved']} personas. Nadie recibió acceso a la app: invítalos en Usuarios y acceso.",
139
141
  )
142
+
143
+
144
+ class TipoAusencia(str, Enum):
145
+ vacaciones = "vacaciones"
146
+ permiso = "permiso"
147
+ licencia = "licencia"
148
+
149
+
150
+ # people/models.py Absence.KIND_CHOICES. An incapacidad goes with its
151
+ # certificate, in the app.
152
+ ABSENCE_KEYS = {"vacaciones": "vacation", "permiso": "permit", "licencia": "leave"}
153
+ HOURS = re.compile(r"^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$")
154
+
155
+
156
+ def _hours(text):
157
+ """'8:00-12:00' → ('08:00', '12:00')."""
158
+ found = HOURS.match(text.strip())
159
+ if not found:
160
+ raise CliError(f"«{text}» no son horas: escríbelas como 08:00-12:00.")
161
+ start_hour, start_minute, end_hour, end_minute = found.groups()
162
+ return f"{int(start_hour):02d}:{start_minute}", f"{int(end_hour):02d}:{end_minute}"
163
+
164
+
165
+ @app.command()
166
+ def ausencia(
167
+ ctx: typer.Context,
168
+ persona: Annotated[str, typer.Argument(help="El nombre o código de la persona.")],
169
+ tipo: Annotated[TipoAusencia, typer.Option("--tipo")],
170
+ desde: Annotated[str, typer.Option("--desde", help="El primer día: 02/11/2026.")],
171
+ hasta: Annotated[Optional[str], typer.Option("--hasta", help="El último día; sin él, el mismo.")] = None,
172
+ horas: Annotated[Optional[str], typer.Option("--horas", help="Un permiso de unas horas: 08:00-12:00.")] = None,
173
+ motivo: Annotated[Optional[str], typer.Option("--motivo")] = None,
174
+ si: YesOption = False,
175
+ as_json: JsonOption = False,
176
+ ):
177
+ """Registra una ausencia: vacaciones, un permiso o una licencia. Queda por aprobar. Una incapacidad, en la app."""
178
+ client = client_of(ctx)
179
+ person = find_one(client, "employees", persona, ["user_name", "employee_code"], "persona")
180
+ start = day(desde)
181
+ absence = {"employee_id": person["id"], "kind": ABSENCE_KEYS[tipo.value], "start_date": start, "end_date": day(hasta) or start}
182
+ if horas:
183
+ absence["hours_from"], absence["hours_to"] = _hours(horas)
184
+ if motivo:
185
+ absence["reason"] = motivo
186
+ when = shown_moment(start) if absence["end_date"] == start else f"del {shown_moment(start)} al {shown_moment(absence['end_date'])}"
187
+ if horas:
188
+ when += f", de {absence['hours_from']} a {absence['hours_to']}"
189
+ confirm_write(f"{tipo.value.capitalize()} de {person['user_name']}: {when}.", si)
190
+ created = client.send("POST", "people/absences", json=absence).json()
191
+ print_result(created, as_json, (
192
+ f"Ausencia registrada: {created.get('kind_label', tipo.value.capitalize())} de {person['user_name']}, {when}. "
193
+ f"{created.get('status_label', 'Por aprobar')}."
194
+ ))
@@ -1,6 +1,7 @@
1
1
  """Equipos' day at the plant, under `operx equipos` (app.py): a machine's
2
2
  stops (equipment/stops.py) and the findings someone marked on it
3
- (equipment/findings.py). Deciding a finding stays in the app."""
3
+ (equipment/findings.py), and the work order for one. Watching or closing a
4
+ finding stays in the app."""
4
5
 
5
6
  from datetime import date, timedelta
6
7
  from enum import Enum
@@ -12,6 +13,7 @@ from ..lookup import find_in, find_one
12
13
  from ..output import print_rows
13
14
  from ..values import now_iso, shown_moment
14
15
  from .common import (
16
+ CliError,
15
17
  CsvOption,
16
18
  JsonOption,
17
19
  YesOption,
@@ -24,6 +26,7 @@ from .common import (
24
26
  print_result,
25
27
  without_empty,
26
28
  )
29
+ from .ordenes import PRIORITY_KEYS, Prioridad
27
30
 
28
31
  MachineArgument = Annotated[str, typer.Argument(help="El código, nombre o id del equipo.")]
29
32
  STOPS_DAYS = 7
@@ -170,3 +173,32 @@ def hallazgo(
170
173
  confirm_write(f"Marcar «{titulo}» en «{machine['name']}».", si)
171
174
  created = client.send("POST", "equipment/findings", json=finding).json()
172
175
  print_result(created, as_json, f"Hallazgo {created['id']} marcado en {machine['name']}.")
176
+
177
+
178
+ def hallazgo_a_orden(
179
+ ctx: typer.Context,
180
+ hallazgo: Annotated[int, typer.Argument(help="El número del hallazgo (operx equipos hallazgos).")],
181
+ titulo: Annotated[Optional[str], typer.Option("--titulo", help="El título de la orden; sin él, el del hallazgo.")] = None,
182
+ descripcion: Annotated[Optional[str], typer.Option("--descripcion")] = None,
183
+ prioridad: Annotated[Optional[Prioridad], typer.Option("--prioridad", help="Sin ella, media.")] = None,
184
+ si: YesOption = False,
185
+ as_json: JsonOption = False,
186
+ ):
187
+ """Crea la orden de trabajo para un hallazgo: queda ligada a él y a su equipo."""
188
+ client = client_of(ctx)
189
+ if not titulo:
190
+ # The findings list has it unless it's closed. One that already has its
191
+ # order is refused by the API (equipment/findings.py), never doubled.
192
+ found = [finding for finding in client.get("equipment/findings") if finding.get("id") == hallazgo]
193
+ if not found:
194
+ raise CliError(f"No encontré el hallazgo {hallazgo} entre los abiertos. Si es otro, dale un título: --titulo.")
195
+ titulo = found[0]["title"]
196
+ order = without_empty({
197
+ "title": titulo,
198
+ "description": descripcion,
199
+ "priority": PRIORITY_KEYS[prioridad.value] if prioridad else None,
200
+ })
201
+ confirm_write(f"Crear la orden «{titulo}» para el hallazgo {hallazgo}.", si)
202
+ finding = client.send("POST", f"equipment/findings/{hallazgo}/work-order", json=order).json()
203
+ created = finding.get("work_order") or {}
204
+ print_result(finding, as_json, f"Orden {created.get('id')} creada desde el hallazgo {hallazgo}: {created.get('title', titulo)}.")
@@ -36,6 +36,7 @@ ARGUMENT_RECORDS = {
36
36
  ("clientes", "ver"): CUSTOMERS,
37
37
  ("clientes", "editar"): CUSTOMERS,
38
38
  ("articulos", "ver"): ITEMS,
39
+ ("personas", "ausencia"): PEOPLE,
39
40
  ("lecturas", "puntos"): MACHINES,
40
41
  }
41
42
 
@@ -44,11 +44,16 @@ COMMAND_PRODUCTS = {
44
44
  "equipos reanudar": ("equipment",),
45
45
  "equipos hallazgos": ("equipment",),
46
46
  "equipos hallazgo": ("equipment",),
47
+ "equipos hallazgo-a-orden": ("equipment",),
48
+ # Parts on an order are Inventario's.
49
+ "ordenes repuestos": ("inventory",),
50
+ "ordenes repuesto": ("inventory",),
47
51
  "clientes": ("maintenance", "inventory", "sst", "environment"),
48
52
  "inventario": ("inventory",),
49
53
  "articulos": ("inventory",),
50
54
  "lecturas": ("equipment",),
51
55
  "personas importar": ("people",),
56
+ "personas ausencia": ("people",),
52
57
  "sst": ("sst",),
53
58
  "ambiental": ("environment",),
54
59
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.6.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"
@@ -221,9 +227,9 @@ tipo, si es obligatorio y los valores permitidos. También están en Acceso
221
227
  por API › Documentación:
222
228
 
223
229
  ```sh
224
- operx api rutas repuestos
225
- operx api campos inventory/work-orders/1234/parts/ --metodo POST
226
- operx api POST inventory/work-orders/1234/parts/ -d '{"action": "usar", "item": 3, "quantity": "2", "location": 2}'
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"}'
227
233
  operx api GET pm/schedules/ -q search=compresor
228
234
  operx api POST contacts/ -d @contacto.json
229
235
  operx api GET work-orders/export/ -o ordenes.csv
@@ -50,6 +50,7 @@ tests/test_articulos.py
50
50
  tests/test_cards.py
51
51
  tests/test_client.py
52
52
  tests/test_completar.py
53
+ tests/test_daily_flows.py
53
54
  tests/test_inventario_lecturas.py
54
55
  tests/test_json_writes.py
55
56
  tests/test_login.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "operx-cli"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,186 @@
1
+ """The daily flows that had only `operx api`: parts used on an order, what to
2
+ buy, an order from a finding, an absence. Fixtures: real answers from a
3
+ local backend with a personal token."""
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from operx_cli.shell import offered_to
10
+
11
+ from .fake_api import FakeApi, page, run
12
+
13
+ # GET /v1/inventory/work-orders/6/parts/ (inventory/stage_views.py WorkOrderPartsView)
14
+ PARTS = {
15
+ "work_order_id": 6,
16
+ "parts": [{
17
+ "id": 1, "item_id": 2, "item_code": "CON-0001", "item_name": "Filtro e2e", "unit": "und", "item_photo": None,
18
+ "location_id": 5, "location_name": "Bodega e2e", "planned": "1.000", "used": "2.500", "cost": "62.50",
19
+ "billable": True, "billed_invoice_id": None, "note": "", "sale_price": None, "average_cost": "25.0000",
20
+ }],
21
+ "cost": "62.50",
22
+ }
23
+ FILTER = {"id": 2, "code": "CON-0001", "name": "Filtro e2e"}
24
+ STORE = {"id": 5, "name": "Bodega e2e"}
25
+ # GET /v1/inventory/por-comprar/ (inventory/purchasing_views.py ToBuyView)
26
+ TO_BUY = [{
27
+ "key": "low-3", "request": None, "item": {"id": 2, "code": "CON-0001", "name": "Filtro e2e", "unit": "und", "photo": None},
28
+ "description": "", "quantity": "12.500", "unit_price": "25.00", "supplier": None, "place_id": 5,
29
+ "place_name": "Bodega e2e", "on_hand": "7.500", "min_qty": "10.000", "max_qty": "20.000", "target": "20.000",
30
+ "asked": "0", "coming": "0", "orders": [], "is_critical": False,
31
+ }]
32
+ REQUESTED = {
33
+ "key": "request-4", "request": {"id": 4, "number": "SC-0004", "requested_by": "Ana Ruiz", "reason": "", "work_order_id": None},
34
+ "item": None, "description": "Guantes de nitrilo talla M", "quantity": "20.000", "unit_price": None,
35
+ "supplier": {"id": 9, "name": "Filtros Ltda", "email": ""}, "place_id": None, "place_name": "", "on_hand": None,
36
+ "min_qty": None, "max_qty": None, "target": None, "asked": None, "coming": None, "orders": [], "is_critical": False,
37
+ }
38
+ # GET /v1/equipment/findings/ and POST …/findings/1/work-order/ (equipment/findings.py)
39
+ MACHINE = {"id": 1, "code": "BOM-04", "name": "Bomba 4", "place_id": 2, "place_path": "Planta Norte", "parent": None}
40
+ FINDING = {
41
+ "id": 1, "title": "Fuga en el sello", "note": "", "machine": MACHINE, "status": "new", "severity": "high",
42
+ "photos": [], "found_at": "2026-10-23T13:39:04.100703-05:00", "found_by_name": "Admin Smoke",
43
+ }
44
+ WITH_ORDER = {**FINDING, "status": "work_order", "work_order": {"id": 7, "number": "OT #7", "title": "Cambiar el sello"}}
45
+ # GET /v1/employees/ and POST /v1/people/absences/ (people/absence_views.py)
46
+ PEOPLE = page({"id": 1, "user_id": 1, "user_name": "Admin Smoke", "avatar": None, "employee_code": "", "is_active": True})
47
+ ABSENCE = {
48
+ "id": 1, "employee": {"id": 1, "name": "Admin Smoke", "avatar": None}, "kind": "permit", "kind_label": "Permiso",
49
+ "status": "pending", "status_label": "Por aprobar", "start_date": "2026-11-03", "end_date": "2026-11-03",
50
+ "hours_from": "08:00", "hours_to": "12:00", "reason": "Cita médica", "business_days": 0, "has_certificate": False,
51
+ "requested_by": "Admin Smoke", "approver": "Un administrador",
52
+ }
53
+
54
+
55
+ @pytest.fixture(autouse=True)
56
+ def signed_in(monkeypatch):
57
+ monkeypatch.setenv("OPERX_TOKEN", "opx_test")
58
+
59
+
60
+ # ── Parts on an order ──────────────────────────────────────────────────────
61
+
62
+
63
+ def test_repuestos_lists_what_an_order_planned_and_used_and_its_cost():
64
+ result = run(FakeApi({("GET", "inventory/work-orders/6/parts/"): PARTS}), "ordenes", "repuestos", "6")
65
+ assert result.exit_code == 0, result.output
66
+ for text in ("CON-0001", "Filtro e2e", "Bodega e2e", "2,5", "Costo de repuestos: 62,5"):
67
+ assert text in result.output
68
+
69
+
70
+ def test_repuestos_json_is_the_apis_answer():
71
+ result = run(FakeApi({("GET", "inventory/work-orders/6/parts/"): PARTS}), "ordenes", "repuestos", "6", "--json")
72
+ assert json.loads(result.stdout) == PARTS
73
+
74
+
75
+ def parts_api():
76
+ return FakeApi({
77
+ ("GET", "inventory/items/"): page(FILTER),
78
+ ("GET", "locations/"): page(STORE),
79
+ ("POST", "inventory/work-orders/6/parts/"): PARTS,
80
+ })
81
+
82
+
83
+ def test_repuesto_records_a_part_used_from_a_place():
84
+ fake = parts_api()
85
+ result = run(fake, "ordenes", "repuesto", "6", "--articulo", "CON-0001", "--lugar", "Bodega e2e", "--cantidad", "2,5", "--si")
86
+ assert result.exit_code == 0, result.output
87
+ assert fake.sent("POST", "inventory/work-orders/6/parts/") == {"action": "usar", "item": 2, "quantity": "2.5", "location": 5}
88
+ assert "Costo de repuestos de la orden 6: 62,5" in result.output
89
+
90
+
91
+ def test_repuesto_planeado_only_reserves_it():
92
+ fake = parts_api()
93
+ run(fake, "ordenes", "repuesto", "6", "--articulo", "CON-0001", "--lugar", "Bodega e2e", "--cantidad", "1", "--planeado", "--si")
94
+ assert fake.sent("POST", "inventory/work-orders/6/parts/")["action"] == "planear"
95
+
96
+
97
+ def test_repuesto_used_earlier_keeps_its_moment():
98
+ fake = parts_api()
99
+ run(fake, "ordenes", "repuesto", "6", "--articulo", "CON-0001", "--lugar", "Bodega e2e", "--cantidad", "1",
100
+ "--fecha", "22/10/2026 08:00", "--si")
101
+ assert fake.sent("POST", "inventory/work-orders/6/parts/")["occurred_at"].startswith("2026-10-22T08:00")
102
+
103
+
104
+ def test_parts_need_inventario_in_the_prompt():
105
+ assert not offered_to("ordenes repuestos", ["maintenance"])
106
+ assert offered_to("ordenes repuesto", ["maintenance", "inventory"])
107
+
108
+
109
+ # ── What to buy ────────────────────────────────────────────────────────────
110
+
111
+
112
+ def test_por_comprar_lists_what_is_short_and_what_was_asked_for():
113
+ fake = FakeApi({("GET", "inventory/por-comprar/"): TO_BUY + [REQUESTED]})
114
+ result = run(fake, "inventario", "por-comprar")
115
+ assert result.exit_code == 0, result.output
116
+ for text in ("CON-0001", "Bodega e2e", "12,5", "7,5", "Guantes de nitrilo talla M", "Filtros Ltda", "SC-0004"):
117
+ assert text in result.output
118
+
119
+
120
+ def test_por_comprar_json_is_the_apis_answer():
121
+ result = run(FakeApi({("GET", "inventory/por-comprar/"): TO_BUY}), "inventario", "por-comprar", "--json")
122
+ assert json.loads(result.stdout) == TO_BUY
123
+
124
+
125
+ # ── An order from a finding ────────────────────────────────────────────────
126
+
127
+
128
+ def test_hallazgo_a_orden_titles_it_like_the_finding_unless_told():
129
+ fake = FakeApi({("GET", "equipment/findings/"): [FINDING], ("POST", "equipment/findings/1/work-order/"): (201, WITH_ORDER)})
130
+ result = run(fake, "equipos", "hallazgo-a-orden", "1", "--si")
131
+ assert result.exit_code == 0, result.output
132
+ assert fake.sent("POST", "equipment/findings/1/work-order/") == {"title": "Fuga en el sello"}
133
+ assert "Orden 7 creada desde el hallazgo 1: Cambiar el sello." in result.output
134
+
135
+
136
+ def test_hallazgo_a_orden_with_its_own_title_and_priority():
137
+ fake = FakeApi({("POST", "equipment/findings/1/work-order/"): (201, WITH_ORDER)})
138
+ run(fake, "equipos", "hallazgo-a-orden", "1", "--titulo", "Cambiar el sello", "--prioridad", "alta", "--si")
139
+ assert fake.sent("POST", "equipment/findings/1/work-order/") == {"title": "Cambiar el sello", "priority": "high"}
140
+ assert not fake.called("GET", "equipment/findings/")
141
+
142
+
143
+ def test_hallazgo_a_orden_of_a_finding_not_open_needs_a_title():
144
+ result = run(FakeApi({("GET", "equipment/findings/"): []}), "equipos", "hallazgo-a-orden", "1", "--si")
145
+ assert result.exit_code == 1
146
+ assert "--titulo" in result.output
147
+
148
+
149
+ # ── An absence ─────────────────────────────────────────────────────────────
150
+
151
+
152
+ def absence_api():
153
+ return FakeApi({("GET", "employees/"): PEOPLE, ("POST", "people/absences/"): (201, ABSENCE)})
154
+
155
+
156
+ def test_ausencia_by_hours_is_a_permit_of_one_day():
157
+ fake = absence_api()
158
+ result = run(
159
+ fake, "personas", "ausencia", "Admin Smoke", "--tipo", "permiso", "--desde", "03/11/2026", "--horas", "8:00-12:00",
160
+ "--motivo", "Cita médica", "--si",
161
+ )
162
+ assert result.exit_code == 0, result.output
163
+ assert fake.sent("POST", "people/absences/") == {
164
+ "employee_id": 1, "kind": "permit", "start_date": "2026-11-03", "end_date": "2026-11-03",
165
+ "hours_from": "08:00", "hours_to": "12:00", "reason": "Cita médica",
166
+ }
167
+ assert "Ausencia registrada: Permiso de Admin Smoke, 03/11/2026, de 08:00 a 12:00. Por aprobar." in result.output
168
+
169
+
170
+ def test_ausencia_of_several_days():
171
+ fake = absence_api()
172
+ run(fake, "personas", "ausencia", "Admin Smoke", "--tipo", "vacaciones", "--desde", "02/11/2026", "--hasta", "06/11/2026", "--si")
173
+ sent = fake.sent("POST", "people/absences/")
174
+ assert sent["kind"] == "vacation" and sent["start_date"] == "2026-11-02" and sent["end_date"] == "2026-11-06"
175
+
176
+
177
+ def test_ausencia_hours_must_read_as_a_range():
178
+ result = run(absence_api(), "personas", "ausencia", "Admin Smoke", "--tipo", "permiso", "--desde", "03/11/2026",
179
+ "--horas", "por la mañana", "--si")
180
+ assert result.exit_code == 1
181
+ assert "08:00-12:00" in result.output
182
+
183
+
184
+ def test_ausencia_json_is_the_apis_answer():
185
+ result = run(absence_api(), "personas", "ausencia", "Admin Smoke", "--tipo", "permiso", "--desde", "03/11/2026", "--si", "--json")
186
+ assert json.loads(result.stdout) == ABSENCE
@@ -20,6 +20,8 @@ def test_listar_shows_status_and_priority_in_spanish():
20
20
  result = run(FakeApi({("GET", "work-orders/"): page(ORDER)}), "ordenes", "listar")
21
21
  assert result.exit_code == 0, result.output
22
22
  assert "En progreso" in result.output and "Alta" in result.output and "Cambio de rodamiento" in result.output
23
+ # The due date the way people read it, like every other list.
24
+ assert "30/10/2026" in result.output and "2026-10-30" not in result.output
23
25
 
24
26
 
25
27
  def test_listar_json_keeps_the_apis_values():
@@ -272,7 +272,7 @@ def test_equipos_shows_its_stops_and_findings_only_with_equipos():
272
272
 
273
273
  def test_tab_hides_a_subcommand_of_a_product_the_company_lacks():
274
274
  assert names(suggestions(ROOT, "personas ", products=["maintenance"])) == ["listar"]
275
- assert set(names(suggestions(ROOT, "personas ", products=["people"]))) == {"listar", "importar"}
275
+ assert set(names(suggestions(ROOT, "personas ", products=["people"]))) == {"listar", "importar", "ausencia"}
276
276
 
277
277
 
278
278
  def test_ayuda_lists_every_word_the_prompt_understands(capsys):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes