operx-cli 0.7.5__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.
- {operx_cli-0.7.5 → operx_cli-0.7.7}/PKG-INFO +1 -1
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/AGENTES.md +15 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/__init__.py +1 -1
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/app.py +2 -1
- operx_cli-0.7.7/operx_cli/commands/muestreo.py +197 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/registros.py +5 -4
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/shell.py +1 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli.egg-info/PKG-INFO +1 -1
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli.egg-info/SOURCES.txt +2 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/pyproject.toml +1 -1
- operx_cli-0.7.7/tests/test_muestreo.py +119 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/README.md +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/agent_guide.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/api_fields.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/browser_login.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/client.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/__init__.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/account.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/ambiental.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/articulos.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/avisos.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/campos.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/common.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/completar.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/empresas.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/inventario.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/lecturas.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/ordenes.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/personas.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/planta.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/preventivos.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/raw.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/reportes.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/rutinas.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/search.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/sst.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/commands/update.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/lookup.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/output.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/record_completion.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/self_update.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/settings.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/spreadsheet.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/updates.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/values.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli/welcome.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli.egg-info/dependency_links.txt +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli.egg-info/entry_points.txt +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli.egg-info/requires.txt +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/operx_cli.egg-info/top_level.txt +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/setup.cfg +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_agentes.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_api_campos.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_app.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_articulos.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_campos.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_cards.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_client.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_completar.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_daily_flows.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_inventario_lecturas.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_json_writes.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_login.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_logout_and_routes.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_lookup.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_more_of_the_api.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_ordenes.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_output.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_personas.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_preventivos.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_record_completion.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_registros.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_rutinas.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_self_update.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_settings.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_shell.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_spreadsheet.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_sst_ambiental.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_updates.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_values.py +0 -0
- {operx_cli-0.7.5 → operx_cli-0.7.7}/tests/test_welcome.py +0 -0
|
@@ -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
|
```
|
|
@@ -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)
|
|
@@ -70,16 +70,17 @@ class Rol(str, Enum):
|
|
|
70
70
|
gestor = "gestor"
|
|
71
71
|
laboratorio = "laboratorio"
|
|
72
72
|
consultor = "consultor"
|
|
73
|
+
contratista = "contratista"
|
|
73
74
|
|
|
74
75
|
|
|
75
|
-
# customers/models.py ROLES: Ambiental's outside firms
|
|
76
|
+
# customers/models.py ROLES: Ambiental's outside firms, then Trabajos' contractors.
|
|
76
77
|
ROLE_KEYS = {
|
|
77
78
|
"cliente": "client", "proveedor": "supplier", "gestor": "waste_manager",
|
|
78
|
-
"laboratorio": "lab", "consultor": "consultant",
|
|
79
|
+
"laboratorio": "lab", "consultor": "consultant", "contratista": "contractor",
|
|
79
80
|
}
|
|
80
81
|
ROLE_LABELS = {
|
|
81
82
|
"client": "Cliente", "supplier": "Proveedor", "waste_manager": "Gestor de residuos",
|
|
82
|
-
"lab": "Laboratorio", "consultant": "Consultor",
|
|
83
|
+
"lab": "Laboratorio", "consultant": "Consultor", "contractor": "Contratista",
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
|
|
@@ -268,7 +269,7 @@ def crear_cliente(
|
|
|
268
269
|
direccion_facturacion: Annotated[Optional[str], typer.Option("--direccion-facturacion")] = None,
|
|
269
270
|
notas: Annotated[Optional[str], typer.Option("--notas")] = None,
|
|
270
271
|
rol: Annotated[
|
|
271
|
-
Optional[list[Rol]], typer.Option("--rol", help="cliente (sin --rol), proveedor, gestor, laboratorio o
|
|
272
|
+
Optional[list[Rol]], typer.Option("--rol", help="cliente (sin --rol), proveedor, gestor, laboratorio, consultor o contratista. Se repite.")
|
|
272
273
|
] = None,
|
|
273
274
|
si: YesOption = False,
|
|
274
275
|
as_json: JsonOption = False,
|
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|