feat/49-provenance #54

Merged
mono merged 22 commits from feat/49-provenance into main 2026-08-16 22:21:13 -05:00
2 changed files with 263 additions and 0 deletions
Showing only changes of commit 8335ad2313 - Show all commits

View 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
)
)
}

View 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' }])
})
})