Repository navigation
feat: [TESIS-141] open the shipment of an order from its detail - #77
Conversation
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
left a comment
There was a problem hiding this comment.
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.
- El detalle muestra «La orden todavía no tiene un envío creado.» y, al lado, «Crear envío».
- 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».
- «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. - 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.
canOpenShipmentno 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
onSettledpara 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.
CarrierStepPagellama auseLeaveWarning(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 dehooks/useConfirmDraftOrder.tsenarchitecture.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
pendingy 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,testybuilden 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
|
Corregido en 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
Los tests El del hook se llamaba En la página, los tres del bloque Les pasé los dientes:
Sobre el ⚪: tenés razón,
Gracias por la review: encontraste lo único que el PR dejaba inconsistente, y el arreglo terminó restando más de lo que suma. |
Sanntinat
left a comment
There was a problem hiding this comment.
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,shipmentMissingycreatedShipmentId. El reintento sigue retomando desde donde quedó, porqueprogress.current.shipmentIdvive adentro del hook. - Los tests se reescribieron sobre el comportamiento y no sobre el estado borrado: el del hook ahora cuenta llamadas (
createOrderShipmentdos veces,createOrderuna). SacarleaveIsBlockedtambié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.
…ipment-from-order-detail
… 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
…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>
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
useConfirmDraftOrderencadenacreateOrder→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:
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) concreateOrderShipment, que ya existía enapi.tsy hasta ahora sólo usaba el asistente. Al resolver se invalida el dominio de órdenes: la tarjeta pasa a mostrar el envíopendingy 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 dedispatchableShipment.canOpenShipmentaplica 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
onSettledy la pantalla avisa que el envío ya estaba en vez de insistir. Mismo criterio queuseDispatchShipmentcon 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 -blo marcaba.Por qué faltaba (para el contexto de la review)
Está documentado, no fue un olvido:
paid(opción A, que nombrabaOrders::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».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.db/seeds.rbcrea a mano el envíopendingde la orden de webhook de ejemplo, así que en desarrollo esa orden siempre apareció con envío.Cómo probar
npm run dev, entrar comoadmin@norte.com.pendingy aparece «Despachar».Verificación
npm run lint✅npm run test✅ — 75 archivos, 698 tests (+15 sobremaster, que tiene 683)npm run build✅ (tsc -bincluido)npm run format:check✅Tests nuevos:
canOpenShipment(6 ejemplos, incluidos los tres estados en los que el envío es una duda), el flujo enOrderDetailPage(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) yuseCreateOrderShipment(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
GET /shipmentsexiste desde TESIS-113 y no tiene pantalla.shipping_label_urly el front no lo lee en ningún lado.🤖 Generated with Claude Code
https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp