diff --git a/src/components/PublicOrderSummary.vue b/src/components/PublicOrderSummary.vue
index 484b2a8..c45f4e2 100644
--- a/src/components/PublicOrderSummary.vue
+++ b/src/components/PublicOrderSummary.vue
@@ -40,6 +40,10 @@
+
@@ -51,6 +55,7 @@
import OrderLines from '@/components/order/OrderLines.vue'
import OrderPayment from '@/components/order/OrderPayment.vue'
import OrderTotal from '@/components/order/OrderTotal.vue'
+ import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
defineProps({
purchase: {
diff --git a/src/components/provenance/ProductSupplierOrganizationChart.vue b/src/components/provenance/ProductSupplierOrganizationChart.vue
new file mode 100644
index 0000000..5e2d6a4
--- /dev/null
+++ b/src/components/provenance/ProductSupplierOrganizationChart.vue
@@ -0,0 +1,95 @@
+
+
+
+ La información de proveedores y organizaciones de este pedido estará
+ disponible próximamente.
+
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProductTerritoryChart.vue b/src/components/provenance/ProductTerritoryChart.vue
new file mode 100644
index 0000000..d77cb48
--- /dev/null
+++ b/src/components/provenance/ProductTerritoryChart.vue
@@ -0,0 +1,97 @@
+
+
+
+ La información de municipio, departamento y país de este pedido estará
+ disponible próximamente.
+
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProvenanceDetailModal.vue b/src/components/provenance/ProvenanceDetailModal.vue
new file mode 100644
index 0000000..f013836
--- /dev/null
+++ b/src/components/provenance/ProvenanceDetailModal.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+ {{ kindLabel }}
+
+
+
+
+ {{ selected.label }}
+
+
+ Descripción
+ {{ description }}
+
+
+ Sitio web
+
+ {{ website }}
+
+
+
+ Correo de contacto
+ {{ contactEmail }}
+
+
+ Teléfono de contacto
+ {{ contactPhone }}
+
+
+
+
+
+ Cerrar
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProvenanceSection.vue b/src/components/provenance/ProvenanceSection.vue
new file mode 100644
index 0000000..efae78d
--- /dev/null
+++ b/src/components/provenance/ProvenanceSection.vue
@@ -0,0 +1,29 @@
+
+
+
+
Origen e historia de los productos
+
+ Conoce quiénes producen los productos que compras y de qué territorios provienen.
+
+
+
Proveedores y organizaciones
+
+
+
+
+
Departamento y municipio
+
+
+
+
+
diff --git a/src/services/graph/graph-layout.js b/src/services/graph/graph-layout.js
index f1eb3e7..ef635ef 100644
--- a/src/services/graph/graph-layout.js
+++ b/src/services/graph/graph-layout.js
@@ -51,17 +51,20 @@ export function computeColumnLayout (nodes, edges, options = {}) {
export function toCytoscapeElements ({ nodes, edges }) {
return [
- ...nodes.map(node => ({
- data: {
- id: node.id,
- kind: node.kind,
- label: node.label,
- image: node.image || null,
- entity: node.entity || null,
- },
- classes: [node.kind],
- position: { x: node.x, y: node.y },
- })),
+ ...nodes.map(node => {
+ const classes = node.image ? [node.kind, 'has-image'] : [node.kind]
+ return {
+ data: {
+ id: node.id,
+ kind: node.kind,
+ label: node.label,
+ image: node.image || null,
+ entity: node.entity || null,
+ },
+ classes,
+ position: { x: node.x, y: node.y },
+ }
+ }),
...edges.map(edge => ({
data: {
id: `edge:${edge.from}:${edge.to}`,
diff --git a/tests/unit/components/PublicOrderSummary.spec.js b/tests/unit/components/PublicOrderSummary.spec.js
index d4bb791..45ea149 100644
--- a/tests/unit/components/PublicOrderSummary.spec.js
+++ b/tests/unit/components/PublicOrderSummary.spec.js
@@ -6,8 +6,15 @@ import OrderCustomer from '@/components/order/OrderCustomer.vue'
import OrderLines from '@/components/order/OrderLines.vue'
import OrderTotal from '@/components/order/OrderTotal.vue'
import OrderPayment from '@/components/order/OrderPayment.vue'
+import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
import vuetify from '@/plugins/vuetify'
+const ProvenanceSectionStub = {
+ name: 'ProvenanceSection',
+ props: ['provenance'],
+ template: '
',
+}
+
const saleData = {
id: 5,
code: 'abc123',
@@ -33,7 +40,10 @@ const catalogData = {
function mountSummary (props) {
return mount(PublicOrderSummary, {
props,
- global: { plugins: [vuetify] },
+ global: {
+ plugins: [vuetify],
+ stubs: { ProvenanceSection: ProvenanceSectionStub },
+ },
})
}
@@ -87,4 +97,28 @@ describe('PublicOrderSummary', () => {
expect(wrapper.text()).not.toContain('Camilo')
expect(wrapper.findComponent(OrderLines).exists()).toBe(false)
})
+
+ it('no renderiza la sección de provenance cuando la respuesta no la incluye', () => {
+ const wrapper = mountSummary({ purchase: saleData })
+
+ expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(false)
+ })
+
+ it('renderiza la sección de provenance cuando el resumen la incluye', () => {
+ const withProvenance = {
+ ...saleData,
+ product_provenance: [
+ {
+ product: { id: 10, name: 'Panela' },
+ suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' }, country: { id: 1, name: 'Colombia', code: 'CO' } }],
+ },
+ ],
+ }
+ const wrapper = mountSummary({ purchase: withProvenance })
+
+ expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(true)
+ expect(wrapper.findComponent(ProvenanceSection).props('provenance')).toEqual(
+ withProvenance.product_provenance
+ )
+ })
})
diff --git a/tests/unit/components/graph/CytoscapeChart.spec.js b/tests/unit/components/graph/CytoscapeChart.spec.js
index 81cca6e..323d7b8 100644
--- a/tests/unit/components/graph/CytoscapeChart.spec.js
+++ b/tests/unit/components/graph/CytoscapeChart.spec.js
@@ -1,3 +1,4 @@
+import cytoscape from 'cytoscape'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import CytoscapeChart from '@/components/graph/CytoscapeChart.vue'
@@ -17,8 +18,6 @@ vi.mock('cytoscape', () => ({
default: vi.fn(() => cy),
}))
-import cytoscape from 'cytoscape'
-
const elements = [
{ data: { id: 'product:1', kind: 'product', label: 'Panela' }, position: { x: 100, y: 100 } },
{ data: { id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }, position: { x: 320, y: 100 } },
diff --git a/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js
new file mode 100644
index 0000000..ab71d38
--- /dev/null
+++ b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import CytoscapeChart from '@/components/graph/CytoscapeChart.vue'
+import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
+import ProductSupplierOrganizationChart from '@/components/provenance/ProductSupplierOrganizationChart.vue'
+import vuetify from '@/plugins/vuetify'
+
+const cy = {
+ on: vi.fn(),
+ elements: vi.fn(() => ({ remove: vi.fn() })),
+ add: vi.fn(),
+ layout: vi.fn(() => ({ run: vi.fn() })),
+ destroy: vi.fn(),
+}
+
+vi.mock('cytoscape', () => ({
+ default: vi.fn(() => cy),
+}))
+
+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: null,
+ department: null,
+ country: null,
+ },
+ ],
+ },
+]
+
+function mountChart (props = {}) {
+ return mount(ProductSupplierOrganizationChart, {
+ props: { provenance, ...props },
+ global: { plugins: [vuetify] },
+ })
+}
+
+describe('ProductSupplierOrganizationChart', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renderiza el gráfico con los elementos de productos, proveedores y organizaciones', () => {
+ const wrapper = mountChart()
+
+ const chart = wrapper.findComponent(CytoscapeChart)
+ expect(chart.exists()).toBe(true)
+ const elements = chart.props('elements')
+ const nodeIds = elements.filter(e => e.data.source === undefined).map(e => e.data.id)
+ expect(nodeIds).toContain('product:1')
+ expect(nodeIds).toContain('supplier:5')
+ expect(nodeIds).toContain('organization:3')
+ })
+
+ it('no muestra mensaje de próximamente cuando existen relaciones', () => {
+ const wrapper = mountChart()
+
+ expect(wrapper.text()).not.toContain('próximamente')
+ })
+
+ it('muestra mensaje de próximamente cuando ningún producto tiene proveedores', () => {
+ const wrapper = mountChart({
+ provenance: [{ product: { id: 1, name: 'Panela' }, suppliers: [] }],
+ })
+
+ expect(wrapper.findComponent(CytoscapeChart).exists()).toBe(false)
+ expect(wrapper.text()).toContain('próximamente')
+ })
+
+ it('abre el modal de detalle al seleccionar un nodo', async () => {
+ const wrapper = mountChart()
+
+ const chart = wrapper.findComponent(CytoscapeChart)
+ await chart.vm.$emit('select', { kind: 'supplier', label: 'Asociación Agropecuaria La Mesa', entity: { kind: 'supplier' } })
+
+ expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true)
+ expect(document.body.textContent).toContain('Asociación Agropecuaria La Mesa')
+ })
+})
diff --git a/tests/unit/components/provenance/ProductTerritoryChart.spec.js b/tests/unit/components/provenance/ProductTerritoryChart.spec.js
new file mode 100644
index 0000000..528df3b
--- /dev/null
+++ b/tests/unit/components/provenance/ProductTerritoryChart.spec.js
@@ -0,0 +1,84 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import CytoscapeChart from '@/components/graph/CytoscapeChart.vue'
+import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
+import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart.vue'
+import vuetify from '@/plugins/vuetify'
+
+const cy = {
+ on: vi.fn(),
+ elements: vi.fn(() => ({ remove: vi.fn() })),
+ add: vi.fn(),
+ layout: vi.fn(() => ({ run: vi.fn() })),
+ destroy: vi.fn(),
+}
+
+vi.mock('cytoscape', () => ({
+ default: vi.fn(() => cy),
+}))
+
+const provenance = [
+ {
+ product: { id: 1, name: 'Panela regional por Kg' },
+ suppliers: [
+ {
+ supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' },
+ organization: null,
+ municipality: { id: 7, name: 'La Mesa' },
+ department: { id: 2, name: 'Cundinamarca' },
+ country: { id: 1, name: 'Colombia', code: 'CO' },
+ },
+ ],
+ },
+]
+
+function mountChart (props = {}) {
+ return mount(ProductTerritoryChart, {
+ props: { provenance, ...props },
+ global: { plugins: [vuetify] },
+ })
+}
+
+describe('ProductTerritoryChart', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renderiza el gráfico con municipio, departamento y país', () => {
+ const wrapper = mountChart()
+
+ const chart = wrapper.findComponent(CytoscapeChart)
+ expect(chart.exists()).toBe(true)
+ const elements = chart.props('elements')
+ const nodeIds = elements.filter(e => e.data.source === undefined).map(e => e.data.id)
+ expect(nodeIds).toContain('product:1')
+ expect(nodeIds).toContain('supplier:5')
+ expect(nodeIds).toContain('municipality:7')
+ expect(nodeIds).toContain('department:2')
+ expect(nodeIds).toContain('country:1')
+ })
+
+ it('muestra mensaje de próximamente cuando ningún proveedor tiene territorio', () => {
+ const wrapper = mountChart({
+ provenance: [
+ {
+ product: { id: 1, name: 'Panela' },
+ suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: null, department: null, country: null }],
+ },
+ ],
+ })
+
+ expect(wrapper.findComponent(CytoscapeChart).exists()).toBe(false)
+ expect(wrapper.text()).toContain('próximamente')
+ })
+
+ it('abre el modal de detalle al seleccionar un municipio', async () => {
+ const wrapper = mountChart()
+
+ const chart = wrapper.findComponent(CytoscapeChart)
+ await chart.vm.$emit('select', { kind: 'municipality', label: 'La Mesa', entity: { kind: 'municipality', name: 'La Mesa' } })
+
+ expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true)
+ expect(document.body.textContent).toContain('La Mesa')
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceDetailModal.spec.js b/tests/unit/components/provenance/ProvenanceDetailModal.spec.js
new file mode 100644
index 0000000..ecbf0ea
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceDetailModal.spec.js
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
+import vuetify from '@/plugins/vuetify'
+
+const supplier = {
+ id: 'supplier:5',
+ kind: 'supplier',
+ label: 'Asociación Agropecuaria La Mesa',
+ image: null,
+ entity: {
+ id: 5,
+ name: 'Asociación Agropecuaria La Mesa',
+ description: 'Cooperativa de campesinos',
+ website: 'https://agro.example.org',
+ contact_email: 'contacto@agro.example.org',
+ contact_phone: '300 123 4567',
+ kind: 'supplier',
+ },
+}
+
+const product = {
+ id: 'product:1',
+ kind: 'product',
+ label: 'Panela regional por Kg',
+ image: 'http://localhost/media/panela.jpg',
+ entity: { id: 1, name: 'Panela regional por Kg', kind: 'product' },
+}
+
+function mountModal (props) {
+ return mount(ProvenanceDetailModal, {
+ props,
+ global: { plugins: [vuetify] },
+ })
+}
+
+function bodyText () {
+ return document.body.textContent
+}
+
+describe('ProvenanceDetailModal', () => {
+ it('muestra la información detallada del proveedor seleccionado', () => {
+ mountModal({ visible: true, selected: supplier })
+
+ expect(bodyText()).toContain('Proveedor')
+ expect(bodyText()).toContain('Asociación Agropecuaria La Mesa')
+ expect(bodyText()).toContain('Cooperativa de campesinos')
+ expect(bodyText()).toContain('https://agro.example.org')
+ expect(bodyText()).toContain('contacto@agro.example.org')
+ expect(bodyText()).toContain('300 123 4567')
+ })
+
+ it('muestra la imagen del producto cuando existe', () => {
+ mountModal({ visible: true, selected: product })
+
+ expect(document.querySelector('img').getAttribute('src')).toBe(
+ 'http://localhost/media/panela.jpg'
+ )
+ })
+
+ it('no muestra contenido cuando no hay entidad seleccionada', () => {
+ mountModal({ visible: true, selected: null })
+
+ expect(bodyText()).not.toContain('Proveedor')
+ expect(bodyText()).not.toContain('Asociación')
+ })
+
+ it('no muestra el diálogo cuando visible es false', () => {
+ mountModal({ visible: false, selected: supplier })
+
+ expect(bodyText()).not.toContain('Asociación Agropecuaria La Mesa')
+ })
+
+ it('omite los campos vacíos', () => {
+ const minimal = {
+ id: 'country:1',
+ kind: 'country',
+ label: 'Colombia',
+ image: null,
+ entity: { id: 1, name: 'Colombia', kind: 'country' },
+ }
+ mountModal({ visible: true, selected: minimal })
+
+ expect(bodyText()).toContain('Colombia')
+ expect(bodyText()).not.toContain('Descripción')
+ expect(bodyText()).not.toContain('Sitio web')
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js
new file mode 100644
index 0000000..b8c8612
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceSection.spec.js
@@ -0,0 +1,72 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ProductSupplierOrganizationChart from '@/components/provenance/ProductSupplierOrganizationChart.vue'
+import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart.vue'
+import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
+import vuetify from '@/plugins/vuetify'
+
+const cy = {
+ on: vi.fn(),
+ elements: vi.fn(() => ({ remove: vi.fn() })),
+ add: vi.fn(),
+ layout: vi.fn(() => ({ run: vi.fn() })),
+ destroy: vi.fn(),
+}
+
+vi.mock('cytoscape', () => ({
+ default: vi.fn(() => cy),
+}))
+
+const provenance = [
+ {
+ product: { id: 1, name: 'Panela regional por Kg' },
+ suppliers: [
+ {
+ supplier: { id: 5, name: 'Asociación La Mesa' },
+ organization: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: { id: 7, name: 'La Mesa' },
+ department: { id: 2, name: 'Cundinamarca' },
+ country: { id: 1, name: 'Colombia', code: 'CO' },
+ },
+ ],
+ },
+]
+
+function mountSection (props = {}) {
+ return mount(ProvenanceSection, {
+ props: { provenance, ...props },
+ global: { plugins: [vuetify] },
+ })
+}
+
+describe('ProvenanceSection', () => {
+ it('no renderiza nada cuando no hay provenance', () => {
+ const wrapper = mountSection({ provenance: null })
+
+ expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(false)
+ expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(false)
+ })
+
+ it('no renderiza nada cuando provenance es una lista vacía', () => {
+ const wrapper = mountSection({ provenance: [] })
+
+ expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(false)
+ expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(false)
+ })
+
+ it('muestra los dos gráficos con sus títulos cuando hay provenance', () => {
+ const wrapper = mountSection()
+
+ expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(true)
+ expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(true)
+ expect(wrapper.text()).toContain('Proveedores y organizaciones')
+ expect(wrapper.text()).toContain('Departamento y municipio')
+ })
+
+ it('pasa el provenance a ambos gráficos', () => {
+ const wrapper = mountSection()
+
+ expect(wrapper.findComponent(ProductSupplierOrganizationChart).props('provenance')).toStrictEqual(provenance)
+ expect(wrapper.findComponent(ProductTerritoryChart).props('provenance')).toStrictEqual(provenance)
+ })
+})
diff --git a/tests/unit/services/graph/graph-layout.spec.js b/tests/unit/services/graph/graph-layout.spec.js
index eeb8b23..a05afef 100644
--- a/tests/unit/services/graph/graph-layout.spec.js
+++ b/tests/unit/services/graph/graph-layout.spec.js
@@ -87,4 +87,14 @@ describe('toCytoscapeElements', () => {
expect(elements[0].data.entity).toEqual({ name: 'Panela', price: 3000 })
})
+
+ it('agrega la clase has-image a los nodos con imagen', () => {
+ const withImage = [
+ { id: 'product:1', kind: 'product', label: 'Panela', image: 'http://x/panela.jpg' },
+ ]
+ const layout = computeColumnLayout(withImage, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 })
+ const elements = toCytoscapeElements(layout)
+
+ expect(elements[0].classes).toContain('has-image')
+ })
})