Skip to content

feat: [TESIS-141] open the shipment of an order from its detail - #77

Merged
TomasMartin2004 merged 3 commits into
masterfrom
TESIS-141-create-shipment-from-order-detail
Oct 5, 2026
Merged

TomasMartin2004 merged 3 commits into
masterfrom
TESIS-141-create-shipment-from-order-detail

Conversation

@TomasMartin2004

@TomasMartin2004 TomasMartin2004 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Descripción

Una orden que no entró por el asistente de alta manual no tenía forma de llegar al circuito logístico desde la app.

El envío lo abría un solo lugar: el paso 3 del alta manual, donde useConfirmDraftOrder encadena createOrder → createOrderShipment → dispatchShipment. Su propio comentario lo decía: «sin envío, este paso sigue siendo el único lugar que lo abre». Toda orden que entra por webhook nace sin envío, y el detalle mostraba «La orden todavía no tiene un envío creado.» sin ninguna acción al lado.

Son todas las ventas de canal, que es justamente lo que el OMS existe para resolver. Contra la base de desarrollo:

#6  manual   paid       envio=ready_to_ship
#7  webhook  pending    envio=pending      ← se lo pone db/seeds.rb, no la app
#8  webhook  cancelled  envio=NINGUNO
#11 webhook  paid       envio=NINGUNO      ← la venta real de Shopify

La orden #11 entró por el webhook de Shopify (TESIS-138), está pagada, con el stock descontado, y no se puede despachar desde ninguna pantalla.

Qué hace

El detalle de la orden ofrece «Crear envío» en la tarjeta del ciclo de vida, en el mismo lugar donde aparece «Despachar». Llama a POST /orders/:order_id/shipment (TESIS-105) con createOrderShipment, que ya existía en api.ts y hasta ahora sólo usaba el asistente. Al resolver se invalida el dominio de órdenes: la tarjeta pasa a mostrar el envío pending y con él aparece «Despachar» (TESIS-134).

Decisiones que conviene mirar

Las dos acciones quedan separadas, no encadenadas. El asistente las encadena porque ahí la persona ya eligió courier en el mismo paso; acá abrir el circuito y elegir operador son decisiones distintas, y encadenarlas obligaría a cotizar a quien sólo quiere dejar la orden lista. Lo que sigue a crear el envío es que aparezca el botón de despachar.

La regla va en utils/dispatch.ts, al lado de dispatchableShipment. canOpenShipment aplica la misma exclusión que el backend (Shipments::CreateShipment::NON_SHIPPABLE_STATUSES: canceladas y nada más). Y se niega también mientras la consulta del envío carga o falla: que el envío no exista tiene que ser un hecho y no una duda, porque ofrecerlo sobre un envío que no se pudo leer invita a un 409.

El 409 no se reintenta. Significa que el envío ya existe —el asistente, u otra pestaña—, así que la mutación invalida en onSettled y la pantalla avisa que el envío ya estaba en vez de insistir. Mismo criterio que useDispatchShipment con su 409.

El id se captura fuera del callback (loadedOrderId). El angostado de los returns tempranos no alcanza adentro de una función, que TypeScript no sabe cuándo se llama; tsc -b lo marcaba.

Por qué faltaba (para el contexto de la review)

Está documentado, no fue un olvido:

  • TESIS-105 planteó la decisión de si el envío se crea solo al pasar la orden a paid (opción A, que nombraba Orders::ProcessWebhookOrder) o con una acción explícita (opción B). Se eligió B, y la propia card escribió su contra: «una orden pagada puede quedarse sin envío por olvido».
  • TESIS-134 llevó el despacho al detalle con encuadre de recuperación del wizard: su condición es «si la orden tiene envío pending». Al descartar su alternativa B dice «no sirve para órdenes que no entraron por el asistente» — nombra la categoría y después queda afuera del alcance.
  • TESIS-105 no tenía card hermana de front. TESIS-46 la tuvo (TESIS-59) y TESIS-47 la tuvo (TESIS-134).
  • Los seeds tapaban el síntoma: db/seeds.rb crea a mano el envío pending de la orden de webhook de ejemplo, así que en desarrollo esa orden siempre apareció con envío.

Cómo probar

  1. npm run dev, entrar como admin@norte.com.
  2. Órdenes → la orden de Shopify (feat: [TESIS-71] semantic status components (StatusBadge, StatusSelect) #11). La tarjeta del envío dice que no tiene uno y ahora ofrece «Crear envío».
  3. Clic: la tarjeta pasa a mostrar el envío pending y aparece «Despachar».
  4. Despachar: cotiza, se elige operador y la orden sale con su número de seguimiento.
  5. Una orden cancelada (docs: [TESIS-50] add CLAUDE.md with project guidelines and architecture #8) no ofrece ninguna de las dos acciones.

Verificación

  • npm run lint ✅
  • npm run test ✅ — 75 archivos, 698 tests (+15 sobre master, que tiene 683)
  • npm run build ✅ (tsc -b incluido)
  • npm run format:check ✅

Tests nuevos: canOpenShipment (6 ejemplos, incluidos los tres estados en los que el envío es una duda), el flujo en OrderDetailPage (9: se ofrece, con qué orden, el éxito, el 409, el error, el estado «Creando…», la orden cancelada, la precedencia del despacho y la consulta sin resolver) y useCreateOrderShipment (3).

No toqué la orden #11: la dejé sin envío para que la pruebes vos desde el navegador, que es el criterio de aceptación que falta marcar.

Fuera de alcance

  • Crear el envío automáticamente al ingresar la venta (opción A de TESIS-105). Si se quiere revisar esa decisión, es otra card y toca el backend.
  • Listado de envíos: GET /shipments existe desde TESIS-113 y no tiene pantalla.
  • Mostrar la etiqueta: el backend guarda shipping_label_url y el front no lo lee en ningún lado.

🤖 Generated with Claude Code

https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

An order that did not come through the manual order wizard had no way into the
logistics circuit from the app. The shipment was opened in exactly one place:
step 3 of the wizard, where `useConfirmDraftOrder` chains createOrder →
createOrderShipment → dispatchShipment. Its own comment said it: "sin envío,
este paso sigue siendo el único lugar que lo abre". Every order that arrives by
webhook is born without a shipment, and the detail showed "La orden todavía no
tiene un envío creado." with no action next to it.

That is every channel sale, which is what the OMS exists to resolve. The Shopify
order that came in through TESIS-138 is paid, has its stock deducted and cannot
be dispatched from any screen.

The detail now offers "Crear envío" in the shipment card, in the same slot where
"Despachar" appears, calling `POST /orders/:order_id/shipment` (TESIS-105)
through `createOrderShipment`, which already existed in api.ts and was only used
by the wizard. When it resolves, the orders domain is invalidated: the card
shows the pending shipment and "Despachar" (TESIS-134) appears with it.

The two actions stay separate on purpose. The wizard chains them because there
the person already chose a carrier in the same step; here, opening the circuit
and choosing an operator are different decisions.

`canOpenShipment` lives next to `dispatchableShipment` in utils/dispatch.ts and
applies the same exclusion as the backend (`Shipments::CreateShipment`:
cancelled orders and nothing else). It also refuses while the shipment query is
loading or failed: offering it over a shipment that could not be read would
invite a 409.

A 409 is not retried. It means the shipment already exists — the wizard, or
another tab — so the mutation invalidates on settled and the detail reports that
the shipment was already there instead of insisting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

@Sanntinat Sanntinat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisión — TESIS-141 (PR #77) · Crear el envío de una orden desde su detalle

Revisión de TESIS-141-create-shipment-from-order-detail (082abc0), contra origin/master de proyecto-web. La hice junto con #76 y #78, y los tres los probé también mergeados entre sí.

Check Resultado
CI (GitHub Actions) Los cuatro jobs en verde
Rama vs master Al día. Mergea limpio
Local, la rama sola lint, prettier y tsc -b limpios · 75 archivos · 700 tests · 0 fallas
Local, #76 + #77 + #78 mergeados lint, prettier, tsc -b y build limpios · 75 archivos · 705 tests · 0 fallas
Navegador El circuito completo con una orden de canal, contra la API de master. Detalle abajo
Conflictos con otros PRs #78 (sólo architecture.md, mecánico), #65 y #68 (OrderDetailPage.tsx, su test y architecture.md)

✅ El circuito de canal anda de punta a punta

No tengo la orden #11 de Shopify de tu base, así que armé el mismo caso en una base de verificación: una orden de Norte paid, con external_order_id, una línea con depósito y sin envío, como la que deja el webhook.

  1. El detalle muestra «La orden todavía no tiene un envío creado.» y, al lado, «Crear envío».
  2. Al crear, aparece el aviso «Envío creado. Ya se puede despachar.». La tarjeta pasa a mostrar las cuatro etapas con «Pendiente», el encabezado a PENDIENTE, y el botón pasa a ser «Despachar».
  3. «Despachar» abre el diálogo con el depósito de las líneas (Depósito Central) y la cotización de Andreani. Al confirmar sale el seguimiento AND-REVIEW-1.
  4. Controles: la orden cancelada (#8, sin envío) no ofrece ninguna de las dos acciones. Una en tránsito (#1) tampoco.

✅ Las decisiones están bien tomadas

  • Separar crear de despachar es lo correcto. En el asistente se encadenan porque ahí el courier ya está elegido; acá encadenarlos obligaría a cotizar a quien sólo quiere dejar la orden lista.
  • canOpenShipment no ofrece la acción mientras el envío carga o falla, y lo dice: un envío que no se pudo leer no es un envío que no existe.
  • Invalidar en onSettled para que un 409 traiga el envío que ya estaba, con un aviso propio en vez del error.

✅ Los specs tienen dientes

Rompí cada regla de a una y corrí features/orders:

Rotura Falla
Ofrecer «Crear envío» en una orden cancelada refuses a cancelled order, does not offer it for a cancelled order
Ofrecerlo mientras la consulta del envío carga o falla refuses while the shipment is unknown, does not offer it while the shipment query has not resolved
Tratar el 409 como un error común says the shipment was already there when the api answers 409
Invalidar sólo en onSuccess refreshes the shipment also when the order already had one

🔴 El paso 3 del alta sigue diciendo que ninguna otra pantalla puede abrir el envío

Este PR cierra justamente el hueco que la card cita como prueba: el comentario de useConfirmDraftOrder que decía «sin envío, este paso sigue siendo el único lugar que lo abre». Pero no toca lo que se apoyaba en eso, y después del merge quedan cuatro cosas que dicen o hacen lo contrario de lo que este PR vuelve verdad:

  • Lo que ve el operador. Si en el paso 3 falla la apertura del envío, el error dice: «Reintentalo antes de salir: la orden ya descontó el stock y todavía no tiene envío, y ninguna otra pantalla lo puede abrir.» (content.ts, carrier.errors.shipmentMissing). Ahora el detalle de la orden lo abre.
  • Lo que hace el navegador. CarrierStepPage llama a useLeaveWarning(shipmentMissing), que pregunta antes de recargar o cerrar la pestaña. Ese aviso existía sólo porque no había otra salida, y ahora retiene al operador sin motivo.
  • El comentario de useConfirmDraftOrder.ts:36, y la fila de hooks/useConfirmDraftOrder.ts en architecture.md («el paso 3 es el único lugar que lo abre»).
  • Los tests de CarrierStepPage.test.tsx (líneas 370 y 388), que fijan ese texto y ese aviso.

El arreglo es chico, y lo propongo porque esos textos los escribí yo en TESIS-134: con #77, los dos casos de falla del paso 3 pasan a tener la misma salida. Alcanza con un solo mensaje que remita al detalle (el actual dispatchLater sirve casi tal cual), sacar useLeaveWarning y shipmentMissing, y ajustar el comentario, la fila del doc y esos tests. createdShipmentId en useConfirmDraftOrder puede quedar o irse; si nada lo lee después del cambio, mejor sacarlo.

⚪ La cuenta de tests del cuerpo del PR

Dice «700 tests (+22)». master tiene 683, así que son +17 (es lo que da el diff: 17 it nuevos y ninguno borrado). Los 700 están bien; el +22 parece medido contra los 678 de #76.

Los criterios de la card

  • Una orden sin envío y no cancelada muestra «Crear envío».
  • Una orden cancelada no lo muestra.
  • Una orden con envío no lo muestra: «Despachar» si está pending, nada si ya salió.
  • Después de crear, la tarjeta muestra el envío pending y ofrece «Despachar».
  • Una orden de canal se puede crear envío y despachar de punta a punta. Lo verifiqué con una orden equivalente en una base de verificación; con la #11 real de tu base todavía falta.
  • lint, test y build en verde.

Veredicto

REQUEST CHANGES, sólo por el 🔴.

El feature está bien hecho y probado, y en el navegador anda de punta a punta. Pero deja al paso 3 diciéndole al operador algo que este mismo PR vuelve falso, y frenándolo antes de salir sin necesidad. Corregido eso, lo apruebo.

…e shipment

Santiago's review of #77 caught it: this card closes the gap its own
description cites as proof, and left the proof speaking in the past tense.

Four things still said, or did, the opposite of what the card makes true:

- The error of step 3 told the operator "la orden ya descontó el stock y
  todavía no tiene envío, y ninguna otra pantalla lo puede abrir". The order
  detail opens it now.
- CarrierStepPage asked before reloading or closing the tab
  (useLeaveWarning(shipmentMissing)). That warning existed only because there
  was no other way out, and it was holding the operator for nothing.
- The comment of useConfirmDraftOrder and its row in architecture.md said step
  3 is the only place that opens the shipment.
- Two tests pinned that text and that warning.

With the order already created, both failures of step 3 now have the same way
out, so they get one message: dispatchLater, reworded from "despachar" to
"terminar el envío desde el detalle de la orden" because the detail both opens
and dispatches it. createdShipmentId existed only to tell those two cases
apart and had no other consumer, so it goes with them; `progress` still tracks
the shipment internally, which is what makes the retry resume instead of
creating a second sale.

The test of the hook keeps what mattered in it — the retry opens the shipment
it could not open and does not create the order twice — now asserted on the
call counts instead of on the removed state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
@TomasMartin2004

Copy link
Copy Markdown
Contributor Author

Corregido en c64afd1. El 🔴 era correcto, y lo verifiqué uno por uno contra master antes de tocar nada: el texto de content.ts:367, el useLeaveWarning(shipmentMissing) de CarrierStepPage.tsx:92-94, el comentario de useConfirmDraftOrder.ts:36, la fila 337 de architecture.md y los dos tests. Las cinco cosas decían lo que este PR vuelve falso.

De las dos, la que más me molestaba después de leerte es la segunda: el aviso del navegador no era un texto desactualizado, era retener al operador en una pantalla que ya tiene salida.

Qué hice

  • Un solo mensaje para las dos fallas que dejan la orden creada. Usé dispatchLater como propusiste, cambiándole «despachar» por «terminar el envío desde el detalle de la orden»: el detalle ahora hace las dos cosas, abrirlo y despacharlo, y «despachar» se quedaba corto para el caso en que no hay envío todavía.
  • Se van shipmentMissing, useLeaveWarning y el useEffect que lo sostenía.
  • Se va createdShipmentId. Tenías razón en que quedaba sin consumidores: lo leían sólo las líneas 93 y 139 de CarrierStepPage, las dos para distinguir los casos que ahora son uno. progress.current.shipmentId sigue adentro del hook, que es lo que hace que el reintento retome en vez de crear una segunda venta.
  • Comentario del hook y fila del doc reescritos.

Los tests

El del hook se llamaba tells the page whether the shipment was opened before the dispatch failed y afirmaba sobre el estado que se borró. Lo que probaba de fondo sigue importando, así que quedó como retries the shipment it could not open without creating the order twice, afirmando sobre las llamadas: createShipment dos veces, createOrder una.

En la página, los tres del bloque when the shipment could not even be opened pasan a dos: que el error remite al detalle igual que una falla de despacho, y que el reintento abre el envío sin volver a crear la orden. leaveIsBlocked se fue entero, y con él el does not ask before reloading or closing the tab del bloque de despacho: sin ningún listener registrado, afirmar que el navegador no pregunta ya no prueba nada.

Les pasé los dientes:

Rotura Falla
El reintento vuelve a crear la orden retries from where it failed, without creating the order again, retries the shipment it could not open without creating the order twice, retries only the dispatch, opens the shipment on the retry, without creating the order again
El error deja de remitir al detalle points to the order detail, where the shipment can be dispatched later, points at the order detail, the same way a failed dispatch does, opens the shipment on the retry, without creating the order again

Sobre el ⚪: tenés razón, master tiene 683 y medí contra los 678 de antes de #76. Corregí el cuerpo de este PR y el de #78. La cuenta ahora: 698 tests, que son +15 sobre master — este commit saca tres ejemplos y suma uno.

lint, format:check, tsc -b, test y build en verde.

Gracias por la review: encontraste lo único que el PR dejaba inconsistente, y el arreglo terminó restando más de lo que suma.

@Sanntinat Sanntinat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-revisión — TESIS-141 (PR #77) · Crear el envío de una orden desde su detalle

Segunda vuelta sobre TESIS-141-create-shipment-from-order-detail. La primera la hice sobre 082abc0 con REQUEST CHANGES. Desde entonces entró un commit, c64afd1.

Check Resultado
CI (GitHub Actions) Los cuatro jobs en verde
Rama vs master 5 commits atrás (#63, #60, #62, #73, #72). Mergea limpio
Sobre el merge con master (local) lint, prettier y tsc -b limpios · 81 archivos · 737 tests · 0 fallas

✅ El 🔴 está resuelto, y el arreglo resta más de lo que suma

c64afd1 hace lo que pedí, y en un punto mejor que como lo propuse:

  • Un solo mensaje para las dos fallas que dejan la orden creada. Cambiar «despachar el envío» por «terminar el envío desde el detalle de la orden» es más preciso que mi propuesta: el detalle ahora lo abre y lo despacha, y «despachar» se quedaba corto para la orden sin envío.
  • Se van useLeaveWarning, shipmentMissing y createdShipmentId. El reintento sigue retomando desde donde quedó, porque progress.current.shipmentId vive adentro del hook.
  • Los tests se reescribieron sobre el comportamiento y no sobre el estado borrado: el del hook ahora cuenta llamadas (createOrderShipment dos veces, createOrder una). Sacar leaveIsBlocked también es correcto: sin ningún listener registrado, afirmar que el navegador no pregunta no prueba nada.

El diente que declarás, probado de nuevo. Saqué el mensaje del error en CarrierStepPage y fallaron exactamente los tres de tu tabla: points to the order detail, where the shipment can be dispatched later, points at the order detail, the same way a failed dispatch does y opens the shipment on the retry, without creating the order again.

🟡 El comentario de cabecera de CarrierStepPage todavía cuenta lo de antes

Líneas 59-62 de CarrierStepPage.tsx:

Si ni siquiera se abrió el envío, esta pantalla es el único lugar que lo abre: el error lo dice y el navegador pregunta antes de recargar o cerrar la pestaña.

Es el único resto que queda: las tres afirmaciones son ahora falsas, y está en el mismo archivo que el commit tocó. Es un comentario y no lo que ve el operador, así que no bloquea; alcanza con decir que, con la orden creada, el error remite al detalle en los dos casos.

Los criterios de la card

Siguen cumplidos como en la primera vuelta. Allí verifiqué en el navegador el circuito completo con una orden de canal: «Crear envío», «Despachar», el seguimiento y la etiqueta. Este commit no toca el detalle.

Veredicto

APPROVE.

El punto que bloqueaba está resuelto, y bien. El comentario de cabecera conviene corregirlo antes de mergear, pero no hace falta otra vuelta de review para eso.

@TomasMartin2004
TomasMartin2004 merged commit e8fab40 into master Oct 5, 2026
4 checks passed
@TomasMartin2004
TomasMartin2004 deleted the TESIS-141-create-shipment-from-order-detail branch October 5, 2026 14:43
TomasMartin2004 added a commit that referenced this pull request Oct 9, 2026
… the store

The 🔴 of the review. The lifecycle card said the right thing -- "El cliente
retira esta orden en el local. No lleva envío." -- and put a «Crear envío»
button next to it. Pressing it answered 422 (`PickupOrderError`, api#115) and
the operator got the generic "No pudimos crear el envío de la orden."

It is the first acceptance criterion of the card that touches pickups: an
order picked up at the store does not offer to create a shipment.

Neither branch introduced it on its own. TESIS-141 (#77) added the button with
`canOpenShipment(status, view)`, which looks at the order status and whether
the shipment exists; this branch added `requiresShipping`. Git saw no conflict
because each side touched different lines, and the suite passed with the button
on screen.

`canOpenShipment` now takes `requiresShipping`, which keeps both exclusions in
the one place that mirrors `Shipments::CreateShipment`: a cancelled order
answers `CancelledOrderError` and a pickup `PickupOrderError`, both 422. Making
the page drop the action instead would have split the rule in two.

Two specs pin it, and each fails on its own when the condition is removed: the
unit one on the helper, and one on the detail screen, which is where the button
was actually reachable.

837 tests, 0 failures. ESLint and `tsc -b` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
TomasMartin2004 added a commit that referenced this pull request Oct 10, 2026
…gures and the shipments listing (#79)

* feat: [TESIS-163] clean the header and the panel, model pickups and show real stock figures

From the system audit of 04/10. Nine of its ten screen findings; the tenth, the
shipments listing, goes in its own PR (see below).

**The header.** The search box is gone: it never received an `onSearch`, it was
a decorative input that searched nothing. "Mi perfil" is gone too — the header
never passed its handler, there is no profile screen and no RF asks for one, so
the menu keeps who is signed in and "Cerrar sesión" instead of an action that
leads nowhere.

**The bell opens the activity feed.** It did nothing: `Header` never passed
`onNotificationsClick` and only the design system demo lit the badge. It now
opens a panel over `GET /activity` with the last orders, dispatches and events
that fell to the retry queue. It lives in `shared/components` because the
header is in `app/` and cannot import a feature, so its destinations arrive as
props.

**The panel.** The "Salud del sistema" KPI and the Integraciones card are gone.
RF-26 names "salud de los nodos de integración" among the four elements of the
panel, so the replacements are not filler: the "Eventos fallidos" KPI is the
same health said as a number somebody can act on, and the "Últimos envíos" card
reinforces the other dimension RF-26 names. Both come from endpoints that
already existed, so no mock anywhere.

**Pickup orders.** Step 2 now asks how the customer gets the order. With a
pickup, step 3 neither quotes nor dispatches, and the order detail and the
listing say so: an order that carries no shipment stopped looking like one that
is missing it.

**Real stock figures.** Committed, in transit and available to promise show
numbers, and **zero when the value is zero** — the figure exists and is zero.
Category, packaging and technical standard come from the API too, so every
"this does not exist in the API yet" notice is off the screen.

**The occupancy bar is real** where the warehouse declared its capacity, and
keeps comparing warehouses against each other where it did not.

**«Editar stock» edits stock.** It used to mount the whole product form. It is
a scope of the same modal and not a second one: the save, the `If-Match` and
the 412 are the same and duplicating them would mean two versions of the
delicate part.

**The catalogue resolves in three requests**, not six: the four tab counters
were one request each, reading `meta.total` of a page of one row.

Depends on proyecto-api#115 (TESIS-162).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

* feat: [TESIS-163] add the shipments listing and act on the first round of feedback

Four things from trying it in the browser.

**NaN.** The three stock figures printed `NaN` against an API that does not
send them yet — a front deployed before its backend. `formatUnits` now answers
with the em dash for anything that is not a finite number: TypeScript says it
cannot happen, but the value comes from the API, and printing `NaN` at the
operator is the one outcome nobody wants.

**The failed events KPI is out.** "Unidades en stock" takes its place: the
inventory the operation lives off, from the same data that already feeds the
load per warehouse, so it costs no extra request. It does mean the panel no
longer carries anything about the health of the integration nodes, which RF-26
names — that was the whole argument for the previous KPI, and the call was made
twice, so it goes.

**The activity rows got taller.** The panel is a history somebody reads, not a
menu scanned with the eyes: each row now carries an icon tile for its kind, sits
on 14px of vertical padding, separates with a divider, and the panel widened
from 360 to 420.

**The shipments listing exists.** `/shipments`, with tabs over the lifecycle and
pagination on `GET /api/v1/shipments`, which has been there since TESIS-113 and
only fed the panel KPI. There is no screen for a single shipment and that is
not an oversight: its lifecycle, its log and its label live in the detail of its
order, which is where the operator decides something, so every row leads there.

Three cells say what is missing rather than drawing a blank, because the courier,
the tracking and the cost are all written by the dispatch: a shipment with no
quote does not cost zero, it has no price yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

* feat: [TESIS-163] put the eye straight in the shipments row

The listing had a single action behind a kebab, and its column reused the
"Estado" header, so the table showed that word twice: once over the badge
and once over a menu that only held "Ver la orden". Opening a menu to pick
the only thing in it is a click that buys nothing.

The action is now its own column with the eye in every row, under an
"Acciones" header. Each button names the shipment it belongs to, because the
icon repeats down the table and "Ver la orden" alone would not say which one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

* fix: [TESIS-163] act on the review: stale caches, a lost pickup flag and absent fields

Five of the findings reported on this PR.

The shipments listing, its tab counters and the bell were never invalidated.
Creating an order opens a shipment and writes an activity entry, dispatching
one changes its state, and editing an order can drop it, but the three
mutations only refreshed orders and inventory, so three screens kept showing
the previous state for the five minutes of staleTime. `['shipments']` goes in
as a literal because it belongs to another feature; the activity feed lives in
`shared`, so that one uses its own key factory.

`requiresShipping` was in the draft store but not in its `partialize`, so
reloading on step 2 or 3 dropped it back to its `true` default and silently
turned a pickup into a shipment.

Three places trusted a field to be there:

- A warehouse with no `capacity` passed the `!== null` check and made the bar
  `NaN %`. A capacity of zero did the same through `0 / 0`. Absent now reads as
  "not declared", and the guard is `> 0`.
- The stored-units KPI summed an empty list when `/warehouses` failed and
  announced zero units. Zero is a real answer -- the company stores nothing --
  and this is not it, so the figure is now absent and the card says the load
  could not be loaded instead of claiming there are no warehouses.
- An order whose API does not send `requires_shipping` was read as a pickup,
  because `undefined` is falsy. Both mappings default to shipping, which is
  what every order was before pickups existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

* fix: [TESIS-163] freeze the pickup flag and headline the stock the buckets add up to

Two more findings from the review.

Step 3 kept reading `requiresShipping` from the store while everything else
came from the copy frozen when the button was pressed. `onOrderCreated` empties
the draft as soon as the order exists, and that puts the flag back to its
`true` default, so mid-confirmation a pickup started asking the carriers for
prices, the panel flickered into the carrier list and the confirm button went
disabled for want of a chosen option. The flag now travels inside the snapshot,
and the whole render reads it from there.

The master stock card headlined `totalStock` -- the units nobody sold yet --
while one of its buckets counted the committed ones. The two never added up:
committed + available-to-promise is `onHand`, which the API already sends and
nothing read. The headline is now `onHand` and the card closes, with in-transit
still outside it because those units are in no warehouse yet. Its caption
counts distribution positions instead of `stocks`, so a warehouse whose last
unit was sold still counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

* fix: [TESIS-163] close the minor findings: the pickup form, the bars, the modal and the rows

The rest of the review, minus two that became their own cards.

Step 2 demanded a delivery address even for a pickup. A comment right above
the section said it stopped being asked for, and it did not: the form gated
the button on the destination schema whatever the choice. Now it only gates
when the order ships, the section says the address is optional, and whatever
was typed still travels -- the customer's address can matter for the invoice,
and step 3 needs a destination to not send you back.

The load bars without a declared capacity said how many units a warehouse
holds but not what the bar compares against, while the ones with a capacity
read as an occupancy. The two look identical and mean different things, so the
card now says it once below the list. Their accessible label was also keyed on
`capacity === null`, which disagreed with the bar itself for a capacity of
zero: it would have announced "of 0 units of capacity" while the bar compared
against the fullest warehouse.

The stock editor flashed the full product form on its way out: the scope was
read as `editing ?? 'product'`, and closing set `editing` to null while the
modal was still animating. Open state and scope are two pieces of state now,
and the scope only changes when something opens.

Rows in the bell panel that lead nowhere were still announced as buttons --
`ListItemButton` does that even mounted on a `div` -- and closed the panel when
touched. A row that only informs no longer promises an action. In "Últimos
envíos" the icon of every row carried the card's own title as its label, which
is noise to a screen reader, and the row's name now says what activating it
does.

Three footnotes explained that the API did not expose the committed units, the
per-warehouse in-transit, the packaging or the technical standard. It exposes
all four since TESIS-162 and TESIS-144, so the text, the copy and the three
props nothing passes any more are gone.

New tests for the feed, for `fetchRecentShipments` and for the warehouse load
mapping, which the review asked for.

Two findings left this PR as cards of their own, because both reach well past
it: the five requests behind the shipment tab counters (TESIS-165, it needs an
endpoint) and the ten copies of three number formatters spread over seven
features (TESIS-166).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

* feat: [TESIS-164] search the shipments listing by tracking and order (#80)

The listing shipped with tabs and pagination but no way in. When a buyer
calls, the operator holds the tracking number the courier issued or the id
the sales channel gave the order, and neither could be typed anywhere.

A search box goes in the header, debounced so a sixteen-character tracking
number is one request and not sixteen. The term travels to the API as the
`search` param TESIS-164 added on the backend: the filtering is the database's
job, because filtering here would only see the page already fetched.

Three things the term has to respect:

- It goes into the tab counters too. Without that the tabs would keep
  reporting how many shipments the company has while the table shows three.
- It resets the page. Searching from page 3 returned an empty page, since the
  result of a search fits in one.
- It narrows the active tab instead of replacing it.

The empty table now says which emptiness it is: a tab with no shipments and a
search with no matches read the same to the code and not to the person, so the
page picks the message and the table takes it as a prop.


Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: [TESIS-163] stop offering «Crear envío» on an order picked up at the store

The 🔴 of the review. The lifecycle card said the right thing -- "El cliente
retira esta orden en el local. No lleva envío." -- and put a «Crear envío»
button next to it. Pressing it answered 422 (`PickupOrderError`, api#115) and
the operator got the generic "No pudimos crear el envío de la orden."

It is the first acceptance criterion of the card that touches pickups: an
order picked up at the store does not offer to create a shipment.

Neither branch introduced it on its own. TESIS-141 (#77) added the button with
`canOpenShipment(status, view)`, which looks at the order status and whether
the shipment exists; this branch added `requiresShipping`. Git saw no conflict
because each side touched different lines, and the suite passed with the button
on screen.

`canOpenShipment` now takes `requiresShipping`, which keeps both exclusions in
the one place that mirrors `Shipments::CreateShipment`: a cancelled order
answers `CancelledOrderError` and a pickup `PickupOrderError`, both 422. Making
the page drop the action instead would have split the rule in two.

Two specs pin it, and each fails on its own when the condition is removed: the
unit one on the helper, and one on the detail screen, which is where the button
was actually reachable.

837 tests, 0 failures. ESLint and `tsc -b` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

* fix: [TESIS-163] close the two screen findings of the review

Both are the same confusion the card came to remove: a word that means one
thing in one place and another thing next to it.

**The shipping panel of an order that carries no shipment.** The lifecycle card
said «El cliente retira esta orden en el local. No lleva envío.» and the column
beside it kept announcing «Número de seguimiento: Pendiente de despacho» and
«Etiqueta de envío: Se emite al despachar» -- three statements about something
that is never going to exist. The panel is for an order that ships, so a pickup
does not render it, and the lifecycle card is left to explain why.

The payment summary had the same problem in one line: «Envío: Sin cotizar»
reads as a price that has not arrived yet. A pickup will not be quoted ever, so
the row says «Retiro en el local».

**«En depósito» meant two things on the same screen.** The headline of the
stock card is `onHand` -- physical units, which TESIS-162 defines as free plus
committed -- and the distribution table used the same words for a column that
showed only the free ones. On NOR-005 the headline read 44 while its rows added
up to 40, and the Depósito Central row said 15 with 4 of its units sold and
still on its shelf.

`DistributionPosition` now carries `onHand` next to `quantity`, so the column
and the headline read the same number from the same definition. A warehouse
with no stock row keeps its committed units as its whole physical count: the
sale took its last free unit, the goods are still there.

Each fix is pinned on its own. Removing the panel guard fails only "hides the
shipping panel of an order picked up at the store". Turning the summary's
pickup flag off fails only "says the pickup has no shipping cost instead of
leaving it unquoted". Putting `onHand` back to `quantity` fails the two that
count physical units and add the rows up to the headline.

842 tests, 0 failures. ESLint and `tsc -b` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

* refactor: [TESIS-166] leave one number formatter instead of eleven copies (#81)

Seven features each wrote their own. `formatUnits` in inventory and warehouses,
`formatCount` in orders, shipments and failed events, `formatMoney` in orders,
shipments and the dashboard, plus three loose `Intl.NumberFormat` in the
dashboard and in reports. Same locale, same options, different names.

`shared/utils/number.ts` holds the four the product actually has:

- `formatInteger` -- "4.280", what `formatUnits` and `formatCount` both were
- `formatDecimal` -- "1,45", a weight or a measure
- `formatMoney` -- "$ 1.478.300,49", with cents, for billed money
- `formatRoundMoney` -- "$ 58.300", for the shipping cost read at a glance

**The copies did not agree.** Only one of them -- inventory's, from a review of
TESIS-163 -- refused to print `NaN` when the API did not send a field the
screen already reads. The other ten printed it. Now every screen inherits the
guard, and the four shared functions answer "—" for anything that is not a
finite number.

`features/dashboard/utils/recentOrders.ts` carried a comment saying the
duplication with `features/orders/utils` was deliberate, that a feature cannot
import from another feature (architecture.md §3.2), and that converging the two
copies was a follow-up card. This is that card; the comment is updated to say
so. The status labels stay duplicated on purpose: those are screen vocabulary,
not formatting.

**What is not a copy and stays.** The weight of an order (one decimal and a
"kg" suffix), the compact notation of reports ("7,5k", "4,2 MM") and the trend
chip of `StatCard` (`signDisplay: 'exceptZero'`). Three different formats, not
three copies of one.

Their examples moved to `shared/utils/number.test.ts`, out of the three feature
suites that each tested its own copy.

Of fifteen `Intl.NumberFormat` in `src/`, seven are left: the four of the
shared module and those three.

88 files, 841 tests, 0 failures. ESLint, `tsc -b` and the build clean.

Depends on proyecto-web#79 (TESIS-163): the shipments feature, which holds two
of the copies, lands with it.


Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants