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
- El usuario introduce los datos operativos en la interfaz ArkTS.
- La capa JavaScript valida y serializa los valores en una cadena delimitada por
;. - La función Kotlin/JS realiza los cálculos y devuelve el informe formateado.
- 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.