8 Commits

8 changed files with 932 additions and 963 deletions

View File

@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:**
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:<productId>` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` renderiza un mapa leaflet con un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload; si varios productos coinciden en el mismo municipio se separan en anillo cuyo radio en grados se recalcula en `zoomend` para mantener ~40px de separación visual en cualquier zoom) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un producto abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). `ProvenanceDetailModal.vue`
- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:<productId>` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— como panel lateral izquierdo junto al mapa (en columna en pantallas < 900px) y, si hay al menos un municipio con coordenadas, el mapa leaflet al lado: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; un botón flotante en el título del panel (`data-test="map-reset-zoom"`) re-ejecuta `fitBounds` para volver al zoom general; clic en un marcador individual abre un **popup de leaflet** anclado al ícono con el detalle (imagen + producto + proveedor + organización + territorio); clic en un producto del popup agrupado reemplaza su contenido por el detalle de ese producto. Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el popup de detalle en el marcador; si no, abre `ProvenanceRelationModal.vue` (único caso donde se usa el modal web, pues no hay ícono en el mapa) que muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada." o "Aún no se ha vinculado un proveedor a este producto.". `ProvenanceDetailModal.vue`
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products/<id>/` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products/<id>/`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`

1105
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,48 @@
<template>
<div>
<v-alert
v-if="!hasMarkers"
class="my-4"
type="info"
variant="tonal"
<template v-if="productRows.length">
<div class="map-layout">
<v-card class="product-panel" data-test="map-product-box" variant="tonal">
<v-card-title class="d-flex align-center justify-space-between py-2">
<span class="text-subtitle-1 font-weight-bold">
Productos ({{ productRows.length }})
</span>
<v-btn
v-if="hasMarkers"
data-test="map-reset-zoom"
density="comfortable"
icon="mdi-fit-to-page"
size="small"
variant="flat"
@click="resetZoom"
/>
</v-card-title>
<v-divider />
<v-list density="compact">
<v-list-item
v-for="row in productRows"
:key="row.product.id"
data-test="map-product-row"
:prepend-avatar="row.image"
role="button"
:subtitle="row.locationLabel"
:title="row.product.name"
@click="onRowClick(row)"
>
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert>
<template #append>
<v-icon v-if="row.position" color="primary" size="small">
mdi-map-marker
</v-icon>
<v-icon v-else color="medium-emphasis" size="small">
mdi-map-marker-off
</v-icon>
</template>
</v-list-item>
</v-list>
</v-card>
<template v-else>
<div class="map-area">
<template v-if="hasMarkers">
<div ref="mapEl" class="map-wrapper" />
<v-alert
v-if="!storePosition"
@@ -20,9 +53,20 @@
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
</v-alert>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
Pasa el cursor sobre un producto para ver su recorrido hasta la tienda.
Pasa el cursor sobre un producto para ver su recorrido hasta la tienda. Haz clic en un marcador para ver el detalle.
</p>
</template>
<v-alert
v-else
class="my-2"
type="info"
variant="tonal"
>
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert>
</div>
</div>
</template>
<ProvenanceRelationModal
:product="selectedProduct"
@@ -67,36 +111,6 @@
return [Number(current.latitude), Number(current.longitude)]
})
const MIN_PIXEL_GAP = 40
function degreesPerPixel (zoom) {
return 360 / (256 * Math.pow(2, zoom))
}
function groupedPositionKeys () {
const counts = new Map()
for (const markerData of markers.value) {
const key = markerData.basePosition.join(',')
counts.set(key, (counts.get(key) || 0) + 1)
}
return new Set(
[...counts.entries()].filter(entry => entry[1] > 1).map(entry => entry[0])
)
}
function positionAtZoom (markerData, zoom) {
const key = markerData.basePosition.join(',')
if (!groupedPositionKeys().has(key)) return markerData.basePosition
const group = markers.value.filter(m => m.basePosition.join(',') === key)
const index = group.findIndex(m => m === markerData)
const angle = (index / group.length) * Math.PI * 2
const offset = MIN_PIXEL_GAP * degreesPerPixel(zoom)
return [
markerData.basePosition[0] + Math.cos(angle) * offset,
markerData.basePosition[1] + Math.sin(angle) * offset,
]
}
const markers = computed(() => {
const result = []
for (const entry of props.provenance || []) {
@@ -107,7 +121,7 @@
result.push({
product: entry.product,
relation: rel,
basePosition: [Number(municipality.latitude), Number(municipality.longitude)],
position: [Number(municipality.latitude), Number(municipality.longitude)],
})
}
}
@@ -115,8 +129,44 @@
return result
})
const productRows = computed(() => {
const rows = []
for (const entry of props.provenance || []) {
if (!entry.product) continue
const suppliers = entry.suppliers || []
const geoRelations = suppliers.filter(rel =>
rel.municipality && rel.municipality.latitude != null && rel.municipality.longitude != null
)
const municipalities = [...new Map(
geoRelations.map(rel => [rel.municipality.id, rel.municipality])
).values()]
rows.push({
product: entry.product,
relation: suppliers[0] || null,
image: entry.product.catalogue_images?.[0] || null,
position: geoRelations.length
? [Number(geoRelations[0].municipality.latitude), Number(geoRelations[0].municipality.longitude)]
: null,
locationLabel: municipalities.length
? municipalities.map(m => m.name).join(', ')
: 'Sin geolocalización',
})
}
return rows
})
const hasMarkers = computed(() => markers.value.length > 0)
const markerGroups = computed(() => {
const groups = new Map()
for (const markerData of markers.value) {
const key = markerData.position.join(',')
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(markerData)
}
return [...groups.values()].map(items => ({ position: items[0].position, items }))
})
const personIcon = L.divIcon({
className: 'provenance-store-pin',
html: '<i class="mdi mdi-account-circle" style="font-size:46px;line-height:1;color:#f44336;"></i>',
@@ -137,6 +187,95 @@
})
}
function groupIcon (group) {
return L.divIcon({
className: 'provenance-group-pin',
html: `<span class="provenance-group-count">${group.items.length}</span><i class="mdi mdi-package-variant"></i>`,
iconSize: [38, 38],
iconAnchor: [19, 38],
})
}
function buildGroupPopup (items, onItemClick) {
const container = document.createElement('div')
container.className = 'provenance-popup-list'
items.forEach((item, index) => {
const row = document.createElement('div')
row.className = 'provenance-popup-item'
row.setAttribute('role', 'button')
row.setAttribute('data-test', `map-popup-item-${index}`)
const image = item.product.catalogue_images?.[0]
if (image) {
const img = document.createElement('img')
img.src = image
img.className = 'provenance-popup-img'
row.appendChild(img)
}
const name = document.createElement('span')
name.className = 'provenance-popup-name'
name.textContent = item.product.name
row.appendChild(name)
row.addEventListener('click', event => {
event.stopPropagation()
onItemClick(item)
})
container.appendChild(row)
})
return container
}
function appendEntityRow (container, label, icon, color, entity) {
if (!entity) return
const row = document.createElement('div')
row.className = 'provenance-detail-row'
const iconEl = document.createElement('i')
iconEl.className = `mdi ${icon}`
iconEl.style.color = color
row.appendChild(iconEl)
const body = document.createElement('div')
const labelEl = document.createElement('div')
labelEl.className = 'provenance-detail-label'
labelEl.textContent = label
const nameEl = document.createElement('div')
nameEl.className = 'provenance-detail-name'
nameEl.textContent = entity.name
body.appendChild(labelEl)
body.appendChild(nameEl)
row.appendChild(body)
container.appendChild(row)
}
function buildDetailPopup (markerData) {
const container = document.createElement('div')
container.className = 'provenance-detail-popup'
const header = document.createElement('div')
header.className = 'provenance-detail-header'
const image = markerData.product.catalogue_images?.[0]
if (image) {
const img = document.createElement('img')
img.src = image
img.className = 'provenance-detail-img'
header.appendChild(img)
}
const title = document.createElement('div')
title.className = 'provenance-detail-title'
title.textContent = markerData.product.name
header.appendChild(title)
container.appendChild(header)
const rel = markerData.relation || {}
appendEntityRow(container, 'Proveedor', 'mdi-truck', '#1976d2', rel.supplier)
appendEntityRow(container, 'Organización', 'mdi-domain', '#fb8c00', rel.organization)
appendEntityRow(container, 'Municipio', 'mdi-map-marker', '#4caf50', rel.municipality)
appendEntityRow(container, 'Departamento', 'mdi-map', '#3f51b5', rel.department)
appendEntityRow(container, 'País', 'mdi-earth', '#9c27b0', rel.country)
return container
}
function openProductDetail (marker, markerData) {
marker.setPopupContent(buildDetailPopup(markerData))
marker.openPopup()
}
function showLine (from, to) {
const line = L.polyline([from, to], {
color: '#26a69a',
@@ -156,48 +295,70 @@
storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map)
storeMarker.bindTooltip('Tienda')
storeMarker.on('mouseover', () => {
productMarkers.forEach(item => showLine(item.marker.getLatLng(), storePosition.value))
markers.value.forEach(markerData => showLine(markerData.position, storePosition.value))
})
storeMarker.on('mouseout', clearLines)
}
function addProductMarkers () {
const zoom = map.getZoom()
markers.value.forEach(markerData => {
const marker = L.marker(positionAtZoom(markerData, zoom), { icon: productIcon(markerData) }).addTo(map)
marker.bindTooltip(markerData.product.name)
marker.on('click', () => openDialog(markerData))
markerGroups.value.forEach(group => {
if (group.items.length === 1) {
const item = group.items[0]
const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map)
marker.bindTooltip(item.product.name)
marker.bindPopup(buildDetailPopup(item))
marker.on('mouseover', () => {
if (storePosition.value) showLine(marker.getLatLng(), storePosition.value)
if (storePosition.value) showLine(item.position, storePosition.value)
})
marker.on('mouseout', clearLines)
productMarkers.push({ marker, data: markerData })
})
productMarkers.push({ marker, items: [item] })
return
}
function onZoomEnd () {
const zoom = map.getZoom()
productMarkers.forEach(item => {
item.marker.setLatLng(positionAtZoom(item.data, zoom))
const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map)
marker.bindTooltip(`${group.items.length} productos en este punto`)
marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
marker.on('popupclose', () => {
marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
})
marker.on('mouseover', () => {
if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value))
})
marker.on('mouseout', clearLines)
productMarkers.push({ marker, items: group.items })
})
}
function fitBounds () {
const bounds = L.latLngBounds()
markers.value.forEach(markerData => bounds.extend(markerData.basePosition))
markers.value.forEach(markerData => bounds.extend(markerData.position))
if (storePosition.value) bounds.extend(storePosition.value)
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60] })
}
function openDialog (markerData) {
selectedProduct.value = markerData.product
selectedRelation.value = markerData.relation
function onRowClick (row) {
if (row.position && map) {
map.flyTo(row.position, Math.max(map.getZoom(), 12), { duration: 0.6 })
const entry = productMarkers.find(entry =>
entry.items.some(item => item.product.id === row.product.id && item.relation === row.relation)
)
if (entry) {
const item = entry.items.find(i => i.relation === row.relation) || entry.items[0]
openProductDetail(entry.marker, item)
return
}
}
selectedProduct.value = row.product
selectedRelation.value = row.relation
modalVisible.value = true
}
function resetZoom () {
if (map) fitBounds()
}
function initMap () {
if (!mapEl.value || map) return
const center = storePosition.value || markers.value[0].basePosition
const center = storePosition.value || markers.value[0].position
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
@@ -205,7 +366,6 @@
}).addTo(map)
addStoreMarker()
addProductMarkers()
map.on('zoomend', onZoomEnd)
fitBounds()
}
@@ -236,6 +396,23 @@
</script>
<style scoped>
.map-layout {
display: flex;
gap: 12px;
align-items: stretch;
}
.product-panel {
flex: 0 0 300px;
max-height: 480px;
overflow-y: auto;
}
.map-area {
flex: 1;
min-width: 0;
}
.map-wrapper {
position: relative;
width: 100%;
@@ -245,6 +422,17 @@
z-index: 0;
}
@media (max-width: 900px) {
.map-layout {
flex-direction: column;
}
.product-panel {
flex-basis: auto;
max-height: none;
}
}
.map-wrapper :deep(.provenance-product-pin) {
display: flex;
align-items: center;
@@ -281,6 +469,100 @@
background: transparent;
}
.map-wrapper :deep(.provenance-group-pin) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #26a69a;
border: 2px solid #ffffff;
border-radius: 50%;
color: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: pointer;
}
.map-wrapper :deep(.provenance-group-pin .provenance-group-count) {
font-weight: 700;
font-size: 14px;
line-height: 1;
}
.map-wrapper :deep(.provenance-group-pin .mdi) {
font-size: 12px;
line-height: 1;
margin-top: 2px;
}
.map-wrapper :deep(.provenance-popup-item) {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
cursor: pointer;
}
.map-wrapper :deep(.provenance-popup-item:hover) {
text-decoration: underline;
}
.map-wrapper :deep(.provenance-popup-img) {
width: 28px;
height: 28px;
border-radius: 4px;
object-fit: cover;
}
.map-wrapper :deep(.provenance-popup-name) {
font-weight: 500;
}
.map-wrapper :deep(.provenance-detail-popup) {
min-width: 200px;
max-width: 260px;
}
.map-wrapper :deep(.provenance-detail-header) {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.map-wrapper :deep(.provenance-detail-img) {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.map-wrapper :deep(.provenance-detail-title) {
font-weight: 700;
}
.map-wrapper :deep(.provenance-detail-row) {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 0;
}
.map-wrapper :deep(.provenance-detail-row .mdi) {
font-size: 18px;
margin-top: 2px;
}
.map-wrapper :deep(.provenance-detail-label) {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
opacity: 0.7;
}
.map-wrapper :deep(.provenance-detail-name) {
font-weight: 500;
}
.map-wrapper :deep(.leaflet-control-attribution) {
font-size: 10px;
}

View File

@@ -4,7 +4,7 @@
:model-value="visible"
@update:model-value="onUpdateVisible"
>
<v-card v-if="relation">
<v-card v-if="relation || product">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="teal" icon="mdi-package-variant" />
<span class="font-weight-bold">{{ productName }}</span>
@@ -17,6 +17,20 @@
class="provenance-image rounded-lg border mb-3"
:src="productImage"
>
<div
v-if="!hasLocation"
class="d-flex align-start mb-3"
>
<v-icon class="mr-3" color="grey" icon="mdi-map-marker-off" />
<div>
<div class="text-subtitle-2 font-weight-bold">
Geolocalización
</div>
<div class="text-body-1">
{{ locationNote }}
</div>
</div>
</div>
<div
v-for="row in rows"
:key="row.kind"
@@ -78,6 +92,11 @@
const productName = computed(() => props.product?.name || 'Producto')
const productImage = computed(() => props.product?.catalogue_images?.[0] || null)
const hasLocation = computed(() => !!(props.relation?.municipality || props.relation?.department))
const locationNote = computed(() => {
if (props.relation) return 'Aún sin geolocalización registrada.'
return 'Aún no se ha vinculado un proveedor a este producto.'
})
function entityDetails (kind, entity) {
const details = []

View File

@@ -116,6 +116,39 @@
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-indigo text-white">🏷️ Categorías creadas ({{ result.created_categories?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.created_categories)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' }
]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-deep-purple text-white">♻️ Categorías actualizadas ({{ result.updated_categories?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.updated_categories)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Detalle', key: 'detail' }
]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" class="text-center mt-4">
<v-btn color="primary" @click="$router.push('/')">
Volver al inicio

View File

@@ -15,6 +15,10 @@ const leaflet = vi.hoisted(() => {
addTo: vi.fn(function () { return this }),
on: vi.fn(),
bindTooltip: vi.fn(function () { return this }),
bindPopup: vi.fn(function () { return this }),
setPopupContent: vi.fn(),
openPopup: vi.fn(),
closePopup: vi.fn(),
remove: vi.fn(),
getLatLng: vi.fn(function () { return this.pos }),
setLatLng: vi.fn(),
@@ -25,6 +29,7 @@ const leaflet = vi.hoisted(() => {
const map = {
setView: vi.fn(function () { return this }),
fitBounds: vi.fn(),
flyTo: vi.fn(),
remove: vi.fn(),
invalidateSize: vi.fn(),
on: vi.fn(),
@@ -84,6 +89,17 @@ const provenance = [
},
]
const sameMuni = [
{
product: { id: 1, name: 'Panela', catalogue_images: [] },
suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
},
{
product: { id: 2, name: 'Arroz', catalogue_images: [] },
suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
},
]
function mountMap (props = {}) {
const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) }
const pinia = createPinia()
@@ -160,15 +176,23 @@ describe('ProvenanceMap', () => {
expect(leaflet.polylines[0].remove).toHaveBeenCalled()
})
it('al hacer clic en un producto abre el diálogo con el proveedor', async () => {
it('muestra el detalle del producto en un popup sobre el marcador', async () => {
mountMap()
await flushPromises()
handlerOf(leaflet.markers[1], 'click')()
await nextTick()
const marker = leaflet.markers[1]
expect(marker.bindPopup).toHaveBeenCalledTimes(1)
const popupEl = marker.bindPopup.mock.calls[0][0]
expect(popupEl.textContent).toContain('Panela regional')
expect(popupEl.textContent).toContain('Asociación La Mesa')
expect(popupEl.textContent).toContain('Santa Bárbara')
})
expect(document.body.textContent).toContain('Asociación La Mesa')
expect(document.body.textContent).toContain('Panela regional')
it('no abre el modal web para productos con ubicación', async () => {
mountMap()
await flushPromises()
expect(document.body.textContent).not.toContain('Proveedor')
})
it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => {
@@ -200,50 +224,155 @@ describe('ProvenanceMap', () => {
expect(wrapper.text()).toContain('coordenadas')
})
it('separa los productos del mismo municipio para que no se superpongan', async () => {
const sameMuni = [
{
product: { id: 1, name: 'Panela', catalogue_images: [] },
suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
},
{
product: { id: 2, name: 'Arroz', catalogue_images: [] },
suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
},
]
it('mantiene la posición exacta del municipio aunque varios productos coincidan', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
const productPositions = leaflet.markers.slice(1).map(marker => marker.pos.join(','))
expect(productPositions).toHaveLength(2)
expect(new Set(productPositions).size).toBe(2)
expect(productPositions).not.toContain('4.631028,-74.461588')
expect(leaflet.markers).toHaveLength(2)
expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
expect(leaflet.map.on).not.toHaveBeenCalledWith('zoomend', expect.anything())
})
it('reduce la separación en grados al hacer zoom in para mantener la distancia visual', async () => {
const sameMuni = [
{
product: { id: 1, name: 'Panela', catalogue_images: [] },
suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
},
{
product: { id: 2, name: 'Arroz', catalogue_images: [] },
suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
},
]
it('agrupa los productos del mismo punto en un marcador con contador', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
const deg8 = 40 * (360 / (256 * Math.pow(2, 8)))
expect(leaflet.markers[1].pos[0]).toBeCloseTo(4.631028 + deg8, 5)
const groupIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].className === 'provenance-group-pin')
expect(groupIcon).toBeDefined()
expect(groupIcon[0].html).toContain('2')
expect(groupIcon[0].html).toContain('mdi-package-variant')
const groupMarker = leaflet.markers[1]
expect(groupMarker.bindPopup).toHaveBeenCalled()
expect(groupMarker.bindTooltip.mock.calls[0][0]).toContain('2 productos')
})
leaflet.map.getZoom.mockReturnValue(9)
const zoomHandler = leaflet.map.on.mock.calls.find(args => args[0] === 'zoomend')[1]
zoomHandler()
it('al hacer clic en el marcador agrupado despliega los productos internos', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
const deg9 = 40 * (360 / (256 * Math.pow(2, 9)))
expect(leaflet.markers[1].setLatLng).toHaveBeenCalled()
expect(leaflet.markers[1].setLatLng.mock.calls[0][0][0]).toBeCloseTo(4.631028 + deg9, 5)
const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0]
expect(popupEl.textContent).toContain('Panela')
expect(popupEl.textContent).toContain('Arroz')
})
it('al hacer clic en un producto del popup agrupado muestra su detalle sin cerrar el popup ni propagar el clic al mapa', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
const groupMarker = leaflet.markers[1]
const popupEl = groupMarker.bindPopup.mock.calls[0][0]
let bubbled = false
popupEl.addEventListener('click', () => { bubbled = true })
const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(bubbled).toBe(false)
expect(groupMarker.closePopup).not.toHaveBeenCalled()
expect(groupMarker.setPopupContent).toHaveBeenCalledTimes(1)
const detailEl = groupMarker.setPopupContent.mock.calls[0][0]
expect(detailEl.textContent).toContain('Arroz')
expect(detailEl.textContent).toContain('B')
expect(groupMarker.openPopup).toHaveBeenCalled()
})
it('al pasar sobre la tienda dibuja una línea por cada producto agrupado', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
handlerOf(leaflet.markers[0], 'mouseover')()
expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
})
it('al pasar sobre el marcador agrupado dibuja las líneas de sus productos', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
handlerOf(leaflet.markers[1], 'mouseover')()
expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
expect(leaflet.polylines[0].points[0]).toEqual([4.631028, -74.461588])
})
it('muestra un recuadro con todos los productos, incluidos los sin geolocalización', async () => {
const withNoGeo = [
{
product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
},
{
product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
},
{
product: { id: 3, name: 'Miel pura', catalogue_images: [] },
suppliers: [],
},
]
const { wrapper } = mountMap({ provenance: withNoGeo })
await flushPromises()
const rows = wrapper.findAll('[data-test="map-product-row"]')
expect(rows).toHaveLength(3)
expect(rows.map(row => row.text().replace(/\s+/g, ' ').trim())).toEqual([
expect.stringContaining('Panela regional'),
expect.stringContaining('Café de montaña'),
expect.stringContaining('Miel pura'),
])
expect(rows[1].text()).toContain('Sin geolocalización')
expect(rows[2].text()).toContain('Sin geolocalización')
expect(rows[0].text()).toContain('Santa Bárbara')
})
it('al hacer clic en un producto con ubicación centra el mapa y abre su detalle en el marcador', async () => {
const { wrapper } = mountMap()
await flushPromises()
await wrapper.find('[data-test="map-product-row"]').trigger('click')
await nextTick()
expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
const marker = leaflet.markers[1]
expect(marker.setPopupContent).toHaveBeenCalled()
expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Panela regional')
expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Asociación La Mesa')
expect(marker.openPopup).toHaveBeenCalled()
expect(document.body.textContent).not.toContain('Proveedor')
})
it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => {
const withNoGeo = [
{
product: { id: 1, name: 'Panela regional', catalogue_images: [] },
suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
},
{
product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
},
]
const { wrapper } = mountMap({ provenance: withNoGeo })
await flushPromises()
const noGeoRow = wrapper.findAll('[data-test="map-product-row"]').find(row => row.text().includes('Café de montaña'))
await noGeoRow.trigger('click')
await nextTick()
expect(leaflet.map.flyTo).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('Café de montaña')
expect(document.body.textContent).toContain('Caficultores del Valle')
expect(document.body.textContent).toContain('sin geolocalización')
})
it('al hacer clic en el botón de zoom general vuelve a encuadrar todos los marcadores', async () => {
const { wrapper } = mountMap()
await flushPromises()
leaflet.map.fitBounds.mockClear()
await wrapper.find('[data-test="map-reset-zoom"]').trigger('click')
expect(leaflet.map.fitBounds).toHaveBeenCalledTimes(1)
})
it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => {

View File

@@ -67,10 +67,25 @@ describe('ProvenanceRelationModal', () => {
expect(bodyText()).not.toContain('País')
})
it('no muestra contenido cuando no hay relación', () => {
it('muestra el producto y un aviso cuando no hay proveedor vinculado', () => {
mountModal({ relation: null })
expect(bodyText()).toContain('Panela regional')
expect(bodyText()).toContain('no se ha vinculado un proveedor')
})
it('no muestra contenido cuando no hay producto ni relación', () => {
mountModal({ relation: null, product: null })
expect(bodyText()).not.toContain('Proveedor')
expect(bodyText()).not.toContain('Producto')
})
it('indica que aún no tiene geolocalización cuando la relación no tiene municipio', () => {
mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
expect(bodyText()).toContain('Asociación La Mesa')
expect(bodyText()).toContain('sin geolocalización')
})
it('no muestra el diálogo cuando visible es false', () => {

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import ProductsFromTrytonPage from '@/pages/sincronizar_productos_tryton.vue'
import vuetify from '@/plugins/vuetify'
const syncResult = {
checked_tryton_products: [{ id: 190, name: 'Producto 1' }],
created_products: [{ id: 3, name: 'Producto 1' }],
updated_products: [],
failed_products: [],
untouched_products: [],
created_categories: [
{ id: 9, name: 'Abarrotes', external_id: '5' },
],
updated_categories: [
{
id: 7,
name: 'Cereales',
external_id: '7',
detail: 'Nombre: Granos → Cereales',
},
],
}
function mockApi () {
return {
getProductsFromTryton: vi.fn().mockResolvedValue(syncResult),
}
}
async function mountPageAsAdmin (api) {
const pinia = createPinia()
setActivePinia(pinia)
const authStore = useAuthStore()
authStore.setUser({ role: 'administrator', username: 'admin' })
const wrapper = mount(ProductsFromTrytonPage, {
global: { plugins: [pinia, vuetify], provide: { api } },
})
await flushPromises()
await wrapper.vm.$nextTick()
return wrapper
}
async function startSync (wrapper) {
const button = wrapper
.findAll('button')
.find(b => b.text().includes('Iniciar Sincronización'))
await button.trigger('click')
await flushPromises()
await wrapper.vm.$nextTick()
}
describe('página sincronizar_productos_tryton', () => {
it('muestra las categorías creadas al sincronizar productos', async () => {
const wrapper = await mountPageAsAdmin(mockApi())
await startSync(wrapper)
expect(wrapper.text()).toContain('Categorías creadas (1)')
expect(wrapper.text()).toContain('Abarrotes')
})
it('muestra las categorías actualizadas con su detalle', async () => {
const wrapper = await mountPageAsAdmin(mockApi())
await startSync(wrapper)
expect(wrapper.text()).toContain('Categorías actualizadas (1)')
expect(wrapper.text()).toContain('Cereales')
expect(wrapper.text()).toContain('Nombre: Granos → Cereales')
})
it('mantiene el resumen de productos al sincronizar', async () => {
const api = mockApi()
const wrapper = await mountPageAsAdmin(api)
await startSync(wrapper)
expect(api.getProductsFromTryton).toHaveBeenCalled()
expect(wrapper.text()).toContain('✅ Creados (1)')
expect(wrapper.text()).toContain('Producto 1')
})
})