operx-cli 0.7.6__tar.gz → 0.7.7__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 (81) hide show
  1. {operx_cli-0.7.6 → operx_cli-0.7.7}/PKG-INFO +1 -1
  2. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/AGENTES.md +15 -0
  3. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/__init__.py +1 -1
  4. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/app.py +2 -1
  5. operx_cli-0.7.7/operx_cli/commands/muestreo.py +197 -0
  6. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/registros.py +1 -1
  7. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/shell.py +1 -0
  8. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli.egg-info/PKG-INFO +1 -1
  9. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli.egg-info/SOURCES.txt +2 -0
  10. {operx_cli-0.7.6 → operx_cli-0.7.7}/pyproject.toml +1 -1
  11. operx_cli-0.7.7/tests/test_muestreo.py +119 -0
  12. {operx_cli-0.7.6 → operx_cli-0.7.7}/README.md +0 -0
  13. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/agent_guide.py +0 -0
  14. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/api_fields.py +0 -0
  15. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/browser_login.py +0 -0
  16. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/client.py +0 -0
  17. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/__init__.py +0 -0
  18. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/account.py +0 -0
  19. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/ambiental.py +0 -0
  20. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/articulos.py +0 -0
  21. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/avisos.py +0 -0
  22. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/campos.py +0 -0
  23. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/common.py +0 -0
  24. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/completar.py +0 -0
  25. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/empresas.py +0 -0
  26. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/inventario.py +0 -0
  27. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/lecturas.py +0 -0
  28. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/ordenes.py +0 -0
  29. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/personas.py +0 -0
  30. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/planta.py +0 -0
  31. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/preventivos.py +0 -0
  32. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/raw.py +0 -0
  33. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/reportes.py +0 -0
  34. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/rutinas.py +0 -0
  35. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/search.py +0 -0
  36. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/sst.py +0 -0
  37. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/commands/update.py +0 -0
  38. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/lookup.py +0 -0
  39. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/output.py +0 -0
  40. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/record_completion.py +0 -0
  41. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/self_update.py +0 -0
  42. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/settings.py +0 -0
  43. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/spreadsheet.py +0 -0
  44. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/updates.py +0 -0
  45. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/values.py +0 -0
  46. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli/welcome.py +0 -0
  47. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli.egg-info/dependency_links.txt +0 -0
  48. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli.egg-info/entry_points.txt +0 -0
  49. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli.egg-info/requires.txt +0 -0
  50. {operx_cli-0.7.6 → operx_cli-0.7.7}/operx_cli.egg-info/top_level.txt +0 -0
  51. {operx_cli-0.7.6 → operx_cli-0.7.7}/setup.cfg +0 -0
  52. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_agentes.py +0 -0
  53. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_api_campos.py +0 -0
  54. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_app.py +0 -0
  55. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_articulos.py +0 -0
  56. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_campos.py +0 -0
  57. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_cards.py +0 -0
  58. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_client.py +0 -0
  59. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_completar.py +0 -0
  60. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_daily_flows.py +0 -0
  61. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_inventario_lecturas.py +0 -0
  62. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_json_writes.py +0 -0
  63. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_login.py +0 -0
  64. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_logout_and_routes.py +0 -0
  65. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_lookup.py +0 -0
  66. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_more_of_the_api.py +0 -0
  67. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_ordenes.py +0 -0
  68. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_output.py +0 -0
  69. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_personas.py +0 -0
  70. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_preventivos.py +0 -0
  71. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_record_completion.py +0 -0
  72. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_registros.py +0 -0
  73. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_rutinas.py +0 -0
  74. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_self_update.py +0 -0
  75. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_settings.py +0 -0
  76. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_shell.py +0 -0
  77. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_spreadsheet.py +0 -0
  78. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_sst_ambiental.py +0 -0
  79. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_updates.py +0 -0
  80. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_values.py +0 -0
  81. {operx_cli-0.7.6 → operx_cli-0.7.7}/tests/test_welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.7.6
3
+ Version: 0.7.7
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
@@ -105,6 +105,13 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
105
105
  - El responsable de una acción es una persona de la empresa (su nombre o
106
106
  código exacto) o, si no está en la empresa, alguien de afuera por su nombre:
107
107
  `operx` avisa cuál de las dos tomó.
108
+ - Muestreo: `muestreo campanas`, `campana`, `relojes` e `informes` leen.
109
+ Planear va por `operx api`: campañas y sus visitas, puntos y sus límites, y
110
+ lotes de etiquetas (las rutas `sampling/…`; los ids de clientes, puntos,
111
+ personas y parámetros, en `operx api GET sampling/options/`). Las entregas
112
+ de muestras, la recepción, confirmar resultados, anular una etiqueta, las
113
+ fotos y los PDF con firmas o fotos (cadena de custodia, actas, paquete) son
114
+ de la app.
108
115
  - `operx personas importar ARCHIVO` sin `--confirmar` solo revisa el archivo
109
116
  y no guarda nada: hazlo primero y muéstrale el resultado a la persona.
110
117
  - Un acceso de solo lectura no escribe: la API responde 403.
@@ -257,4 +264,12 @@ operx sst acciones --estado abiertas --json
257
264
  # Lo que vence este mes en lo ambiental, y registrar residuos
258
265
  operx ambiental obligaciones --json
259
266
  operx ambiental residuo --corriente "Aceite usado" --kg 12,5 --si --json
267
+
268
+ # Los frascos de Muestreo que vencen hoy, y las campañas a las que les falta algo
269
+ operx muestreo relojes --horas 24 --json
270
+ operx muestreo informes --pendientes --json
271
+
272
+ # Una campaña de muestreo con su visita (no tiene comando propio): primero los ids
273
+ operx api GET sampling/options/ --json
274
+ operx api POST sampling/campaigns/ -d '{"name": "Seguimiento trimestral", "customer": 4, "starts_on": "2026-11-10", "ends_on": "2026-11-10", "visits": [{"point": 12, "planned_at": "2026-11-10T08:00:00-05:00", "parameters": ["pH", "DBO₅"]}]}' --si --json
260
275
  ```
@@ -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.7.6"
5
+ __version__ = "0.7.7"
@@ -14,7 +14,7 @@ from . import __version__, settings, shell
14
14
  from .agent_guide import guide_text
15
15
  from .client import ApiError
16
16
  from .commands import (
17
- account, ambiental, articulos, avisos, campos, completar, inventario, lecturas, ordenes, personas, planta,
17
+ account, ambiental, articulos, avisos, campos, completar, inventario, lecturas, muestreo, ordenes, personas, planta,
18
18
  preventivos, raw, registros, reportes, rutinas, search, sst, update,
19
19
  )
20
20
  from .commands.common import CliError, Session
@@ -160,6 +160,7 @@ app.add_typer(campos.app, name="campos")
160
160
  app.add_typer(reportes.app, name="reportes")
161
161
  app.add_typer(sst.app, name="sst")
162
162
  app.add_typer(ambiental.app, name="ambiental")
163
+ app.add_typer(muestreo.app, name="muestreo")
163
164
 
164
165
 
165
166
  @app.command()
@@ -0,0 +1,197 @@
1
+ """`operx muestreo`: Muestreo, for the lab's coordinator or an agent: the
2
+ campaigns and what each visit took, the bottles' clocks, and what each
3
+ campaign still needs before its report goes to the client. Planning, label
4
+ batches and the files go through `operx api` (AGENTES.md); signatures,
5
+ receptions and confirming results stay in the app."""
6
+
7
+ from datetime import datetime, timezone
8
+ from enum import Enum
9
+ from typing import Annotated, Optional
10
+
11
+ import typer
12
+
13
+ from ..lookup import find_in
14
+ from ..output import print_card, print_record, print_rows
15
+ from ..values import shown_moment
16
+ from .common import AllOption, CliError, CsvOption, JsonOption, SearchOption, client_of, list_rows, output_format
17
+
18
+ app = typer.Typer(help="Muestreo: campañas, relojes de los frascos e informes para el cliente.", no_args_is_help=True)
19
+
20
+ CAMPAIGN_STATUS = {"planned": "Planeada", "in_field": "En campo", "closed": "Cerrada", "cancelled": "Cancelada"}
21
+ VISIT_STATUS = {"pending": "Por hacer", "done": "Tomada", "not_done": "No se hizo"}
22
+ WHERE = {"in_field": "Con quien muestreó", "in_transit": "En camino al laboratorio"}
23
+
24
+ CAMPAIGN_COLUMNS = [
25
+ ("code", "Código"), ("name", "Campaña"), ("customer_name", "Cliente"), ("dates", "Fechas"), ("status_label", "Estado"),
26
+ ("visits", "Visitas"), ("samples_text", "Muestras"),
27
+ ]
28
+ VISIT_COLUMNS = [("label", "Visita"), ("when", "Cuándo"), ("sampler_name", "Muestrea"), ("status_label", "Estado"), ("sample_code", "Muestra")]
29
+ CLOCK_COLUMNS = [
30
+ ("due", "Vence"), ("left", "Faltan"), ("label_code", "Etiqueta"), ("parameter", "Parámetro"), ("sample_code", "Muestra"),
31
+ ("campaign_code", "Campaña"), ("where_label", "Dónde"), ("holder", "Quién la tiene"),
32
+ ]
33
+ REPORT_COLUMNS = [("code", "Campaña"), ("name", "Nombre"), ("customer", "Cliente"), ("state", "Estado"), ("progress", "Avance")]
34
+
35
+
36
+ class EstadoCampana(str, Enum):
37
+ planeada = "planeada"
38
+ en_campo = "en-campo"
39
+ cerrada = "cerrada"
40
+ cancelada = "cancelada"
41
+
42
+
43
+ CAMPAIGN_STATUS_OF = {"planeada": "planned", "en-campo": "in_field", "cerrada": "closed", "cancelada": "cancelled"}
44
+
45
+
46
+ def _name(record):
47
+ return (record or {}).get("name") or ""
48
+
49
+
50
+ def _dates(starts_on, ends_on):
51
+ start, end = shown_moment(starts_on), shown_moment(ends_on)
52
+ return start if start == end else f"{start} a {end}"
53
+
54
+
55
+ def _samples_text(counts):
56
+ """{'in_field': 0, 'in_transit': 1, 'received': 2} → 'En camino 1 · Recibidas 2'."""
57
+ labels = [("in_field", "En campo"), ("in_transit", "En camino"), ("received", "Recibidas")]
58
+ return " · ".join(f"{label} {counts.get(key)}" for key, label in labels if counts.get(key))
59
+
60
+
61
+ def _campaign_row(campaign):
62
+ return {
63
+ **campaign, "customer_name": _name(campaign.get("customer")) or "Sitios propios",
64
+ "dates": _dates(campaign.get("starts_on"), campaign.get("ends_on")),
65
+ "status_label": CAMPAIGN_STATUS.get(campaign.get("status"), campaign.get("status")),
66
+ "visits": f"{campaign.get('visits_done', 0)}/{campaign.get('visits_total', 0)}",
67
+ "samples_text": _samples_text(campaign.get("samples") or {}),
68
+ }
69
+
70
+
71
+ def _matches(campaign, text):
72
+ wanted = text.casefold()
73
+ return any(wanted in str(value or "").casefold() for value in (campaign.get("code"), campaign.get("name"), _name(campaign.get("customer"))))
74
+
75
+
76
+ @app.command()
77
+ def campanas(
78
+ ctx: typer.Context,
79
+ estado: Annotated[Optional[EstadoCampana], typer.Option("--estado", help="Solo las de este estado.")] = None,
80
+ buscar: SearchOption = None,
81
+ as_json: JsonOption = False,
82
+ as_csv: CsvOption = False,
83
+ ):
84
+ """Las campañas de muestreo: cliente, fechas, estado, visitas tomadas y muestras."""
85
+ rows = client_of(ctx).get("sampling/campaigns")
86
+ if estado:
87
+ rows = [row for row in rows if row.get("status") == CAMPAIGN_STATUS_OF[estado.value]]
88
+ if buscar:
89
+ rows = [row for row in rows if _matches(row, buscar)]
90
+ output = output_format(as_json, as_csv)
91
+ print_rows(CAMPAIGN_COLUMNS, rows if output == "json" else [_campaign_row(row) for row in rows], output)
92
+
93
+
94
+ def _visit_row(visit):
95
+ return {
96
+ **visit, "when": shown_moment(visit.get("planned_at")), "sampler_name": _name(visit.get("sampler")),
97
+ "status_label": VISIT_STATUS.get(visit.get("status"), visit.get("status")),
98
+ "sample_code": (visit.get("sample") or {}).get("code") or "",
99
+ }
100
+
101
+
102
+ @app.command()
103
+ def campana(
104
+ ctx: typer.Context,
105
+ codigo: Annotated[str, typer.Argument(help="El código de la campaña (C-2026-001) o su id.")],
106
+ as_json: JsonOption = False,
107
+ ):
108
+ """Una campaña de muestreo: su equipo, sus visitas y lo que tomó cada una."""
109
+ client = client_of(ctx)
110
+ found = find_in(client.get("sampling/campaigns"), codigo, ["code", "id"], "campaña")
111
+ campaign = client.get(f"sampling/campaigns/{found['id']}")
112
+ if as_json:
113
+ print_record(campaign, "json")
114
+ return
115
+ row = _campaign_row(campaign)
116
+ console = print_card(f"{campaign.get('code')} · {campaign.get('name')}", [
117
+ ("Cliente", row["customer_name"]),
118
+ ("Fechas", row["dates"]),
119
+ ("Estado", row["status_label"]),
120
+ ("Visitas tomadas", row["visits"]),
121
+ ("Muestras", row["samples_text"]),
122
+ ("Líder", _name(campaign.get("leader"))),
123
+ ("Equipo", ", ".join(_name(person) for person in campaign.get("team") or [])),
124
+ ("Laboratorio", _name(campaign.get("receiving_lab")) or "El propio"),
125
+ ("Trabajo", (campaign.get("work_order") or {}).get("title")),
126
+ ("Notas", campaign.get("notes")),
127
+ ])
128
+ console.print()
129
+ print_rows(VISIT_COLUMNS, [_visit_row(visit) for visit in campaign.get("visits") or []], "tabla")
130
+
131
+
132
+ def _hours_left(deadline, now):
133
+ """'5 h', or 'Vencido' once past."""
134
+ hours = (datetime.fromisoformat(deadline.replace("Z", "+00:00")) - now).total_seconds() / 3600
135
+ if hours < 0:
136
+ return "Vencido"
137
+ return f"{hours:.0f} h" if hours >= 1 else "Menos de 1 h"
138
+
139
+
140
+ @app.command()
141
+ def relojes(
142
+ ctx: typer.Context,
143
+ horas: Annotated[Optional[int], typer.Option("--horas", min=1, help="Solo los que vencen en estas horas: 24.")] = None,
144
+ as_json: JsonOption = False,
145
+ as_csv: CsvOption = False,
146
+ ):
147
+ """Los frascos aún sin recibir en el laboratorio, por su tiempo máximo: el que vence primero arriba."""
148
+ bottles = client_of(ctx).get("sampling/panel").get("bottles", [])
149
+ now = datetime.now(timezone.utc)
150
+ if horas:
151
+ bottles = [
152
+ bottle for bottle in bottles
153
+ if (datetime.fromisoformat(bottle["deadline"].replace("Z", "+00:00")) - now).total_seconds() <= horas * 3600
154
+ ]
155
+ output = output_format(as_json, as_csv)
156
+ if output != "json":
157
+ bottles = [
158
+ {**bottle, "due": shown_moment(bottle.get("deadline")), "left": _hours_left(bottle["deadline"], now),
159
+ "where_label": WHERE.get(bottle.get("where"), bottle.get("where"))}
160
+ for bottle in bottles
161
+ ]
162
+ print_rows(CLOCK_COLUMNS, bottles, output)
163
+
164
+
165
+ def _progress(row):
166
+ """'Tomadas 2/2 · Recibidas 2/2 · Resultados 8/8', as Informes shows it."""
167
+ parts = [
168
+ f"Tomadas {row.get('visits_done')}/{row.get('visits_planned')}",
169
+ f"Recibidas {row.get('received')}/{row.get('to_lab')}",
170
+ f"Resultados {row.get('results_confirmed')}/{row.get('results_expected')}",
171
+ ]
172
+ if row.get("visits_not_done"):
173
+ parts.append(f"No se hicieron {row['visits_not_done']}")
174
+ if row.get("rejected"):
175
+ parts.append(f"Rechazadas {row['rejected']}")
176
+ return " · ".join(parts)
177
+
178
+
179
+ @app.command()
180
+ def informes(
181
+ ctx: typer.Context,
182
+ listas: Annotated[bool, typer.Option("--listas", help="Solo las listas para entregar al cliente.")] = False,
183
+ pendientes: Annotated[bool, typer.Option("--pendientes", help="Solo las que aún les falta algo.")] = False,
184
+ buscar: SearchOption = None,
185
+ todas: AllOption = False,
186
+ as_json: JsonOption = False,
187
+ as_csv: CsvOption = False,
188
+ ):
189
+ """Cada campaña con muestras y lo que le falta para entregar su informe al cliente."""
190
+ if listas and pendientes:
191
+ raise CliError("Usa --listas o --pendientes, no los dos.")
192
+ estado = "listos" if listas else "pendientes" if pendientes else None
193
+ rows = list_rows(client_of(ctx), "sampling/reports", {"estado": estado, "buscar": buscar}, todas)
194
+ output = output_format(as_json, as_csv)
195
+ if output != "json":
196
+ rows = [{**row, "state": "Listo" if row.get("ready") else row.get("missing"), "progress": _progress(row)} for row in rows]
197
+ print_rows(REPORT_COLUMNS, rows, output)
@@ -269,7 +269,7 @@ def crear_cliente(
269
269
  direccion_facturacion: Annotated[Optional[str], typer.Option("--direccion-facturacion")] = None,
270
270
  notas: Annotated[Optional[str], typer.Option("--notas")] = None,
271
271
  rol: Annotated[
272
- Optional[list[Rol]], typer.Option("--rol", help="cliente (sin --rol), proveedor, gestor, laboratorio o consultor. Se repite.")
272
+ Optional[list[Rol]], typer.Option("--rol", help="cliente (sin --rol), proveedor, gestor, laboratorio, consultor o contratista. Se repite.")
273
273
  ] = None,
274
274
  si: YesOption = False,
275
275
  as_json: JsonOption = False,
@@ -60,6 +60,7 @@ COMMAND_PRODUCTS = {
60
60
  "rutinas": ("people",),
61
61
  "sst": ("sst",),
62
62
  "ambiental": ("environment",),
63
+ "muestreo": ("sampling",),
63
64
  }
64
65
 
65
66
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.7.6
3
+ Version: 0.7.7
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
@@ -34,6 +34,7 @@ operx_cli/commands/completar.py
34
34
  operx_cli/commands/empresas.py
35
35
  operx_cli/commands/inventario.py
36
36
  operx_cli/commands/lecturas.py
37
+ operx_cli/commands/muestreo.py
37
38
  operx_cli/commands/ordenes.py
38
39
  operx_cli/commands/personas.py
39
40
  operx_cli/commands/planta.py
@@ -60,6 +61,7 @@ tests/test_login.py
60
61
  tests/test_logout_and_routes.py
61
62
  tests/test_lookup.py
62
63
  tests/test_more_of_the_api.py
64
+ tests/test_muestreo.py
63
65
  tests/test_ordenes.py
64
66
  tests/test_output.py
65
67
  tests/test_personas.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "operx-cli"
7
- version = "0.7.6"
7
+ version = "0.7.7"
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,119 @@
1
+ """`operx muestreo`: the campaigns, the bottles' clocks and Informes.
2
+ Fixtures: real answers a personal token got from a local backend
3
+ (backend/sampling/tests/test_api_tokens.py), as sampling/api_descriptions.py
4
+ describes them."""
5
+
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ import pytest
9
+
10
+ from .fake_api import FakeApi, run
11
+
12
+ CAMPAIGN_ROW = {
13
+ "id": 1, "code": "C-2026-001", "name": "Vertimientos", "customer": {"id": 1, "name": "Lácteos del Valle S.A.S."},
14
+ "starts_on": "2026-10-29", "ends_on": "2026-10-30", "status": "in_field", "visits_done": 1, "visits_total": 2,
15
+ "samples": {"in_field": 0, "in_transit": 1, "received": 0},
16
+ }
17
+ PLANNED_ROW = {
18
+ "id": 2, "code": "C-2026-002", "name": "Pozos", "customer": None, "starts_on": "2026-11-12", "ends_on": "2026-11-12",
19
+ "status": "planned", "visits_done": 0, "visits_total": 1, "samples": {"in_field": 0, "in_transit": 0, "received": 0},
20
+ }
21
+ VISIT_TAKEN = {
22
+ "id": 1, "point": {"id": 1, "code": "V-1", "client_code": "VF-01", "name": "Vertimiento final"}, "label": "V-1 · Vertimiento final",
23
+ "kind": "regular", "planned_at": "2026-10-29T14:00:00+00:00", "sampler": {"id": 440, "name": "Camila Rojas"},
24
+ "sample_type": "grab", "composite_hours": None, "aliquots": None, "parameters": ["pH", "DBO₅"], "status": "done",
25
+ "not_done_reason": "", "planned_containers": [],
26
+ "sample": {"id": 1, "code": "M-0001", "kind": "regular", "status": "in_transit", "started_at": "2026-10-29T14:10:00+00:00",
27
+ "ended_at": None, "accuracy_m": "4", "distance_m": "9.8", "field_values": [], "containers": []},
28
+ }
29
+ VISIT_PENDING = {**VISIT_TAKEN, "id": 2, "point": {"id": 2, "code": "V-2", "client_code": "EP-01", "name": "Entrada PTAR"},
30
+ "label": "V-2 · Entrada PTAR", "sampler": None, "status": "pending", "sample": None}
31
+ CAMPAIGN = {
32
+ **CAMPAIGN_ROW, "leader": {"id": 439, "name": "Laura Gómez"}, "team": [{"id": 440, "name": "Camila Rojas"}],
33
+ "receiving_lab": None, "qa_ratio": 10, "notes": "Llevar la nevera grande.", "work_order": None,
34
+ "visits": [VISIT_TAKEN, VISIT_PENDING], "kit": [],
35
+ }
36
+ REPORT_READY = {
37
+ "id": 1, "code": "C-2026-001", "name": "Vertimientos", "customer": "Lácteos del Valle S.A.S.", "starts_on": "2026-10-29",
38
+ "ends_on": "2026-10-29", "visits_planned": 2, "visits_done": 1, "visits_not_done": 1, "samples": 1, "to_lab": 1,
39
+ "received": 1, "rejected": 0, "results_expected": 2, "results_confirmed": 2, "missing": "", "ready": True,
40
+ }
41
+
42
+
43
+ def bottle(code, parameter, deadline):
44
+ return {
45
+ "label_code": code, "parameter": parameter, "sample_id": 1, "sample_code": "M-0001", "campaign_id": 1, "visit_id": 1,
46
+ "campaign_code": "C-2026-001", "customer": "Lácteos del Valle S.A.S.", "where": "in_transit",
47
+ "holder": "Jorge Medina · Transportes Ruta Verde", "holding": {"hours": 48.0},
48
+ "filled_at": "2026-10-29T21:08:08+00:00", "deadline": deadline.isoformat(),
49
+ }
50
+
51
+
52
+ @pytest.fixture(autouse=True)
53
+ def signed_in(monkeypatch):
54
+ monkeypatch.setenv("OPERX_TOKEN", "opx_test")
55
+
56
+
57
+ def test_campanas_lists_each_campaign_with_its_visits_and_samples():
58
+ fake = FakeApi({("GET", "sampling/campaigns/"): [CAMPAIGN_ROW, PLANNED_ROW]})
59
+
60
+ result = run(fake, "muestreo", "campanas", "--estado", "en-campo", "--csv")
61
+
62
+ assert result.exit_code == 0, result.output
63
+ lines = result.output.splitlines()
64
+ assert lines[0] == "Código;Campaña;Cliente;Fechas;Estado;Visitas;Muestras", "with its BOM, for Excel"
65
+ assert lines[1:] == ["C-2026-001;Vertimientos;Lácteos del Valle S.A.S.;29/10/2026 a 30/10/2026;En campo;1/2;En camino 1"]
66
+
67
+
68
+ def test_campanas_finds_by_text_and_gives_the_api_rows_in_json():
69
+ fake = FakeApi({("GET", "sampling/campaigns/"): [CAMPAIGN_ROW, PLANNED_ROW]})
70
+
71
+ result = run(fake, "muestreo", "campanas", "--buscar", "pozos", "--json")
72
+
73
+ assert result.exit_code == 0, result.output
74
+ assert '"code": "C-2026-002"' in result.output and "C-2026-001" not in result.output
75
+
76
+
77
+ def test_campana_shows_one_campaign_by_its_code_with_each_visit():
78
+ fake = FakeApi({("GET", "sampling/campaigns/"): [CAMPAIGN_ROW, PLANNED_ROW], ("GET", "sampling/campaigns/1/"): CAMPAIGN})
79
+
80
+ result = run(fake, "muestreo", "campana", "c-2026-001")
81
+
82
+ assert result.exit_code == 0, result.output
83
+ assert "C-2026-001 · Vertimientos" in result.output and "Laura Gómez" in result.output and "El propio" in result.output
84
+ assert "M-0001" in result.output and "Por hacer" in result.output and "Entrada PTAR" in result.output
85
+
86
+
87
+ def test_relojes_puts_the_nearest_deadline_first_and_says_what_is_late():
88
+ now = datetime.now(timezone.utc)
89
+ bottles = [bottle("OPX-000003-4", "DBO₅", now - timedelta(hours=1)), bottle("OPX-000004-2", "DQO", now + timedelta(hours=5, minutes=10)),
90
+ bottle("OPX-000005-0", "Nitratos", now + timedelta(days=20))]
91
+ fake = FakeApi({("GET", "sampling/panel/"): {"bottles": bottles}})
92
+
93
+ result = run(fake, "muestreo", "relojes", "--horas", "24", "--csv")
94
+
95
+ assert result.exit_code == 0, result.output
96
+ rows = [line.split(";") for line in result.output.splitlines()[1:]]
97
+ assert [(row[1], row[2], row[6]) for row in rows] == [
98
+ ("Vencido", "OPX-000003-4", "En camino al laboratorio"), ("5 h", "OPX-000004-2", "En camino al laboratorio"),
99
+ ]
100
+
101
+
102
+ def test_informes_asks_for_the_campaigns_ready_to_hand_over():
103
+ fake = FakeApi({("GET", "sampling/reports/"): {
104
+ "count": 1, "next": None, "previous": None, "results": [REPORT_READY], "counts": {"todos": 3, "listos": 1, "pendientes": 2},
105
+ }})
106
+
107
+ result = run(fake, "muestreo", "informes", "--listas", "--csv")
108
+
109
+ assert result.exit_code == 0, result.output
110
+ assert fake.requests[-1].url.params["estado"] == "listos"
111
+ assert result.output.splitlines()[1] == (
112
+ "C-2026-001;Vertimientos;Lácteos del Valle S.A.S.;Listo;Tomadas 1/2 · Recibidas 1/1 · Resultados 2/2 · No se hicieron 1"
113
+ )
114
+
115
+
116
+ def test_informes_takes_one_of_listas_or_pendientes():
117
+ result = run(FakeApi({}), "muestreo", "informes", "--listas", "--pendientes")
118
+
119
+ assert result.exit_code != 0 and "no los dos" in result.output
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes