Sistema de análisis de datos de operación de restaurantes con KMP y OpenHarmony

En este artículo se describe una solución multiplataforma para el análisis de datos de operación de restaurantes, construida sobre Kotlin Multiplatform (KMP) y OpenHarmony. La propuesta combina una capa de dominio en Kotlin, un adaptador en JavaScript y una interfaz de usuario en ArkTS, permitiendo evaluar ingresos, costos, tráfico de clientes, satisfacción y calidad del menú, para emitir recomendaciones operativas personalizadas.

Arquitectura del sistema

La aplicación se organiza en tres capas que colaboran sin necesidad de replicar lógica de negocio:

  • Dominio en Kotlin: contiene el motor de cálculo, las reglas de clasificación y la generación del informe. Se compila a JavaScript mediante Kotlin/JS y se expone mediante @JsExport.
  • Adaptador en JavaScript: valida la entrada, serializa los datos y coordina la llamada a la función Kotlin, además de calcular métricas adicionales.
  • Interfaz en ArkTS: recoge los datos del usuario y muestra el informe generado, aprovechando el sistema de estados de OpenHarmony.

Módulos funcionales

  • Análisis de indicadores clave: ingresos mensuales, costos, visitantes y antigüedad del negocio.
  • Evaluación de rentabilidad: beneficio mensual, margen y ticket promedio.
  • Análisis de costos: clasificación del nivel de costos y proporción respecto a los ingresos.
  • Satisfacción del cliente: puntuación del servicio y percepción del menú.
  • Recomendaciones operativas: sugerencias personalizadas según las debilidades detectadas.

Capa de dominio en Kotlin

La lógica de negocio reside en un módulo Kotlin/JS. En lugar de un único bloque monolítico, el cálculo se divide en funciones auxiliares que clasifican ingresos, costos, eficiencia y competitividad. La función principal recibe una cadena con los valores separados por ; y devuelve un informe de texto.

<![CDATA[
@JsExport
fun restaurantBusinessAnalysis(input: String): String {
    val items = input.split(";").map { it.trim() }
    if (items.size != 7) {
        return "Error de formato. Uso: ID;ingreso_mensual;costo_mensual;" +
               "visitantes;satisfaccion;calidad_platos;antiguedad"
    }

    val branchId = items[0].lowercase()
    val revenue = items[1].toDoubleOrNull()
    val cost = items[2].toDoubleOrNull()
    val visitors = items[3].toIntOrNull()
    val satisfaction = items[4].toIntOrNull()
    val dishScore = items[5].toIntOrNull()
    val years = items[6].toIntOrNull()

    if (revenue == null || cost == null || visitors == null ||
        satisfaction == null || dishScore == null || years == null) {
        return "Error numérico: verifique que todos los valores sean válidos."
    }

    if (revenue < 0 || cost < 0 || visitors < 0 || years < 0 ||
        satisfaction !in 1..5 || dishScore !in 1..5) {
        return "Error de rango: ingreso/costo/visitantes/antigüedad ≥ 0; " +
               "satisfacción y platos entre 1 y 5."
    }

    val profit = revenue - cost
    val margin = if (revenue > 0.0) (profit / revenue) * 100.0 else 0.0

    fun revenueLabel(value: Double) = when {
        value >= 100 -> "Alto ingreso"
        value >= 50 -> "Ingreso medio-alto"
        value >= 20 -> "Ingreso medio"
        value >= 10 -> "Ingreso básico"
        else -> "Ingreso bajo"
    }

    fun costLabel(value: Double) = when {
        value >= 40 -> "Costo muy alto"
        value >= 25 -> "Costo alto"
        value >= 15 -> "Costo razonable"
        value >= 5 -> "Costo bajo"
        else -> "Costo muy bajo"
    }

    fun scoreLabel(score: Int) = when (score) {
        5 -> "Excelente"
        4 -> "Bueno"
        3 -> "Aceptable"
        2 -> "Deficiente"
        else -> "Crítico"
    }

    val perCapita = if (visitors > 0) (revenue * 10000.0) / visitors else 0.0

    val efficiency = when {
        margin >= 40 && satisfaction >= 4 && dishScore >= 4 -> "Muy alta"
        margin >= 30 && satisfaction >= 3 && dishScore >= 3 -> "Alta"
        margin >= 20 && satisfaction >= 2 -> "Media"
        else -> "Baja"
    }

    val totalScore = run {
        var points = 0
        points += when {
            margin >= 40 -> 30
            margin >= 30 -> 20
            margin >= 20 -> 10
            else -> 3
        }
        points += when {
            satisfaction >= 4 -> 25
            satisfaction >= 3 -> 15
            else -> 5
        }
        points += when {
            dishScore >= 4 -> 25
            dishScore >= 3 -> 15
            else -> 5
        }
        points += when {
            visitors >= 5000 -> 20
            visitors >= 2000 -> 12
            else -> 5
        }
        points
    }

    val scoreText = when {
        totalScore >= 95 -> "Sobresaliente ($totalScore)"
        totalScore >= 80 -> "Bueno ($totalScore)"
        totalScore >= 65 -> "Aceptable ($totalScore)"
        totalScore >= 50 -> "Regular ($totalScore)"
        else -> "Necesita mejora ($totalScore)"
    }

    val tips = mutableListOf<string>()
    if (margin < 30) tips.add("El margen es bajo; revise precios o reduzca costos.")
    if (satisfaction < 3) tips.add("La satisfacción del cliente es insuficiente; mejore el servicio.")
    if (dishScore < 3) tips.add("La calidad del menú requiere atención; actualice recetas.")
    if (visitors < 2000) tips.add("Bajo tráfico; impulse campañas de marketing.")
    if (cost > revenue * 0.5) tips.add("Costos elevados; negocie con proveedores.")

    return buildString {
        appendLine("Informe de Análisis de Restaurante")
        appendLine("-----------------------------------")
        appendLine("Sucursal: $branchId")
        appendLine("Ingreso mensual: ${revenue} (${revenueLabel(revenue)})")
        appendLine("Costo mensual: ${cost} (${costLabel(cost)})")
        appendLine("Beneficio mensual: ${profit}")
        appendLine("Margen: ${String.format(\"%.1f\", margin)}%")
        appendLine("Visitantes mensuales: ${visitors}")
        appendLine("Ticket promedio: ${String.format(\"%.2f\", perCapita)}")
        appendLine("Satisfacción: ${satisfaction}/5 (${scoreLabel(satisfaction)})")
        appendLine("Calidad de platos: ${dishScore}/5 (${scoreLabel(dishScore)})")
        appendLine("Antigüedad: ${years} años")
        appendLine("Eficiencia operativa: ${efficiency}")
        appendLine("Puntuación global: ${scoreText}")
        appendLine("Recomendaciones:")
        if (tips.isEmpty()) {
            appendLine("  Sin observaciones críticas.")
        } else {
            tips.forEach { appendLine("  - $it") }
        }
    }
}
]]></string>

