#49 feat: builders de grafos de provenance (proveedores-organizaciones y territorios)
This commit is contained in:
104
src/components/provenance/provenance-graph.js
Normal file
104
src/components/provenance/provenance-graph.js
Normal file
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
159
tests/unit/components/provenance/provenance-graph.spec.js
Normal file
159
tests/unit/components/provenance/provenance-graph.spec.js
Normal file
@@ -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' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user