diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/ListadoDistribucionGeneralViewModel.cs b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/ListadoDistribucionGeneralViewModel.cs index a00e722..6ecba77 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/ListadoDistribucionGeneralViewModel.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/ListadoDistribucionGeneralViewModel.cs @@ -14,8 +14,6 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels public string MesConsultado { get; set; } = string.Empty; public string FechaReporte { get; set; } = DateTime.Now.ToString("dd/MM/yyyy"); - // --- PROPIEDAD PARA LOS TOTALES GENERALES DE PROMEDIOS --- - // Esta propiedad calcula los promedios generales basados en los datos del resumen mensual. public ListadoDistribucionGeneralPromedioDiaDto? PromedioGeneral { get @@ -24,21 +22,30 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels { return null; } + + // 1. Filtrar solo los días con actividad para no diluir el promedio. + var diasActivos = ResumenMensual.Where(r => r.CantidadTirada > 0).ToList(); - // Contar solo los días con tirada > 0 para promediar correctamente - var diasConTirada = ResumenMensual.Count(d => d.CantidadTirada > 0); - if (diasConTirada == 0) return null; + if (!diasActivos.Any()) + { + return null; // No hay días con actividad, no se puede calcular el promedio. + } + + // 2. Usar el conteo de días activos como divisor. + var totalDiasActivos = diasActivos.Count; return new ListadoDistribucionGeneralPromedioDiaDto { Dia = "General", - CantidadDias = diasConTirada, - PromedioTirada = (int)ResumenMensual.Average(r => r.CantidadTirada), - PromedioSinCargo = (int)ResumenMensual.Average(r => r.SinCargo), - PromedioPerdidos = (int)ResumenMensual.Average(r => r.Perdidos), - PromedioLlevados = (int)ResumenMensual.Average(r => r.Llevados), - PromedioDevueltos = (int)ResumenMensual.Average(r => r.Devueltos), - PromedioVendidos = (int)ResumenMensual.Average(r => r.Vendidos) + CantidadDias = totalDiasActivos, + // 3. Calcular el promedio real: Suma de valores / Cantidad de días activos. + // Se usa división entera para que coincida con el formato sin decimales. + PromedioTirada = diasActivos.Sum(r => r.CantidadTirada) / totalDiasActivos, + PromedioSinCargo = diasActivos.Sum(r => r.SinCargo) / totalDiasActivos, + PromedioPerdidos = diasActivos.Sum(r => r.Perdidos) / totalDiasActivos, + PromedioLlevados = diasActivos.Sum(r => r.Llevados) / totalDiasActivos, + PromedioDevueltos = diasActivos.Sum(r => r.Devueltos) / totalDiasActivos, + PromedioVendidos = diasActivos.Sum(r => r.Vendidos) / totalDiasActivos }; } } diff --git a/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs b/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs index 321839b..c585b7c 100644 --- a/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs +++ b/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs @@ -12,7 +12,6 @@ namespace GestionIntegral.Api.Services.Reportes { private readonly IReportesRepository _reportesRepository; private readonly ILogger _logger; - // No necesitas _connectionFactory aquí si toda la lógica de BD está en el repositorio. public ReportesService(IReportesRepository reportesRepository, ILogger logger) { diff --git a/Frontend/src/pages/Reportes/ReporteListadoDistribucionCanillasPage.tsx b/Frontend/src/pages/Reportes/ReporteListadoDistribucionCanillasPage.tsx index 462d6e9..f485fb7 100644 --- a/Frontend/src/pages/Reportes/ReporteListadoDistribucionCanillasPage.tsx +++ b/Frontend/src/pages/Reportes/ReporteListadoDistribucionCanillasPage.tsx @@ -1,4 +1,3 @@ -// src/pages/Reportes/ReporteListadoDistribucionCanillasPage.tsx import React, { useState, useCallback } from 'react'; import { Box, Typography, Paper, CircularProgress, Alert, Button diff --git a/Frontend/src/pages/Reportes/ReporteListadoDistribucionGeneralPage.tsx b/Frontend/src/pages/Reportes/ReporteListadoDistribucionGeneralPage.tsx index c313e4b..b7c97a8 100644 --- a/Frontend/src/pages/Reportes/ReporteListadoDistribucionGeneralPage.tsx +++ b/Frontend/src/pages/Reportes/ReporteListadoDistribucionGeneralPage.tsx @@ -1,16 +1,30 @@ import React, { useState, useCallback } from 'react'; import { - Box, Typography, Paper, CircularProgress, Alert, Button, - TableContainer, Table, TableHead, TableRow, TableCell, TableBody + Box, Typography, Paper, CircularProgress, Alert, Button } from '@mui/material'; +import { DataGrid, type GridColDef, GridFooterContainer } from '@mui/x-data-grid'; +import { esES } from '@mui/x-data-grid/locales'; import reportesService from '../../services/Reportes/reportesService'; import type { ListadoDistribucionGeneralResponseDto } from '../../models/dtos/Reportes/ListadoDistribucionGeneralResponseDto'; +import type { ListadoDistribucionGeneralResumenDto } from '../../models/dtos/Reportes/ListadoDistribucionGeneralResumenDto'; +import type { ListadoDistribucionGeneralPromedioDiaDto } from '../../models/dtos/Reportes/ListadoDistribucionGeneralPromedioDiaDto'; import SeleccionaReporteListadoDistribucionGeneral from './SeleccionaReporteListadoDistribucionGeneral'; import * as XLSX from 'xlsx'; import axios from 'axios'; +interface ResumenDiarioExtendido extends ListadoDistribucionGeneralResumenDto { + id: string; +} + +interface PromediosPorDiaExtendido extends ListadoDistribucionGeneralPromedioDiaDto { + id: string; +} + const ReporteListadoDistribucionGeneralPage: React.FC = () => { const [reportData, setReportData] = useState(null); + const [resumenCalculado, setResumenCalculado] = useState([]); + const [promediosCalculado, setPromediosCalculado] = useState([]); + const [loading, setLoading] = useState(false); const [loadingPdf, setLoadingPdf] = useState(false); const [error, setError] = useState(null); @@ -18,12 +32,32 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { const [showParamSelector, setShowParamSelector] = useState(true); const [currentParams, setCurrentParams] = useState<{ idPublicacion: number; - fechaDesde: string; // Primer día del mes - fechaHasta: string; // Último día del mes - nombrePublicacion?: string; // Para el nombre del archivo - mesAnioParaNombreArchivo?: string; // Para el nombre del archivo (ej. YYYY-MM) + fechaDesde: string; + fechaHasta: string; + nombrePublicacion?: string; + mesAnioParaNombreArchivo?: string; } | null>(null); + const [totalesResumen, setTotalesResumen] = useState({ + cantidadTirada: 0, + sinCargo: 0, + perdidos: 0, + llevados: 0, + devueltos: 0, + vendidos: 0, + }); + + const [totalesPromedios, setTotalesPromedios] = useState({ + cantidadDias: 0, + promedioTirada: 0, + promedioSinCargo: 0, + promedioPerdidos: 0, + promedioLlevados: 0, + promedioDevueltos: 0, + promedioVendidos: 0, + }); + + const handleGenerarReporte = useCallback(async (params: { idPublicacion: number; fechaDesde: string; @@ -32,19 +66,67 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { setLoading(true); setError(null); setApiErrorParams(null); - - // Para el nombre del archivo y título del PDF + setReportData(null); + setResumenCalculado([]); + setPromediosCalculado([]); + setTotalesResumen({ cantidadTirada: 0, sinCargo: 0, perdidos: 0, llevados: 0, devueltos: 0, vendidos: 0 }); + setTotalesPromedios({ cantidadDias: 0, promedioTirada: 0, promedioSinCargo: 0, promedioPerdidos: 0, promedioLlevados: 0, promedioDevueltos: 0, promedioVendidos: 0 }); + const pubService = (await import('../../services/Distribucion/publicacionService')).default; const pubData = await pubService.getPublicacionById(params.idPublicacion); - const mesAnioParts = params.fechaDesde.split('-'); // YYYY-MM-DD -> [YYYY, MM, DD] + const mesAnioParts = params.fechaDesde.split('-'); const mesAnioNombre = `${mesAnioParts[1]}/${mesAnioParts[0]}`; + setCurrentParams({ ...params, nombrePublicacion: pubData?.nombre, mesAnioParaNombreArchivo: mesAnioNombre }); - setCurrentParams({...params, nombrePublicacion: pubData?.nombre, mesAnioParaNombreArchivo: mesAnioNombre }); try { const data = await reportesService.getListadoDistribucionGeneral(params); + + const resumenConId = data.resumen.map((item, index) => ({ + ...item, + id: `resumen-${index}` + })); + setResumenCalculado(resumenConId); + + const totalesResumenCalculados = data.resumen.reduce((acc, item) => ({ + cantidadTirada: acc.cantidadTirada + item.cantidadTirada, + sinCargo: acc.sinCargo + item.sinCargo, + perdidos: acc.perdidos + item.perdidos, + llevados: acc.llevados + item.llevados, + devueltos: acc.devueltos + item.devueltos, + vendidos: acc.vendidos + item.vendidos, + }), { cantidadTirada: 0, sinCargo: 0, perdidos: 0, llevados: 0, devueltos: 0, vendidos: 0 }); + setTotalesResumen(totalesResumenCalculados); + + const promediosConId = data.promediosPorDia.map((item, index) => ({ + ...item, + id: `promedio-${index}` + })); + setPromediosCalculado(promediosConId); + + const totalDias = data.promediosPorDia.reduce((sum, item) => sum + item.cantidadDias, 0); + const promediosPonderados = data.promediosPorDia.reduce((acc, item) => ({ + tirada: acc.tirada + item.promedioTirada * item.cantidadDias, + sinCargo: acc.sinCargo + item.promedioSinCargo * item.cantidadDias, + perdidos: acc.perdidos + item.promedioPerdidos * item.cantidadDias, + llevados: acc.llevados + item.promedioLlevados * item.cantidadDias, + devueltos: acc.devueltos + item.promedioDevueltos * item.cantidadDias, + vendidos: acc.vendidos + item.promedioVendidos * item.cantidadDias, + }), { tirada: 0, sinCargo: 0, perdidos: 0, llevados: 0, devueltos: 0, vendidos: 0 }); + + setTotalesPromedios({ + cantidadDias: totalDias, + promedioTirada: totalDias > 0 ? promediosPonderados.tirada / totalDias : 0, + promedioSinCargo: totalDias > 0 ? promediosPonderados.sinCargo / totalDias : 0, + promedioPerdidos: totalDias > 0 ? promediosPonderados.perdidos / totalDias : 0, + promedioLlevados: totalDias > 0 ? promediosPonderados.llevados / totalDias : 0, + promedioDevueltos: totalDias > 0 ? promediosPonderados.devueltos / totalDias : 0, + promedioVendidos: totalDias > 0 ? promediosPonderados.vendidos / totalDias : 0, + }); + setReportData(data); - if ((!data.resumen || data.resumen.length === 0) && (!data.promediosPorDia || data.promediosPorDia.length === 0)) { + + if (resumenConId.length === 0 && promediosConId.length === 0) { setError("No se encontraron datos para los parámetros seleccionados."); } setShowParamSelector(false); @@ -65,17 +147,19 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { setError(null); setApiErrorParams(null); setCurrentParams(null); + setResumenCalculado([]); + setPromediosCalculado([]); }, []); const handleExportToExcel = useCallback(() => { - if (!reportData || (!reportData.resumen?.length && !reportData.promediosPorDia?.length)) { + if (!resumenCalculado.length && !promediosCalculado.length) { alert("No hay datos para exportar."); return; } const wb = XLSX.utils.book_new(); - if (reportData.resumen?.length) { - const resumenToExport = reportData.resumen.map(item => ({ + if (resumenCalculado.length) { + const resumenToExport = resumenCalculado.map(item => ({ "Fecha": item.fecha ? new Date(item.fecha).toLocaleDateString('es-AR', { timeZone: 'UTC' }) : '-', "Tirada": item.cantidadTirada, "Sin Cargo": item.sinCargo, @@ -84,12 +168,21 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { "Devueltos": item.devueltos, "Vendidos": item.vendidos, })); + resumenToExport.push({ + "Fecha": "TOTALES", + "Tirada": totalesResumen.cantidadTirada, + "Sin Cargo": totalesResumen.sinCargo, + "Perdidos": totalesResumen.perdidos, + "Llevados": totalesResumen.llevados, + "Devueltos": totalesResumen.devueltos, + "Vendidos": totalesResumen.vendidos, + }); const wsResumen = XLSX.utils.json_to_sheet(resumenToExport); XLSX.utils.book_append_sheet(wb, wsResumen, "ResumenDiario"); } - if (reportData.promediosPorDia?.length) { - const promediosToExport = reportData.promediosPorDia.map(item => ({ + if (promediosCalculado.length) { + const promediosToExport = promediosCalculado.map(item => ({ "Día Semana": item.dia, "Cant. Días": item.cantidadDias, "Prom. Tirada": item.promedioTirada, @@ -99,6 +192,16 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { "Prom. Devueltos": item.promedioDevueltos, "Prom. Vendidos": item.promedioVendidos, })); + promediosToExport.push({ + "Día Semana": "GENERAL", + "Cant. Días": totalesPromedios.cantidadDias, + "Prom. Tirada": totalesPromedios.promedioTirada, + "Prom. Sin Cargo": totalesPromedios.promedioSinCargo, + "Prom. Perdidos": totalesPromedios.promedioPerdidos, + "Prom. Llevados": totalesPromedios.promedioLlevados, + "Prom. Devueltos": totalesPromedios.promedioDevueltos, + "Prom. Vendidos": totalesPromedios.promedioVendidos, + }); const wsPromedios = XLSX.utils.json_to_sheet(promediosToExport); XLSX.utils.book_append_sheet(wb, wsPromedios, "PromediosPorDia"); } @@ -110,7 +213,7 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { } fileName += ".xlsx"; XLSX.writeFile(wb, fileName); - }, [reportData, currentParams]); + }, [resumenCalculado, promediosCalculado, currentParams, totalesResumen, totalesPromedios]); const handleGenerarYAbrirPdf = useCallback(async () => { if (!currentParams) { @@ -122,8 +225,8 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { try { const blob = await reportesService.getListadoDistribucionGeneralPdf({ idPublicacion: currentParams.idPublicacion, - fechaDesde: currentParams.fechaDesde, // El servicio y SP esperan fechaDesde para el mes/año - fechaHasta: currentParams.fechaHasta // El SP no usa esta, pero el servicio de reporte sí para el nombre + fechaDesde: currentParams.fechaDesde, + fechaHasta: currentParams.fechaHasta }); if (blob.type === "application/json") { const text = await blob.text(); @@ -141,6 +244,56 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { } }, [currentParams]); + const columnsResumen: GridColDef[] = [ + { field: 'fecha', headerName: 'Fecha', flex: 0.8, minWidth: 100, valueFormatter: (value) => value ? new Date(value).toLocaleDateString('es-AR', { timeZone: 'UTC' }) : '-' }, + { field: 'cantidadTirada', headerName: 'Tirada', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') }, + { field: 'sinCargo', headerName: 'Sin Cargo', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') }, + { field: 'perdidos', headerName: 'Perdidos', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') }, + { field: 'llevados', headerName: 'Llevados', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') }, + { field: 'devueltos', headerName: 'Devueltos', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') }, + { field: 'vendidos', headerName: 'Vendidos', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') }, + ]; + + const columnsPromedios: GridColDef[] = [ + { field: 'dia', headerName: 'Día Semana', flex: 1, minWidth: 130 }, + { field: 'cantidadDias', headerName: 'Cant. Días', type: 'number', flex: 0.8, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') }, + { field: 'promedioTirada', headerName: 'Prom. Tirada', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) }, + { field: 'promedioSinCargo', headerName: 'Prom. Sin Cargo', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) }, + { field: 'promedioPerdidos', headerName: 'Prom. Perdidos', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) }, + { field: 'promedioLlevados', headerName: 'Prom. Llevados', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) }, + { field: 'promedioDevueltos', headerName: 'Prom. Devueltos', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) }, + { field: 'promedioVendidos', headerName: 'Prom. Vendidos', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) }, + ]; + + const CustomFooterResumen = () => ( + + + TOTALES: + {totalesResumen.cantidadTirada.toLocaleString('es-AR')} + {totalesResumen.sinCargo.toLocaleString('es-AR')} + {totalesResumen.perdidos.toLocaleString('es-AR')} + {totalesResumen.llevados.toLocaleString('es-AR')} + {totalesResumen.devueltos.toLocaleString('es-AR')} + {totalesResumen.vendidos.toLocaleString('es-AR')} + + + ); + + const CustomFooterPromedios = () => ( + + + GENERAL: + {totalesPromedios.cantidadDias.toLocaleString('es-AR')} + {totalesPromedios.promedioTirada.toLocaleString('es-AR', { maximumFractionDigits: 0 })} + {totalesPromedios.promedioSinCargo.toLocaleString('es-AR', { maximumFractionDigits: 0 })} + {totalesPromedios.promedioPerdidos.toLocaleString('es-AR', { maximumFractionDigits: 0 })} + {totalesPromedios.promedioLlevados.toLocaleString('es-AR', { maximumFractionDigits: 0 })} + {totalesPromedios.promedioDevueltos.toLocaleString('es-AR', { maximumFractionDigits: 0 })} + {totalesPromedios.promedioVendidos.toLocaleString('es-AR', { maximumFractionDigits: 0 })} + + + ); + if (showParamSelector) { return ( @@ -179,70 +332,34 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => { {!loading && !error && reportData && ( <> Resumen Diario - {reportData.resumen && reportData.resumen.length > 0 ? ( - - - - - Fecha - Tirada - Sin Cargo - Perdidos - Llevados - Devueltos - Vendidos - - - - {reportData.resumen.map((row, idx) => ( - - {row.fecha ? new Date(row.fecha).toLocaleDateString('es-AR', { timeZone: 'UTC' }) : '-'} - {row.cantidadTirada.toLocaleString('es-AR')} - {row.sinCargo.toLocaleString('es-AR')} - {row.perdidos.toLocaleString('es-AR')} - {row.llevados.toLocaleString('es-AR')} - {row.devueltos.toLocaleString('es-AR')} - {row.vendidos.toLocaleString('es-AR')} - - ))} - -
-
- ) : (No hay datos de resumen diario.)} + {resumenCalculado.length > 0 ? ( + + + + ) : (No hay datos de resumen diario.)} Promedios por Día de Semana - {reportData.promediosPorDia && reportData.promediosPorDia.length > 0 ? ( - - - - - Día - Cant. Días - Prom. Tirada - Prom. Sin Cargo - Prom. Perdidos - Prom. Llevados - Prom. Devueltos - Prom. Vendidos - - - - {reportData.promediosPorDia.map((row, idx) => ( - - {row.dia} - {row.cantidadDias.toLocaleString('es-AR')} - {row.promedioTirada.toLocaleString('es-AR')} - {row.promedioSinCargo.toLocaleString('es-AR')} - {row.promedioPerdidos.toLocaleString('es-AR')} - {row.promedioLlevados.toLocaleString('es-AR')} - {row.promedioDevueltos.toLocaleString('es-AR')} - {row.promedioVendidos.toLocaleString('es-AR')} - - ))} - -
-
- ) : (No hay datos de promedios por día.)} + {promediosCalculado.length > 0 ? ( + + + + ) : (No hay datos de promedios por día.)} )}
diff --git a/README.md b/README.md index 9c29e88..6a23c0a 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ El sistema está organizado en varios módulos clave para cubrir todas las área - **Acceso a Datos:** Dapper (Micro ORM) - **Base de Datos:** Microsoft SQL Server - **Autenticación:** JWT Bearer Tokens -- **Reportes PDF:** `Microsoft.ReportingServices.ReportViewerControl.WebForms` (a través de wrappers para .NET Core) +- **Reportes PDF:** QuestPDF C# PDF Generation Library - **Exportación Excel:** NPOI ### Frontend @@ -158,6 +158,8 @@ cd GestionIntegralWeb ### Backend (`Backend/GestionIntegral.Api/`) ``` /Controllers/ # Controladores de la API, organizados por módulo + Reportes/ + PdfTemplates/ # Templates de los reportes utilizados mediante QuestPDF /Data/ /Repositories/ # Lógica de acceso a datos con Dapper para cada entidad DbConnectionFactory.cs # Clase para crear conexiones a la BD diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0342c1c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + # --- Servicio del Backend --- + api-gestion: + image: dmolinari/gestionintegralweb-backend:latest + restart: always + networks: + - shared-net + environment: + - DB_SA_PASSWORD=${DB_SA_PASSWORD} + - ConnectionStrings__DefaultConnection=Server=db-sqlserver;Database=SistemaGestion;User ID=sa;Password=${DB_SA_PASSWORD};TrustServerCertificate=True; + ports: + - "8081:8080" + + # --- Servicio del Frontend --- + web-gestion: + image: dmolinari/gestionintegralweb-frontend:latest + restart: always + networks: + - shared-net + ports: + - "8080:80" + depends_on: + - api-gestion + + +networks: + # Nos conectamos a la red que creará el otro stack + shared-net: + external: true