Capa intermedia en JavaScript

El adaptador JavaScript encapsula la validación, la serialización y el cálculo de métricas complementarias. De este modo, la interfaz no interactúa directamente con la capa Kotlin, facilitando pruebas unitarias y cambios futuros.

<![CDATA[
const RestaurantAnalyzer = (() => {
    function validatePayload(payload) {
        const required = ['id', 'revenue', 'cost', 'visitors', 'satisfaction', 'dishScore', 'years'];
        for (const key of required) {
            if (!(key in payload)) {
                throw new Error(`Falta el campo obligatorio: ${key}`);
            }
        }

        const { id, revenue, cost, visitors, satisfaction, dishScore, years } = payload;

        if (typeof id !== 'string' || id.trim().length === 0) {
            throw new Error('El identificador del restaurante debe ser un string no vacío');
        }

        for (const [label, value] of [
            ['revenue', revenue],
            ['cost', cost],
            ['visitors', visitors],
            ['years', years]
        ]) {
            if (typeof value !== 'number' || value < 0) {
                throw new Error(`${label} debe ser un número no negativo`);
            }
        }

        if (!Number.isInteger(satisfaction) || satisfaction < 1 || satisfaction > 5) {
            throw new Error('La satisfacción debe estar entre 1 y 5');
        }

        if (!Number.isInteger(dishScore) || dishScore < 1 || dishScore > 5) {
            throw new Error('La calidad de platos debe estar entre 1 y 5');
        }

        return { id: id.trim(), revenue, cost, visitors, satisfaction, dishScore, years };
    }

    function buildInputString(data) {
        const { id, revenue, cost, visitors, satisfaction, dishScore, years } = data;
        return [id, revenue, cost, visitors, satisfaction, dishScore, years].join(';');
    }

    function computeMetrics(data) {
        const profit = data.revenue - data.cost;
        const margin = data.revenue > 0 ? (profit / data.revenue) * 100 : 0;
        const avgTicket = data.visitors > 0 ? (data.revenue * 10000) / data.visitors : 0;
        const costRatio = data.revenue > 0 ? (data.cost / data.revenue) * 100 : 0;
        return { profit, margin, avgTicket, costRatio };
    }

    async function runAnalysis(payload) {
        try {
            const data = validatePayload(payload);

            if (!window.hellokjs?.restaurantBusinessAnalysis) {
                throw new Error('El módulo Kotlin/JS no está disponible');
            }

            const input = buildInputString(data);
            const rawReport = window.hellokjs.restaurantBusinessAnalysis(input);
            const metrics = computeMetrics(data);

            return {
                success: true,
                report: rawReport,
                metrics: {
                    profit: Number(metrics.profit.toFixed(2)),
                    margin: Number(metrics.margin.toFixed(1)),
                    avgTicket: Number(metrics.avgTicket.toFixed(2)),
                    costRatio: Number(metrics.costRatio.toFixed(1))
                },
                generatedAt: new Date().toISOString()
            };
        } catch (err) {
            return { success: false, error: err.message };
        }
    }

    return { runAnalysis, validatePayload, computeMetrics };
})();

export default RestaurantAnalyzer;
]]>

Interfaz con ArkTS

La interfaz de usuario se implementa en ArkTS con un formulario vertical y un área de resultados. Utiliza decoradores de estado para reflejar los cambios de forma reactiva y delega el aálisis a la capa JavaScript.

<![CDATA[
import { restaurantBusinessAnalysis } from './hellokjs'

@Entry
@Component
struct RestaurantDashboard {
  @State branchId: string = 'REST001'
  @State revenue: string = '50'
  @State cost: string = '20'
  @State visitors: string = '5000'
  @State satisfaction: string = '4'
  @State dishScore: string = '4'
  @State years: string = '3'
  @State report: string = ''
  @State busy: boolean = false

  build() {
    Column({ space: 12 }) {
      Text('Análisis de restaurante')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1565C0')

      this.inputGroup()

      Row({ space: 12 }) {
        Button('Analizar')
          .type(ButtonType.Capsule)
          .backgroundColor('#1565C0')
          .onClick(() => this.analyze())

        Button('Limpiar')
          .type(ButtonType.Capsule)
          .backgroundColor('#EF5350')
          .onClick(() => this.clear())
      }

      if (this.busy) {
        LoadingProgress()
          .width(48)
          .height(48)
          .color('#1565C0')
        Text('Procesando...')
          .fontSize(14)
          .fontColor('#757575')
      } else if (this.report.length > 0) {
        Scroll() {
          Text(this.report)
            .fontSize(14)
            .fontFamily('monospace')
            .width('100%')
            .padding(12)
        }
        .backgroundColor('#E3F2FD')
        .borderRadius(8)
        .layoutWeight(1)
      } else {
        Text('Ingrese los datos y presione Analizar')
          .fontSize(14)
          .fontColor('#757575')
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#FAFAFA')
  }

  @Builder
  inputGroup() {
    Column({ space: 10 }) {
      this.field('ID del restaurante', this.branchId, (v) => this.branchId = v)
      this.field('Ingreso mensual (x10k)', this.revenue, (v) => this.revenue = v)
      this.field('Costo mensual (x10k)', this.cost, (v) => this.cost = v)
      this.field('Visitantes mensuales', this.visitors, (v) => this.visitors = v)
      this.field('Satisfacción (1-5)', this.satisfaction, (v) => this.satisfaction = v)
      this.field('Calidad platos (1-5)', this.dishScore, (v) => this.dishScore = v)
      this.field('Años operando', this.years, (v) => this.years = v)
    }
  }

  @Builder
  field(label: string, value: string, onChange: (v: string) => void) {
    Column() {
      Text(label)
        .fontSize(13)
        .fontColor('#424242')
      TextInput({ text: value })
        .height(36)
        .width('100%')
        .onChange(onChange)
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%')
  }

  private analyze() {
    const parts = [
      this.branchId, this.revenue, this.cost, this.visitors,
      this.satisfaction, this.dishScore, this.years
    ]

    if (parts.some(p => p.trim().length === 0)) {
      this.report = 'Complete todos los campos.'
      return
    }

    this.busy = true
    setTimeout(() => {
      try {
        const input = parts.map(p => p.trim()).join(';')
        this.report = restaurantBusinessAnalysis(input)
      } catch (e) {
        this.report = `Error: ${e}`
      } finally {
        this.busy = false
      }
    }, 80)
  }

  private clear() {
    this.branchId = 'REST001'
    this.revenue = '50'
    this.cost = '20'
    this.visitors = '5000'
    this.satisfaction = '4'
    this.dishScore = '4'
    this.years = '3'
    this.report = ''
  }
}
]]>

Flujo de trabajo

  1. El usuario introduce los datos operativos en la interfaz ArkTS.
  2. La capa JavaScript valida y serializa los valores en una cadena delimitada por ;.
  3. La función Kotlin/JS realiza los cálculos y devuelve el informe formateado.
  4. El resultado se presenta en el área de resultados, con la posibilidad de extenderlo a métricas adicionales en el adaptador.

Caso práctico

Para un restaurante con ingreso mensual de 50 (miles de yuanes), costo de 20, 5000 visitantes, satisfacción 4, calidad de platos 4 y 3 años de operación, el sistema reporta un margen del 60 %, una eficiencia operativa muy alta y una puntuación global sobresaliente. Las recomendaciones generadas inlcuyen revisar la cadena de suministro, mantener la calidad del servicio y potenciar las campañas de fidelización.

Etiquetas: Kotlin Multiplatform OpenHarmony ArkTS Kotlin/JS Analítica de restaurantes

Publicado el 6-27 18:36