diff --git a/src/components/provenance/provenance-graph.js b/src/components/provenance/provenance-graph.js new file mode 100644 index 0000000..dc051b3 --- /dev/null +++ b/src/components/provenance/provenance-graph.js @@ -0,0 +1,104 @@ +/** + * Builders de grafos de provenance (específicos del dominio). + * + * Convierten el payload `product_provenance` del resumen de compra/pedido en + * un grafo en capas usando el servicio genérico `services/graph/graph-layout`. + */ + +import { computeColumnLayout } from '@/services/graph/graph-layout' + +const SUPPLIER_ORG_COLUMNS = { product: 0, supplier: 1, organization: 2 } +const TERRITORY_COLUMNS = { product: 0, supplier: 1, municipality: 2, department: 3, country: 4 } + +function makeCollector () { + const nodes = [] + const edges = [] + const seen = new Map() + return { + nodes, + edges, + addNode (kind, entity) { + const id = `${kind}:${entity.id}` + if (!seen.has(id)) { + const node = { + id, + kind, + label: entity.name, + image: entity.catalogue_images?.[0] || null, + entity: { ...entity, kind }, + } + seen.set(id, node) + nodes.push(node) + } + return seen.get(id) + }, + addEdge (from, to) { + edges.push({ from, to }) + }, + } +} + +function layout (collector, columns) { + return computeColumnLayout(collector.nodes, collector.edges, { + columnOf: node => columns[node.kind] ?? 0, + columnWidth: 220, + rowHeight: 72, + padding: 24, + }) +} + +export function buildSupplierOrganizationGraph (provenance) { + const g = makeCollector() + for (const entry of provenance || []) { + if (!entry.product) continue + const product = g.addNode('product', entry.product) + for (const rel of entry.suppliers || []) { + if (!rel.supplier) continue + const supplier = g.addNode('supplier', rel.supplier) + g.addEdge(product.id, supplier.id) + if (rel.organization) { + const organization = g.addNode('organization', rel.organization) + g.addEdge(supplier.id, organization.id) + } + } + } + return layout(g, SUPPLIER_ORG_COLUMNS) +} + +export function buildTerritoryGraph (provenance) { + const g = makeCollector() + for (const entry of provenance || []) { + if (!entry.product) continue + const product = g.addNode('product', entry.product) + for (const rel of entry.suppliers || []) { + if (!rel.supplier) continue + const supplier = g.addNode('supplier', rel.supplier) + g.addEdge(product.id, supplier.id) + if (rel.municipality) { + const municipality = g.addNode('municipality', rel.municipality) + g.addEdge(supplier.id, municipality.id) + if (rel.department) { + const department = g.addNode('department', rel.department) + g.addEdge(municipality.id, department.id) + if (rel.country) { + const country = g.addNode('country', rel.country) + g.addEdge(department.id, country.id) + } + } + } + } + } + return layout(g, TERRITORY_COLUMNS) +} + +export function hasAnySupplier (provenance) { + return (provenance || []).some(entry => (entry.suppliers || []).length > 0) +} + +export function hasAnyTerritory (provenance) { + return (provenance || []).some(entry => + (entry.suppliers || []).some(rel => + rel.municipality || rel.department || rel.country + ) + ) +} diff --git a/tests/unit/components/provenance/provenance-graph.spec.js b/tests/unit/components/provenance/provenance-graph.spec.js new file mode 100644 index 0000000..6b7944b --- /dev/null +++ b/tests/unit/components/provenance/provenance-graph.spec.js @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { + buildSupplierOrganizationGraph, + buildTerritoryGraph, + hasAnySupplier, + hasAnyTerritory, +} from '@/components/provenance/provenance-graph' + +const provenance = [ + { + product: { + id: 1, + name: 'Panela regional por Kg', + catalogue_images: ['http://localhost/media/panela.jpg'], + }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa', description: 'Cooperativa' }, + organization: { id: 3, name: 'Red de Economía Solidaria', description: 'Red' }, + municipality: { id: 7, name: 'La Mesa' }, + department: { id: 2, name: 'Cundinamarca' }, + country: { id: 1, name: 'Colombia', code: 'CO' }, + }, + ], + }, +] + +describe('hasAnySupplier', () => { + it('es true cuando algún producto tiene proveedores', () => { + expect(hasAnySupplier(provenance)).toBe(true) + }) + + it('es false cuando todos los productos están sin proveedores', () => { + const empty = [{ product: { id: 1, name: 'Panela' }, suppliers: [] }] + expect(hasAnySupplier(empty)).toBe(false) + }) + + it('es false cuando no hay provenance', () => { + expect(hasAnySupplier([])).toBe(false) + }) +}) + +describe('hasAnyTerritory', () => { + it('es true cuando algún proveedor tiene territorio', () => { + expect(hasAnyTerritory(provenance)).toBe(true) + }) + + it('es false cuando todos los territorios vienen en null', () => { + const noTerritory = [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: null, municipality: null, department: null, country: null }], + }, + ] + expect(hasAnyTerritory(noTerritory)).toBe(false) + }) + + it('es false cuando no hay suppliers', () => { + const noSuppliers = [{ product: { id: 1, name: 'Panela' }, suppliers: [] }] + expect(hasAnyTerritory(noSuppliers)).toBe(false) + }) +}) + +describe('buildSupplierOrganizationGraph', () => { + it('crea nodos de producto, proveedor y organización con sus aristas', () => { + const graph = buildSupplierOrganizationGraph(provenance) + + const ids = graph.nodes.map(n => n.id) + expect(ids).toContain('product:1') + expect(ids).toContain('supplier:5') + expect(ids).toContain('organization:3') + + expect(graph.edges).toContainEqual({ from: 'product:1', to: 'supplier:5' }) + expect(graph.edges).toContainEqual({ from: 'supplier:5', to: 'organization:3' }) + }) + + it('incluye la imagen del producto y el detalle de la entidad', () => { + const graph = buildSupplierOrganizationGraph(provenance) + const product = graph.nodes.find(n => n.id === 'product:1') + + expect(product.image).toBe('http://localhost/media/panela.jpg') + expect(product.label).toBe('Panela regional por Kg') + expect(product.entity.kind).toBe('product') + expect(product.entity.name).toBe('Panela regional por Kg') + }) + + it('omite el nodo de organización cuando la relación es null', () => { + const data = [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: null }], + }, + ] + const graph = buildSupplierOrganizationGraph(data) + + expect(graph.nodes.map(n => n.id)).not.toContain('organization:') + expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5' }]) + }) + + it('no duplica nodos que 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' } }] }, + ] + const graph = buildSupplierOrganizationGraph(data) + + 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) + }) + + it('ubica producto, proveedor y organización en columnas crecientes', () => { + const graph = buildSupplierOrganizationGraph(provenance) + + const product = graph.nodes.find(n => n.id === 'product:1') + const supplier = graph.nodes.find(n => n.id === 'supplier:5') + const organization = graph.nodes.find(n => n.id === 'organization:3') + + expect(product.x).toBeLessThan(supplier.x) + expect(supplier.x).toBeLessThan(organization.x) + }) + + it('devuelve un grafo vacío cuando no hay provenance', () => { + const graph = buildSupplierOrganizationGraph([]) + + expect(graph.nodes).toEqual([]) + expect(graph.edges).toEqual([]) + }) +}) + +describe('buildTerritoryGraph', () => { + it('crea la cadena producto → proveedor → municipio → departamento → país', () => { + const graph = buildTerritoryGraph(provenance) + + const ids = graph.nodes.map(n => n.id) + expect(ids).toContain('product:1') + expect(ids).toContain('supplier:5') + expect(ids).toContain('municipality:7') + expect(ids).toContain('department:2') + expect(ids).toContain('country:1') + + expect(graph.edges).toContainEqual({ from: 'supplier:5', to: 'municipality:7' }) + expect(graph.edges).toContainEqual({ from: 'municipality:7', to: 'department:2' }) + expect(graph.edges).toContainEqual({ from: 'department:2', to: 'country:1' }) + }) + + it('no crea nodos de territorio cuando el proveedor no tiene municipio', () => { + const data = [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: null, department: null, country: null }], + }, + ] + const graph = buildTerritoryGraph(data) + + expect(graph.nodes.map(n => n.id)).toEqual(['product:1', 'supplier:5']) + expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5' }]) + }) +})