P2 · Diseño y aplicación
Publicado: semana 6 · Entrega: semana 9 · En parejas
Chasqui va a abrir nuevos servicios y el esquema actual no alcanza. Esta entrega tiene dos partes: diseñar la ampliación con el modelo E-R y la teoría de normalización, y construir encima una aplicación de línea de comandos en Node.js. Cubre las semanas 5 a 8.
1. Requisitos de la ampliación
La gerencia los dictó en una reunión; están escritos como los dijo, con ambigüedades incluidas. Parte del trabajo es encontrarlas y decidir:
- Las tarifas ya no dependen solo de la ruta: hay zonas (costa, sierra, selva) y una tabla de precios por rango de peso para cada par de zonas. Una tarifa tiene vigencia desde una fecha; la anterior no se borra.
- Cada agencia tiene empleados; algunos son conductores, con número de licencia y categoría. Cada viaje tiene un conductor titular y puede tener un copiloto.
- Un envío se paga una o más veces (un adelanto y el saldo), en efectivo, con tarjeta o por transferencia; cada medio guarda datos distintos.
- Los clientes pueden presentar reclamos sobre un envío; un reclamo tiene un estado y una historia de respuestas de los empleados.
Hoy las tarifas viven en una planilla de Excel. El área comercial la exportó
tal cual a rate_sheet.sql, que crea y llena la tabla rate_sheet: una fila
por par de ciudades (las de agency.city), por rango de peso y por versión de
la tarifa, con las zonas de las dos ciudades, los límites del rango
(min_kg, max_kg), el precio, lo que se cobra por kilo adicional
(extra_per_kg) y la vigencia (valid_from, valid_to). El comentario al
inicio del archivo explica cada columna. Iquitos no aparece en la planilla
porque no tiene rutas; la gerencia aclara que es selva.
2. Diseño (design/)
- El diagrama E-R de todo el esquema (el de P1 más la ampliación), en la notación de Silberschatz o en crow’s foot, exportado a PDF o PNG. Marcar claves, cardinalidades, participación total y entidades débiles.
design/normalization.md: las dependencias funcionales de cada tabla nueva y la verificación de que está en BCNF; si alguna queda en 3NF, el motivo. Incluir la descomposición de la tablarate_sheet: sus dependencias funcionales, sus claves candidatas, por qué no está en BCNF, un ejemplo de cada anomalía (de actualización, de inserción y de borrado) con sus filas, y la descomposición paso a paso, mostrando que es sin pérdida y si preserva las dependencias. Una dependencia que se cumple en los datos se puede revisar con una consulta (GROUP BYdel lado izquierdo,HAVING count(DISTINCT ...) > 1del derecho); que se cumpla en estos datos no prueba que sea una regla del negocio.schema/migration.sql: pasa del esquema de P1, con datos, al nuevo, en una sola transacción, sin perder datos. Las pruebas cargan los datos de P1, corren la migración y verifican el resultado (sección 4).
3. La aplicación chasqui
node chasqui.mjs <comando> [argumentos], con estos comandos:
| Comando | Qué hace |
|---|---|
customer <dni> |
Datos del cliente y sus últimos 10 envíos |
quote <origen> <destino> <peso> [AAAA-MM-DD] |
Precio según la tarifa vigente (hoy, o en la fecha dada) |
ship <dni-remitente> <dni-destinatario> <origen> <destino> <peso> |
Registra un envío con su evento received y su precio; imprime el shipment_id |
assign <trip_id> |
Asigna al viaje los envíos pendientes de su ruta, en orden de llegada, sin pasar la capacidad del vehículo |
deliver <shipment_id> |
Marca la entrega y registra el evento |
report <AAAA-MM> |
Ingresos del mes por agencia |
<origen> y <destino> son agency_id (los nombres de las agencias tienen
espacios). La conexión sale de las variables de entorno de PostgreSQL
(PGDATABASE, PGHOST, PGUSER, …), como en top-routes.mjs.
Requisitos:
- Todas las consultas son parametrizadas. Las pruebas privadas intentan inyecciones SQL en cada argumento.
ship,assignydeliverson transacciones: o se hace todo o nada. Usanpool.connect()y el mismo cliente paraBEGIN, las sentencias yCOMMIT/ROLLBACK, y liberan el cliente en unfinally.assignes correcto aunque dos empleados lo corran a la vez sobre el mismo viaje (lo revisaremos de nuevo en la semana 13; por ahora, basta conSELECT ... FOR UPDATEsobre el viaje).- Errores de uso o de datos (un DNI que no existe, un peso negativo): mensaje
claro en
stderry código de salida distinto de 0, sin un stack trace.
Argumentos y códigos de salida
| Código | Cuándo |
|---|---|
| 0 | Todo salió bien. |
| 2 | Error de uso: falta el comando o es uno que no existe, sobran o faltan argumentos, o un argumento está mal escrito. |
| 1 | Error de datos: los argumentos están bien escritos, pero el pedido no se puede cumplir (un DNI que no está registrado, una agencia o un viaje que no existe, cualquier error de PostgreSQL). |
Un argumento está bien escrito si cumple su formato:
| Argumento | Formato |
|---|---|
<dni> |
8 dígitos (07654321) |
<origen>, <destino>, <trip_id>, <shipment_id> |
entero positivo, solo dígitos y sin ceros a la izquierda (12; no 012, 0, -3, 1.5 ni doce) |
<peso> |
kilos, mayor que 0, con punto decimal y a lo más dos decimales (2, 2.5, 30.01; no 0, -1, 2,5 ni 1.234) |
AAAA-MM-DD |
una fecha que existe (2026-02-28; no 2026-02-30) |
AAAA-MM |
un mes (2026-03; no 2026-3 ni 2026-13) |
Cuando hay un error, stdout queda vacío y el mensaje va a stderr, sin
stack trace. Con un error de uso conviene imprimir también cómo se usa el
programa.
Qué imprime cada comando
Las salidas son texto con los campos separados por un tabulador, así se
pueden comparar con un archivo y procesar con otros programas. Las horas y los
meses van en hora de Lima, aunque la sesión use otra zona horaria: las
pruebas corren la aplicación con la sesión en UTC. Los números salen como los
devuelve PostgreSQL para las columnas de P1 (3.50, 45.07).
-
customer <dni>. Un encabezado y la fila del cliente; una línea en blanco; un encabezado y sus últimos 10 envíos, como remitente o como destinatario, del más reciente al más antiguo (created_atdescendente; a igual hora,shipment_iddescendente). Si no tiene teléfono,phoneva vacío. Si no tiene envíos, se imprime solo el encabezado de los envíos. Por ejemplo,node chasqui.mjs customer 61830975con los datos de ejemplo empieza así:customer_id dni name phone city 1 61830975 Wilmer Chávez Ríos 909975104 Ayacucho shipment_id role created_at origin_agency dest_agency weight_kg price status 8298 sender 2026-06-26 17:50 Ayacucho - Centro Lima - La Victoria 58.50 80.15 delivered 9956 sender 2026-05-30 19:01 Ayacucho - Centro Lima - La Victoria 11.90 38.21 delivered 1636 recipient 2026-05-17 00:39 Ica - Centro Ayacucho - Centro 90.80 109.17 delivered ...nameesfirst_name || ' ' || last_name;roleessenderorecipient;created_atva con el formatoAAAA-MM-DD HH:MM;origin_agencyydest_agencyson los nombres de las agencias. -
quote. Solo el precio, con dos decimales:18.50. -
ship. Solo elshipment_iddel envío nuevo. -
assign. Losshipment_idasignados, uno por línea, en el orden en que se asignaron. Si no se asigna ninguno, no imprime nada (y sale con 0). -
deliver. No imprime nada. -
report <AAAA-MM>. Un encabezado y una fila por agencia, todas las agencias, ordenadas poragency_id. Los ingresos de una agencia son la suma depricede los envíos que se registraron en ella (agencia de origen) durante ese mes en hora de Lima, cualquiera sea su estado; es la misma cuenta que la vistaagency_daily_revenuede P1. Una agencia sin envíos ese mes sale con0y0.00. Las dos primeras líneas denode chasqui.mjs report 2026-03:agency_id agency shipments revenue 1 Lima - La Victoria 313 20395.34
Cómo calcula quote el precio
- Las dos agencias existen y hay una ruta de
<origen>a<destino>enroute; si no, es un error de datos. Chasqui solo cotiza lo que puede llevar. - Cada agencia está en la zona de su ciudad.
- El rango de peso es el que cumple
min_kg < peso <= max_kg; el último rango no tiene máximo. Un envío de 5 kg está en el rango de 1 a 5 kg. - La tarifa vigente en una fecha es, para ese par de zonas y ese rango, la de
valid_frommás reciente que no pasa de la fecha. Sin fecha, la fecha es hoy en Lima. Si no hay ninguna (una fecha anterior a todas las tarifas), es un error de datos. - El precio es
round(price + extra_per_kg * (peso - min_kg), 2), con elroundde PostgreSQL.extra_per_kges 0 en todos los rangos menos el último.
Por ejemplo, 45.5 kg de Lima a Trujillo (costa a costa) con la tarifa de
2026: round(29.00 + 0.75 * (45.5 - 30), 2) = 40.63.
ship, assign y deliver en detalle
shipbusca a los dos clientes por DNI y calcula el precio comoquotepara hoy. Inserta el envío en estadoregistered, sin viaje, sindeclared_value, concreated_atigual a la hora actual, y su evento número 1,received, en la agencia de origen y con la misma hora quecreated_at. Dosshipa la vez registran dos envíos distintos (las pruebas lo hacen):max(shipment_id) + 1no sirve, por eso la migración le da ashipment_idun valor por defecto (sección 4).assignsolo acepta un viaje que todavía no sale (departure_atposterior a ahora). Un envío está pendiente si estáregistered(y por lo tanto sin viaje) y tiene el mismo origen y destino que la ruta del viaje. Recorre los pendientes en orden de llegada (created_at, y a igual hora,shipment_id) y asigna cada uno que cabe en lo que le queda al viaje (capacity_kgmenos la carga que ya lleva); el que no cabe se salta y sigue pendiente, y se sigue con el siguiente. Un envío asignado queda con eltrip_iddel viaje y en estadoin_transit(la regla 3 de P1 no permite otra cosa). Si otra transacción tiene bloqueado un envío pendiente,assignlo espera; no se lo salta (conSKIP LOCKED, por ejemplo), porque se rompería el orden de llegada.assignno registra eventos: eldepartedse registra cuando el viaje sale, y eso no es parte de la aplicación.deliversolo acepta un envíoin_transitcuyo viaje ya llegó (arrival_atno esNULLy no es posterior a ahora). Lo pasa adeliveredcondelivered_atigual a la hora actual y agrega su eventodelivered, con el número de secuencia siguiente, en la agencia de destino y a la misma hora.
4. La migración: qué tiene que dejar
make test aplica la migración así: carga los datos de ejemplo, aplica
schema/constraints.sql y schema/views.sql de P1, carga rate_sheet.sql y
corre schema/migration.sql con psql --single-transaction -v ON_ERROR_STOP=1. Es una sola transacción: si algo falla, no queda nada de la
migración y make test se detiene. Por eso migration.sql no lleva BEGIN
ni COMMIT.
Las pruebas de P0 y de P1 corren después de la migración, sobre el esquema nuevo. Así que la migración:
- no borra ni modifica ninguna fila de las tablas de P1, y no les quita ni les cambia el nombre a sus columnas;
- deja en pie las restricciones, los triggers y las vistas de P1;
- si agrega columnas a una tabla de P1 (por ejemplo, el conductor de un
viaje), tienen que aceptar que no se las den, con
NULLo con unDEFAULT: las pruebas de P1 y de P2 insertan clientes, viajes y envíos solo con las columnas de partida. Qué significa un viaje de P1 sin conductor es una de las ambigüedades que tienen que resolver; - le da a
shipment_idun valor por defecto que sirva con variosshipa la vez y que empiece después del mayorshipment_idde los datos. Las pruebas de P1 siguen insertando envíos conshipment_idexplícito, y eso también tiene que funcionar.
Además, la migración:
- crea las tablas de la ampliación, cada una con su clave primaria;
- pasa los datos de
rate_sheeta las tablas nuevas y borrarate_sheet; - crea dos vistas, que son lo único del diseño nuevo que miran las pruebas (el resto lo mira la revisión). Si alguna tabla suya se llama igual, cámbienle el nombre a la tabla.
| Vista | Columna | Qué es |
|---|---|---|
agency_zone |
agency_id |
cada agencia, todas |
zone |
su zona: costa, sierra o selva |
|
rate_card |
origin_zone, dest_zone |
el par de zonas |
min_kg, max_kg |
el rango de peso (max_kg es NULL en el último) |
|
price, extra_per_kg |
lo que cobra la tarifa | |
valid_from |
desde cuándo rige |
rate_card tiene una fila por tarifa distinta de la planilla: por par de
zonas, rango y valid_from, también las que ya no rigen. Los tipos de las
columnas los eligen ustedes; las pruebas los convierten antes de comparar.
README
El diagrama, las ambigüedades que encontraron en los requisitos y cómo las resolvieron, y cómo probaron la migración.
Archivos
| Archivo | Qué es | ¿Lo modifican? |
|---|---|---|
rate_sheet.sql |
La planilla de tarifas (sección 1) | No |
schema/migration.sql |
La migración (secciones 2 y 4) | Sí |
chasqui.mjs |
La aplicación (sección 3); por ahora solo imprime cómo se usa | Sí |
design/ |
El diagrama E-R y normalization.md (sección 2) |
Lo crean ustedes |
Makefile |
El de P1 con más pasos (ver abajo); reemplaza al de P1 | No |
p2.public.test.mjs |
Pruebas públicas | No |
expected/p2/ |
Resultados esperados de las vistas y de la aplicación | No |
Los archivos de P0 y de P1 se quedan en la misma carpeta, como antes.
Pruebas
make test corre las pruebas de P0, P1 y P2. El Makefile es el de P1 con
más pasos:
- crea de nuevo la base de datos de pruebas con los datos de ejemplo;
- aplica
schema/constraints.sqlyschema/views.sql, cargarate_sheet.sqly aplicaschema/migration.sqlen una sola transacción, todo conpsql -v ON_ERROR_STOP=1: si un script falla,make testse detiene ahí y no corre ninguna prueba; - copia esa base de datos en una segunda,
chasqui_test_app.ship,assignydelivercambian datos, y las pruebas de P0 y P1 comparan los datos de la primera conexpected/: por eso la aplicación corre sobre la copia; - corre todos los
*.test.mjs, uno detrás de otro: las pruebas de P0, de P1 y de P2, y las suyas.
Las pruebas de P2 corren node chasqui.mjs como lo haría un usuario y miran
la salida, el código de salida y lo que quedó en la base de datos. Para
revisar que una transacción no deje nada a medias, simulan una falla en la
mitad (con un trigger temporal que hace fallar un INSERT o un UPDATE) y
comparan una huella de todas las tablas antes y después. Para revisar la
concurrencia, lanzan dos procesos a la vez y los hacen chocar: mientras una
transacción de la prueba tiene un lock, los dos se quedan esperando, y al
soltarlo siguen al mismo tiempo. Si sus pruebas propias corren comandos
que cambian datos, que lo hagan en la copia (su nombre está en la variable
CHASQUI_APP_DB) y con viajes y envíos que insertan ellas mismas, como las
pruebas públicas: la copia no se vuelve a crear entre un archivo de pruebas y
el siguiente.