operx-cli 0.7.6__tar.gz → 0.8.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.
- {operx_cli-0.7.6 → operx_cli-0.8.0}/PKG-INFO +1 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/AGENTES.md +16 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/__init__.py +1 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/app.py +2 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/ambiental.py +3 -22
- operx_cli-0.8.0/operx_cli/commands/muestreo.py +197 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/ordenes.py +7 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/registros.py +1 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/shell.py +1 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli.egg-info/PKG-INFO +1 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli.egg-info/SOURCES.txt +2 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/pyproject.toml +1 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_cards.py +1 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_json_writes.py +1 -4
- operx_cli-0.8.0/tests/test_muestreo.py +119 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_ordenes.py +14 -1
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_sst_ambiental.py +0 -17
- {operx_cli-0.7.6 → operx_cli-0.8.0}/README.md +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/agent_guide.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/api_fields.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/browser_login.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/client.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/__init__.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/account.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/articulos.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/avisos.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/campos.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/common.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/completar.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/empresas.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/inventario.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/lecturas.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/personas.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/planta.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/preventivos.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/raw.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/reportes.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/rutinas.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/search.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/sst.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/commands/update.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/lookup.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/output.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/record_completion.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/self_update.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/settings.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/spreadsheet.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/updates.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/values.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli/welcome.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli.egg-info/dependency_links.txt +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli.egg-info/entry_points.txt +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli.egg-info/requires.txt +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/operx_cli.egg-info/top_level.txt +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/setup.cfg +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_agentes.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_api_campos.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_app.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_articulos.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_campos.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_client.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_completar.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_daily_flows.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_inventario_lecturas.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_login.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_logout_and_routes.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_lookup.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_more_of_the_api.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_output.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_personas.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_preventivos.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_record_completion.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_registros.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_rutinas.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_self_update.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_settings.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_shell.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_spreadsheet.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_updates.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_values.py +0 -0
- {operx_cli-0.7.6 → operx_cli-0.8.0}/tests/test_welcome.py +0 -0
|
@@ -79,7 +79,7 @@ llamada queda en el historial del token, marcada como hecha desde la CLI.
|
|
|
79
79
|
`rutinas crear`, `cambiar` y `quitar`; `campos crear`, `cambiar`,
|
|
80
80
|
`quitar` y `guardar`.
|
|
81
81
|
- SG-SST y Ambiental: `sst nueva-accion` y `accion-hecha`;
|
|
82
|
-
`ambiental nueva-accion`, `accion-hecha
|
|
82
|
+
`ambiental nueva-accion`, `accion-hecha` y `residuo`.
|
|
83
83
|
- `avisos --marcar-leidos`, y `operx api` con POST, PUT, PATCH o DELETE.
|
|
84
84
|
- Un error se corrige con su contrario, nunca borrando: `inventario revertir`
|
|
85
85
|
deshace un movimiento, `lecturas anular` una lectura. La excepción es
|
|
@@ -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()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""`operx ambiental`: Ambiental, per company: its action plan, permits and
|
|
2
|
-
obligations, and the
|
|
3
|
-
readings. A wrong record is voided in
|
|
2
|
+
obligations, and the record made most often, waste. Consumption is kept
|
|
3
|
+
by the month in the app (no meter readings). A wrong record is voided in
|
|
4
|
+
the app, never edited."""
|
|
4
5
|
|
|
5
6
|
from datetime import date
|
|
6
7
|
from typing import Annotated, Optional
|
|
@@ -158,23 +159,3 @@ def _stream(client, path, corriente, sede):
|
|
|
158
159
|
if not streams:
|
|
159
160
|
raise CliError("Esta empresa aún no tiene corrientes de residuos: créalas en la app, en Ambiental › Residuos.")
|
|
160
161
|
return find_in(streams, corriente, ["name"], "corriente")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
@app.command()
|
|
164
|
-
def lectura(
|
|
165
|
-
ctx: typer.Context,
|
|
166
|
-
medidor: Annotated[str, typer.Option("--medidor", help="El nombre del medidor o de lo que mide: Acueducto.")],
|
|
167
|
-
valor: Annotated[str, typer.Option("--valor", help="Lo que marca: 1.234,5.")],
|
|
168
|
-
fecha: FechaOption = None,
|
|
169
|
-
empresa: EmpresaOption = None,
|
|
170
|
-
si: YesOption = False,
|
|
171
|
-
as_json: JsonOption = False,
|
|
172
|
-
):
|
|
173
|
-
"""Registra la lectura de un medidor (agua, energía, gas)."""
|
|
174
|
-
client = client_of(ctx)
|
|
175
|
-
path = company_path(client, ENVIRONMENT, empresa)
|
|
176
|
-
meter = find_in(client.get(f"{path}/consumption/meters"), medidor, ["name", "meter_name"], "medidor")
|
|
177
|
-
reading = {"source": meter["id"], "read_on": day(fecha) or date.today().isoformat(), "value": number(valor)}
|
|
178
|
-
confirm_write(f"Lectura {valor} en «{meter['name']}» el {shown_moment(reading['read_on'])}.", si)
|
|
179
|
-
recorded = answer_of(client.send("POST", f"{path}/consumption/readings", json=reading))
|
|
180
|
-
print_result(recorded, as_json, f"Lectura registrada: {valor} en {meter['name']}.")
|
|
@@ -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)
|
|
@@ -72,11 +72,17 @@ PersonOption = Annotated[
|
|
|
72
72
|
]
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def _assignee(order):
|
|
76
|
+
"""The person doing the order, or the linked contractor doing it instead."""
|
|
77
|
+
return order.get("assigned_to_name") or (order.get("assigned_company") or {}).get("name")
|
|
78
|
+
|
|
79
|
+
|
|
75
80
|
def _in_spanish(order):
|
|
76
81
|
return {
|
|
77
82
|
**order,
|
|
78
83
|
"status": STATUS_LABELS.get(order.get("status"), order.get("status")),
|
|
79
84
|
"priority": PRIORITY_LABELS.get(order.get("priority"), order.get("priority")),
|
|
85
|
+
"assigned_to_name": _assignee(order),
|
|
80
86
|
"due_date": shown_moment(order.get("due_date")),
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -127,7 +133,7 @@ def ver(ctx: typer.Context, orden: Annotated[int, typer.Argument(help="El númer
|
|
|
127
133
|
("Estado", STATUS_LABELS.get(order.get("status"), order.get("status"))),
|
|
128
134
|
("Prioridad", PRIORITY_LABELS.get(order.get("priority"), order.get("priority"))),
|
|
129
135
|
("Tipo", TYPE_LABELS.get(order.get("work_type"), order.get("work_type"))),
|
|
130
|
-
("Asignada a", order
|
|
136
|
+
("Asignada a", _assignee(order)),
|
|
131
137
|
("También", order.get("extra_assignee_names")),
|
|
132
138
|
("Equipo", order.get("asset_name")),
|
|
133
139
|
("Lugar", order.get("site_name")),
|
|
@@ -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
|
|
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,
|
|
@@ -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
|
+
version = "0.8.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"
|
|
@@ -17,7 +17,7 @@ ORDER = {
|
|
|
17
17
|
"site_name": "Planta Norte",
|
|
18
18
|
"site_arrival": {"address": "", "city": "", "latitude": None, "longitude": None, "directions": ""},
|
|
19
19
|
"customer_id": None, "customer_name": None, "assigned_to_name": None, "assigned_to_avatar": None,
|
|
20
|
-
"extra_assignee_names": [], "location_description": "", "custom_fields": {}, "signature": None,
|
|
20
|
+
"assigned_company": None, "extra_assignee_names": [], "location_description": "", "custom_fields": {}, "signature": None,
|
|
21
21
|
"signed_by_name": "", "signed_at": None, "created_at": "2026-10-23T02:19:42.253068-05:00",
|
|
22
22
|
"updated_at": "2026-10-23T02:19:42.253068-05:00",
|
|
23
23
|
"comments": [{
|
|
@@ -12,7 +12,7 @@ from operx_cli.app import app
|
|
|
12
12
|
from .fake_api import FakeApi, page, run
|
|
13
13
|
from .test_more_of_the_api import FINDING, MOVEMENT, REASONS, STOP
|
|
14
14
|
from .test_preventivos import ASSET, NOTES, PLAN
|
|
15
|
-
from .test_sst_ambiental import EMPLOYEES, ENV_ACTION, ENV_COMPANIES,
|
|
15
|
+
from .test_sst_ambiental import EMPLOYEES, ENV_ACTION, ENV_COMPANIES, SST_ACTION, SST_COMPANIES, STREAMS
|
|
16
16
|
|
|
17
17
|
FILTER = {"id": 3, "code": "FIL-010", "name": "Filtro de aceite"}
|
|
18
18
|
STORE = {"id": 2, "name": "Bodega central"}
|
|
@@ -74,9 +74,6 @@ CASES = [
|
|
|
74
74
|
(["ambiental", "residuo", "--corriente", "aceite usado", "--kg", "12,5"],
|
|
75
75
|
{("GET", "environment/companies/"): ENV_COMPANIES, ("GET", "environment/companies/1/waste/streams/"): STREAMS},
|
|
76
76
|
("POST", "environment/companies/1/waste/entries/"), (201, {"id": 9, "kg": "12.500"})),
|
|
77
|
-
(["ambiental", "lectura", "--medidor", "Acueducto", "--valor", "1.234,5"],
|
|
78
|
-
{("GET", "environment/companies/"): ENV_COMPANIES, ("GET", "environment/companies/1/consumption/meters/"): METERS},
|
|
79
|
-
("POST", "environment/companies/1/consumption/readings/"), (201, {"id": 3})),
|
|
80
77
|
(["avisos", "--marcar-leidos"], {}, ("POST", "notifications/mark-read/"), {"marked": 4}),
|
|
81
78
|
]
|
|
82
79
|
|
|
@@ -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
|
|
@@ -6,7 +6,12 @@ from .fake_api import FakeApi, page, run
|
|
|
6
6
|
|
|
7
7
|
ORDER = {
|
|
8
8
|
"id": 15, "title": "Cambio de rodamiento", "status": "in_progress", "priority": "high",
|
|
9
|
-
"assigned_to_name": "Pedro Pérez", "asset_name": "Bomba 4", "site_name": "Planta",
|
|
9
|
+
"assigned_to_name": "Pedro Pérez", "assigned_company": None, "asset_name": "Bomba 4", "site_name": "Planta",
|
|
10
|
+
"due_date": "2026-10-30T00:00:00-05:00",
|
|
11
|
+
}
|
|
12
|
+
# The same order while a linked contractor does it: nobody of the company is on it.
|
|
13
|
+
HELD_BY_CONTRACTOR = {
|
|
14
|
+
**ORDER, "assigned_to_name": None, "assigned_company": {"name": "Climas del Valle", "kind": "contractor"},
|
|
10
15
|
}
|
|
11
16
|
PEOPLE = page({"id": 3, "user_id": 21, "user_name": "Pedro Pérez", "employee_code": "T-01"})
|
|
12
17
|
|
|
@@ -102,3 +107,11 @@ def test_ver_shows_one_order():
|
|
|
102
107
|
result = run(FakeApi({("GET", "work-orders/15/"): ORDER}), "ordenes", "ver", "15")
|
|
103
108
|
assert result.exit_code == 0, result.output
|
|
104
109
|
assert "Cambio de rodamiento" in result.output
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_an_order_a_contractor_has_names_the_contractor():
|
|
113
|
+
listed = run(FakeApi({("GET", "work-orders/"): page(HELD_BY_CONTRACTOR)}), "ordenes", "listar")
|
|
114
|
+
shown = run(FakeApi({("GET", "work-orders/15/"): HELD_BY_CONTRACTOR}), "ordenes", "ver", "15")
|
|
115
|
+
assert listed.exit_code == 0, listed.output
|
|
116
|
+
assert shown.exit_code == 0, shown.output
|
|
117
|
+
assert "Climas del Valle" in listed.output and "Climas del Valle" in shown.output
|
|
@@ -52,9 +52,6 @@ STREAMS = {
|
|
|
52
52
|
"moving_average_kg": None, "storage_alert_months": [], "certificate_days": 30,
|
|
53
53
|
"streams": [{"id": 11, "name": "Aceite usado", "kind": "hazardous", "hazardous": True}],
|
|
54
54
|
}
|
|
55
|
-
METERS = [{"id": 21, "name": "Acueducto", "meter_name": "Contador principal", "qr_code": "x", "establishment": "Planta Norte"}]
|
|
56
|
-
|
|
57
|
-
|
|
58
55
|
@pytest.fixture(autouse=True)
|
|
59
56
|
def signed_in(monkeypatch):
|
|
60
57
|
monkeypatch.setenv("OPERX_TOKEN", "opx_test")
|
|
@@ -224,20 +221,6 @@ def test_ambiental_residuo_finds_the_stream_by_its_name():
|
|
|
224
221
|
assert fake.sent("POST", "environment/companies/1/waste/entries/") == {"stream": 11, "date": "2026-10-22", "kg": "12.5"}
|
|
225
222
|
|
|
226
223
|
|
|
227
|
-
def test_ambiental_lectura_of_a_meter():
|
|
228
|
-
fake = FakeApi({
|
|
229
|
-
("GET", "environment/companies/"): ENV_COMPANIES,
|
|
230
|
-
("GET", "environment/companies/1/consumption/meters/"): METERS,
|
|
231
|
-
("POST", "environment/companies/1/consumption/readings/"): (201, {"id": 3}),
|
|
232
|
-
})
|
|
233
|
-
result = run(fake, "ambiental", "lectura", "--medidor", "Acueducto", "--valor", "1.234,5", "--fecha", "22/10/2026", "--si")
|
|
234
|
-
|
|
235
|
-
assert result.exit_code == 0, result.output
|
|
236
|
-
assert fake.sent("POST", "environment/companies/1/consumption/readings/") == {
|
|
237
|
-
"source": 21, "read_on": "2026-10-22", "value": "1234.5",
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
224
|
# ── Personas ──────────────────────────────────────────────────────────────
|
|
242
225
|
|
|
243
226
|
|
|
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
|