feat/49-provenance #54
@@ -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` 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"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan) 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 marcador abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `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"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: 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; clic en un marcador individual abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "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 }`
|
||||
|
||||
@@ -138,6 +138,16 @@
|
||||
|
||||
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>',
|
||||
@@ -158,6 +168,40 @@
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
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', () => openDialog(item))
|
||||
container.appendChild(row)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
function showLine (from, to) {
|
||||
const line = L.polyline([from, to], {
|
||||
color: '#26a69a',
|
||||
@@ -177,21 +221,33 @@
|
||||
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 () {
|
||||
markers.value.forEach(markerData => {
|
||||
const marker = L.marker(markerData.position, { 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.on('click', () => openDialog(item))
|
||||
marker.on('mouseover', () => {
|
||||
if (storePosition.value) showLine(item.position, storePosition.value)
|
||||
})
|
||||
marker.on('mouseout', clearLines)
|
||||
productMarkers.push({ marker, data: item })
|
||||
return
|
||||
}
|
||||
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))
|
||||
marker.on('mouseover', () => {
|
||||
if (storePosition.value) showLine(marker.getLatLng(), storePosition.value)
|
||||
if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value))
|
||||
})
|
||||
marker.on('mouseout', clearLines)
|
||||
productMarkers.push({ marker, data: markerData })
|
||||
productMarkers.push({ marker, data: group.items })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -302,6 +358,54 @@
|
||||
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(.leaflet-control-attribution) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 }),
|
||||
remove: vi.fn(),
|
||||
getLatLng: vi.fn(function () { return this.pos }),
|
||||
setLatLng: vi.fn(),
|
||||
@@ -85,6 +86,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()
|
||||
@@ -202,26 +214,68 @@ describe('ProvenanceMap', () => {
|
||||
})
|
||||
|
||||
it('mantiene la posición exacta del municipio aunque varios productos coincidan', 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' } }],
|
||||
},
|
||||
]
|
||||
mountMap({ provenance: sameMuni })
|
||||
await flushPromises()
|
||||
|
||||
const productPositions = leaflet.markers.slice(1).map(marker => marker.pos)
|
||||
expect(productPositions).toHaveLength(2)
|
||||
expect(productPositions[0]).toEqual([4.631028, -74.461588])
|
||||
expect(productPositions[1]).toEqual([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('agrupa los productos del mismo punto en un marcador con contador', async () => {
|
||||
mountMap({ provenance: sameMuni })
|
||||
await flushPromises()
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('al hacer clic en el marcador agrupado despliega los productos internos', async () => {
|
||||
mountMap({ provenance: sameMuni })
|
||||
await flushPromises()
|
||||
|
||||
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 abre el diálogo', async () => {
|
||||
mountMap({ provenance: sameMuni })
|
||||
await flushPromises()
|
||||
|
||||
const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0]
|
||||
const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
|
||||
item.dispatchEvent(new Event('click'))
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('Arroz')
|
||||
expect(document.body.textContent).toContain('B')
|
||||
})
|
||||
|
||||
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 = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user