diff --git a/AGENTS.md b/AGENTS.md index 1ab464b..14db67a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`): builders puros en `provenance-graph.js` (`buildSupplierOrganizationGraph`, `buildTerritoryGraph`, `hasAnySupplier`, `hasAnyTerritory`) 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:` (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). Los charts (que muestran "próximamente estará disponible" cuando no hay relaciones), `ProvenanceDetailModal.vue` y `ProvenanceSection.vue` +- **Específico público** (`src/components/provenance/`): builders puros en `provenance-graph.js` (`buildSupplierOrganizationGraph`, `buildTerritoryGraph`, `hasAnySupplier`, `hasAnyTerritory`) 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:` (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). El gráfico de territorio va solo hasta departamento (sin país, por ahora Colombia). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. Los charts (que muestran "próximamente estará disponible" cuando no hay relaciones), `ProvenanceDetailModal.vue` y `ProvenanceSection.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//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//` - Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }` diff --git a/src/components/provenance/provenance-graph.js b/src/components/provenance/provenance-graph.js index 54dd22d..2b149be 100644 --- a/src/components/provenance/provenance-graph.js +++ b/src/components/provenance/provenance-graph.js @@ -17,6 +17,7 @@ function makeCollector () { const nodes = [] const edges = [] const seen = new Map() + const edgeSeen = new Map() return { nodes, edges, @@ -36,7 +37,14 @@ function makeCollector () { return seen.get(id) }, addEdge (from, to, certain) { - edges.push({ from, to, certain: Boolean(certain) }) + const key = `${from}\u0000${to}` + if (edgeSeen.has(key)) { + edgeSeen.get(key).certain = edgeSeen.get(key).certain && Boolean(certain) + return + } + const edge = { from, to, certain: Boolean(certain) } + edgeSeen.set(key, edge) + edges.push(edge) }, } } @@ -97,7 +105,6 @@ export function buildTerritoryGraph (provenance) { const municipalityCertain = isCertain(distinctValues(relations.map(rel => rel.municipality))) const departmentCertain = isCertain(distinctValues(relations.map(rel => rel.department))) - const countryCertain = isCertain(distinctValues(relations.map(rel => rel.country))) for (const rel of relations) { if (!rel.municipality) continue @@ -110,12 +117,6 @@ export function buildTerritoryGraph (provenance) { const department = collector.addNode('department', rel.department) collector.addEdge(municipality.id, department.id, departmentCertain) } - for (const rel of relations) { - if (!rel.department || !rel.country) continue - const department = collector.addNode('department', rel.department) - const country = collector.addNode('country', rel.country) - collector.addEdge(department.id, country.id, countryCertain) - } } return { nodes: collector.nodes, edges: collector.edges } } @@ -127,7 +128,7 @@ export function hasAnySupplier (provenance) { export function hasAnyTerritory (provenance) { return (provenance || []).some(entry => (entry.suppliers || []).some(rel => - rel.municipality || rel.department || rel.country + rel.municipality || rel.department ) ) } diff --git a/src/components/provenance/provenance-vis.js b/src/components/provenance/provenance-vis.js index 8e19847..1bfb8f2 100644 --- a/src/components/provenance/provenance-vis.js +++ b/src/components/provenance/provenance-vis.js @@ -14,17 +14,49 @@ const COLORS = { junction: '#78909c', } +const PRODUCT_SPACING = 110 +const COLUMN_X = { + product: -240, + junction: -120, + supplier: 0, + organization: 240, + municipality: 240, + department: 480, +} + export function toVisNodes (nodes) { + const products = nodes.filter(node => node.kind === 'product') + const productY = new Map() + products.forEach((node, index) => { + productY.set(node.entity.id, (index - (products.length - 1) / 2) * PRODUCT_SPACING) + }) return nodes.map(node => { const color = COLORS[node.kind] || '#cfd8dc' const { image, ...rest } = node if (node.kind === 'junction') { - return { ...rest, shape: 'dot', color, size: 10, label: '' } + return { + ...rest, + shape: 'dot', + color, + size: 10, + label: '', + x: COLUMN_X.junction, + y: productY.get(node.entity.id), + fixed: { x: true, y: true }, + } } - if (image) { - return { ...rest, image, shape: 'circularImage', borderWidth: 2 } + const base = image + ? { ...rest, image, shape: 'circularImage', borderWidth: 2 } + : { ...rest, shape: 'dot', color, borderWidth: 2 } + if (node.kind === 'product') { + return { + ...base, + x: COLUMN_X.product, + y: productY.get(node.entity.id), + fixed: { x: true, y: true }, + } } - return { ...rest, shape: 'dot', color, borderWidth: 2 } + return { ...base, x: COLUMN_X[node.kind], fixed: { x: true, y: false } } }) } diff --git a/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js index 3b5dcfd..8adf0e9 100644 --- a/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js +++ b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js @@ -139,4 +139,44 @@ describe('ProductSupplierOrganizationChart', () => { expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(false) }) + + it('alinea los productos en una columna vertical fija y centrada', () => { + const provenance = [ + { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' } }] }, + { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 6, name: 'B' } }] }, + { product: { id: 3, name: 'Queso' }, suppliers: [{ supplier: { id: 7, name: 'C' } }] }, + ] + const wrapper = mountChart({ provenance }) + + const products = visChart(wrapper).props('nodes').filter(node => node.kind === 'product') + expect(products).toHaveLength(3) + expect(new Set(products.map(node => node.x)).size).toBe(1) + expect(products.map(node => node.y)).toEqual([-110, 0, 110]) + products.forEach(node => { + expect(node.fixed).toEqual({ x: true, y: true }) + }) + }) + + it('alinea proveedores y organizaciones en columnas por nivel', () => { + const wrapper = mountChart() + + const supplier = visChart(wrapper).props('nodes').find(node => node.id === 'supplier:5') + expect(supplier.x).toBe(0) + expect(supplier.fixed).toEqual({ x: true, y: false }) + expect(supplier.y).toBeUndefined() + + const organization = visChart(wrapper).props('nodes').find(node => node.id === 'organization:3') + expect(organization.x).toBe(240) + expect(organization.fixed).toEqual({ x: true, y: false }) + }) + + it('el punto de disyunción queda a la misma altura que su producto', () => { + const wrapper = mountChart({ provenance: provenanceWithTwoSuppliers }) + + const junction = visChart(wrapper).props('nodes').find(node => node.id === 'junction:1') + const product = visChart(wrapper).props('nodes').find(node => node.id === 'product:1') + expect(junction.x).toBe(-120) + expect(junction.y).toBe(product.y) + expect(junction.fixed).toEqual({ x: true, y: true }) + }) }) diff --git a/tests/unit/components/provenance/ProductTerritoryChart.spec.js b/tests/unit/components/provenance/ProductTerritoryChart.spec.js index 191653c..f2ca526 100644 --- a/tests/unit/components/provenance/ProductTerritoryChart.spec.js +++ b/tests/unit/components/provenance/ProductTerritoryChart.spec.js @@ -63,7 +63,7 @@ describe('ProductTerritoryChart', () => { vi.clearAllMocks() }) - it('renderiza el gráfico con municipio, departamento y país', () => { + it('renderiza el gráfico con municipio y departamento, sin país', () => { const wrapper = mountChart() const chart = visChart(wrapper) @@ -73,7 +73,7 @@ describe('ProductTerritoryChart', () => { expect(nodeIds).toContain('supplier:5') expect(nodeIds).toContain('municipality:7') expect(nodeIds).toContain('department:2') - expect(nodeIds).toContain('country:1') + expect(nodeIds).not.toContain('country:1') }) it('incluye la etiqueta del nodo y el color según el tipo', () => { @@ -96,7 +96,20 @@ describe('ProductTerritoryChart', () => { expect(edges.find(edge => edge.from === 'junction:1' && edge.to === 'supplier:5').dashes).toBe(true) expect(edges.find(edge => edge.from === 'supplier:5' && edge.to === 'municipality:7').dashes).toBe(true) expect(edges.find(edge => edge.from === 'municipality:7' && edge.to === 'department:2').dashes).toBe(true) - expect(edges.find(edge => edge.from === 'department:2' && edge.to === 'country:1').dashes).toBe(false) + }) + + it('con varios productos del mismo proveedor solo traza una línea al municipio', () => { + const provenanceWithRepeatedSupplier = [ + { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] }, + { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] }, + { product: { id: 3, name: 'Queso' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] }, + ] + const wrapper = mountChart({ provenance: provenanceWithRepeatedSupplier }) + + const chart = visChart(wrapper) + const edges = chart.props('edges') + expect(edges.filter(edge => edge.from === 'supplier:5' && edge.to === 'municipality:7')).toHaveLength(1) + expect(chart.props('nodes').filter(node => node.id === 'municipality:7')).toHaveLength(1) }) it('muestra mensaje de próximamente cuando ningún proveedor tiene territorio', () => { @@ -121,4 +134,13 @@ describe('ProductTerritoryChart', () => { expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true) expect(document.body.textContent).toContain('La Mesa') }) + + it('alinea municipios y departamentos en columnas por nivel', () => { + const wrapper = mountChart() + + const nodes = visChart(wrapper).props('nodes') + expect(nodes.find(node => node.id === 'supplier:5').x).toBe(0) + expect(nodes.find(node => node.id === 'municipality:7').x).toBe(240) + expect(nodes.find(node => node.id === 'department:2').x).toBe(480) + }) }) diff --git a/tests/unit/components/provenance/provenance-graph.spec.js b/tests/unit/components/provenance/provenance-graph.spec.js index 62298b4..90f1465 100644 --- a/tests/unit/components/provenance/provenance-graph.spec.js +++ b/tests/unit/components/provenance/provenance-graph.spec.js @@ -87,6 +87,26 @@ describe('hasAnyTerritory', () => { const noSuppliers = [{ product: { id: 1, name: 'Panela' }, suppliers: [] }] expect(hasAnyTerritory(noSuppliers)).toBe(false) }) + + it('es true cuando hay municipio aunque el país venga null', () => { + const data = [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: null, country: null }], + }, + ] + expect(hasAnyTerritory(data)).toBe(true) + }) + + it('es false cuando solo hay país', () => { + const data = [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: null, department: null, country: { id: 1, name: 'Colombia' } }], + }, + ] + expect(hasAnyTerritory(data)).toBe(false) + }) }) describe('buildSupplierOrganizationGraph', () => { @@ -146,7 +166,7 @@ describe('buildSupplierOrganizationGraph', () => { expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5', certain: true }]) }) - it('no duplica nodos que se repiten entre productos', () => { + it('no duplica nodos ni aristas cuando se repiten entre productos', () => { const data = [ { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' } }] }, { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' } }] }, @@ -155,7 +175,19 @@ describe('buildSupplierOrganizationGraph', () => { expect(graph.nodes.filter(n => n.id === 'supplier:5')).toHaveLength(1) expect(graph.nodes.filter(n => n.id === 'organization:3')).toHaveLength(1) - expect(graph.edges).toHaveLength(4) + expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'organization:3')).toHaveLength(1) + expect(graph.edges).toHaveLength(3) + }) + + it('si el mismo par de nodos repite con distinta certeza, gana la duda', () => { + const data = [ + { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, organization: { id: 3, name: 'Red' } }] }, + { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'A' }, organization: { id: 3, name: 'Red' } }, { supplier: { id: 6, name: 'B' }, organization: { id: 4, name: 'Coop' } }] }, + ] + const graph = buildSupplierOrganizationGraph(data) + + expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'organization:3')).toHaveLength(1) + expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(false) }) it('no incluye posiciones de layout', () => { @@ -181,7 +213,25 @@ describe('buildTerritoryGraph', () => { expect(edgeOf(graph, 'product:1', 'supplier:5')).toEqual({ from: 'product:1', to: 'supplier:5', certain: true }) expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toEqual({ from: 'supplier:5', to: 'municipality:7', certain: true }) expect(edgeOf(graph, 'municipality:7', 'department:2')).toEqual({ from: 'municipality:7', to: 'department:2', certain: true }) - expect(edgeOf(graph, 'department:2', 'country:1')).toEqual({ from: 'department:2', to: 'country:1', certain: true }) + }) + + it('no incluye nodos ni aristas de país', () => { + const graph = buildTerritoryGraph(singleSupplier) + + expect(graph.nodes.map(n => n.id)).not.toContain('country:1') + expect(graph.edges.some(edge => edge.from.startsWith('country:') || edge.to.startsWith('country:'))).toBe(false) + }) + + it('con varios productos del mismo proveedor solo traza una línea hacia el municipio', () => { + const data = [ + { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] }, + { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] }, + { product: { id: 3, name: 'Queso' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] }, + ] + const graph = buildTerritoryGraph(data) + + expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'municipality:7')).toHaveLength(1) + expect(graph.nodes.filter(n => n.id === 'municipality:7')).toHaveLength(1) }) it('cuando los proveedores comparten municipio, el territorio es cierto desde allí', () => { @@ -194,7 +244,6 @@ describe('buildTerritoryGraph', () => { expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toEqual({ from: 'supplier:5', to: 'municipality:7', certain: true }) expect(edgeOf(graph, 'supplier:6', 'municipality:7')).toEqual({ from: 'supplier:6', to: 'municipality:7', certain: true }) expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(true) - expect(edgeOf(graph, 'department:2', 'country:1').certain).toBe(true) }) it('municipios distintos pero mismo departamento: dudoso hasta el municipio y cierto desde el departamento', () => { @@ -206,18 +255,15 @@ describe('buildTerritoryGraph', () => { expect(edgeOf(graph, 'supplier:6', 'municipality:8').certain).toBe(false) expect(edgeOf(graph, 'municipality:7', 'department:2')).toEqual({ from: 'municipality:7', to: 'department:2', certain: true }) expect(edgeOf(graph, 'municipality:8', 'department:2')).toEqual({ from: 'municipality:8', to: 'department:2', certain: true }) - expect(edgeOf(graph, 'department:2', 'country:1').certain).toBe(true) }) - it('municipios y departamentos distintos mantienen la duda, y el país común es cierto', () => { + it('municipios y departamentos distintos mantienen la duda', () => { const graph = buildTerritoryGraph(twoSuppliers()) expect(edgeOf(graph, 'supplier:5', 'municipality:7').certain).toBe(false) expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(false) expect(edgeOf(graph, 'supplier:6', 'municipality:8').certain).toBe(false) expect(edgeOf(graph, 'municipality:8', 'department:3').certain).toBe(false) - expect(edgeOf(graph, 'department:2', 'country:1')).toEqual({ from: 'department:2', to: 'country:1', certain: true }) - expect(edgeOf(graph, 'department:3', 'country:1')).toEqual({ from: 'department:3', to: 'country:1', certain: true }) }) it('no crea nodos de territorio cuando el proveedor no tiene municipio', () => {