revision_salud.sh ya existe, pero lleva el umbral de disco escrito a mano en el cuerpo y la IP del servidor justo encima. El día que Marta pida vigilar también el servidor de pruebas tendrás que duplicar el fichero, y a partir de ahí tendrás dos scripts que se separan poco a poco hasta que uno de los dos miente. La cura es sacar los valores del cuerpo y meterlos en variables bien pensadas.

Esta lección va de eso, pero sobre todo va de algo más profundo: en Bash todo es una cadena de texto, y esa única frase explica el 90 % de las sorpresas que te vas a llevar. A cambio, Bash te da un arsenal de manipulación de cadenas —la expansión de parámetros— tan potente que sustituye a basename, dirname, cut, tr y buena parte de sed sin lanzar un solo proceso.

Contenido

  1. Bash no tiene tipos: todo es cadena
  2. Asignación: las tres reglas que rompen a todo el mundo
  3. Ámbito: script, subshell y entorno
  4. declare y sus atributos
  5. Expansión de parámetros a fondo
  6. Aritmética, y por qué Bash no sabe de decimales
  7. Arrays indexados
  8. Arrays asociativos: reservas por casa
  9. Cadenas y printf
  10. Aplicación: revision_salud.sh configurable

  1. Bash no tiene tipos: todo es cadena

En Python o Java declaras el tipo y el lenguaje te protege. En Bash no hay tipos: toda variable guarda una cadena de bytes, y el «tipo» aparece solo al usarla, según el contexto.

operador@srv-tramontana:~$ n=42; echo "${n} + 1 = $((n + 1))"
42 + 1 = 43
operador@srv-tramontana:~$ n=42abc; echo "$((n + 1))"
-bash: 42abc: valor demasiado grande para la base (el elemento de error es "42abc")

La misma variable es un número dentro de $(( )) y texto fuera. Nadie te avisó al asignar 42abc; el error llegó al intentar sumar. Tres consecuencias que conviene interiorizar hoy:

  • Nada valida lo que guardas. Validar es tu trabajo, y lo formalizaremos en 04-04 con [[ $v =~ ^[0-9]+$ ]].
  • Comparar números y comparar cadenas son operaciones distintas. "10" < "9" es cierto como texto y falso como número; de ahí que existan -lt y < por separado.
  • No hay decimales. $(( 7842 / 25 )) da 313, no 313.70 (apartado 6). Las únicas excepciones parciales a la regla son los atributos de declare y los arrays, que sí son una estructura de datos de verdad.

  1. Asignación: las tres reglas que rompen a todo el mundo

operador@srv-tramontana:~$ version=3.2.1              # correcto
operador@srv-tramontana:~$ version = 3.2.1
-bash: version: orden no encontrada
operador@srv-tramontana:~$ mensaje=copia completa
-bash: completa: orden no encontrada
operador@srv-tramontana:~$ mensaje="copia completa"   # correcto
operador@srv-tramontana:~$ LC_ALL=C sort ~/datos/casas.txt | head -1
cal-ferrer
operador@srv-tramontana:~$ echo "LC_ALL sigue valiendo: '${LC_ALL:-vacío}'"
LC_ALL sigue valiendo: 'vacío'
  • Sin espacios alrededor del =. version = 3.2.1 no es una asignación: Bash lo lee como el comando version con los argumentos = y 3.2.1. Es la regla más quebrantada por quien viene de otros lenguajes, y el mensaje de error no ayuda nada.
  • Comillas si hay espacios. Sin ellas la asignación termina en el primer espacio. Regla operativa: entrecomilla siempre el valor, tenga espacios o no.
  • Al usarla, $; al asignarla, no. nombre=valor para escribir, "$nombre" para leer.

Las dos últimas líneas del ejemplo muestran otra cosa: anteponer una variable a un comando la hace existir solo durante esa ejecución, que es exactamente lo que hace la convención LC_ALL=C del curso. Sobre los nombres: letras, dígitos y _, sin empezar por dígito; MAYÚSCULAS para constantes y entorno, minúsculas para las de trabajo. Y evita pisar PATH, HOME, IFS, PWD, RANDOM o SECONDS, que tienen dueño.

  1. Ámbito: script, subshell y entorno

Retomando 03-01, ahora desde dentro de un script:

Ámbito Cómo se crea Quién la ve
De shell VAR=valor Solo el script actual, incluidas sus funciones
De entorno export VAR=valor El script y todos los procesos hijos que lance
Local local var=valor (en funciones) Solo la función y las que ella llame
operador@srv-tramontana:~$ cat /tmp/ambito.sh
CASA="mas-figueres"; export RELEASE="3.2.1"
bash -c 'echo "hijo ve CASA=[${CASA:-}] RELEASE=[${RELEASE:-}]"'
( CASA="can-ventos"; echo "subshell cambia CASA a $CASA" )
echo "el padre sigue con CASA=$CASA"
operador@srv-tramontana:~$ bash /tmp/ambito.sh
hijo ve CASA=[] RELEASE=[3.2.1]
subshell cambia CASA a can-ventos
el padre sigue con CASA=mas-figueres

Tres lecciones en seis líneas. El hijo no ve CASA porque no está exportada, pero sí RELEASE. El subshell —los paréntesis— sí ve todo lo del padre porque es una copia de él, pero sus cambios no vuelven: el mismo muro que encontraste con cd, y el que hará que un while read dentro de una tubería pierda sus variables (04-03). En cuanto a local, solo puede usarse dentro de una función y es un adelanto de 04-05, pero la regla es tan importante que la enunciamos ya: toda variable creada dentro de una función debe ser local; sin él es global, y una función puede pisar silenciosamente el contador de otra.

  1. declare y sus atributos

declare (o su sinónimo typeset) crea variables con atributos, lo más parecido a un tipo que tiene Bash.

Atributo Efecto
-r / -x Solo lectura (= readonly) / exportada (= export)
-i Entero: las asignaciones se evalúan como aritmética
-a / -A Array indexado / asociativo — este último es obligatorio declararlo
-l / -u Convierte a minúsculas / mayúsculas al asignar
-p No declara: imprime cómo está declarada una variable
operador@srv-tramontana:~$ declare -r RELEASE_ACTIVO="3.2.1"; RELEASE_ACTIVO="3.3.0"
-bash: RELEASE_ACTIVO: variable de sólo lectura
operador@srv-tramontana:~$ declare -i errores=0; errores+=14; errores+=5; echo "$errores"
19
operador@srv-tramontana:~$ declare -u codigo="ok"; echo "$codigo"; declare -p errores
OK
declare -i errores="19"

Fíjate en errores+=14: con el atributo -i suma, mientras que sin él concatenaría y dejaría 014. Ese comportamiento distinto según un atributo invisible es la razón por la que muchos prefieren la aritmética explícita con $(( )); usaremos -i solo para contadores evidentes. readonly sí lo usaremos mucho: convierte un error lógico —reasignar una constante por accidente— en un error inmediato y ruidoso, siempre mejor que uno silencioso.

  1. Expansión de parámetros a fondo

Aquí Bash deja de parecer un lenguaje de juguete. Todo lo que sigue ocurre dentro del shell, sin lanzar procesos. Partimos de un nombre real de nuestras copias:

operador@srv-tramontana:~$ f="/srv/tramontana/backups/envios/reservas-2026-08-18.csv.gz"
operador@srv-tramontana:~$ base="${f##*/}"; echo "$base"   # tras la última /
reservas-2026-08-18.csv.gz
operador@srv-tramontana:~$ echo "${f%/*}"                 # antes de la última /
/srv/tramontana/backups/envios
operador@srv-tramontana:~$ echo "${base%%.*}"             # hasta el primer punto
reservas-2026-08-18
operador@srv-tramontana:~$ echo "${base#*-}"              # tras el primer guion
2026-08-18.csv.gz

Eso es basename y dirname sin basename ni dirname. La regla mnemotécnica es visual: en un teclado, # está a la izquierda del $ y quita por la izquierda; % está a la derecha y quita por la derecha. Doblar el símbolo hace el patrón codicioso, como la codicia de 03-02. Ojo: son patrones de globbing (*, ?, [...]), no expresiones regulares.

Expansión Qué devuelve
${VAR:-valor} / ${VAR:=valor} valor si VAR está vacía o no definida; la segunda forma además asigna
${VAR:?mensaje} El valor, o aborta el script con ese mensaje si está vacía
${VAR:+valor} valor solo si VAR tiene contenido; si no, cadena vacía
${#VAR} / ${VAR:desp:long} Longitud en caracteres / subcadena desde desp (base 0)
${VAR#patrón} / ${VAR##patrón} Quita el prefijo más corto / más largo que case
${VAR%patrón} / ${VAR%%patrón} Quita el sufijo más corto / más largo que case
${VAR/pat/nuevo} / ${VAR//pat/nuevo} Sustituye la primera aparición / todas
${VAR^^} / ${VAR,,} Todo a mayúsculas / a minúsculas

Los cuatro primeros son la base de la configuración por defecto, y :+ es el menos conocido: añade algo solo si hay algo que añadir.

operador@srv-tramontana:~$ fecha="2026-08-18"; nivel="aviso"; ruta="/casas/el-molí"; verbose=""
operador@srv-tramontana:~$ echo "Año ${fecha:0:4}, mes ${fecha:5:2}, longitud ${#fecha}"
Año 2026, mes 08, longitud 10
operador@srv-tramontana:~$ echo "${ruta//í/i} → [${nivel^^}]"
/casas/el-moli → [AVISO]
operador@srv-tramontana:~$ echo "curl -s ${verbose:+-v} ${URL:?falta la URL a comprobar}"
-bash: URL: falta la URL a comprobar

El ${ruta//í/i} resuelve, sin llamar a sed, la tilde que la aplicación mete en los enlaces a /casas/el-molí y que provoca los cuatro 404 de acceso.log. El ${verbose:+-v} añade la opción solo si la variable tiene contenido, que es como se construyen líneas de comando condicionales. Y ${VAR:?mensaje}, ya visto en 03-01, es la forma más barata de exigir un valor obligatorio.

  1. Aritmética, y por qué Bash no sabe de decimales

Bash tiene dos formas de hacer cuentas y las dos trabajan solo con enteros con signo de 64 bits.

operador@srv-tramontana:~$ echo "$(( (14 + 5 + 4) * 100 / 412 ))"     # % de errores
5
operador@srv-tramontana:~$ i=0; (( i++ )); (( i += 10 )); echo "$i"
11

$(( )) sustituye por el resultado, así que se usa donde quieras el valor. (( )) evalúa y no imprime nada: sirve para modificar variables o como condición, devolviendo 0 (éxito) si el resultado es distinto de cero. Dentro de ambas las variables van sin $ y dispones de + - * / % **, comparadores, && || !, ++ --, += y el ternario a ? b : c.

Dos avisos: un número con cero a la izquierda se interpreta en octal: $(( 08 )) es un error de sintaxis, lo que arruina cualquier cuenta con meses (08, 09); fuérzalo con $(( 10#08 )). Y (( i++ )) con i a 0 devuelve 1, porque el valor de la expresión es el previo al incremento, y con set -e eso aborta el script; es una trampa de 04-06, y por ahora usa (( ++i )) o i=$(( i + 1 )).

Para decimales hay que salir de Bash:

operador@srv-tramontana:~$ echo "scale=2; 7842.50 / 25" | bc
313.70
operador@srv-tramontana:~$ LC_ALL=C awk -F';' 'NR>1 { s += $6; n++ } END { printf "%.2f\n", s/n }' \
    /home/operador/datos/reservas.csv
313.70

El importe medio por reserva de Tramontana es de 313,70 €. A bc hay que decirle la escala (scale=2) porque por defecto trunca a cero decimales al dividir; bc -l carga la librería matemática y la sube a 20. Pero fíjate en el segundo ejemplo: si los datos ya están en un fichero, awk hace lectura y cuenta a la vez. La regla del curso: bc para una cuenta suelta, awk en cuanto haya un fichero. Y el LC_ALL=C no es decorativo: con locale español printf '%.2f' imprimiría 313,70 con coma, y esa coma rompe cualquier comparación posterior.

  1. Arrays indexados

Un array es una lista ordenada de cadenas con índice numérico desde 0. Resuelve «guardar varias cosas en una variable» sin recurrir a cadenas separadas por espacios, que se rompen en cuanto un elemento contiene uno.

operador@srv-tramontana:~$ releases=(3.1.0 3.2.0 3.2.1); releases+=(3.3.0)
operador@srv-tramontana:~$ echo "${releases[0]} ... ${releases[-1]}  (total ${#releases[@]})"
3.1.0 ... 3.3.0  (total 4)
operador@srv-tramontana:~$ echo "${releases[@]:1:2}"
3.2.0 3.2.1
operador@srv-tramontana:~$ unset 'releases[0]'; echo "${!releases[@]}"
1 2 3

Se crea con paréntesis, se añade con +=(...), [-1] es el último, ${#array[@]} cuenta elementos y ${array[@]:1:2} es una rebanada. El último comando revela algo importante: unset deja un hueco, los índices no se recolocan; por eso existe ${!array[@]}, que da los índices y no los valores.

${array[@]} frente a ${array[*]}

La diferencia que más bugs causa, y solo se manifiesta dentro de comillas dobles:

operador@srv-tramontana:~$ ficheros=("informe agosto.pdf" "reservas.csv")
operador@srv-tramontana:~$ for f in "${ficheros[@]}"; do echo -n "[$f] "; done; echo
[informe agosto.pdf] [reservas.csv]
operador@srv-tramontana:~$ for f in "${ficheros[*]}"; do echo -n "[$f] "; done; echo
[informe agosto.pdf reservas.csv]
operador@srv-tramontana:~$ for f in ${ficheros[@]}; do echo -n "[$f] "; done; echo
[informe] [agosto.pdf] [reservas.csv]

"${array[@]}" produce un argumento por elemento, respetando espacios: es lo que quieres el 99 % de las veces. "${array[*]}" produce un único argumento con los elementos unidos por el primer carácter de IFS, útil solo para imprimir. Y sin comillas Bash divide por espacios, y «informe agosto.pdf» se convierte en dos ficheros que no existen. Grábatelo: arrays siempre con "${array[@]}".

  1. Arrays asociativos: reservas por casa

Un array asociativo indexa por cadena en vez de por número: es lo que en 03-05 te daba awk, ahora dentro de Bash. Hay que declararlo con declare -A antes de usarlo; si no, Bash lo trata como indexado y todas las claves colapsan en el índice 0.

#!/usr/bin/env bash
set -euo pipefail
CSV="/home/operador/datos/reservas.csv"
declare -A noches_casa

# tail -n +2 salta la cabecera. IFS=';' parte cada línea por el separador
# del CSV; read -r asigna cada campo a una variable (04-03 lo detalla).
# ${var:-0} da 0 la primera vez que aparece una casa, con set -u activo.
while IFS=';' read -r _id _fecha casa _huesped noches _importe; do
    noches_casa["$casa"]=$(( ${noches_casa["$casa"]:-0} + noches ))
done < <(tail -n +2 "$CSV")

# ${!array[@]} son las CLAVES; sort las deja en orden reproducible.
for casa in $(printf '%s\n' "${!noches_casa[@]}" | LC_ALL=C sort); do
    printf '%-14s %3d noches\n' "$casa" "${noches_casa[$casa]}"
done
operador@srv-tramontana:~$ bash /tmp/por_casa.sh
cal-ferrer      11 noches      can-ventos      17 noches
el-moli          8 noches      la-solana       13 noches
mas-figueres    21 noches

Las 70 noches conocidas, repartidas (aquí en dos columnas para ahorrar espacio). Dos piezas nuevas: < <(tail -n +2 "$CSV") es una sustitución de proceso que evita que el while corra en un subshell y pierda los arrays al terminar —lo detalla 04-03—, y los _id, _fecha, _huesped son la convención para «leo este campo pero no lo uso», que además calla a shellcheck. Nota honesta: este informe cabe en una línea de awk. Los arrays asociativos brillan cuando lo que acumulas no es texto tabular —códigos de salida por servicio, rutas ya procesadas, un caché—; con un CSV, awk sigue ganando. La regla se formaliza en 04-04.

  1. Cadenas y printf

Concatenar es escribir seguido, y += añade al final:

operador@srv-tramontana:~$ host="10.0.2.15"; puerto=8080
operador@srv-tramontana:~$ url="http://${host}:${puerto}"; url+="/salud"; echo "$url"
http://10.0.2.15:8080/salud

Las llaves de ${host} no son opcionales: sin ellas $host: funcionaría por casualidad, pero $puertoX buscaría una variable llamada puertoX. Usa llaves siempre que la variable toque texto pegado. Para la salida, printf sustituye a echo en todo lo serio: echo varía entre shells y hace cosas raras con -n y las barras invertidas, mientras que printf está definido con precisión y reutiliza el formato mientras queden argumentos, lo que lo convierte en un generador de tablas.

Directiva Significado
%s / %d Cadena / entero decimal
%.2f Coma flotante con dos decimales
%-20s Cadena alineada a la izquierda en 20 columnas
%10.2f Flotante en 10 columnas, alineado a la derecha
%% / \n / \t Un % literal / salto de línea / tabulador
operador@srv-tramontana:~$ LC_ALL=C printf '%-14s %6d %10.2f\n' \
    mas-figueres 21 2450 can-ventos 17 1980 la-solana 13 1425.50
mas-figueres       21    2450.00
can-ventos         17    1980.00
la-solana          13    1425.50

Una sola llamada, tres filas: el formato se ha reutilizado tres veces. Esa es la tabla del informe de Marta, y la razón por la que echo queda ya solo para mensajes de una línea sin formato.

  1. Aplicación: revision_salud.sh configurable

El cambio de fondo: ningún valor está ya clavado en el cuerpo. Todos son constantes al principio, y las que tiene sentido ajustar desde fuera se leen del entorno con ${VAR:-defecto}.

#!/usr/bin/env bash
#
# revision_salud.sh - Revisión rápida de salud de Tramontana Reservas.
#
# Autor : Operador de sistemas <operador@srv-tramontana>
# Fecha : 2026-08-18
# Uso   : revision_salud.sh
# Entorno: TRAMONTANA_HOST, TRAMONTANA_PUERTO, TRAMONTANA_UMBRAL_DISCO,
#          TRAMONTANA_LOG_ERRORES

set -euo pipefail

# --- Configuración (entorno > valor por defecto) --------------------------
readonly HOST="${TRAMONTANA_HOST:-10.0.2.15}"
readonly PUERTO="${TRAMONTANA_PUERTO:-8080}"
readonly UMBRAL_DISCO="${TRAMONTANA_UMBRAL_DISCO:-80}"
readonly LOG_ERRORES="${TRAMONTANA_LOG_ERRORES:-/var/log/tramontana/errores.log}"
readonly ESPERA_MAX=5
readonly URL_SALUD="http://${HOST}:${PUERTO}/salud"

# --- Cuerpo --------------------------------------------------------------
LC_ALL=C printf '=== Salud de Tramontana Reservas (%s) ===\n' "$(date '+%F %T')"
printf 'Servidor : %s   Objetivo: %s\n\n' "$(hostname)" "$URL_SALUD"

codigo_http=$(curl -s -o /dev/null -w '%{http_code}' \
    --max-time "$ESPERA_MAX" "$URL_SALUD" || true)
uso_disco=$(df --output=pcent / | tail -1 | tr -dc '0-9')
libre_kb=$(df --output=avail / | tail -1 | tr -dc '0-9')
# Un entero de KiB no dice nada a un humano: lo pasamos a GiB con un decimal.
libre_gb=$(LC_ALL=C awk -v k="$libre_kb" 'BEGIN { printf "%.1f", k/1048576 }')
hoy=$(date '+%Y-%m-%d')
errores_hoy=$(grep -c "^$hoy" "$LOG_ERRORES" || true)

LC_ALL=C printf '%-14s %s\n' \
    "HTTP:"    "$codigo_http" \
    "Disco:"   "${uso_disco}% usado, ${libre_gb} GiB libres (umbral ${UMBRAL_DISCO}%)" \
    "Errores:" "$errores_hoy hoy"
exit 0
operador@srv-tramontana:~$ ~/scripts/revision_salud.sh
=== Salud de Tramontana Reservas (2026-08-18 09:41:02) ===
Servidor : srv-tramontana   Objetivo: http://10.0.2.15:8080/salud

HTTP:          200
Disco:         30% usado, 15.4 GiB libres (umbral 80%)
Errores:       6 hoy
operador@srv-tramontana:~$ TRAMONTANA_PUERTO=9090 ~/scripts/revision_salud.sh | sed -n 2p
Servidor : srv-tramontana   Objetivo: http://10.0.2.15:9090/salud

Sin tocar el fichero, el script apunta a otro puerto. Ese es el primer paso de la cadena de precedencia argumentos > entorno > fichero de configuración > defectos que completaremos en 04-03 y cerraremos en 04-07. Sigue sin comparar nada con UMBRAL_DISCO: eso llega en 04-04.

Errores Comunes y Consejos

  • Espacios alrededor del =. VAR = valor no asigna nada: es el error número uno. El segundo es usar $VAR sin comillas: si el valor contiene espacios o un *, Bash lo parte o lo expande contra el disco. SC2086 es esto, y no es un aviso pedante: es un fallo esperando su fichero con espacio.
  • Olvidar declare -A. Sin él, datos[mas-figueres]=1 evalúa la clave como aritmética, la convierte en 0 y todas las casas se pisan entre sí, sin error alguno. Y no confundas ${VAR:-x} con ${VAR:=x}: el primero no modifica la variable, el segundo sí.
  • ${array[*]} en un bucle. Recorre un único elemento gigante; usa siempre "${array[@]}". Y cuidado con los ceros a la izquierda: $(( 08 + 1 )) falla por octal, así que escribe 10#$mes.
  • Esperar decimales de $(( )). $(( 7842 / 25 )) da 313 y se pierde el resto sin avisar. Para dinero, awk o bc con LC_ALL=C.
  • Consejo: declare -p VAR te dice qué es realmente una variable —array, entero, exportada— cuando algo no cuadra. Es el stat de las variables.
  • Consejo: prefiere expansión de parámetros a basename, dirname, cut o tr para lo simple: en un bucle de mil iteraciones son mil procesos que te ahorras. Y declara readonly toda constante.

Ejercicios

Ejercicio 1. Dado f="/srv/tramontana/backups/pre-despliegue/app-3.2.1-2026-08-11.tar.gz", obtén solo con expansión de parámetros (ni basename, ni cut, ni sed): el directorio, el nombre del fichero, el nombre sin extensión, la versión 3.2.1 y la fecha 2026-08-11. Explica cada expansión.

Ejercicio 2. Escribe ~/scripts/resumen_reservas.sh que, leyendo reservas.csv, imprima una tabla alineada con casa, reservas, noches e importe, más una línea de totales. Usa un array asociativo y printf. Debe coincidir con los datos conocidos: 25 reservas, 70 noches, 7.842,50 €.

Ejercicio 3. Luis propone esto para calcular el porcentaje de peticiones POST sobre las 412 de acceso.log. Explica los tres defectos y reescríbelo.

posts = 114
total = 412
pct=$(($posts/$total*100))
echo "POST: $pct %"

Soluciones

Solución 1.

operador@srv-tramontana:~$ f="/srv/tramontana/backups/pre-despliegue/app-3.2.1-2026-08-11.tar.gz"
operador@srv-tramontana:~$ base="${f##*/}"; nombre="${base%%.tar.gz}"; sinapp="${nombre#app-}"
operador@srv-tramontana:~$ printf '%s\n%s\n%s\n%s\n%s\n' "${f%/*}" "$base" "$nombre" \
    "${sinapp%%-*}" "${nombre##*-[0-9].[0-9].[0-9]-}"
/srv/tramontana/backups/pre-despliegue
app-3.2.1-2026-08-11.tar.gz
app-3.2.1-2026-08-11
3.2.1
2026-08-11
  • ${f%/*} quita el sufijo más corto que empiece por /: es dirname. ${f##*/} quita el prefijo más largo acabado en /: es basename.
  • ${base%%.tar.gz} recorta la extensión compuesta. Aquí %% y % dan lo mismo porque el patrón es literal, pero %%.* habría sido peligroso: cortaría también en el punto de 3.2.1, dejando app-3.
  • La versión sale en dos pasos: #app- quita el prefijo y %%-* recorta desde el primer guion restante, el que precede a la fecha. Para la fecha, ##*-[0-9].[0-9].[0-9]- usa un patrón de globbing —no una regex— que consume todo hasta pasado el número de versión.

Solución 2.

#!/usr/bin/env bash
#
# resumen_reservas.sh - Tabla de reservas, noches e importe por casa.
# Autor : Operador de sistemas <operador@srv-tramontana>  Fecha: 2026-08-18

set -euo pipefail
readonly CSV="${TRAMONTANA_CSV:-/home/operador/datos/reservas.csv}"
declare -A n_reservas n_noches importes
declare -i total_reservas=0 total_noches=0
todos=""
# Los importes se acumulan como texto separado por espacios, porque Bash
# no suma decimales; la suma la hace awk con LC_ALL=C para el punto.
suma() { LC_ALL=C awk '{ s=0; for (i=1;i<=NF;i++) s+=$i; printf "%.2f", s }' <<<"$1"; }

while IFS=';' read -r _id _fecha casa _huesped noches importe; do
    n_reservas["$casa"]=$(( ${n_reservas["$casa"]:-0} + 1 ))
    n_noches["$casa"]=$((  ${n_noches["$casa"]:-0} + noches ))
    importes["$casa"]="${importes["$casa"]:-}${importe} "
    todos+="${importe} "
    total_reservas+=1; total_noches+=noches
done < <(tail -n +2 "$CSV")

LC_ALL=C printf '%-14s %9s %8s %12s\n' CASA RESERVAS NOCHES IMPORTE
for casa in $(printf '%s\n' "${!n_reservas[@]}" | LC_ALL=C sort); do
    LC_ALL=C printf '%-14s %9d %8d %12s\n' \
        "$casa" "${n_reservas[$casa]}" "${n_noches[$casa]}" "$(suma "${importes[$casa]}")"
done
LC_ALL=C printf '%-14s %9d %8d %12s\n' TOTAL "$total_reservas" "$total_noches" "$(suma "$todos")"

exit 0
operador@srv-tramontana:~$ ~/scripts/resumen_reservas.sh
CASA            RESERVAS   NOCHES      IMPORTE
cal-ferrer             4       11      1120.00
can-ventos             6       17      1980.00
el-moli                3        8       867.00
la-solana              5       13      1425.50
mas-figueres           7       21      2450.00
TOTAL                 25       70      7842.50

Coincide con los datos conocidos. La función suma es un adelanto de 04-05, pero ilustra por qué las funciones son inevitables: la misma línea de awk se usa en dos sitios.

Solución 3. Los tres defectos:

  1. Espacios alrededor del =. posts = 114 intenta ejecutar el comando posts y el script muere en la primera línea.
  2. El orden de las operaciones trunca el resultado. $posts/$total*100 calcula primero 114/412, que en aritmética entera es 0, y luego 0*100 = 0. Siempre multiplica antes de dividir.
  3. Los $ sobran dentro de $(( )) —ruido, no error— y sobre todo un porcentaje pide un decimal que Bash no puede dar.
posts=114
total=412
# Multiplicar antes de dividir da el entero correcto: 27
pct_entero=$(( posts * 100 / total ))
# Y con un decimal, saliendo a awk con locale fijo:
pct=$(LC_ALL=C awk -v p="$posts" -v t="$total" 'BEGIN { printf "%.1f", p*100/t }')
LC_ALL=C printf 'POST: %s de %s (%s %% entero, %s %% exacto)\n' \
    "$posts" "$total" "$pct_entero" "$pct"
# -> POST: 114 de 412 (27 % entero, 27.7 % exacto)

Fíjate en el %% del formato: dentro de printf, un % literal se escribe doblado.

Conclusión

Ya no dependes de valores clavados en el código, y de paso te has llevado la parte de Bash que más productividad da por línea escrita.

  • Sabes que en Bash todo es una cadena y que el «tipo» lo pone el contexto: nada se valida, comparar números no es comparar texto, y no hay decimales. Asignas sin espacios alrededor del =, entrecomillas siempre el valor, distingues los ámbitos de shell, entorno y subshell sabiendo que los cambios del hijo nunca vuelven al padre, y usas declare con sus atributos -r, -i, -a, -A, -x y -p.
  • Dominas la expansión de parámetros completa: :-, :=, :? y :+, longitudes, subcadenas, recortes con #, ##, %, %%, sustituciones con / y //, y cambio de caso. Ya no necesitas basename ni cut para lo simple.
  • Haces aritmética entera con $(( )) y (( )), conoces la trampa del octal y la de (( i++ )), y sales a bc o awk con LC_ALL=C en cuanto aparece un decimal: el importe medio de Tramontana es 313,70 €.
  • Manejas arrays indexados —siempre "${array[@]}", nunca ${array[*]}—, cuentas por clave con arrays asociativos tras el imprescindible declare -A, formateas con printf (que ya produce la tabla del informe de Marta), y revision_salud.sh lee su configuración del entorno con valores por defecto.

Pero el script sigue siendo mudo: no sabes pedirle ayuda, no puedes decirle «usa umbral 60» sin exportar una variable, y no hay forma de que lea el mes de un informe. En la siguiente lección, Entrada, Salida y Argumentos de un Script, le damos una interfaz de verdad: parámetros posicionales y la diferencia crítica entre $@ y $*, una función de ayuda, getopts para opciones cortas, las convenciones -h, -v, --dry-run y -- que hacen que un script parezca profesional, read en todas sus variantes y el patrón canónico para leer un fichero línea a línea, la regla de oro de stdout para resultados y stderr para humanos, y los códigos de salida con significado. Al terminarla, revision_salud.sh aceptará opciones y nacerá informe_reservas.sh.

Curso de Linux: De Principiante a Administrador de Sistemas

Módulo 1: Introducción a Linux

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados