feat/49-provenance #54
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