Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ features/[nombre]/
| `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 |
Expand Down
2 changes: 2 additions & 0 deletions src/features/orders/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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: [
{
Expand Down
5 changes: 5 additions & 0 deletions src/features/orders/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[]
}
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions src/features/orders/components/ShippingLabelField.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ShippingLabelField shipment={DISPATCHED} />)

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(<ShippingLabelField shipment={DISPATCHED} />)

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(<ShippingLabelField shipment={PENDING} />)

expect(screen.getByText('Se emite al despachar.')).toBeInTheDocument()
expect(printLink()).not.toBeInTheDocument()
})

it('says the same while there is no shipment at all', () => {
renderWithTheme(<ShippingLabelField shipment={null} />)

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(<ShippingLabelField shipment={{ ...DISPATCHED, labelUrl: null }} />)

expect(screen.getByText('El operador no devolvió la etiqueta.')).toBeInTheDocument()
expect(printLink()).not.toBeInTheDocument()
})
})
59 changes: 59 additions & 0 deletions src/features/orders/components/ShippingLabelField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography variant="labelSm" color="text.secondary">
{labelCopy.title}
</Typography>

{shipment !== null && shipment.labelUrl !== null ? (
<Button
component="a"
href={shipment.labelUrl}
target="_blank"
rel="noopener noreferrer"
size="small"
variant="outlined"
startIcon={<PrintOutlinedIcon />}
sx={{ alignSelf: 'flex-start' }}
>
{labelCopy.action}
</Button>
) : (
<Typography variant="bodyMd" color="text.disabled">
{dispatched ? labelCopy.missing : labelCopy.pending}
</Typography>
)}
</Box>
)
}
9 changes: 9 additions & 0 deletions src/features/orders/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,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',
Expand Down
2 changes: 2 additions & 0 deletions src/features/orders/pages/OrderDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const SHIPMENT: Shipment = {
trackingNumber: 'AND-9920-X8829-Z',
shippingCost: 58300,
courier: { id: 4, serviceId: 7, name: 'Andreani' },
labelUrl: null,
events: [],
}

Expand Down Expand Up @@ -257,6 +258,7 @@ describe('OrderDetailPage', () => {
trackingNumber: null,
shippingCost: null,
courier: null,
labelUrl: null,
}
const dispatchButton = () => screen.queryByRole('button', { name: 'Despachar' })

Expand Down
5 changes: 4 additions & 1 deletion src/features/orders/pages/OrderDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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 { useCreateOrderShipment } from '../hooks/useCreateOrderShipment'
Expand Down Expand Up @@ -294,8 +295,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). */}
<TrackingNumberField trackingNumber={resolved?.trackingNumber ?? null} />
<ShippingLabelField shipment={resolved} />
</InfoPanel>
<PaymentSummaryCard
subtotal={formatMoney(payment.subtotal)}
Expand Down
1 change: 1 addition & 0 deletions src/features/orders/pages/OrderEditPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ describe('OrderEditPage', { timeout: 15_000 }, () => {
trackingNumber: 'AND-1',
shippingCost: 58300,
courier: { id: 4, serviceId: 7, name: 'Andreani' },
labelUrl: null,
events: [],
},
},
Expand Down
6 changes: 6 additions & 0 deletions src/features/orders/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand Down
1 change: 1 addition & 0 deletions src/features/orders/utils/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const PENDING: Shipment = {
trackingNumber: null,
shippingCost: null,
courier: null,
labelUrl: null,
events: [],
}

Expand Down
32 changes: 32 additions & 0 deletions src/features/orders/utils/shipment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
headerStatus,
lifecycleProgress,
SHIPMENT_STAGES,
printableLabel,
} from './shipment'

let nextEventId = 1
Expand All @@ -33,6 +34,7 @@ function shipment(status: ShipmentStatus, events: ShipmentEvent[] = []): Shipmen
trackingNumber: null,
shippingCost: null,
courier: null,
labelUrl: null,
events,
}
}
Expand Down Expand Up @@ -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,<script>alert(1)</script>')).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()
})
})
31 changes: 31 additions & 0 deletions src/features/orders/utils/shipment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading