From a1c006b4a3a046de1922f8d40de3e20caab43ae6 Mon Sep 17 00:00:00 2001 From: Tomas Martin Date: Sat, 3 Oct 2026 14:08:32 -0300 Subject: [PATCH] feat: [TESIS-142] show the shipping label in the order detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RF-23 commits "obtener el número de tracking y el link a la etiqueta de envío imprimible, almacenándolos en el registro del envío", and the traceability matrix of E4a §3 assigns it the screen "Detalle de Envío — Etiqueta". The backend holds up its end: ConfirmDispatch stores shipping_label_url since TESIS-47 — the Andreani template maps 'etiqueta.url' to it — and ShipmentSerializer exposes it. The front dropped it: ApiShipment did not even declare the field, so it arrived in the JSON and was discarded on translation. The label — the barcode slip that gets printed and stuck on the package — was visible nowhere. The shipping panel of the order detail now carries it, below the tracking number, with three states and no blanks: before dispatch it says the carrier issues it on dispatch, dispatched without one it says the carrier did not return it rather than leaving the field empty, and with one, the link. printableLabel narrows the URL to http(s) before it reaches an href. That URL is not written by the OMS: the carrier returns it and the template maps it verbatim, so a javascript: value there would run in the operator's browser on click. It lives in utils/shipment.ts as a pure function with its own tests, and a value that cannot be opened renders as no label at all, which is the truth from where the operator stands. The link opens in another tab with rel="noopener noreferrer": the destination is the carrier's site, not a route of the app. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp --- docs/guidelines/architecture.md | 3 +- src/features/orders/api.test.ts | 2 + src/features/orders/api.ts | 5 ++ .../ShipmentLifecycleCard.test.tsx | 1 + .../components/ShippingLabelField.test.tsx | 70 +++++++++++++++++++ .../orders/components/ShippingLabelField.tsx | 59 ++++++++++++++++ src/features/orders/content.ts | 9 +++ .../orders/pages/OrderDetailPage.test.tsx | 2 + src/features/orders/pages/OrderDetailPage.tsx | 5 +- .../orders/pages/OrderEditPage.test.tsx | 1 + src/features/orders/types.ts | 6 ++ src/features/orders/utils/dispatch.test.ts | 1 + src/features/orders/utils/shipment.test.ts | 32 +++++++++ src/features/orders/utils/shipment.ts | 31 ++++++++ 14 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/features/orders/components/ShippingLabelField.test.tsx create mode 100644 src/features/orders/components/ShippingLabelField.tsx diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 02c5c52..e7e0ff3 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -322,10 +322,11 @@ features/[nombre]/ | Archivo | Rol | | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `api.ts` | Frontera con Rails. Además de traducir el `snake_case`, resuelve el envío de una orden: `GET /shipments?order_id=` dice cuántos hay y `GET /shipments/:id` trae la bitácora, que el listado no incluye | -| `utils/shipment.ts` | Reglas del ciclo de vida: qué etapa está vigente, cuándo entró en cada una y qué badge muestra el encabezado. El estado vigente sale del envío y no del último evento, que puede llegar desordenado | +| `utils/shipment.ts` | Reglas del ciclo de vida: qué etapa está vigente, cuándo entró en cada una y qué badge muestra el encabezado. El estado vigente sale del envío y no del último evento, que puede llegar desordenado. Y `printableLabel`, que acota el enlace de la etiqueta a http(s): esa URL la devuelve el courier y la plantilla la mapea tal cual, así que ponerla en un `href` sin mirarla dejaría que un `javascript:` corriera en el navegador del operador | | `utils/payment.ts` | Las cuentas del detalle, en centavos para que sumar importes no deje restos de punto flotante. El subtotal es el `total_amount` persistido (TESIS-114), no la suma de las líneas recalculada | | `components/InfoPanel/` | Panel lateral de pares rótulo-valor, compartido por los datos del cliente y los del envío. Se extrajo al aparecer el **segundo** consumidor, no antes — `feature-structure.md` §6 | | `components/ShipmentStateMessage.tsx` | Lo que muestran los paneles del envío cuando no hay uno que dibujar (sin envío, envío duplicado, cargando, error). Lo comparten el ciclo de vida y los datos del envío, así los dos dicen lo mismo | +| `components/ShippingLabelField.tsx` | La etiqueta del panel de envío (RF-23): el rótulo imprimible que emite el courier al despachar. Tres estados y ningún hueco — sin despachar avisa que se emite ahí, despachado sin etiqueta dice que el operador no la devolvió, y con etiqueta el enlace, que abre aparte y con `rel="noopener noreferrer"` porque el destino es el sitio del courier | | `utils/draft.ts` | Reglas del paso 1 del alta manual: el filtro del buscador (SKU o nombre, en memoria), qué línea es válida, los totales del borrador y cuándo se habilita «Siguiente» (`canProceed`). Puras, para probarlas sin montar la pantalla | | `components/ProductPicker/` | Buscador + cantidad + precio unitario para sumar un SKU al borrador. `GET /products` no busca, así que trae una página (100, el máximo) y filtra del lado del cliente. No hay precio de lista en `products`: el precio se carga a mano | | `utils/shipping.ts` | Reglas del envío en el alta: qué depósito cubre el borrador entero (`warehouseCoverage`, paso 2) y los requests del paso 3: cotizar el borrador (`toDraftQuotePayload`), crear la orden (`toCreateOrderPayload`), despachar la opción elegida (`toDispatchPayload`) y el total con envío (`totalWithShipping`). La orden sale de un solo depósito: el `warehouse_id` es el mismo en todas las líneas | diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts index 0d51dc5..4a58c24 100644 --- a/src/features/orders/api.test.ts +++ b/src/features/orders/api.test.ts @@ -32,6 +32,7 @@ const SHIPMENT = { status: 'in_transit', tracking_number: 'AND-9920-X8829-Z', shipping_cost: 58300, + shipping_label_url: 'https://andreani.test/etiquetas/AND-9920.pdf', courier: { id: 4, service_id: 7, name: 'Andreani' }, events: [ { @@ -125,6 +126,7 @@ describe('fetchOrderShipment', () => { status: 'in_transit', trackingNumber: 'AND-9920-X8829-Z', shippingCost: 58300, + labelUrl: 'https://andreani.test/etiquetas/AND-9920.pdf', courier: { id: 4, serviceId: 7, name: 'Andreani' }, events: [ { diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index 1fa26bc..bb34e4b 100644 --- a/src/features/orders/api.ts +++ b/src/features/orders/api.ts @@ -18,6 +18,7 @@ import type { UpdateOrderPayload, } from './types' import type { OrderQuotePayload } from './utils/dispatch' +import { printableLabel } from './utils/shipment' import type { CreateOrderPayload, DispatchPayload, DraftQuotePayload } from './utils/shipping' // Frontera con la API Rails. Lo que entra en snake_case se traduce acá y sale @@ -114,6 +115,7 @@ interface ApiShipment { status: ShipmentStatus tracking_number: string | null shipping_cost: number | null + shipping_label_url: string | null courier: ApiCourier | null events: ApiShipmentEvent[] } @@ -249,6 +251,9 @@ function toShipment(shipment: ApiShipment): Shipment { status: shipment.status, trackingNumber: shipment.tracking_number, shippingCost: shipment.shipping_cost, + // Viene de la respuesta del courier, así que se acota a un enlace que se + // pueda abrir (ver `printableLabel`). La API lo guarda tal cual lo mandó. + labelUrl: printableLabel(shipment.shipping_label_url), courier: toCourier(shipment.courier), // El backend ya los ordena por `occurred_at` (con desempate por id): no se // reordenan acá para no tener dos definiciones del mismo orden. diff --git a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx index f3f2995..e31bd2d 100644 --- a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx +++ b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx @@ -13,6 +13,7 @@ const SHIPMENT: Shipment = { trackingNumber: 'AND-9920-X8829-Z', shippingCost: 58300, courier: { id: 4, serviceId: 7, name: 'Andreani' }, + labelUrl: null, events: [ { id: 1, diff --git a/src/features/orders/components/ShippingLabelField.test.tsx b/src/features/orders/components/ShippingLabelField.test.tsx new file mode 100644 index 0000000..6ea8255 --- /dev/null +++ b/src/features/orders/components/ShippingLabelField.test.tsx @@ -0,0 +1,70 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { renderWithTheme } from '../../../test/renderWithTheme' +import type { Shipment } from '../types' + +import { ShippingLabelField } from './ShippingLabelField' + +const DISPATCHED: Shipment = { + id: 31, + orderId: 8829, + status: 'in_transit', + trackingNumber: 'AND-9920-X8829-Z', + shippingCost: 58300, + courier: { id: 4, serviceId: 7, name: 'Andreani' }, + labelUrl: 'https://andreani.test/etiquetas/AND-9920.pdf', + events: [], +} + +const PENDING: Shipment = { + ...DISPATCHED, + status: 'pending', + trackingNumber: null, + shippingCost: null, + courier: null, + labelUrl: null, +} + +const printLink = () => screen.queryByRole('link', { name: 'Imprimir etiqueta' }) + +describe('ShippingLabelField', () => { + it('links to the label the courier issued', () => { + renderWithTheme() + + expect(printLink()).toHaveAttribute('href', 'https://andreani.test/etiquetas/AND-9920.pdf') + }) + + // El destino es el sitio del courier, no una ruta de la app: se abre aparte y + // sin entregarle la referencia a esta ventana. + it('opens it in another tab without handing over this window', () => { + renderWithTheme() + + expect(printLink()).toHaveAttribute('target', '_blank') + expect(printLink()).toHaveAttribute('rel', 'noopener noreferrer') + }) + + // La emite el courier al confirmar el despacho: antes de eso no existe, y un + // campo vacío se leería como un dato perdido. + it('says it is pending while the shipment was not dispatched', () => { + renderWithTheme() + + expect(screen.getByText('Se emite al despachar.')).toBeInTheDocument() + expect(printLink()).not.toBeInTheDocument() + }) + + it('says the same while there is no shipment at all', () => { + renderWithTheme() + + expect(screen.getByText('Se emite al despachar.')).toBeInTheDocument() + }) + + // Despachado y sin etiqueta es otra cosa: el courier no la devolvió, y + // decirlo evita que el operador la espere. + it('says the courier did not return one when the shipment already went out', () => { + renderWithTheme() + + expect(screen.getByText('El operador no devolvió la etiqueta.')).toBeInTheDocument() + expect(printLink()).not.toBeInTheDocument() + }) +}) diff --git a/src/features/orders/components/ShippingLabelField.tsx b/src/features/orders/components/ShippingLabelField.tsx new file mode 100644 index 0000000..85c3e0e --- /dev/null +++ b/src/features/orders/components/ShippingLabelField.tsx @@ -0,0 +1,59 @@ +import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined' +import { Box, Button, Typography } from '@mui/material' + +import { ordersCopy } from '../content' +import type { Shipment } from '../types' + +const labelCopy = ordersCopy.detail.shipping.label + +interface ShippingLabelFieldProps { + /** El envío resuelto, o null mientras no hay uno que mostrar. */ + shipment: Shipment | null +} + +/** + * Etiqueta de envío del panel de S08 (RF-23). + * + * Es el rótulo que emite el courier al confirmar el despacho: el PDF con el + * código de barras que se imprime y se pega al paquete. El backend lo guarda + * desde TESIS-47 (`shipments.shipping_label_url`) y hasta esta card no se veía + * en ninguna pantalla. + * + * Tres estados y ningún hueco: sin despachar dice que está pendiente, porque la + * etiqueta la emite el courier; despachado sin etiqueta lo dice también, en vez + * de dejar el campo vacío como si se hubiera perdido; y con etiqueta, el enlace. + * + * Abre en una pestaña nueva porque el destino es el sitio del courier y no una + * ruta de la app, con `rel="noopener noreferrer"` para no entregarle la + * referencia a esta ventana. + */ +export function ShippingLabelField({ shipment }: ShippingLabelFieldProps) { + const dispatched = shipment !== null && shipment.trackingNumber !== null + + return ( + + + {labelCopy.title} + + + {shipment !== null && shipment.labelUrl !== null ? ( + + ) : ( + + {dispatched ? labelCopy.missing : labelCopy.pending} + + )} + + ) +} diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts index 2f3f408..4898ede 100644 --- a/src/features/orders/content.ts +++ b/src/features/orders/content.ts @@ -155,6 +155,15 @@ export const ordersCopy = { copied: 'Número de seguimiento copiado.', copyFailed: 'No se pudo copiar. Seleccioná el número y copialo a mano.', }, + /** El rótulo que emite el courier al despachar (RF-23). */ + label: { + title: 'Etiqueta de envío', + action: 'Imprimir etiqueta', + /** Sin despachar no hay etiqueta: la emite el courier (TESIS-47). */ + pending: 'Se emite al despachar.', + /** Despachado y sin etiqueta: el courier no la devolvió. */ + missing: 'El operador no devolvió la etiqueta.', + }, fields: { serviceType: 'Tipo de servicio', origin: 'Depósito de origen', diff --git a/src/features/orders/pages/OrderDetailPage.test.tsx b/src/features/orders/pages/OrderDetailPage.test.tsx index 5830a00..fe9e4a8 100644 --- a/src/features/orders/pages/OrderDetailPage.test.tsx +++ b/src/features/orders/pages/OrderDetailPage.test.tsx @@ -78,6 +78,7 @@ const SHIPMENT: Shipment = { trackingNumber: 'AND-9920-X8829-Z', shippingCost: 58300, courier: { id: 4, serviceId: 7, name: 'Andreani' }, + labelUrl: null, events: [], } @@ -234,6 +235,7 @@ describe('OrderDetailPage', () => { trackingNumber: null, shippingCost: null, courier: null, + labelUrl: null, } const dispatchButton = () => screen.queryByRole('button', { name: 'Despachar' }) diff --git a/src/features/orders/pages/OrderDetailPage.tsx b/src/features/orders/pages/OrderDetailPage.tsx index 334b181..19bcb40 100644 --- a/src/features/orders/pages/OrderDetailPage.tsx +++ b/src/features/orders/pages/OrderDetailPage.tsx @@ -18,6 +18,7 @@ import { OrderMetrics } from '../components/OrderMetrics' import type { OrderMetric } from '../components/OrderMetrics' import { PaymentSummaryCard } from '../components/PaymentSummaryCard' import { ShipmentLifecycleCard } from '../components/ShipmentLifecycleCard' +import { ShippingLabelField } from '../components/ShippingLabelField' import { TrackingNumberField } from '../components/TrackingNumberField' import { formatCount, ordersCopy } from '../content' import { useOrder, useOrderShipment } from '../hooks/useOrderDetail' @@ -233,8 +234,10 @@ export function OrderDetailPage() { > {/* Sin envío resuelto el tracking tampoco existe: el panel dice «Pendiente de despacho», que es lo mismo que ve el operador - mientras el courier no confirma. */} + mientras el courier no confirma. La etiqueta sigue el mismo + criterio: la emite el courier al despachar (RF-23). */} + { trackingNumber: 'AND-1', shippingCost: 58300, courier: { id: 4, serviceId: 7, name: 'Andreani' }, + labelUrl: null, events: [], }, }, diff --git a/src/features/orders/types.ts b/src/features/orders/types.ts index 9bab6f8..3bfaccf 100644 --- a/src/features/orders/types.ts +++ b/src/features/orders/types.ts @@ -140,6 +140,12 @@ export interface Shipment { shippingCost: number | null /** Null hasta que se asigna el courier al confirmar el despacho. */ courier: Courier | null + /** + * El rótulo imprimible que emite el courier al despachar (RF-23): el PDF con + * el código de barras que se pega al paquete. Null hasta el despacho, y + * también después si el courier no lo devolvió. + */ + labelUrl: string | null /** Ordenados por `occurredAt`, del más viejo al más nuevo. */ events: ShipmentEvent[] } diff --git a/src/features/orders/utils/dispatch.test.ts b/src/features/orders/utils/dispatch.test.ts index ee50247..77b0f22 100644 --- a/src/features/orders/utils/dispatch.test.ts +++ b/src/features/orders/utils/dispatch.test.ts @@ -11,6 +11,7 @@ const PENDING: Shipment = { trackingNumber: null, shippingCost: null, courier: null, + labelUrl: null, events: [], } diff --git a/src/features/orders/utils/shipment.test.ts b/src/features/orders/utils/shipment.test.ts index 9fe650b..2d9dc90 100644 --- a/src/features/orders/utils/shipment.test.ts +++ b/src/features/orders/utils/shipment.test.ts @@ -9,6 +9,7 @@ import { headerStatus, lifecycleProgress, SHIPMENT_STAGES, + printableLabel, } from './shipment' let nextEventId = 1 @@ -33,6 +34,7 @@ function shipment(status: ShipmentStatus, events: ShipmentEvent[] = []): Shipmen trackingNumber: null, shippingCost: null, courier: null, + labelUrl: null, events, } } @@ -152,3 +154,33 @@ describe('eventsNewestFirst', () => { expect(source.events.map((item) => item.internalStatus)).toEqual(['pending', 'in_transit']) }) }) + +describe('printableLabel', () => { + it('keeps the https link the courier returned', () => { + expect(printableLabel('https://andreani.test/etiquetas/AND-9920.pdf')).toBe( + 'https://andreani.test/etiquetas/AND-9920.pdf', + ) + }) + + it('keeps an http one too: some carriers still serve the pdf without tls', () => { + expect(printableLabel('http://oca.test/label/123')).toBe('http://oca.test/label/123') + }) + + it('has nothing to show without a label', () => { + expect(printableLabel(null)).toBeNull() + expect(printableLabel(' ')).toBeNull() + }) + + // La URL la devuelve el courier y la plantilla la mapea tal cual: un `href` + // con `javascript:` corre al hacer clic, en el navegador del operador. + it('refuses a link that is not a web address', () => { + expect(printableLabel('javascript:alert(document.cookie)')).toBeNull() + expect(printableLabel('data:text/html,')).toBeNull() + }) + + // Una relativa apuntaría a nuestro sitio, y el destino es el del courier. + it('refuses something that is not an absolute url', () => { + expect(printableLabel('/etiquetas/AND-9920.pdf')).toBeNull() + expect(printableLabel('no es una url')).toBeNull() + }) +}) diff --git a/src/features/orders/utils/shipment.ts b/src/features/orders/utils/shipment.ts index 603d4e7..18f4188 100644 --- a/src/features/orders/utils/shipment.ts +++ b/src/features/orders/utils/shipment.ts @@ -8,6 +8,37 @@ import { statusLabel, statusVariant } from './status' // Reglas del ciclo de vida del envío que muestra el detalle de la orden. Viven // acá y no en los componentes para poder probarlas sin montar la pantalla. +/** + * Los esquemas que puede tener el enlace de la etiqueta para que la pantalla lo + * abra. Es una lista blanca y no una lista negra: lo que no sea una dirección + * web no se pinta como enlace. + */ +const PRINTABLE_SCHEMES = ['https:', 'http:'] + +/** + * El enlace a la etiqueta, o null si no hay uno que se pueda abrir. + * + * La URL no la escribe el OMS: la devuelve el courier y la plantilla la mapea + * tal cual (`'etiqueta.url' => 'shipping_label_url'`). Ponerla en un `href` sin + * mirarla deja que un proveedor —o alguien que le entre la respuesta— decida + * qué ejecuta el navegador del operador: `javascript:` en un `href` corre al + * hacer clic. Acotarlo a http(s) cierra eso sin pedirle nada al backend. + * + * Un valor que no se puede abrir no se muestra roto: el campo queda como si no + * hubiera etiqueta, que es la verdad desde donde está parado el operador. + */ +export function printableLabel(url: string | null | undefined): string | null { + if (url === null || url === undefined || url.trim() === '') return null + + try { + return PRINTABLE_SCHEMES.includes(new URL(url).protocol) ? url : null + } catch { + // No es una URL absoluta: una relativa no sirve, porque el destino es el + // sitio del courier y no el nuestro. + return null + } +} + /** Las etapas del ciclo, en orden. Es el mismo vocabulario que `Shipment::STATUSES`. */ export const SHIPMENT_STAGES: readonly ShipmentStatus[] = [ 'pending',