@medine-tech/backoffice-cli 0.2.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MedineTech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,601 @@
1
+ # Backoffice CLI
2
+
3
+ CLI de MedineTech ERP con catálogo offline, descubrimiento progresivo y
4
+ solicitudes validadas para las operaciones de empresa de Backoffice. El ejecutable se llama `backoffice`; el paquete
5
+ `@medine-tech/backoffice-cli` tiene su propia versión, actualmente `0.2.0`.
6
+
7
+ La interfaz de negocio permite descubrir las referencias de una factura de
8
+ compra, resolverlas contra el servidor, crearla y verificar el resultado con
9
+ una lectura posterior. La operación POST genérica sigue disponible y ejecuta el
10
+ mismo contrato sin verificar su efecto.
11
+
12
+ ## Instalación
13
+
14
+ Requiere Node.js `^24.15.0 || >=26.0.0` y npm `^12.0.2`.
15
+
16
+ **La primera publicación en npm aún no ocurre.** Hasta entonces, genera el
17
+ archivo distribuible desde el workspace `apps/backoffice-cli`:
18
+
19
+ ```sh
20
+ npm pack --pack-destination /tmp
21
+ npm install --global /tmp/medine-tech-backoffice-cli-0.2.0.tgz
22
+ backoffice --version
23
+ backoffice --help
24
+ ```
25
+
26
+ Una vez publicado, la instalación será:
27
+
28
+ ```sh
29
+ npm install --global @medine-tech/backoffice-cli
30
+ ```
31
+
32
+ El paquete es público y se publica bajo la licencia MIT. Sus dependencias de
33
+ runtime son `@oclif/core` 5.0.0, `ajv` 8.20.0, `ajv-draft-04` 1.0.0 y
34
+ `ajv-formats` 3.0.1; el cliente de API y el catálogo viajan dentro del paquete,
35
+ así que no necesita el workspace del cliente ni el código del ERP.
36
+
37
+ ## Descubrir el contrato sin conexión
38
+
39
+ Estos comandos no leen perfiles ni archivos de entrada y no hacen solicitudes:
40
+
41
+ ```sh
42
+ backoffice help
43
+ backoffice purchases --help
44
+ backoffice purchases invoice help
45
+ backoffice purchases invoice create --help
46
+ backoffice search "factura de compra" --json
47
+ backoffice purchases invoice get --schema --json
48
+ backoffice purchases invoice create --schema --json
49
+ backoffice purchases invoice create --example
50
+ backoffice purchases invoice references --json
51
+ backoffice api list --module purchases --json
52
+ backoffice api describe purchases.invoice.create --json
53
+ ```
54
+
55
+ La raíz muestra módulos. Cada módulo separa dos cosas que no son intercambiables:
56
+ `commands` son los sustantivos ejecutables —`backoffice purchases invoice`— y
57
+ `catalog_paths` son segmentos de ruta del catálogo sin comando de negocio, que
58
+ solo se consultan con `api list` o `api describe`. Escribir un `catalog_paths`
59
+ como si fuera un comando produce `INVALID_INPUT`. La factura expone acciones,
60
+ estados, referencias y próximos pasos.
61
+ El alias posicional `help` se reconoce en topics y acciones de factura, y en
62
+ comandos registrados sin argumentos posicionales (`api list help`,
63
+ `profile check help`). En `search help`, `api describe help` y `api request help`,
64
+ `help` conserva su significado de argumento. Tampoco se reescriben valores de
65
+ flags ni texto después de `--`.
66
+
67
+ Cada comando de negocio documenta su propia superficie además del contrato:
68
+ `--help` publica `command`, `usage`, `arguments` y `flags`, con el nombre de
69
+ cada opción, su forma de argumento —`--input <archivo|->`, `--per-page
70
+ <entero>`— y cuáles son obligatorias. Las opciones publicadas son exactamente
71
+ las que declara el comando, más `--help` y, en las acciones respaldadas por una
72
+ operación del catálogo, `--schema` y `--example`. Un argumento posicional con
73
+ valores cerrados publica sus `options`, como `reference <name>`.
74
+
75
+ `INVALID_INPUT` nombra lo que recibió y qué acepta. `details` publica `kind`
76
+ —`module`, `resource`, `action`, `command`, `flag`, `flag_value` o
77
+ `argument_value`—, el token `received`, las `expected` disponibles cuando la
78
+ lista es cerrada y un `next_step`. Los dos últimos añaden `parameter` con la
79
+ opción o el argumento que rechazó el valor.
80
+
81
+ Un valor fuera de un conjunto cerrado se rechaza en el cliente, antes de
82
+ cualquier solicitud, y su token sí viaja en `received`: es una alternativa
83
+ publicada, no un dato del usuario. El valor de una opción de texto libre nunca
84
+ aparece, ni siquiera en la forma `--opcion=valor`.
85
+
86
+ Los identificadores son estables: `get:purchases/purchases-invoices/{id}` o
87
+ su alias `purchases.invoice.get`. Los hashes `operationId` no se aceptan.
88
+ La búsqueda ignora mayúsculas y acentos. `describe` muestra contrato, ejemplos,
89
+ permisos y soporte; `--schema` entrega solo los parámetros/body y sus referencias
90
+ transitivas, con `schema_format: "openapi-3.0"` y tenant ligado al perfil.
91
+
92
+ Cuando el CLI acepta un cuerpo que el contrato publicado no aceptaría, `--schema`
93
+ lo dice junto al contrato en `cli_input`, con `scope: "cli_only"` y una regla por
94
+ campo: qué exige el contrato, qué acepta el CLI, qué genera cuando el campo falta
95
+ y en qué campo del resultado publica esa procedencia. El contrato publicado no
96
+ cambia; `--help` repite la misma sección para que ninguna de las dos superficies
97
+ describa una entrada distinta.
98
+
99
+ ```json
100
+ {
101
+ "scope": "cli_only",
102
+ "rules": [
103
+ {
104
+ "field": "id",
105
+ "contract": "required",
106
+ "cli": "optional",
107
+ "generated_when_omitted": "uuid_v4",
108
+ "reported_in": "id_source",
109
+ "reported_values": ["client", "generated"]
110
+ }
111
+ ]
112
+ }
113
+ ```
114
+
115
+ El artefacto actual contiene las 265 operaciones documentadas de empresa;
116
+ solo excluye el bootstrap invitado `POST /api/backoffice/first-companies`.
117
+ 251 contratos pueden ejecutarse. Las 5 cargas multipart, 6 descargas y 3
118
+ operaciones exclusivas de sesión permanecen visibles con `support.available:
119
+ false` y motivos explícitos. La generación selecciona el conjunto desde OpenAPI;
120
+ el número no es una lista fija de operaciones permitidas.
121
+
122
+ Los valores de `support.reasons` y `unsupported_reasons` forman un vocabulario
123
+ cerrado. Cuando `api request --json` rechaza una operación por esta clasificación,
124
+ `error.reason` publica el motivo como campo opcional; conserva `code` y el código
125
+ de salida. No contiene nombres, valores de entrada ni errores internos.
126
+
127
+ | Motivo | Significado |
128
+ |---|---|
129
+ | `session_authentication_required` | La operación exige una sesión de usuario. |
130
+ | `unsupported_request_media` | El medio del body no está soportado o su declaración es inválida. |
131
+ | `download_response` | La respuesta es una descarga fuera del alcance del cliente. |
132
+ | `unsupported_parameter_serialization` | La ubicación, forma o serialización de parámetros no está soportada. |
133
+ | `unsupported_method` | El método HTTP no está soportado para ejecución. |
134
+ | `unsupported_input_contract` | El esquema de entrada contiene un contrato que el validador no puede garantizar. |
135
+
136
+ El último motivo conserva `UNSUPPORTED_CONTRACT`; los demás usan
137
+ `UNSUPPORTED_OPERATION`. Ambos conservan salida 2. Los demás errores omiten
138
+ `reason`.
139
+
140
+ Los permisos del piloto se verificaron en rutas/controladores; las nueve consultas
141
+ lookup heredan el acceso y membership del grupo. Fuera del piloto se indica
142
+ `not_verified`: el servidor decide los permisos efectivos. El campo
143
+ `permissions.inherited_requirements` publica las restricciones heredadas
144
+ verificadas del piloto: `authentication`, `verified_user`, `tenant_context`,
145
+ `api_key_guardrails`, `company_access`, `permission_team_context` y
146
+ `tenant_membership`. Un `required: []` solo indica ausencia de un permiso
147
+ nombrado propio; las restricciones heredadas siguen aplicando. Los efectos se marcan
148
+ como verificados o inferidos del método; el POST de búsqueda sensible es lectura.
149
+ Las escrituras requieren la habilidad `write` de la clave además de los permisos
150
+ del usuario. Los ejemplos usan IDs ficticios y no acreditan existencia de referencias.
151
+
152
+ ## Ejecutar una operación conocida
153
+
154
+ ```sh
155
+ backoffice api request purchases.invoice.get \
156
+ --path '{"id":"019a46ea-4517-70a5-9afe-4b564dc0ab13"}' --json
157
+
158
+ backoffice api request purchases.providers.lookup \
159
+ --query '{"status":"active","page":1,"per_page":20}' --json
160
+
161
+ backoffice api request post:purchases/contracts --input payload.json --json
162
+ cat payload.json | backoffice api request post:purchases/contracts \
163
+ --input - --json --non-interactive
164
+ ```
165
+
166
+ `--path` y `--query` reciben objetos JSON tipados. `--input` contiene únicamente
167
+ el body JSON desde archivo o `-`; solo esta última opción lee stdin. Stdin TTY
168
+ se rechaza inmediatamente. Archivos/stdin tienen un límite de 1 MiB y 30 segundos.
169
+ JSON inválido, errores de lectura y límites producen errores sanitizados antes de HTTP.
170
+ El catálogo decide método y ruta: no se admiten URLs arbitrarias.
171
+
172
+ El tenant siempre sale de la conexión validada. Enviar `tenant` en path/query
173
+ se rechaza aunque coincida con el perfil. Los valores de path son escalares,
174
+ se codifican por segmento y no admiten vacío, `.`, `..`, slash, backslash,
175
+ controles, Unicode inválido ni `%` (incluidas codificaciones de traversal).
176
+ `?`, `#` y `$&` se conservan como datos codificados. Se verifica el origen,
177
+ el pathname exacto y que ningún valor capture otra ruta literal como `lookup`.
178
+
179
+ Query admite primitivos y arrays `form`: pares repetidos con `explode: true`
180
+ (predeterminado), o elementos codificados individualmente unidos por coma con
181
+ `explode: false`. Se rechazan nombres desconocidos, null, objetos, arrays vacíos,
182
+ header/cookie, `content`, `allowReserved` y estilos no implementados.
183
+ El body admite `application/json`; ausencia, null y `{}` son valores distintos.
184
+
185
+ La validación de entrada conserva tipos, required, nullable, enum, límites
186
+ inclusive/exclusivo, multipleOf, longitudes Unicode, pattern, límites/uniqueness
187
+ de arrays, límites de propiedades, additionalProperties, referencias locales y
188
+ allOf/oneOf/anyOf/not. Usa OpenAPI 3.0 con adaptación a draft-04 y formatos completos
189
+ UUID, date, date-time, email y formatos numéricos admitidos por Ajv. `readOnly`
190
+ se excluye de required y se rechaza al enviarlo; `writeOnly` conserva su obligación declarada.
191
+ Composición de acceso ambigua, discriminator, keywords o formatos desconocidos
192
+ fallan como contrato no soportado. `binary` no habilita uploads.
193
+
194
+ No hay coerción, inserción de defaults ni eliminación de campos. Se rechazan
195
+ valores no JSON, ciclos, números no finitos y enteros fuera del rango seguro.
196
+ Las cadenas decimales se transmiten idénticas. Esto no garantiza precisión
197
+ monetaria para schemas numéricos ni exactitud int64 fuera del rango seguro.
198
+ La validación corresponde a las restricciones publicadas; permisos, referencias
199
+ y reglas de negocio adicionales siguen siendo autoridad del servidor.
200
+
201
+ Toda solicitud se intenta una sola vez, sin confirmaciones, avisos ni reintentos.
202
+ Un éxito genérico informa `operation`, `method`, `http_status`, `effect` y
203
+ `verification: "not_performed"`. Cero bytes se representan como
204
+ `response: {"kind":"empty"}`; JSON válido, incluso null o arrays, como
205
+ `response: {"kind":"json","value":null}`. Un 201 vacío no fabrica un ID.
206
+ Timeout/desconexión o respuesta exitosa ilegible tras intentar una escritura
207
+ producen `WRITE_OUTCOME_UNKNOWN`, exit 9. No se hace GET de reconciliación.
208
+ Un error HTTP conserva el status observado y no acredita rollback del servidor.
209
+
210
+ ## Configurar el acceso
211
+
212
+ Necesitas el origen del servidor HTTP(S), el UUID de la empresa y una clave
213
+ de API administrada en Backoffice. La clave pertenece a un usuario, está
214
+ vinculada a una empresa y conserva sus permisos vigentes.
215
+
216
+ Guarda un perfil leyendo la clave desde stdin. Sustituye el servidor y el
217
+ UUID del ejemplo por los de tu entorno; `BACKOFFICE_API_KEY` debe contener la
218
+ clave obtenida previamente:
219
+
220
+ ```sh
221
+ printf '%s\n' "$BACKOFFICE_API_KEY" | backoffice profile set default \
222
+ --server https://erp.example \
223
+ --tenant 019a46ea-4517-70a5-9afe-4b564dc0ab12 \
224
+ --api-key-stdin --non-interactive
225
+ unset BACKOFFICE_API_KEY
226
+
227
+ backoffice profile check --profile default --json --non-interactive
228
+ ```
229
+
230
+ La clave se trata como un valor opaco, incluido cualquier prefijo con `|`.
231
+ Se admite un salto final LF o CRLF de stdin. Una clave vacía, con controles
232
+ o espacios en los extremos produce un error sin alterarla. Si stdin es una
233
+ terminal, el comando falla inmediatamente y no solicita la clave. No existe un flag para pasar la
234
+ clave como argumento. El servidor debe ser un origen: sin credenciales,
235
+ query, fragmento ni una ruta como `/api`.
236
+
237
+ `profile check` significa **comprobar acceso a la empresa configurada**.
238
+ Ejecuta una consulta de proveedores permitida; no verifica identidad ni
239
+ acredita permiso para todas las operaciones. Listar o consultar facturas
240
+ requiere el permiso `backoffice.purchases.purchases-invoices.view`.
241
+
242
+ Los perfiles se guardan en `~/.config/backoffice/profiles.json`. Puedes elegir
243
+ otro directorio con `BACKOFFICE_CONFIG_DIR`. El documento usa la versión `1`
244
+ y un diccionario de perfiles. En POSIX, el directorio se crea con modo `0700`
245
+ y el archivo con `0600`. Una configuración inválida produce un error; no se
246
+ reemplaza silenciosamente.
247
+
248
+ La selección de conexión sigue este orden:
249
+
250
+ 1. `--profile <nombre>` selecciona un perfil guardado y prevalece sobre el entorno.
251
+ 2. Sin ese flag, una tripleta completa `BACKOFFICE_SERVER`, `BACKOFFICE_TENANT`
252
+ y `BACKOFFICE_API_KEY` configura una conexión temporal.
253
+ 3. Sin tripleta, `BACKOFFICE_PROFILE` selecciona un perfil guardado.
254
+ 4. Sin selección, se usa `default`.
255
+
256
+ Una tripleta parcial es un error. El CLI nunca combina el servidor de una
257
+ fuente con la clave de otra. Por eso el ejemplo elimina la variable de clave
258
+ tras guardar el perfil.
259
+
260
+ ## Consultar facturas
261
+
262
+ ```sh
263
+ backoffice purchases invoice list \
264
+ --status to_be_approved --page 1 --per-page 20 \
265
+ --profile default --json --non-interactive
266
+
267
+ backoffice purchases invoice get 019a46ea-4517-70a5-9afe-4b564dc0ab13 \
268
+ --profile default --json --non-interactive
269
+ ```
270
+
271
+ Filtros de `purchases invoice list`:
272
+
273
+ | Flag | Parámetro de API | Valor |
274
+ | ---------------------- | -------------------- | ------------------------------------ |
275
+ | `--provider-id` | `provider_id` | UUID del proveedor |
276
+ | `--status` | `status` | Estado, por ejemplo `to_be_approved` |
277
+ | `--code` | `code` | Código de factura |
278
+ | `--emission-date-from` | `emission_date_from` | Fecha inicial inclusiva `YYYY-MM-DD` |
279
+ | `--emission-date-to` | `emission_date_to` | Fecha final inclusiva `YYYY-MM-DD` |
280
+ | `--sort-by` | `sort_by` | Campo de orden, por ejemplo `code` |
281
+ | `--sort-order` | `sort_order` | `asc` o `desc` |
282
+ | `--page` | `page` | Entero positivo |
283
+ | `--per-page` | `per_page` | Entero positivo |
284
+
285
+ El orden predeterminado del servidor es `code DESC`. `--sort-by` admite
286
+ `code`, `emission_date`, `total`, `balance`, `status` y `created_at`. Las fechas
287
+ deben existir y el rango inicial no puede superar el final.
288
+
289
+ `--status`, `--sort-by` y `--sort-order` tienen conjuntos cerrados y publicados:
290
+ `backoffice purchases invoice --help --json` da los estados en `states` y
291
+ `... list --help --json` los repite en la forma de argumento de cada opción. Un
292
+ valor fuera del conjunto se rechaza con `INVALID_INPUT` y salida 2 **sin llegar
293
+ al servidor**, nombrando el valor recibido y las alternativas. Un listado vacío
294
+ significa entonces que no hay facturas que cumplan el filtro, nunca que el filtro
295
+ estaba mal escrito. Los filtros de texto libre —`--code`, `--provider-id`— y las
296
+ fechas no tienen conjunto cerrado: el servidor decide.
297
+
298
+ Las consultas usan estas rutas:
299
+
300
+ ```text
301
+ GET /api/backoffice/{tenant}/purchases/purchases-invoices
302
+ GET /api/backoffice/{tenant}/purchases/purchases-invoices/{id}
303
+ GET /api/backoffice/{tenant}/purchases/providers/lookup?per_page=1
304
+ ```
305
+
306
+ Cada solicitud lleva `Authorization: Bearer <clave>` y
307
+ `Accept: application/json`. No se usan cookies. Las redirecciones se
308
+ rechazan sin seguirlas; no hay reintentos automáticos y las solicitudes
309
+ tienen un timeout de 30 segundos.
310
+
311
+ ## Resolver las referencias de una factura
312
+
313
+ Una factura de compra vincula nueve referencias. El catálogo de referencias es
314
+ offline: no lee perfiles ni hace solicitudes.
315
+
316
+ ```sh
317
+ backoffice purchases invoice references --json
318
+ backoffice purchases invoice reference providers --json
319
+ backoffice purchases invoice reference items --search "tornillo" --json
320
+ backoffice purchases invoice reference locations --status inactive --page 2 --per-page 50 --json
321
+ ```
322
+
323
+ | Referencia | Campo de la factura | Operación |
324
+ | --- | --- | --- |
325
+ | `providers` | `provider_id` | `get:purchases/providers/lookup` |
326
+ | `categories` | `items[].category_id` | `get:inventory/categories/lookup` |
327
+ | `items` | `items[].item_id` | `get:inventory/items/lookup` |
328
+ | `units` | `items[].unit_id` | `get:inventory/units/lookup` |
329
+ | `taxes` | `items[].tax_id` | `get:accounting/taxes/lookup` |
330
+ | `accounting-centers` | `accounting_center_id`, `items[].accounting_center_id` | `get:accounting/accounting-centers/lookup` |
331
+ | `accounting-accounts` | `items[].accounting_account_id` | `get:accounting/accounting-accounts/lookup` |
332
+ | `payable-accounts` | `accounting_account_payable_id` | `get:accounting/payable-accounts/lookup` |
333
+ | `locations` | `location_id` (opcional) | `get:inventory/locations/lookup` |
334
+
335
+ El id de `payable-accounts` es el de la **cuenta contable** subyacente, no el de
336
+ la configuración. Dos comprobaciones evitan los rechazos más frecuentes y salen
337
+ gratis de la proyección de `items`: `items[].category_id` debe ser el
338
+ `category_id` del artículo, e `items[].unit_id` debe estar entre sus `unit_ids`.
339
+
340
+ La tercera no evita un rechazo: evita una factura equivocada.
341
+ `items[].tax_percentage` debería ser el `rate` que devuelve
342
+ `accounting/taxes/lookup` para ese `items[].tax_id`, y **el servidor no comprueba
343
+ esa correspondencia**. Valida por separado que `tax_percentage` sea un número
344
+ entre 0 y 100 y que `tax_id` exista, esté activo y pertenezca a la empresa;
345
+ cualquier combinación de los dos se acepta y el porcentaje enviado queda
346
+ almacenado. Resuelve el `rate` con el lookup en lugar de suponerlo. Las tres
347
+ comprobaciones viajan en `cross_checks` de `references --json`.
348
+
349
+ `--status` es `active` por omisión, y solo acepta `active` o `inactive`: solo las
350
+ referencias activas vinculan. Una lista vacía es éxito, con salida 0 y un
351
+ `next_steps` que sugiere `--status inactive` o la consulta genérica. Un nombre de
352
+ referencia fuera de los nueve se rechaza con `INVALID_INPUT`, salida 2 y las nueve
353
+ alternativas en `details.expected`, sin llegar al servidor.
354
+
355
+ **El servidor no publica búsqueda de texto en ningún lookup.** `--search` filtra
356
+ en el cliente las filas que ya descargó: avanza desde `--page` hasta cubrir el
357
+ `total` o hasta un tope de 25 páginas. La respuesta lo dice explícitamente con
358
+ `search_scope: "client_side_paged"`, `pages_scanned` y `truncated`. Sin
359
+ `--search` se lee una sola página y `search_scope` se omite.
360
+
361
+ `truncated` significa lo mismo en ambos modos —quedaron filas sin leer— pero se
362
+ alcanza por dos caminos: con `--search` el recorrido se detuvo en el tope de 25
363
+ páginas antes de cubrir el `total`; sin `--search` la única página leída no
364
+ cubrió el `total`. En los dos casos hay más referencias que las devueltas: sigue
365
+ con `--page`. El recorrido razona siempre con el `per_page` que aplicó el
366
+ servidor, no con el solicitado, por si el servidor acota la página.
367
+
368
+ Ninguna de las nueve consultas declara un permiso propio, y eso no significa
369
+ acceso libre: heredan autenticación, verificación, contexto de empresa,
370
+ guardarraíles de clave y pertenencia al tenant. `references --json` publica esas
371
+ restricciones heredadas junto al catálogo.
372
+
373
+ ## Crear y verificar una factura
374
+
375
+ ```sh
376
+ backoffice purchases invoice create --input factura.json --json
377
+ cat factura.json | backoffice purchases invoice create --input - --json --non-interactive
378
+ backoffice purchases invoice create --help --json
379
+ ```
380
+
381
+ `--input` contiene únicamente el body JSON. El comando valida el cuerpo contra
382
+ el contrato publicado **antes** de cualquier solicitud, envía el POST **una sola
383
+ vez** y después lee la factura con `GET .../purchases-invoices/{id}` usando el
384
+ `id` con el que escribió. El `id` de la factura es el único identificador
385
+ predecible: los `items[].id` los asigna el servidor y un `items[].id` enviado se
386
+ descarta.
387
+
388
+ **El `id` lo aporta el cliente y el CLI puede aportarlo por ti.** Lo publica
389
+ `backoffice purchases invoice create --schema --json` en `cli_input`. El contrato
390
+ publicado lo exige y no cambia: si `--input` no trae `id`, el CLI genera un UUID
391
+ v4, lo usa para el POST y para la lectura de verificación, y lo devuelve en la
392
+ respuesta. Envíalo tú cuando quieras controlar el reintento —repetir la creación
393
+ con el mismo `id` es una decisión tuya, no del CLI— y omítelo cuando solo
394
+ necesites crear la factura. El resultado publica `id_source`: `client` si venía
395
+ en `--input`, `generated` si lo generó el CLI. Un `id` presente pero no válido
396
+ —`null`, un número, una cadena que no es UUID— es un error de contrato: nunca se
397
+ sustituye por uno generado.
398
+
399
+ **El comando necesita dos permisos, uno por cada tramo.** El POST exige
400
+ `backoffice.purchases.purchases-invoices.create` y la habilidad `write` de la
401
+ clave; la lectura de verificación exige
402
+ `backoffice.purchases.purchases-invoices.view`, porque
403
+ `GET .../purchases-invoices/{id}` lo declara en su ruta. Una credencial con
404
+ `.create` pero sin `.view` crea la factura y termina en `CREATE_NOT_VERIFIED`
405
+ (salida 10) con `readback_error_code: "ACCESS_DENIED"`: la factura existe y
406
+ repetir el `verify_command` fallará igual hasta que se conceda `.view`.
407
+
408
+ Los decimales aceptan número o cadena, igual que el validador. **Solo una cadena
409
+ JSON conserva la escala exacta**: `"1.50"` viaja idéntico, mientras que `1.50`
410
+ se transmite como `1.5`. El CLI nunca convierte entre ambas formas. El valor
411
+ almacenado se trunca a dos decimales.
412
+
413
+ Una creación verificada devuelve:
414
+
415
+ ```json
416
+ {
417
+ "ok": true,
418
+ "data": {
419
+ "operation": "post:purchases/purchases-invoices",
420
+ "http_status": 201,
421
+ "create_response": { "kind": "json", "value": {} },
422
+ "verification": "verified",
423
+ "id": "019a46ea-4517-70a5-9afe-4b564dc0ab13",
424
+ "id_source": "client",
425
+ "invoice": { "id": "019a46ea-4517-70a5-9afe-4b564dc0ab13" },
426
+ "next_steps": ["backoffice purchases invoice get 019a46ea-4517-70a5-9afe-4b564dc0ab13 --json"]
427
+ }
428
+ }
429
+ ```
430
+
431
+ `create_response` es la respuesta cruda del POST, que es un objeto vacío sin
432
+ identificador. `invoice` proviene **solo** de la lectura de verificación. Salida
433
+ 0 significa que el POST devolvió 2xx y que esa lectura devolvió una factura cuyo
434
+ `id` coincide, sin distinguir mayúsculas.
435
+
436
+ | Resultado | Código | Exit |
437
+ | --- | --- | --- |
438
+ | Cuerpo inválido según el contrato, sin ninguna solicitud | `REQUEST_VALIDATION_FAILED` | 2 |
439
+ | Clave sin habilidad `write` o usuario sin permiso `.create` | `ACCESS_DENIED` | 4 |
440
+ | Clave de otra empresa | `NOT_FOUND` | 5 |
441
+ | Rechazo del servidor: validación, código duplicado, reglas de orden o cantidad | `CREATE_REJECTED` | 8 |
442
+ | Una o más referencias que la empresa no puede vincular | `REFERENCES_NOT_BINDABLE` | 8 |
443
+ | Escritura intentada con resultado incierto | `WRITE_OUTCOME_UNKNOWN` | 9 |
444
+ | Escritura confirmada y lectura de verificación fallida | `CREATE_NOT_VERIFIED` | 10 |
445
+
446
+ `CREATE_REJECTED` conserva en `details` las claves que escribe el servidor
447
+ —`error_code`, `detail` y `errors`— acotadas en tamaño.
448
+ `REFERENCES_NOT_BINDABLE` conserva `details.violations` con `reference_kind`,
449
+ `reference_id`, `reason` y `human_message`: nombra cada referencia en lugar de
450
+ devolver un error HTTP genérico.
451
+
452
+ Los tres desenlaces inciertos no son el mismo: exit 9 significa que el CLI **no
453
+ sabe** si la fila existe; exit 10 significa que sí existe —se observó un 2xx— y
454
+ no se pudo leer. Exit 10 devuelve `details` con `invoice_id`,
455
+ `create_http_status`, `readback_error_code`, el `verify_command` a ejecutar y un
456
+ `readback_guidance` que distingue el fallo pasajero —repite el `verify_command`—
457
+ del permanente: un `readback_error_code` de `ACCESS_DENIED` significa que la
458
+ credencial no puede leer facturas y necesita `.view`.
459
+
460
+ Ninguna rama reintenta la escritura: como máximo una solicitud POST y una GET.
461
+ El POST no aprueba la factura; la aprobación es un flujo aparte.
462
+
463
+ ## Salida para scripts
464
+
465
+ `--json` escribe un único objeto JSON en stdout, también ante errores, y deja
466
+ stderr vacío. No mezcla banners, logs ni prompts. Comprueba siempre el código
467
+ de salida del proceso. `--non-interactive` está disponible en los comandos;
468
+ esta versión no solicita datos mediante prompts.
469
+
470
+ El listado conserva íntegra la respuesta `{data, meta}` dentro del sobre:
471
+
472
+ ```json
473
+ {
474
+ "ok": true,
475
+ "data": {
476
+ "data": [],
477
+ "meta": { "total": 0, "per_page": 20, "current_page": 1 }
478
+ }
479
+ }
480
+ ```
481
+
482
+ Una lista vacía es un éxito. `meta`, las propiedades adicionales y los
483
+ decimales representados como strings se conservan sin recalcular ni convertir
484
+ a números. En `get`, `data` contiene el objeto de factura recibido del servidor.
485
+
486
+ La comprobación de acceso devuelve:
487
+
488
+ ```json
489
+ {
490
+ "ok": true,
491
+ "data": {
492
+ "profile": "default",
493
+ "tenant": "019a46ea-4517-70a5-9afe-4b564dc0ab12",
494
+ "access": "verified"
495
+ }
496
+ }
497
+ ```
498
+
499
+ Un error de autenticación devuelve:
500
+
501
+ ```json
502
+ {
503
+ "ok": false,
504
+ "error": {
505
+ "code": "AUTHENTICATION_FAILED",
506
+ "message": "La clave no permite autenticar la solicitud.",
507
+ "http_status": 401
508
+ }
509
+ }
510
+ ```
511
+
512
+ `http_status` solo aparece cuando existe una respuesta HTTP. Los mensajes son
513
+ propios: los fallos de schema añaden `details.violations` con ubicación de entrada,
514
+ ruta del schema y keyword (máximo 20). No incluyen rutas de instancia ni nombres
515
+ de propiedades desconocidas del payload. No incluyen claves, cabeceras, cuerpos remotos, destinos de
516
+ redirección ni stack traces. Sin `--json`, el resultado legible va a stdout y
517
+ los errores sanitizados a stderr.
518
+
519
+ | Exit | Significado | Código de error |
520
+ | ---- | ------------------------------------------ | ---------------------------------------------------------------------------------- |
521
+ | `0` | Éxito, incluida una lista vacía | — |
522
+ | `2` | Uso, entrada o perfil inválido/faltante | `INVALID_INPUT`, `INVALID_PROFILE`, `PROFILE_READ_FAILED`, `PROFILE_WRITE_FAILED` o `STDIN_ERROR`; en API: `UNKNOWN_OPERATION`, `INVALID_JSON`, `INPUT_READ_FAILED`, `INPUT_TOO_LARGE`, `REQUEST_VALIDATION_FAILED`, `UNSUPPORTED_OPERATION`, `UNSUPPORTED_CONTRACT`, `COMMAND_NOT_IMPLEMENTED` (acción declarada sin ejecución) |
523
+ | `3` | HTTP 401 | `AUTHENTICATION_FAILED` |
524
+ | `4` | HTTP 403 | `ACCESS_DENIED` |
525
+ | `5` | HTTP 404 | `NOT_FOUND` |
526
+ | `6` | Fallo de transporte o timeout | `TRANSPORT_ERROR` |
527
+ | `7` | Respuesta de lectura inválida o redirección | `INVALID_RESPONSE` o `REDIRECT_REJECTED` |
528
+ | `8` | Otro error HTTP, incluidos 400/422/429/5xx | `HTTP_ERROR`; en creación de negocio: `CREATE_REJECTED` o `REFERENCES_NOT_BINDABLE` |
529
+ | `9` | Resultado de escritura incierto | `WRITE_OUTCOME_UNKNOWN` |
530
+ | `10` | Escritura confirmada y verificación fallida | `CREATE_NOT_VERIFIED` |
531
+ | `1` | Error interno sanitizado | `INTERNAL_ERROR` |
532
+
533
+ ## Limitaciones conocidas
534
+
535
+ - No hay búsqueda de texto en el servidor: `--search` filtra en el cliente y lo
536
+ declara con `search_scope`, `pages_scanned` y `truncated`.
537
+ - `emission_date` se publica como `YYYY-MM-DD`. La regla del validador es más
538
+ laxa; el contrato se estrechó a propósito para que el valor enviado coincida
539
+ con el almacenado y devuelto.
540
+ - Las cadenas decimales rechazan notación exponencial, signo inicial y espacios,
541
+ que PHP aceptaría. La rama numérica sí los admite cuando el JSON los permite.
542
+ - Los `items[].id` los asigna el servidor. Un `items[].id` enviado se descarta
543
+ sin aviso, por lo que solo el `id` de la factura sirve para verificar.
544
+ - Siete de las nueve consultas lookup no tienen cobertura automatizada en el
545
+ backend. El piloto es su única evidencia.
546
+ - No hay auto-aprobación, confirmaciones ni reintentos en ninguna operación.
547
+
548
+ ## Ayuda y verificación local
549
+
550
+ La ayuda funciona sin perfil ni acceso a la red:
551
+
552
+ ```sh
553
+ backoffice --help
554
+ backoffice profile --help
555
+ backoffice profile check --help
556
+ backoffice purchases --help
557
+ backoffice purchases invoice --help
558
+ backoffice purchases invoice list --help
559
+ backoffice purchases invoice get --help
560
+ backoffice purchases invoice create --help
561
+ backoffice purchases invoice references --help
562
+ backoffice purchases invoice reference --help
563
+ ```
564
+
565
+ Desde `apps/backoffice-cli`:
566
+
567
+ ```sh
568
+ npm test
569
+ npm run lint
570
+ npm run check-types
571
+ npm run build
572
+ npm run test:pack
573
+ ```
574
+
575
+ `npm run test:pack -- --catalog-growth` añade una operación de ejemplo únicamente
576
+ a una copia temporal del contrato y del paquete para comprobar que las pruebas
577
+ aceptan crecimiento legítimo cuando coinciden los conjuntos de fuente, catálogo
578
+ y CLI instalada. La ejecución normal verifica el contrato real sin modificarlo.
579
+
580
+ `test:pack` genera e instala el tarball real en un directorio temporal fuera
581
+ del repositorio. Ejecuta el bin instalado, comprueba ayuda offline, perfiles,
582
+ consultas, decimales, paginación y errores contra servidores HTTP locales, y
583
+ verifica que el destino de una redirección no reciba solicitudes. La
584
+ instalación de dependencias puede consultar npm; las pruebas de API no usan
585
+ datos ni credenciales de producción. Los procesos, servidores y archivos
586
+ temporales se limpian al terminar.
587
+
588
+ Desde `packages/backoffice-api`, `npm run generate` genera `schema.d.ts`,
589
+ `catalog.ts` y `checksums.json` desde `storage/api-docs/api-docs.json` y los
590
+ metadatos curados. La transformación pura ordena mapas/operaciones, preserva
591
+ arrays y calcula hashes canónicos de fuente, metadatos y catálogo.
592
+ `npm run generate:check` compara todos los artefactos sin reescribirlos y falla
593
+ si falta uno o hay drift. Las anotaciones PHP se regeneran con `make generate-docs`.
594
+ Backoffice CLI Checks ya ejecuta la comprobación de generación en CI.
595
+
596
+ Los tipos se generan desde el OpenAPI del ERP. Este cliente conserva propiedades
597
+ adicionales en las respuestas. El contrato de creación publica las mismas reglas
598
+ que valida el controlador —uuid, longitudes, mínimo de líneas y decimales como
599
+ número o cadena—; una prueba de arquitectura compara ambos lados campo por campo.
600
+ El smoke del paquete usa servidores locales y no sustituye una prueba contra la
601
+ API Laravel real.
package/bin/run.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../dist/index.js";
3
+
4
+ process.exitCode = await runCli(process.argv.slice(2), import.meta.url, {
5
+ stdout: (text) => {
6
+ process.stdout.write(text);
7
+ },
8
+ stderr: (text) => {
9
+ process.stderr.write(text);
10
+ },
11
+ });