Izan lecina webㅤㅤ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎  

Desenvolupament de prototip (app)

1. Arquitectura del Sistema Programari

El prototip es va dissenyar sota una arquitectura de comunicació en temps real, on la interfície web havia de reaccionar instantàniament a la presència d’un tag físic prop del lector.

  • Entorn d’Execució: Servidor local basat en Node.js o integració directa amb WebSerial API (depenent de la implementació específica).
  • Llenguatge d’Interfície: HTML5 i CSS3 amb frameworks de disseny per a visualització d’inventari.
  • Lògica de Client: JavaScript asíncron per a la gestió de fluxos de dades provinents del port seriï.

2. Implementació de la Capa d’Identificació

La part web del sistema RFID es va centrar en la interpretació de les trames de dades. Un tag RFID no envia “un nom de producte”, sinó una cadena hexadecimal.

Gestió d’Identificadors (UID)

Es va implementar un algorisme de mapatge en JavaScript que funcionava de la manera següent:

  1. Captura de Trama: Recepció del buffer de dades del lector (comunament en format HEX).
  2. Normalització: Neteja de caràcters de control (\n, \r) per a aïllar l’identificador únic.
  3. Lookup Table (Mapatge): Ús d’un objecte JSON o estructura de clau-valor on cada UID es vinculava a un objecte de producte (Nom, SKU, Descripció).

3. Funcionalitats del Panell Web

El desenvolupament de la interfície es va dividir en tres mòduls crítics:

A. Mòdul de Registre (Escriptura Lògica)

A diferència del sistema OCR actual, el RFID requeria “donar d’alta” els tags. La interfície web comptava amb un formulari on:

  • Es detectava un tag “buit”.
  • L’usuari assignava un nom de producte.
  • El programari vinculava aquest UID en la base de dades volàtil per a futures deteccions.

B. Dashboard d’Inventari en Temps Real

Es va dissenyar una taula dinàmica que s’actualitzava sense refrescar la pàgina (mitjançant manipulació del DOM).

  • Esdeveniments d’Entrada: Cada vegada que el lector detectava un tag, la fila corresponent en la web ressaltava en color verd.
  • Comptador de Presència: El sistema incrementava l’estoc visual cada vegada que un UID conegut entrava en el camp de radiofreqüència.

C. Sistema d’Alerta de Duplicats

Es va programar una lògica de validació per a evitar que un mateix tag fos comptabilitzat dues vegades en una sola sessió d’escaneig, implementant un “debounce” de programari (un temps d’espera abans de permetre una nova lectura del mateix ID).

4. Especificacions de Comunicació

Perquè la part web rebés les dades del RFID, es van documentar els següents paràmetres de connexió:

  • Baud Rate: Generalment configurat a 9600 o 115200 bps.
  • Protocol d’Enllaç: Implementació de WebSockets o llibreries de Serial Port per a comunicar el backend (que llegeix el maquinari) amb el frontend (que mostra l’inventari).
@page "/"
@using SeditesaRFID2
@using Microsoft.EntityFrameworkCore
@inject LectorSerial Lector
@inject InventarioContext Db
@implements IDisposable

<div class="container-fluid">

    <table class="table table-striped table-bordered mt-4">
        <thead class="table-dark">
            <tr>
                <th>Producto</th>
                <th>Stock Actual</th>
            </tr>
        </thead>
        <tbody>
            @foreach(var p in ProductosEnPantalla)
            {
                <tr>
                    <td class="fs-5">@p.Nombre</td>
                    <td class="fs-4 fw-bold text-success text-center">@p.Cantidad</td>
                </tr>
            }
            @if(ProductosEnPantalla.Count == 0)
            {
                <tr><td colspan="3" class="text-center">No hay productos en la base de datos. Ve a la página de inicio para registrarlos.</td></tr>
            }
        </tbody>
    </table>

    <p class="text-muted">Pasa los productos por el lector. Si la ID existe, se sumará 1 automáticamente.</p>

    <div class="card p-3 mb-4 bg-light border-primary">
        <h5>Simulador de Escáner</h5>
        <div class="input-group">
            <input @bind="EpcSimulado" class="form-control" placeholder="Ej: rfid-4" />
            <button class="btn btn-primary" @onclick="SimularLectura">Emitir Lectura</button>
        </div>
    </div>

    <div class="alert alert-info fs-5">
        @((MarkupString)Mensaje)
    </div>
</div>

@code {
    string EpcSimulado = "";
    string Mensaje = "Esperando lecturas del escáner...";
    List<Producto> ProductosEnPantalla = new List<Producto>();

    protected override void OnInitialized()
    {
        // 1. Cargar datos iniciales
        RefrescarPantalla();
        
        // 2. Conectar al lector real
        Lector.TagLeido += ProcesarLectura;
    }

    void RefrescarPantalla()
    {
        // Traemos los productos ordenados por el que tiene más cantidad
        ProductosEnPantalla = Db.Productos.OrderByDescending(p => p.Cantidad).ToList();
    }

    // ESTA ES LA FUNCIÓN QUE HACE LA MAGIA
    private void ProcesarLectura(string epc)
    {
        InvokeAsync(() =>
        {
            // 1. Buscamos el producto en la BD que tenga esta ID exacta
            var producto = Db.Productos.FirstOrDefault(p => p.Epc == epc);

            if (producto != null)
            {
                // 2. LO ENCONTRÓ: Le sumamos 1
                producto.Cantidad += 1; 
                Db.SaveChanges(); 
                
                Mensaje = $" <b>{producto.Nombre}</b> detectado. Se ha sumado +1. (Total en stock: {producto.Cantidad})";
            }
            else
            {
                // 3. NO LO ENCONTRÓ
                Mensaje = $"Etiqueta desconocida: <b>{epc}</b>. Regístrala primero en la página de Stock.";
            }

            RefrescarPantalla();
            StateHasChanged();
        });
    }

    void SimularLectura()
    {
        if (!string.IsNullOrWhiteSpace(EpcSimulado))
        {
            ProcesarLectura(EpcSimulado.Trim());
            EpcSimulado = ""; // Limpiar campo
        }
    }

    public void Dispose()
    {
        Lector.TagLeido -= ProcesarLectura;
    }
}
@page "/validar"
@using SeditesaRFID2
@using Microsoft.EntityFrameworkCore
@using System.IO
@inject LectorSerial Lector
@inject InventarioContext Db
@implements IDisposable

<div class="container-fluid">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h3> Validación de Salida (Picking)</h3>
        
        @if (ModoValidacion)
        {
            <button class="btn btn-warning fw-bold" @onclick="ResetearAlbaran">
                 CANCELAR / REINICIAR
            </button>
        }
    </div>

    @if (!ModoValidacion)
    {
        // --- FASE 1: PREPARAR EL PEDIDO ---
        <div class="card p-4 border-primary shadow-sm">
            <h4 class="text-primary mb-3"> Preparar Pedido</h4>
            
            <div class="row">
                <div class="col-md-6 border-end pe-4">
                    <h5 class="text-secondary">Opción A: Selección Manual</h5>
                    <div class="mb-2">
                        <label class="form-label text-muted small mb-0">Producto</label>
                        <select class="form-select" @bind="NombreProductoSeleccionado">
                            <option value="">-- Selecciona Producto --</option>
                            @foreach (var grupo in ResumenStock)
                            {
                                <option value="@grupo.Nombre">@grupo.Nombre (Stock: @grupo.Cantidad)</option>
                            }
                        </select>
                    </div>
                    <div class="mb-3">
                        <label class="form-label text-muted small mb-0">Cantidad</label>
                        <input type="number" class="form-control" @bind="CantidadManual" min="1" />
                    </div>
                    <button class="btn btn-primary w-100 fw-bold" @onclick="AnadirManual">
                        ⬇ Añadir Manualmente
                    </button>
                </div>

                <div class="col-md-6 bg-light p-3 rounded">
                    <h5 class="text-secondary">Opción B:  Subir Albarán (CSV)</h5>
                    <p class="small text-muted mb-2">
                        Sube un archivo .csv separado por <strong>comas</strong>:<br/>
                        <code>Producto,Cantidad</code>
                    </p>
                    
                    <InputFile OnChange="CargarAlbaranCSV" class="form-control border-primary" accept=".csv" />
                    
                    @if(CargandoArchivo)
                    {
                        <div class="d-flex align-items-center text-primary mt-3">
                            <div class="spinner-border spinner-border-sm me-2" role="status"></div>
                            <span> Procesando albarán...</span>
                        </div>
                    }
                </div>
            </div>

            @if (!string.IsNullOrEmpty(MensajeErrorStock))
            {
                <div class="alert alert-danger mt-4 mb-0">
                    @((MarkupString)MensajeErrorStock)
                </div>
            }

            <hr class="my-4" />

            <h5>Resumen del Albarán (@PedidoActual.Count artículos):</h5>
            
            <div class="border rounded shadow-sm" style="max-height: 300px; overflow-y: auto;">
                <table class="table table-sm table-striped table-hover mb-0">
                    <thead class="table-dark sticky-top">
                        <tr>
                            <th>Producto</th>
                            <th class="text-end">Unidades</th>
                            <th class="text-end pe-3">Acción</th>
                        </tr>
                    </thead>
                    <tbody>
                        @foreach (var g in PedidoActual.GroupBy(x => x.Nombre).OrderBy(g => g.Key))
                        {
                            <tr>
                                <td class="fw-bold align-middle">@g.Key</td>
                                <td class="text-end align-middle fs-5">@g.Count()</td>
                                <td class="text-end pe-3">
                                    <button class="btn btn-sm btn-outline-danger" @onclick="() => QuitarUnidad(g.Key)">-1</button>
                                    <button class="btn btn-sm btn-danger ms-1" @onclick="() => QuitarGrupo(g.Key)">Quitar todo</button>
                                </td>
                            </tr>
                        }
                        @if(PedidoActual.Count == 0)
                        {
                            <tr>
                                <td colspan="3" class="text-center py-3 text-muted">El albarán está vacío. Añade productos o sube un CSV.</td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>

            @if (PedidoActual.Count > 0)
            {
                <button class="btn btn-success btn-lg mt-3 w-100 fw-bold shadow" @onclick="ComenzarValidacion">
                     COMENZAR LECTURA RFID
                </button>
            }
        </div>
    }
    else
    {
        // --- FASE 2: VALIDANDO ---
        <div class="progress mb-3 shadow-sm" style="height: 35px; border-radius: 10px;">
            <div class="progress-bar bg-success fw-bold fs-5" role="progressbar" 
                 style="width: @(PorcentajeCompletado)%">
                @ItemsLeidos de @PedidoActual.Count Leídos
            </div>
        </div>

        <div class="alert @(EstadoClase) fs-5 fw-bold shadow-sm">
            @MensajeEstado
        </div>

        <div class="border rounded shadow-sm" style="height: 400px; overflow-y: auto;">
            <table class="table table-bordered mb-0">
                <thead class="table-dark sticky-top">
                    <tr>
                        <th class="text-center">Estado</th>
                        <th>Producto</th>
                        <th>EPC Esperado</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (var item in PedidoActual)
                    {
                        <tr class="@(item.Leido ? "table-success" : "")">
                            <td class="text-center align-middle fs-5">
                                @(item.Leido ? " LISTO" : " PENDIENTE")
                            </td>
                            <td class="fw-bold align-middle">@item.Nombre</td>
                            <td class="align-middle"><code>@item.EpcEsperado</code></td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>

        @if (PedidoCompleto)
        {
            <div class="alert alert-success text-center mt-4 shadow-sm border-success">
                <h3 class="fw-bold text-success mb-3">¡PEDIDO COMPLETADO AL 100%!</h3>
                <button class="btn btn-lg btn-dark w-100 fw-bold" @onclick="FinalizarYBorrarStock">
                     CONFIRMAR SALIDA Y RESTAR DE STOCK
                </button>
            </div>
        }
        
        <div class="card mt-4 border-info shadow-sm">
             <div class="card-body bg-light">
                 <p class="small text-info fw-bold mb-2"> SIMULADOR DE LECTURAS:</p>
                 @foreach(var g in PedidoActual.GroupBy(x => x.Nombre).OrderBy(g => g.Key))
                 {
                     <button class="btn btn-outline-primary me-2 mb-2" 
                             @onclick='() => SimularLecturaPorNombre(g.Key)'>
                         Simular leer: @g.Key
                     </button>
                 }
            </div>
        </div>
    }
</div>

@* --- Mostrar resumen de la última salida CONFIRMADA --- *@
@if (UltimaSalidaResumen != null && UltimaSalidaResumen.Any())
{
    <div class="card mt-4 shadow-sm border-success">
        <div class="card-header bg-success text-white fw-bold">
            ✅ Última salida confirmada
        </div>
        <div class="card-body p-0">
            <table class="table table-striped mb-0">
                <thead class="table-light">
                    <tr>
                        <th class="ps-3">Producto</th>
                        <th class="text-end pe-3">Unidades Restadas</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (var r in UltimaSalidaResumen)
                    {
                        <tr>
                            <td class="ps-3 fw-bold">@r.Nombre</td>
                            <td class="text-end pe-3 text-danger fw-bold">-@r.Cantidad</td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    </div>
}

@code {
    string NombreProductoSeleccionado { get; set; } = "";
    string NuevoEpcManual { get; set; } = "";
    int CantidadManual { get; set; } = 1;

    int ItemsLeidos => PedidoActual.Count(x => x.Leido);
    int PorcentajeCompletado => PedidoActual.Count == 0 ? 0 : (int)((ItemsLeidos * 100.0) / PedidoActual.Count);
    bool PedidoCompleto => PedidoActual.Count > 0 && ItemsLeidos >= PedidoActual.Count;

    class LineaPedido
    {
        public int IdDb { get; set; }
        public string Nombre { get; set; } = "";
        public string EpcEsperado { get; set; } = "";
        public bool Leido { get; set; } = false;
    }

    class ResumenProducto
    {
        public string Nombre { get; set; } = "";
        public int Cantidad { get; set; } = 0;
    }

    List<LineaPedido> PedidoActual { get; set; } = new();
    List<ResumenProducto> ResumenStock { get; set; } = new();
    List<ResumenProducto> UltimaSalidaResumen { get; set; } = new();

    bool ModoValidacion { get; set; } = false;
    bool CargandoArchivo { get; set; } = false;
    
    string MensajeErrorStock { get; set; } = "";
    string MensajeEstado { get; set; } = "";
    string EstadoClase { get; set; } = "";

    protected override void OnInitialized()
    {
        CargarResumenStock();
        try { Lector.TagLeido += OnTagLeido; } catch { }
    }

    void OnTagLeido(string tag) => InvokeAsync(() => ProcesarLectura(tag));

    void CargarResumenStock()
    {
        var todos = Db.Productos.AsNoTracking().ToList();
        ResumenStock = todos
            .GroupBy(p => p.Nombre ?? "")
            .Select(g => new ResumenProducto { Nombre = g.Key, Cantidad = g.Sum(p => p.Cantidad) })
            .OrderBy(r => r.Nombre.ToLowerInvariant())
            .ToList();
    }

    void ComenzarValidacion()
    {
        ModoValidacion = true;
        MensajeEstado = " Escáner listo. Pasa los artículos por el lector...";
        EstadoClase = "alert-primary";
    }

    void ResetearAlbaran()
    {
        PedidoActual.Clear();
        MensajeErrorStock = "";
        ModoValidacion = false;
        CargarResumenStock();
    }

    void QuitarUnidad(string nombre)
    {
        var item = PedidoActual.FirstOrDefault(x => x.Nombre == nombre);
        if (item != null) PedidoActual.Remove(item);
    }

    void QuitarGrupo(string nombre)
    {
        PedidoActual.RemoveAll(x => x.Nombre == nombre);
    }

    // --- NUEVO: FUNCIÓN PARA LEER EL CSV DEL ALBARÁN ---
    // --- FUNCIÓN CORREGIDA PARA LEER EL CSV DEL ALBARÁN ---
    async Task CargarAlbaranCSV(InputFileChangeEventArgs e)
    {
        CargandoArchivo = true;
        MensajeErrorStock = ""; // Limpiamos errores anteriores
        
        try
        {
            using var reader = new StreamReader(e.File.OpenReadStream(maxAllowedSize: 5120000));
            
            while (await reader.ReadLineAsync() is string linea)
            {
                if (string.IsNullOrWhiteSpace(linea)) continue;

                // Dividimos la línea por COMAS (,)
                var partes = linea.Split(','); 
                
                if (partes.Length >= 3)
                {
                    // Tu formato actual: Nombre, RFID, Cantidad
                    string nombreProducto = partes[0].Trim();
                    
                    // La cantidad está en la TERCERA columna (índice 2)
                    if (int.TryParse(partes[2].Trim(), out int cantidadSolicitada))
                    {
                        ProcesarLinea(nombreProducto, cantidadSolicitada);
                    }
                }
                else if (partes.Length == 2)
                {
                    // Por si en el futuro subes uno que solo sea: Nombre, Cantidad
                    string nombreProducto = partes[0].Trim();
                    
                    if (int.TryParse(partes[1].Trim(), out int cantidadSolicitada))
                    {
                        ProcesarLinea(nombreProducto, cantidadSolicitada);
                    }
                }
            }
        }
        catch (Exception ex)
        {
            MensajeErrorStock = $" Error leyendo el archivo CSV: {ex.Message}";
        }
        finally
        {
            CargandoArchivo = false;
        }
    }

    void SimularLecturaPorNombre(string nombre)
    {
        var item = PedidoActual.FirstOrDefault(x => x.Nombre == nombre && !x.Leido);
        if (item != null)
        {
            item.Leido = true;
            MensajeEstado = $"LEÍDO: {item.Nombre}";
            EstadoClase = "alert-success";
            StateHasChanged();
        }
    }

    void ProcesarLectura(string epcLeido)
    {
        if (!ModoValidacion) return;

        var prod = PedidoActual.FirstOrDefault(p => !p.Leido && !string.IsNullOrEmpty(p.EpcEsperado) && p.EpcEsperado == epcLeido);

        if (prod == null)
        {
            prod = PedidoActual.FirstOrDefault(p => !p.Leido && !string.IsNullOrEmpty(p.EpcEsperado)
                && (epcLeido.Contains(p.EpcEsperado, StringComparison.OrdinalIgnoreCase)
                    || p.EpcEsperado.Contains(epcLeido, StringComparison.OrdinalIgnoreCase)));
        }

        if (prod == null)
        {
            prod = PedidoActual.FirstOrDefault(p => !p.Leido);
        }

        if (prod != null)
        {
            prod.Leido = true;
            MensajeEstado = $"LEÍDO: {prod.Nombre}";
            EstadoClase = "alert-success";
            StateHasChanged();
        }
        else
        {
            MensajeEstado = $"Tag leído ({epcLeido}) no está en la lista de preparación o ya fue leído.";
            EstadoClase = "alert-warning";
            StateHasChanged();
        }
    }

    void ProcesarLinea(string nombre, int cantidadSolicitada)
    {
        if (string.IsNullOrWhiteSpace(nombre) || cantidadSolicitada <= 0) return;

        var filas = Db.Productos.Where(p => p.Nombre == nombre).ToList();
        if (!filas.Any())
        {
            MensajeErrorStock += $"<b>{nombre}</b> no existe en el catálogo.<br/>";
            return;
        }

        int quedanTotales = filas.Sum(p => p.Cantidad);
        int yaEnPedido = PedidoActual.Count(x => x.Nombre == nombre);
        int disponibles = Math.Max(0, quedanTotales - yaEnPedido);
        
        if (disponibles <= 0)
        {
            MensajeErrorStock += $"No quedan unidades disponibles de <b>{nombre}</b> para añadir al albarán.<br/>";
            return;
        }

        if (disponibles < cantidadSolicitada)
        {
            MensajeErrorStock += $"Falta stock de <b>{nombre}</b>. Solicitado: {cantidadSolicitada}, Disponibles: {disponibles}. Se han añadido solo los disponibles.<br/>";
            cantidadSolicitada = disponibles;
        }

        int toAdd = cantidadSolicitada;
        foreach (var f in filas)
        {
            if (toAdd <= 0) break;

            int yaReservadasEnEstaFila = PedidoActual.Count(x => x.IdDb == f.Id);
            int quedanEnEstaFila = Math.Max(0, f.Cantidad - yaReservadasEnEstaFila);
            int take = Math.Min(quedanEnEstaFila, toAdd);

            for (int i = 0; i < take; i++)
            {
                var epcEsperado = !string.IsNullOrEmpty(f.Epc) ? f.Epc : "";
                PedidoActual.Add(new LineaPedido { IdDb = f.Id, Nombre = f.Nombre ?? "", EpcEsperado = epcEsperado, Leido = false });
            }

            toAdd -= take;
        }

        CargarResumenStock();
        StateHasChanged();
    }

    void FinalizarYBorrarStock()
    {
        UltimaSalidaResumen = PedidoActual
            .GroupBy(i => i.Nombre)
            .Select(g => new ResumenProducto { Nombre = g.Key, Cantidad = g.Count() })
            .OrderBy(r => r.Nombre.ToLowerInvariant())
            .ToList();

        var cuentas = PedidoActual.GroupBy(i => i.IdDb)
                        .ToDictionary(g => g.Key, g => g.Count());

        foreach (var kv in cuentas)
        {
            var p = Db.Productos.Find(kv.Key);
            if (p != null)
            {
                p.Cantidad = Math.Max(0, p.Cantidad - kv.Value);
                Db.Productos.Update(p);
            }
        }

        Db.SaveChanges();
        ResetearAlbaran();
        MensajeEstado = "Stock actualizado correctamente. ¡Salida completada!";
        EstadoClase = "alert-success";
    }

    void AnadirManual()
    {
        if (!string.IsNullOrWhiteSpace(NombreProductoSeleccionado))
        {
            ProcesarLinea(NombreProductoSeleccionado, Math.Max(1, CantidadManual));
        }
    }

    public void Dispose()
    {
        try { Lector.TagLeido -= OnTagLeido; } catch { }
    }
}
GDPR Cookie Consent with Real Cookie Banner