Saltar a contenido

📚 Unidad ISS-08 · Feature Sale + ProductSale — capa 🧠 APRENDER

🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE) · 📝 Evaluación

Capa Página Para qué
🧠 Aprender esta página comprender, explicar y relacionar
🛠 Construir Feature Sale + ProductSale ejecutar, programar y verificar
✅ GATE Cierre de la unidad condición para pasar al bloque siguiente

Mapa de correspondencias. Cada fila enlaza el mismo tema en las dos capas de la unidad; los enlaces apuntan a secciones reales del material (anclas de MkDocs, sin acentos).

Tema 🧠 Aprender (esta página) 🛠 Construir (ISS técnico)
Ruta y ficha de la unidad Ruta de aprendizaje · Ficha del ISS Contenido de la unidad
Mapas y estructura Mapa mental · Mapa del backend · Árbol de archivos Contenido de la unidad
Recorrido y comandos Comandos explicados · Recorrido paso a paso Contenido de la unidad
Diagnóstico Diagnóstico Criterios de aceptación
Evaluación y cierre Criterios · Evaluación · GATE Condiciones de cierre · Cierre

🖥 Presentación del ISS

Presentación de la unidad. Diapositivas de ISS-08 — Feature Sale + ProductSale (8 diapositivas). Se visualiza aquí, dentro del sitio; no hace falta descargar nada para estudiarla.

⛶ Ver presentación completa ⬇ Archivo editable (.pptx)

8 diapositivas · se visualiza dentro del sitio (archivo editable .pptx como opción secundaria).


🎬 Video explicativo

Recorrido audiovisual de la unidad. Este video explica, bloque por bloque, Sale y ProductSale del ISS-08: el alta con control de stock, el detalle y las bajas. Crear y borrar van en transacción; los update de Sale, no.

11:53 · narración en español · subtítulos activables desde el reproductor.


ISS-08 — Cuaderno de aprendizaje visual

Tema

Feature Sale + feature ProductSale (ventas): crear la cabecera de una venta (/api/ventas) y sus líneas de detalle (/api/detalle-ventas) con la primera transacción real del laboratorio (la unit of work), controlando stock y totales de forma atómica.

Fuente técnica autoritativa

Archivo fuente 09-ISS-08-sale-product-sale.md
Ruta en el repo docs/manual/09-ISS-08-sale-product-sale.md
Estado Solo lectura — este cuaderno no modifica el ISS
Alcance 3.118 líneas, 100 bloques de código, 9 criterios de aceptación (ISS-08)
Features que introduce sales/ (cabecera) y product-sales/ (líneas)
API que expone /api/ventas y /api/detalle-ventas — SIN AUTH

Este cuaderno es una capa pedagógica sobre ese archivo. El ISS es la fuente técnica autoritativa: sus comandos, modelos, versiones y criterios son los que valen. Aquí se explica el por qué, se dibujan las relaciones y se aísla lo que este ISS sí introduce de lo que llega mucho después.

Es el ISS más grande del curso: son 100 bloques de código repartidos en dos features hermanos. Por eso el material incluye, además del marco habitual, una sección dedicada a la transacción (## La transacción, paso a paso) y un despiece archivo por archivo (## Anatomía del código).

Regla del ISS

El propio ISS fija su condición, sin ambigüedad:

Objetivo: ventas con ítems N:M vía feature propio product-sales/ (tabla product_sales, API /api/detalle-ventas; detalle: quantity, unit_price, line_total); create transaccional de cabecera+ítems en /api/ventas con stock.

Bloqueado por: ISS-07 (Feature Product) + ISS-03 (Feature Client).

Criterio de cierre: los 9 criterios de aceptación del ISS en verde y la verificación npx tsc --noEmit + npm run db:seed + curl a /api/ventas y /api/detalle-ventas.

La condición que manda sobre las demás es esta: crear una venta ya no es un INSERT; es un conjunto de escrituras que solo valen juntas. Si la cabecera se inserta pero una línea falla, o si el stock se descuenta pero la venta no llega a guardarse, la base de datos queda mintiendo. El ISS exige que la venta se cree entera o nada, y para eso usa una transacción. Ese es el corazón del ISS.

Cómo leer este cuaderno

Cada concepto se presenta tres veces, desde tres ángulos distintos (regla de las tres representaciones):

                 CONCEPTO
                    │
        ┌───────────┼───────────┐
        ▼           ▼           ▼
   EXPLICACIÓN    CÓDIGO      VISUAL
        │           │           │
    ¿qué es?    ¿dónde está?  ¿cómo lo
    ¿por qué?   ¿qué hace?     visualizo?
    ¿para qué?  ¿cómo opera?  ¿con qué
                               se relaciona?

Pregunta que responde: ¿cómo está organizado este cuaderno y qué espero encontrar en cada parte?

El recorrido de lectura es:

EXPLICACIÓN
    ↓
CÓDIGO
    ↓
VISUAL

Y cada cuaderno contiene los mismos seis componentes:

Componente Dónde vive Para qué sirve
Texto todas las secciones entender el por qué
Código Recorrido del ISS (verbatim) + Anatomía del código ver el qué exacto y dónde está
Diagramas Mapa mental, Mapa del backend, La transacción, Flujos ver el cómo se conecta
Preguntas Evaluación comprobar que entendiste
Evaluación Evaluación practicar y autoevaluarte
GATE GATE saber si puedes pasar al cierre de Fase I

Cómo navegar un ISS tan grande (por sub-ítem)

Este ISS usa una numeración con sufijo b que conviene entender antes de empezar:

  • 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7 → material de la cabecera (sales/).
  • 13.1b, 13.2b, 13.3b, 13.4b, 13.6b → material del detalle (product-sales/).

El orden de lectura del ISS está invertido respecto a la numeración: primero se construye el detalle (los …b), y solo después la cabecera. La razón es pedagógica y técnica a la vez: la venta transaccional necesita que la línea que mueve stock y recalcula totales exista antes.

Consejo de estudio: no leas de un tirón. Toma un sub-ítem, localízalo en la ## Ruta de aprendizaje, léelo en el ## Recorrido del ISS, paso a paso (donde está el código verbatim) y vuelve a la ## Anatomía del código para el despiece. La transacción se entiende mejor después de haber visto el detalle.

Ruta de aprendizaje

Esta ruta es específica de este ISS y respeta su orden real de construcción: primero el detalle (product-sales/), después la cabecera (sales/).

Detalle (product-sales/)
  1. Modelo ProductSale                    ← 13.1
        ↓
  2. Asociaciones Sale ↔ ProductSale        ← 13.1b
     ProductSale ↔ Product
        ↓
  3. DTOs + Repository + Service            ← 13.2b
     + Controller (mueve stock, recalcula)
        ↓
  4. Routes /api/detalle-ventas             ← 13.3b
        ↓
  5. Seeder product_sales                   ← 13.4b
        ↓
  6. Swagger DetalleVentas                  ← 13.6b

Cabecera (sales/)
  7. Modelo Sale + DTOs                     ← 13.1 / 13.2
        ↓
  8. Repository + Service                   ← 13.2
     (create TRANSACCIONAL: cabecera+líneas)
        ↓
  9. Controller + Routes /api/ventas        ← 13.2 / 13.3
        ↓
 10. http/ (peticiones de prueba)           ← 13.3
        ↓
 11. Cableado (parches config/routes)       ← 13.4
        ↓
 12. Relaciones obligatorias                ← 13.5
        ↓
 13. Seeder + Swagger Sale                  ← 13.6
        ↓
 14. Estado final de agregadores            ← 13.7
        ↓
 Verificación / GATE

Pregunta que responde: ¿en qué orden se construye este ISS y por qué el detalle va antes que la cabecera?

Fíjate en lo que no aparece: no hay authenticate, ni authorize, ni roles, ni recursos. Todas las rutas de este ISS se montan con el comentario explícito RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT. La seguridad es la Fase II (ISS-09 en adelante).

Índice

  1. Ficha del ISS
  2. Mapa mental del ISS
  3. Mapa del backend
  4. Árbol de archivos
  5. La transacción, paso a paso
  6. Anatomía del código
  7. Flujos
  8. Comandos explicados
  9. Recorrido del ISS, paso a paso
  10. Diagnóstico
  11. Conexión con el resto del curso
  12. Glosario
  13. Criterios de aceptación
  14. Evaluación
  15. GATE

Ficha del ISS

Campo Valor
ISS ISS-08
Título Feature Sale + feature ProductSale (ventas)
Objetivo CRUD de ventas cabecera+líneas con create transaccional, control de stock y recálculo de totales
Fase Fase I — Business
Tecnología principal Express 5 + TypeScript + Sequelize (transacciones, SELECT … FOR UPDATE, withTransaction)
Depende de ISS-07 — Feature Product + ISS-03 — Feature Client
Habilita Cierre del laboratorio (Fase I — Business)
Archivos creados sales/ y product-sales/: modelo, dto/, repository, service, controller, routes, associations, seeder, swagger y http/
Archivos parcheados src/routes/index.ts, src/config/index.ts, src/database/seeders/counts.ts, src/database/seeders/index.ts, src/swagger/index.ts
Componentes incorporados Primera transacción real (unit of work con withTransaction), orden canónico de locks, snapshot de precio (unit_price/line_total), restauración de stock, borrado lógico en cascada
Verificación principal npx tsc --noEmit + npm run db:seed + curl a /api/ventas y /api/detalle-ventas
Resultado esperado Las 5 features de Business (clients, product-types, products, sales, product-sales) operativas; POST /api/ventas atómico; Swagger con los tags Ventas y DetalleVentas
GATE TypeScript sin errores, seeders OK y ambos endpoints respondiendo JSON

Qué implementamos AHORA

En este ISS el laboratorio gana dos features completos y, con ellos, el primer punto del curso donde una petición escribe en varias tablas a la vez:

  • sales/ — la cabecera de la venta (client_id, sale_date, subtotal, tax, discounts, total, status). El create es transaccional: cabecera + líneas + descuento de stock + recálculo de totales ocurren dentro de una sola transacción.
  • product-sales/ — las líneas de la venta (tabla product_sales, API /api/detalle-ventas): sale_id, product_id, quantity, unit_price y line_total. Cada línea guarda el precio aplicado en el momento de la venta (unit_price), no el precio actual del producto.
  • La primera transacción real — el helper withTransaction (creado en ISS-03 pero sin uso serio hasta ahora) pasa a ser el patrón central: create, update, deletePhysical y deleteLogical de ambos features viven dentro de una transacción.
  • Relaciones N:M explícitas — Sale ↔ ProductSale y ProductSale ↔ Product quedan declaradas en archivos .associations.ts propios de cada feature.
  • Seeders e Swagger — sales y product_sales se suman al SeedersRunner (con la clave product_sales en SeedCounts) y al registry de Swagger.

Qué todavía NO implementamos

Este ISS se apoya en ISS-07 pero no abre la Fase II. La confusión típica es creer que las rutas de ventas ya están protegidas porque el Swagger y los archivos .http mencionan «JWT + RBAC». No: eso es documentación de la modalidad futura; las rutas de este ISS se montan literalmente como RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT.

No se implementa aquí Llega en
patients… (no aplica) cualquier lógica de venta fiscal/IVA real Fuera del alcance del laboratorio
Capa de seguridad base (AppError de auth, JWT HS256, bcrypt, sendError, resource-match) ISS-09
Feature users (hash, cambio de contraseña, permisos efectivos) ISS-10
Features roles y resources (catálogo de recursos) ISS-11
Matriz RBAC role_users y resource_roles ISS-12
Middlewares authenticate / authorize (proteger las rutas de negocio) ISS-13
refresh_tokens (hash SHA-256, rotación, detección de reúso) ISS-14
Sesión (login / refresh / logout / perfil) ISS-15
Que un SELLER tenga o no concedido GET /api/detalle-ventas ISS-12/13 (hoy no existe autorización)

Ojo con la trampa habitual: el Swagger de este ISS ya declara security: bearerSecurity y respuestas 401/403, y los .http ya traen @name loginAdmin / loginSeller. Son anotaciones de la modalidad futura, copiadas para no rehacer la documentación después. En el estado actual del ISS ninguna petición pide token.

Mapa mental del ISS

mindmap
  root((ISS-08<br/>Ventas))
    Objetivo
      Cabecera y lineas
      Primera transaccion real
      Control de stock
    Features
      sales
      product-sales
    Conceptos
      Unit of work
      Atomicidad
      Rollback
      Orden canonico de locks
      Snapshot de precio
      Borrado logico en cascada
    Modelo de datos
      sales
      product_sales
      quantity
      unit_price
      line_total
    API sin auth
      Listar ventas
      Detalle de ventas
    Verificacion
      tsc sin errores
      seeders
      curl
    GATE
      Cierre Fase I Business

Pregunta que responde: ¿de qué trata este ISS y qué piezas conceptuales lo componen?

Mapa del backend

Esta representación responde siempre a la misma pregunta: ¿en qué parte del backend estamos?

IMPLEMENTADO HASTA ESTE ISS
───────────────────────────

   HTTP  →  Express App                         ✅
        ↓
   Routes → Controller → Service → Repository   ✅ (5 features Business)
        ↓
   Model → Sequelize → BD                        ✅ (sync + testConnection)

   Business
   ├── clients          ✅  (ISS-03)
   ├── product-types    ✅  (ISS-06)
   ├── products         ✅  (ISS-07, + asociaciones)
   ├── sales            ✅  ★ NUEVA (ISS-08)
   └── product-sales    ✅  ★ NUEVA (ISS-08, líneas)

   Seeders (SeedersRunner)                      ✅  ★ + sales, + product_sales
   Swagger (api docs)                           ✅  ★ + tags Ventas, DetalleVentas
   TRANSACCIÓN / Unit of work                   ✅  ★ primera en uso real
                                                   (withTransaction)


OBJETIVO DE ARQUITECTURA
────────────────────────

   Business ✅  →  Auth base (JWT, bcrypt)            🎯 ISS-09
                →  users                              🎯 ISS-10
                →  roles + resources                  🎯 ISS-11
                →  role_users + resource_roles (RBAC) 🎯 ISS-12
                →  authenticate / authorize           🎯 ISS-13
                →  refresh_tokens                     🎯 ISS-14
                →  sesión (login/refresh/logout)      🎯 ISS-15

Pregunta que responde: ¿qué capas y features del backend existen ya y cuáles son todavía objetivo?

En este punto el proyecto tiene cinco features de negocio y ha aprendido a escribir de forma atómica. Lo que falta es la capa de seguridad, que es la Fase II completa. Nada de authenticate, authorize, JWT o RBAC existe todavía en el código.

Árbol de archivos

Estructura antes

Así está el proyecto al terminar ISS-07 (la última feature era products):

src/
├── config/
│   └── index.ts                       △ (se parchea en este ISS)
├── database/
│   ├── db.ts
│   └── seeders/
│       ├── counts.ts                  △ (se parchea)
│       └── index.ts                   △ (se parchea)
├── features/
│   └── business/
│       ├── clients/                   ✅ (ISS-03)
│       ├── product-types/             ✅ (ISS-06)
│       └── products/                  ✅ (ISS-07)
├── routes/
│   └── index.ts                       △ (se parchea)
├── shared/
│   ├── database/
│   │   └── with-transaction.ts        ✅ (creado en ISS-03, aún sin uso real)
│   ├── errors/
│   │   └── app-error.ts               ✅
│   └── http/
│       └── base-controller.ts         ✅
├── swagger/
│   └── index.ts                       △ (se parchea)
└── server.ts

Pregunta que responde: ¿de qué punto parte el proyecto antes de añadir las ventas?

Archivos creados / modificados en este ISS

Leyenda: ★ = creado en este ISS · △ = existente y parcheado.

src/features/business/sales/                        ★ NUEVA feature
├── sale.model.ts                                   ★
├── sales.associations.ts                           ★
├── sales.repository.ts                             ★
├── sales.service.ts                                ★ (create transaccional)
├── sales.controller.ts                             ★
├── sales.routes.ts                                 ★
├── sales.seeder.ts                                 ★
├── sales.swagger.ts                                ★
├── dto/
│   ├── create-sale.dto.ts                          ★
│   ├── update-sale.dto.ts                          ★
│   ├── patch-sale.dto.ts                           ★
│   ├── sale-response.dto.ts                        ★
│   └── index.ts                                    ★
└── http/
    ├── sales.get.http                              ★
    ├── sales.create.http                           ★
    ├── sales.update.http                           ★
    └── sales.delete.http                           ★

src/features/business/product-sales/               ★ NUEVA feature
├── product-sale.model.ts                           ★
├── product-sales.associations.ts                   ★
├── product-sales.repository.ts                     ★
├── product-sales.service.ts                        ★ (stock + totales)
├── product-sales.controller.ts                     ★
├── product-sales.routes.ts                         ★
├── product-sales.seeder.ts                         ★
├── product-sales.swagger.ts                        ★
├── dto/
│   ├── create-product-sale.dto.ts                  ★
│   ├── update-product-sale.dto.ts                  ★
│   ├── patch-product-sale.dto.ts                   ★
│   ├── product-sale-response.dto.ts                ★
│   └── index.ts                                    ★
└── http/
    ├── product-sales.get.http                      ★
    ├── product-sales.create.http                   ★
    ├── product-sales.update.http                   ★
    └── product-sales.delete.http                   ★

src/config/index.ts                                 △ (importa modelos/asociaciones, registra 2 rutas)
src/routes/index.ts                                 △ (importa y expone SalesRoutes / ProductSalesRoutes)
src/database/seeders/counts.ts                      △ (+ sales, + product_sales)
src/database/seeders/index.ts                       △ (+ seedSales, + seedProductSales)
src/swagger/index.ts                                △ (+ salesSwagger, + productSalesSwagger)

Pregunta que responde: ¿qué archivos exactos crea y toca este ISS?

Estructura después

src/
├── config/
│   └── index.ts                       △ registra 5 features Business
├── database/
│   └── seeders/
│       ├── counts.ts                  △ { sales, product_sales }
│       └── index.ts                   △ orden: … products → sales → product_sales
├── features/
│   └── business/
│       ├── clients/                   ✅
│       ├── product-types/             ✅
│       ├── products/                  ✅
│       ├── sales/                     ★ ← cabecera de venta
│       └── product-sales/             ★ ← líneas (detalle)
├── routes/
│   └── index.ts                       △ 5 rutas
├── shared/
│   └── database/
│       └── with-transaction.ts        ✅ ← ahora sí, en uso real
├── swagger/
│   └── index.ts                       △ 5 módulos de documentación
└── server.ts

Pregunta que responde: ¿cómo queda la estructura del proyecto después de añadir las ventas?

La transacción, paso a paso

Esta es la sección central del ISS. Todo lo demás (DTOs, routes, seeders) ya se ha hecho en ISS anteriores; lo genuinamente nuevo aquí es aprender a escribir varias tablas como si fueran una sola operación.

El problema: una venta no es una fila

Una venta con dos productos no es un registro: es una cabecera (sales) más dos líneas (product_sales) más dos descuentos de stock (products). Es decir, entre 1 y N escrituras sobre tres tablas distintas.

POST /api/ventas con 2 ítems toca:
   sales          → 1 INSERT
   product_sales  → 2 INSERT
   products       → 2 UPDATE (descuenta stock)
   ───────────────────────────
   total          → 5 escrituras que solo tienen sentido juntas

Pregunta que responde: ¿por qué una venta con varias líneas no puede resolverse con un solo INSERT?

Si esas cinco escrituras ocurren sueltas y algo falla a mitad, la base de datos queda en un estado que nunca debió existir: una venta sin todas sus líneas, o stock descontado sin venta que lo justifique. Eso es una violación de la integridad de datos, y es peor que un error HTTP: el error se corrige, el dato corrupto se queda.

Qué es la unit of work (withTransaction)

La unit of work («unidad de trabajo») es el patrón que agrupa todas esas escrituras en una sola operación indivisible. En el laboratorio se materializa en un helper compartido, withTransaction, que recibe una función (work) y se encarga de abrir, confirmar o deshacer la transacción:

withTransaction(work)
   ↓
BEGIN                (se abre la transacción)
   ↓
ejecuta work(t)      (todas las escrituras usan la MISMA t)
   ↓
  ¿terminó bien?
   ├── SÍ → COMMIT    (los cambios se confirman, en bloque)
   └── NO → ROLLBACK  (todo se deshace, como si nunca hubiera pasado)
                     + se re-lanza el error

Pregunta que responde: ¿qué hace exactamente withTransaction y en qué se diferencia de un try/catch normal?

Dos detalles finos del helper, que conviene entender:

  • La misma transaction viaja por todas las llamadas. El service no crea una transacción suya: la recibe en el parámetro t y la pasa a todos los repositories. Si un repository olvidara pasarla, su escritura se ejecutaría fuera de la transacción y quedaría sin protección.
  • El rollback se hace con catch y luego se re-lanza el error. No se «traga» el fallo: se deshace el trabajo y se devuelve el error original para que el controller lo traduzca a un HTTP con sentido.

Dónde se usa en este ISS

Feature Operaciones transaccionales Qué protegen
product-sales/ create, updatePut, updatePatch, deletePhysical, deleteLogical línea + stock del producto + totales de la venta
sales/ create, deletePhysical, deleteLogical cabecera + todas las líneas + stock de cada producto

SalesService es el ejemplo canónico: su constructor recibe cuatro repositories (SalesRepository, ProductSalesRepository, ProductsRepository, ClientsRepository) y una sola transacción los coordina a todos. Eso es, literalmente, una unit of work.

Qué pasa si falla una línea

Supón un POST /api/ventas con dos ítems y, por error, el segundo no tiene stock suficiente. El service recorre los ítems en orden ascendente de product_id y, al llegar al segundo, lanza AppError(400, "Insufficient stock…"). A partir de ahí:

  1. La excepción sube por dentro de work.
  2. withTransaction captura el error y ejecuta ROLLBACK.
  3. Nada de lo anterior sobrevive: ni la cabecera insertada, ni la primera línea, ni el stock descontado del primer producto.
  4. El error se re-lanza y el controller responde 400.

El rollback es, por tanto, la garantía de que todo lo escrito antes del fallo se desvanece. Es lo que convierte «cinco escrituras sueltas» en «una escritura lógica».

Caso feliz: la venta se confirma entera

sequenceDiagram
    autonumber
    participant C as Cliente HTTP
    participant Ctrl as SalesController
    participant Svc as SalesService
    participant Tx as withTransaction
    participant DB as Base de datos
    C->>Ctrl: POST /api/ventas con client_id e items
    Ctrl->>Svc: create(body)
    Svc->>Tx: withTransaction(work)
    Tx->>DB: BEGIN
    Svc->>DB: SELECT cliente activo
    Svc->>DB: SELECT productos FOR UPDATE en orden ascendente
    Svc->>DB: INSERT sales
    Svc->>DB: INSERT product_sales de la linea 1
    Svc->>DB: INSERT product_sales de la linea 2
    Svc->>DB: UPDATE products descuenta stock de cada linea
    Tx->>DB: COMMIT
    Tx-->>Svc: resultado
    Svc-->>Ctrl: sale + items
    Ctrl-->>C: 201 Created
    Note over DB: La venta, sus lineas y el stock quedan consistentes

Pregunta que responde: ¿qué ocurre, escritura a escritura, cuando una venta se crea correctamente?

Caso con fallo: la venta se deshace entera

sequenceDiagram
    autonumber
    participant C as Cliente HTTP
    participant Svc as SalesService
    participant Tx as withTransaction
    participant DB as Base de datos
    C->>Svc: POST /api/ventas con 2 items, el 2 sin stock
    Svc->>Tx: withTransaction(work)
    Tx->>DB: BEGIN
    Svc->>DB: INSERT sales
    Svc->>DB: INSERT product_sales de la linea 1
    Svc->>DB: UPDATE products descuenta stock de la linea 1
    Svc->>Svc: no hay stock para el item 2
    Svc--xTx: throw AppError 400 Insufficient stock
    Tx->>DB: ROLLBACK
    Tx--xC: re-lanza el error
    Note over DB: Nada se persiste ni venta ni lineas ni stock descontado

Pregunta que responde: si una línea falla a mitad del create, ¿qué se deshace exactamente?

El orden de los locks (por qué importa)

Con transacciones, dos peticiones simultáneas sobre las mismas filas pueden bloquearse mutuamente: es el deadlock. El ISS lo resuelve imponiendo un orden canónico de locks que todos los flujos respetan:

sales  →  products  →  product_sales

(siempre en ese orden, en todos los flujos)

Pregunta que responde: ¿por qué todos los flujos bloquean las tablas en el mismo orden y qué pasa si no lo hacen?

Además de la inmutabilidad de sale_id/product_id (el DTO de update de una línea solo expone quantity), el ISS aplica una segunda regla: los productos se bloquean de menor a mayor product_id. Si dos ventas simultáneas trajeran los mismos productos en distinto orden, ascender siempre por el id evita que una espere a la otra en sentido contrario.

flowchart TD
    A["withTransaction"] --> B["Bloquear primero la fila 'sales' FOR UPDATE"]
    B --> C["Bloquear 'products' de menor a mayor id"]
    C --> D["Insertar/actualizar 'product_sales'"]
    D --> E{"¿Todo correcto?"}
    E -- "Sí" --> F["COMMIT"]
    E -- "No" --> G["ROLLBACK y re-lanzar error"]

Pregunta que responde: ¿en qué orden concreto se adquieren los locks dentro de una transacción de venta?

Anatomía del código

Despiece archivo por archivo. Aquí no se repite el código (el ## Recorrido del ISS, paso a paso lo inserta verbatim): se explica qué hace cada pieza, por qué existe y con quién se conecta.

Archivo: src/features/business/sales/sale.model.ts

Propósito

Definir el modelo Sequelize de la cabecera de la venta (Sale, tabla sales): sus columnas, tipos y el valor por defecto de status.

Explicación

  • La interfaz SaleI describe la forma de la fila: sale_date, subtotal, tax, discounts, total, client_id y status.
  • Los importes se declaran DECIMAL(12, 2) con defaultValue: 0, no FLOAT: el dinero no se representa en coma flotante para evitar errores de redondeo.
  • client_id es allowNull: false más una relación que se declara en sales.associations.ts (el modelo no declara la FK por sí solo).
  • status es ENUM("active", "inactive") con defaultValue: "inactive" como fail-safe: una fila insertada sin estado explícito no queda visible en la API. La vía de creación siempre envía "active".

Se conecta con

  • Entrada: lo instancian SalesRepository y sales.seeder.ts.
  • Salida: sequelize (conexión), y sales.associations.ts para las relaciones.

Archivo: src/features/business/product-sales/product-sale.model.ts

Propósito

Definir el modelo de la línea de venta (ProductSale, tabla product_sales), el detalle N:M entre Sale y Product.

Explicación

  • Guarda sale_id, product_id, quantity, unit_price y line_total, más status.
  • unit_price es un snapshot del precio aplicado al vender: aunque products.price cambie mañana, la línea conserva el precio histórico. Es una desnormalización intencional y correcta.
  • line_total = quantity × unit_price se calcula en el service, nunca lo envía el cliente.

Se conecta con

  • Entrada: ProductSalesRepository, sales.seeder.ts (indirectamente) y product-sales.seeder.ts.
  • Salida: sequelize; las asociaciones viven en product-sales.associations.ts.

Archivo: src/features/business/product-sales/product-sales.associations.ts

Propósito

Declarar las relaciones de la línea con la venta y con el producto.

Explicación

  • ProductSale.belongsTo(Sale, … as: "sale") y ProductSale.belongsTo(Product, … as: "product").
  • Sale.hasMany(ProductSale, … as: "items") y Product.hasMany(ProductSale, … as: "sale_items").
  • Se importa por side-effect (sin usar el export) desde config/index.ts: registrar las asociaciones es el único efecto que se busca.

Se conecta con

  • Entrada: se importa en config/index.ts y en seeders/index.ts.
  • Salida: sale.model.ts y product.model.ts.

Archivo: src/features/business/sales/sales.associations.ts

Propósito

Declarar la relación Client ↔ Sale.

Explicación

  • Sale.belongsTo(Client, { foreignKey: "client_id", as: "client" }) y Client.hasMany(Sale, … as: "sales").
  • La norma de FK del proyecto es singular de la tabla referenciada + _id (client_id, sale_id, product_id).
  • Igual que el de product-sales, es un archivo de side-effect: se importa desde config/index.ts.

Se conecta con

  • Entrada: config/index.ts, seeders/index.ts.
  • Salida: sale.model.ts, client.model.ts.

Archivo: src/features/business/product-sales/product-sales.repository.ts

Propósito

Ser la única capa que habla con Sequelize sobre el modelo ProductSale.

Explicación

  • findAllActive, findById, findByIdForUpdate (con SELECT … FOR UPDATE), findActiveBySaleId (para recalcular), create, update, delete, deleteBySaleId y deactivateBySaleId.
  • Todos los métodos que escriben aceptan una transaction?: Transaction opcional: cuando se les pasa, participan de la unit of work; cuando no, operan sueltos (lo que solo se usa en lecturas).

Se conecta con

  • Entrada: ProductSalesService y SalesService.
  • Salida: product-sale.model.ts.

Archivo: src/features/business/product-sales/product-sales.service.ts

Propósito

Aplicar las reglas de negocio de una línea: validar venta y producto activos, controlar stock, restaurar stock al borrar y recalcular los totales de la venta, todo dentro de una transacción.

Explicación

  • create: bloquea la venta (findByIdForUpdate), exige que esté active; bloquea el producto, exige active y stock suficiente; calcula unit_price y line_total; inserta la línea; descuenta stock (quantity - solicitada); y llama a recalcSaleTotals.
  • updatePut / updatePatch: usan lockLine para bloquear en el orden canónico, aplican el delta de cantidad con applyQuantityDelta y recalculan.
  • deletePhysical: devuelve el stock (+ productSale.quantity) y borra la línea; recalcula.
  • deleteLogical: igual, pero en lugar de borrar pone status = "inactive".
  • lockLine es el helper clave anticompromiso: lee la línea sin lock para descubrir sale_id y product_id (inmutables), luego bloquea venta → producto → línea en ese orden.
  • recalcSaleTotals: subtotal = suma de line_total de líneas activas; total = subtotal + tax − discounts.
  • Cualquier regla incumplida se expresa con AppError(400/404, …), no con errores genéricos.

Se conecta con

  • Entrada: ProductSalesController.
  • Salida: ProductSalesRepository, SalesRepository, ProductsRepository, withTransaction, AppError, sus DTOs.

Archivo: src/features/business/product-sales/product-sales.controller.ts

Propósito

Traducir HTTP ↔ service para las líneas de venta. Nada de negocio aquí.

Explicación

  • Extiende BaseController y delega el manejo de errores en this.run(res, …).
  • getAll, getOne, create, updatePut, updatePatch, deletePhysical y deleteLogical.
  • Los status HTTP que emite: 200 en lecturas/updates/borrados, 201 en create.
  • Devuelve la línea empaquetada como { product_sale } (o { product_sales } en el listado).

Se conecta con

  • Entrada: ProductSalesRoutes.
  • Salida: ProductSalesService y BaseController.

Archivo: src/features/business/product-sales/product-sales.routes.ts

Propósito

Montar las rutas /api/detalle-ventas.

Explicación

  • Instancia el controller y expone routes(app).
  • Rutas: GET /api/detalle-ventas, GET /api/detalle-ventas/:id, POST /api/detalle-ventas, PUT/PATCH /api/detalle-ventas/:id, DELETE /api/detalle-ventas/:id y PATCH /api/detalle-ventas/:id/deactivate (borrado lógico).
  • El comentario destacado es literal: son rutas sin autenticación ni middleware JWT.

Se conecta con

  • Entrada: Routes (src/routes/index.ts) y App (src/config/index.ts).
  • Salida: ProductSalesController.

Archivo: src/features/business/sales/sales.repository.ts

Propósito

Acceso a Sequelize del modelo Sale, incluyendo la hidratación de líneas.

Explicación

  • findAllActiveWithItems y findWithItemsById usan include: [{ model: ProductSale, as: "items" }].
  • findByIdForUpdate bloquea la fila de la venta (necesario para el orden canónico de locks).
  • findWithItemsById no filtra por status: el filtro de borrado lógico es negocio y vive en el service, así que lo reutilizan tanto las lecturas como la purga.

Se conecta con

  • Entrada: SalesService.
  • Salida: sale.model.ts, product-sale.model.ts.

Archivo: src/features/business/sales/sales.service.ts

Propósito

El corazón del ISS: orquestar cuatro repositories en una sola transacción para crear, actualizar y borrar ventas.

Explicación

  • El constructor recibe SalesRepository, ProductSalesRepository, ProductsRepository y ClientsRepository: es el ejemplo canónico de unit of work.
  • create: exige al menos un ítem; valida cliente activo; ordena los ítems por product_id; bloquea cada producto, valida estado y stock; calcula unit_price/line_total y el subtotal; inserta la cabecera; inserta las líneas y descuenta stock; devuelve { sale, items }.
  • updatePut / updatePatch: reemplazan o ajustan solo la cabecera; subtotal y total nunca vienen del cliente.
  • deletePhysical / deleteLogical: restauran el stock de las líneas activas (releaseStockOfActiveLines), borran/desactivan líneas y venta; el lógico relee con findWithItemsById para devolver las líneas ya inactivas.
  • findOrFail: aplica la política de borrado lógico; assertActiveClient: valida client_id.
  • releaseStockOfActiveLines: bloquea los productos en orden ascendente de id (mismo orden que create) para no interbloquear.

Se conecta con

  • Entrada: SalesController.
  • Salida: los cuatro repositories, withTransaction, AppError, toSaleResponse y toProductSaleResponse.

Archivo: src/features/business/sales/sales.controller.ts y sales.routes.ts

Propósito

Capa HTTP de la venta: leer req, llamar al service y responder; montar /api/ventas.

Explicación

  • El controller extiende BaseController y usa this.run para delegar errores. create devuelve 201 con { sale, items }.
  • Las rutas son las mismas operaciones que las de líneas, pero bajo /api/ventas, más PATCH /api/ventas/:id/deactivate para el borrado lógico.
  • Como en el detalle, el comentario del archivo deja claro que no hay JWT todavía.

Se conecta con

  • Entrada: Routes / App.
  • Salida: SalesService.

Archivo: src/features/business/*/dto/*.ts

Propósito

Definir los contratos de entrada y salida de cada operación, un archivo por operación.

Explicación

  • create-*.dto.ts, update-*.dto.ts, patch-*.dto.ts, *-response.dto.ts y un index.ts que reexporta.
  • En ProductSale, el DTO de update solo expone quantity: status se excluye a propósito porque desactivar una línea exige restaurar stock y recalcular, y eso solo lo garantiza DELETE …/deactivate.
  • En Sale, CreateSaleDto incluye items: SaleItemDto[] (cabecera + líneas en una sola petición) y SaleResponseDto añade items? opcional.
  • Los mappers toProductSaleResponse / toSaleResponse convierten la instancia de Sequelize en objeto plano: el service nunca devuelve el modelo.

Se conecta con

  • Entrada: controllers (req.body as …) y services.
  • Salida: los propios services y la documentación Swagger.

Archivo: src/features/business/*/**.seeder.ts

Propósito

Insertar datos falsos de sales y product_sales para poder probar la API.

Explicación

  • seedSales inserta solo cabeceras (sin ítems) usando clientes activos; seedProductSales inserta las líneas, reduce stock y recalcula los totales de cada venta.
  • Ambos son idempotentes: si la tabla ya tiene filas, se omiten.
  • seedProductSales abre su propia transacción por línea y replica la regla de recalcSaleTotals porque escribe directo en la BD sin pasar por el service.
  • El orden importa: las ventas se siembran antes que las líneas (padres → hijos).

Se conecta con

  • Entrada: runAllSeeders (src/database/seeders/index.ts) y resolveSeedCounts (counts.ts).
  • Salida: los modelos Sale, ProductSale, Product, Client y sequelize.

Archivo: src/features/business/*/**.swagger.ts

Propósito

Documentar OpenAPI de ambos features, sin montarlo aquí.

Explicación

  • Exportan objetos con tags y paths que el registry externo (src/swagger/index.ts) recoge y combina.
  • Declaran security: bearerSecurity y respuestas 401/403 como modalidad futura (JWT + RBAC); hoy las rutas están abiertas.
  • Los tags que añaden al documento son Ventas y DetalleVentas.

Se conecta con

  • Entrada: src/swagger/index.ts.
  • Salida: shared/http/swagger-security.

Archivo: src/shared/database/with-transaction.ts

Propósito

El helper de unit of work que envuelve todo el bloque transaccional.

Explicación

  • Abre sequelize.transaction(), ejecuta work(transaction), hace commit si todo va bien y rollback si algo lanza, re-lanzando después el error.
  • Aunque el archivo se creó en ISS-03, este ISS es su primer uso real; toda la lógica transaccional de ventas pasa por él.

Se conecta con

  • Entrada: SalesService y ProductSalesService.
  • Salida: sequelize.

Flujos

Flujo de capas de una petición

El recorrido obligatorio no cambia porque haya transacciones: se respeta capa por capa.

flowchart TD
    A["HTTP request"] --> B["Routes: /api/ventas o /api/detalle-ventas"]
    B --> C["Controller: lee req y llama al service"]
    C --> D["Service: reglas + withTransaction"]
    D --> E["Repository: Sequelize"]
    E --> F["Model"]
    F --> G["Sequelize"]
    G --> H["BD"]
    D -.-> I["AppError si falla una regla"]
    C -.-> J["BaseController.run responde JSON de error"]

Pregunta que responde: ¿por qué capas viaja una petición de venta y en qué punto entra la transacción?

Flujo de la creación de una venta

flowchart TD
    A["POST /api/ventas"] --> B{"¿items trae al menos 1?"}
    B -- "No" --> X["400 Sale requires at least one item"]
    B -- "Sí" --> C["BEGIN"]
    C --> D["Validar cliente activo"]
    D --> E["Ordenar items por product_id"]
    E --> F["Bloquear productos y validar stock"]
    F -- "Sin stock" --> Y["400 Insufficient stock -> ROLLBACK"]
    F -- "OK" --> G["INSERT sales"]
    G --> H["INSERT product_sales + UPDATE stock"]
    H --> I["COMMIT"]
    I --> J["201 { sale, items }"]

Pregunta que responde: ¿qué validaciones y escrituras ocurren, en qué orden, hasta que una venta responde 201?

Flujo del borrado lógico en cascada

stateDiagram-v2
    [*] --> activa: POST venta (status active)
    activa --> activa: PUT o PATCH de cabecera
    activa --> inactiva: PATCH deactivate (restaura stock y desactiva lineas)
    activa --> [*]: DELETE fisico (restaura stock y borra filas)
    inactiva --> [*]: DELETE fisico de purga

Pregunta que responde: ¿qué estados atraviesa una venta y qué hace deactivate con su stock?

Modelo de relaciones

erDiagram
    CLIENTS ||--o{ SALES : "compra"
    SALES ||--o{ PRODUCT_SALES : "contiene"
    PRODUCTS ||--o{ PRODUCT_SALES : "aparece en"
    CLIENTS {
        int id PK
        string status
    }
    SALES {
        int id PK
        int client_id FK
        decimal subtotal
        decimal tax
        decimal discounts
        decimal total
        string status
    }
    PRODUCT_SALES {
        int id PK
        int sale_id FK
        int product_id FK
        int quantity
        decimal unit_price
        decimal line_total
        string status
    }

Pregunta que responde: ¿cómo se relacionan clientes, ventas, productos y líneas de venta?

Comandos explicados

Ningún comando va sin su esquema COMANDO → QUÉ HACE → POR QUÉ → QUÉ CREA → RESULTADO → CÓMO VERIFICARLO. Aquí los esenciales del ISS; el detalle verbatim está en el recorrido.

Preparar las carpetas de pruebas HTTP

COMANDO
   ↓
mkdir -p src/features/business/sales/http
mkdir -p src/features/business/product-sales/http
   ↓
QUÉ HACE
   Crea las carpetas `http/` de cada feature.
   ↓
POR QUÉ SE NECESITA
   Los archivos `.http` (peticiones de prueba) viven en una subcarpeta propia
   dentro del feature, no en una carpeta global.
   ↓
QUÉ CREA O MODIFICA
   Dos carpetas nuevas (vacías).
   ↓
RESULTADO ESPERADO
   Los directorios existen antes de escribir los `.http`.
   ↓
CÓMO VERIFICARLO
   `ls src/features/business/sales/http`

Verificar tipos

COMANDO
   ↓
npx tsc --noEmit
   ↓
QUÉ HACE
   Compila el proyecto con TypeScript sin emitir archivos: solo comprueba tipos.
   ↓
POR QUÉ SE NECESITA
   Es el primer filtro de calidad: detecta imports rotos, DTOs mal tipados o
   firmas incompatibles antes de arrancar el servidor.
   ↓
QUÉ CREA O MODIFICA
   Nada (no emite salida).
   ↓
RESULTADO ESPERADO
   Termina sin imprimir errores.
   ↓
CÓMO VERIFICARLO
   El comando no devuelve errores; con errores, cada uno indica archivo y línea.

Sembrar la base de datos

COMANDO
   ↓
npm run db:seed
   ↓
QUÉ HACE
   Ejecuta el SeedersRunner: sincroniza el esquema y siembra clientes, tipos,
   productos, ventas y líneas.
   ↓
POR QUÉ SE NECESITA
   Las líneas necesitan ventas y productos activos; sin datos, la API no tiene
   nada que devolver.
   ↓
QUÉ CREA O MODIFICA
   Filas en `clients`, `product_types`, `products`, `sales` y `product_sales`.
   ↓
RESULTADO ESPERADO
   La consola informa de cuántos registros insertó cada seeder.
   ↓
CÓMO VERIFICARLO
   `curl -s http://localhost:4000/api/ventas | head` devuelve ventas.

Probar la creación transaccional

COMANDO
   ↓
curl -s -X POST http://localhost:4000/api/ventas -H 'Content-Type: application/json' \
  -d '{"client_id":1,"tax":0,"discounts":0,"items":[{"product_id":1,"quantity":2}],"status":"active"}'
   ↓
QUÉ HACE
   Envía una venta con una línea al endpoint transaccional.
   ↓
POR QUÉ SE NECESITA
   Es la única forma directa de comprobar que cabecera + líneas + stock se
   escriben juntos.
   ↓
QUÉ CREA O MODIFICA
   Una fila en `sales`, una o más en `product_sales` y un descuento de stock.
   ↓
RESULTADO ESPERADO
   `201` con `{ sale, items }` y los totales calculados.
   ↓
CÓMO VERIFICARLO
   Repite el POST con `quantity` mayor que el stock y comprueba que responde
   `400` sin haber dejado rastro en `sales`.

Arrancar el servidor

COMANDO
   ↓
npm run dev
   ↓
QUÉ HACE
   Arranca Express; primero conecta y sincroniza la BD, después abre el puerto.
   ↓
POR QUÉ SE NECESITA
   Es la condición final del ISS: el servidor debe quedar operativo.
   ↓
QUÉ CREA O MODIFICA
   Abre el puerto (4000 por defecto) y expone `/api/docs`.
   ↓
RESULTADO ESPERADO
   Mensaje de servidor ejecutándose, sin errores de conexión ni de FK.
   ↓
CÓMO VERIFICARLO
   Abrir `http://localhost:4000/api/docs` y ver los tags `Ventas` y `DetalleVentas`.

Pregunta que responde: ¿qué comandos cierran el ISS y qué debe devolver cada uno?

Recorrido del ISS, paso a paso

A partir de aquí viene el contenido técnico completo del ISS, verbatim: su objetivo, sus 13 subsecciones, sus 100 bloques de código y sus 9 criterios de aceptación, sin resumir y sin reformatear. Solo se han degradado los encabezados un nivel para que aniden bajo esta sección, y se han ajustado los enlaces relativos para que abran bien desde docs/aprendizaje/ (apuntan a ../manual/). Es el material más extenso del curso: úsalo como referencia de consulta sub-ítem a sub-ítem, sin intentar leerlo de una sola vez.

Fase I: Business — ISS-08 — Feature Sale + feature ProductSale (ventas)

Contexto mínimo (autosuficiente). Si abres este archivo aislado —o lo llevas a otra IA—, esto es lo que hay que saber antes de ejecutarlo. - Proyecto: app-storelab-express-ii — Express 5 + TypeScript + Sequelize, arquitectura por features, Fase I solo Business (sin auth ni roles). - Recorrido obligatorio de una petición: HTTP → Controller → Service → Repository → Model → Sequelize → BD. Ninguna capa se salta a la siguiente. - Diseño de datos (columnas, tipos, FK RESTRICT, índices, transacciones): ../bd-storelab.md, Fase I — Business. - Capas, convenciones y reglas transversales: 00-contexto.md.

Este ISS
Título Feature Sale + feature ProductSale (ventas)
Feature / tabla sales, product_sales → sales/, product-sales/
API /api/ventas, /api/detalle-ventas
Depende de ISS-07 — Feature Product · ISS-03 — Feature Client (CRUD por capas)
Habilita Cierre del laboratorio

Contenido de este ISS

  • 13.1 Modelos Sale y feature ProductSale (product-sales/)
  • 13.1b Associations ProductSale
  • 13.2b DTO + Repository + Service + Controller ProductSale
  • 13.3b Routes ProductSale (/api/detalle-ventas)
  • 13.4b Seeder ProductSale
  • 13.6b Swagger ProductSale
  • 13.2 DTO + Repository + Service + Controller + routes
  • 13.3 HTTP
  • 13.4 Cableado
  • 13.5 Relaciones Sale / ProductSale / Client / Product (obligatorio)
  • 13.6 Seeder + Swagger Sale
  • 13.7 Estado final de agregadores (reemplazar / alinear)

Orden de lectura de este ISS. Primero se construye el detalle (product-sales/, sub-ítems con sufijo b: 13.1b, 13.2b, 13.3b, 13.4b, 13.6b) y después la cabecera (sales/: 13.2 … 13.7), porque la venta transaccional necesita antes la línea que mueve stock y recalcula totales. El número de sección se conserva igual que en el manual original; el sufijo b marca «segundo feature de este mismo ISS».


Objetivo: ventas con ítems N:M vía feature propio product-sales/ (tabla product_sales, API /api/detalle-ventas; detalle: quantity, unit_price, line_total); create transaccional de cabecera+ítems en /api/ventas con stock.
Bloqueado por: ISS-07 (+ Client ISS-03).
API: /api/ventas (cabecera) y /api/detalle-ventas (líneas) — SIN AUTH.

Criterios de aceptación (ISS-08)

  • [ ] Feature propio src/features/business/product-sales/ (model, repository, service, controller, routes, seeder, swagger, http, associations)
  • [ ] Rutas /api/detalle-ventas montadas en aggregators
  • [ ] 13.1 Modelos sale + feature product-sales/ (tabla product_sales)
  • [ ] 13.2 DTOs (dto/) + Repository + Service + Controller de Sale y ProductSale en orden getAll, getOne, create, update PUT/PATCH, delete físico y lógico (el create de Sale sigue siendo transaccional: cliente activo, stock, totales)
  • [ ] 13.3 Routes /api/ventas + /api/detalle-ventas + http/ en ese mismo orden
  • [ ] 13.4 Seeders: sales (cabeceras) → product_sales (líneas); clave product_sales en SeedCounts; swagger registry
  • [ ] 13.5 Relaciones Client↔Sale (sales.associations) y Sale↔ProductSale↔Product (product-sales.associations)
  • [ ] 13.6 Swagger Sale + ProductSale
  • [ ] 13.7 Estado final consolidado (config / routes / seeders / swagger)
mkdir -p src/features/business/sales/http
mkdir -p src/features/business/product-sales/http

13.1 Modelos Sale y feature ProductSale (product-sales/)

: > src/features/business/sales/sale.model.ts
cat >> src/features/business/sales/sale.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";

export interface SaleI {
  id?: number;
  sale_date: Date | string;
  subtotal: number;
  tax: number;
  discounts: number;
  total: number;
  client_id: number;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class Sale extends Model {
  public id!: number;
  public sale_date!: Date;
  public subtotal!: number;
  public tax!: number;
  public discounts!: number;
  public total!: number;
  public client_id!: number;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

Sale.init(
  {
    sale_date: {
      type: DataTypes.DATE,
      allowNull: false,
      defaultValue: DataTypes.NOW,
    },
    subtotal: {
      type: DataTypes.DECIMAL(12, 2),
      allowNull: false,
      defaultValue: 0,
    },
    tax: {
      type: DataTypes.DECIMAL(12, 2),
      allowNull: false,
      defaultValue: 0,
    },
    discounts: {
      type: DataTypes.DECIMAL(12, 2),
      allowNull: false,
      defaultValue: 0,
    },
    total: {
      type: DataTypes.DECIMAL(12, 2),
      allowNull: false,
      defaultValue: 0,
    },
    client_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      // Fail-safe: una fila insertada sin estado explícito NO queda visible en la API.
      // La vía de creación de la API siempre envía "active".
      defaultValue: "inactive",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "Sale",
    tableName: "sales",
    timestamps: true,
  }
);
EOF
: > src/features/business/product-sales/product-sale.model.ts
cat >> src/features/business/product-sales/product-sale.model.ts << 'EOF'
import { DataTypes, Model } from "sequelize";
import { sequelize } from "../../../database/db";

/**
 * Detalle N:M Sale ↔ Product (tabla `product_sales`).
 * Opción A lab: feature propio `product-sales/`; nombre de tabla `product_sales`.
 * `unit_price` = snapshot del precio al vender; `line_total` = quantity × unit_price.
 */
export interface ProductSaleI {
  id?: number;
  sale_id: number;
  product_id: number;
  quantity: number;
  unit_price: number;
  line_total: number;
  status: "active" | "inactive";
  createdAt?: Date;
  updatedAt?: Date;
}

export class ProductSale extends Model {
  public id!: number;
  public sale_id!: number;
  public product_id!: number;
  public quantity!: number;
  public unit_price!: number;
  public line_total!: number;
  public status!: "active" | "inactive";
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
}

ProductSale.init(
  {
    sale_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    product_id: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    quantity: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
    unit_price: {
      type: DataTypes.DECIMAL(12, 2),
      allowNull: false,
    },
    line_total: {
      type: DataTypes.DECIMAL(12, 2),
      allowNull: false,
    },
    status: {
      type: DataTypes.ENUM("active", "inactive"),
      // Fail-safe: una fila insertada sin estado explícito NO queda visible en la API.
      // La vía de creación de la API siempre envía "active".
      defaultValue: "inactive",
      allowNull: false,
    },
  },
  {
    sequelize,
    modelName: "ProductSale",
    tableName: "product_sales",
    timestamps: true,
  }
);
EOF

13.1b Associations ProductSale

: > src/features/business/product-sales/product-sales.associations.ts
cat >> src/features/business/product-sales/product-sales.associations.ts << 'EOF'
import { ProductSale } from "./product-sale.model";
import { Sale } from "../sales/sale.model";
import { Product } from "../products/product.model";

ProductSale.belongsTo(Sale, { foreignKey: "sale_id", as: "sale" });
ProductSale.belongsTo(Product, { foreignKey: "product_id", as: "product" });
Sale.hasMany(ProductSale, { foreignKey: "sale_id", as: "items" });
Product.hasMany(ProductSale, { foreignKey: "product_id", as: "sale_items" });
EOF

13.2b DTO + Repository + Service + Controller ProductSale

Recordemos el flujo por capas del feature:

HTTP (routes) -> Controller -> Service -> Repository -> Model -> Sequelize -> BD

Orden canónico de locks (concurrencia). Una línea de venta toca tres tablas, así que hay que bloquear las filas siempre en el mismo orden: sales -> products -> product_sales. Si cada flujo bloquea en un orden distinto, dos peticiones simultáneas se esperan en ciclo y InnoDB mata una con Deadlock found when trying to get lock.

Problema Por qué pasa Cómo se evita
Inversión de orden create bloquea products y luego inserta en product_sales; update*/delete* bloqueaban product_sales y luego products Helper lockLine(): todos los flujos pasan por el mismo orden
Upgrade S->X por FK El INSERT/UPDATE en product_sales deja un S-lock en la fila padre sales (chequeo de FK) y recalcSaleTotals pide luego un X-lock sobre ella Bloquear sales con FOR UPDATE antes de tocar product_sales

sale_id y product_id son inmutables (el DTO de update solo expone quantity y status), por eso lockLine() los lee sin lock para saber qué filas padre bloquear primero.

DTOs — carpeta dto/ (un archivo por operación)

mkdir -p src/features/business/product-sales/dto

dto/create-product-sale.dto.ts

: > src/features/business/product-sales/dto/create-product-sale.dto.ts
cat >> src/features/business/product-sales/dto/create-product-sale.dto.ts << 'EOF'
/** Datos de entrada de `POST /api/detalle-ventas`. */
export interface CreateProductSaleDto {
  sale_id: number;
  product_id: number;
  quantity: number;
  /** Opcional: por defecto `active`. Tras crearla, el estado sólo cambia con el borrado lógico. */
  status?: "active" | "inactive";
}
EOF

dto/update-product-sale.dto.ts

: > src/features/business/product-sales/dto/update-product-sale.dto.ts
cat >> src/features/business/product-sales/dto/update-product-sale.dto.ts << 'EOF'
/**
 * Datos de entrada de `PUT /api/detalle-ventas/:id` (reemplazo completo).
 *
 * `status` **no** está aquí a propósito: al desactivar una línea hay que
 * **restaurar stock** y recalcular la venta, y eso lo garantiza el borrado
 * lógico (`DELETE /api/detalle-ventas/:id/deactivate`). Permitir `status` por
 * PUT/PATCH dejaría el stock inconsistente.
 */
export interface UpdateProductSaleDto {
  quantity: number;
}
EOF

dto/patch-product-sale.dto.ts

: > src/features/business/product-sales/dto/patch-product-sale.dto.ts
cat >> src/features/business/product-sales/dto/patch-product-sale.dto.ts << 'EOF'
import { UpdateProductSaleDto } from "./update-product-sale.dto";

/** Datos de entrada de `PATCH /api/detalle-ventas/:id` (actualización parcial). */
export type PatchProductSaleDto = Partial<UpdateProductSaleDto>;
EOF

dto/product-sale-response.dto.ts

: > src/features/business/product-sales/dto/product-sale-response.dto.ts
cat >> src/features/business/product-sales/dto/product-sale-response.dto.ts << 'EOF'
import { ProductSale, ProductSaleI } from "../product-sale.model";

/**
 * Respuesta HTTP de una línea de venta. Lo usan `GET /api/detalle-ventas`,
 * `GET /api/detalle-ventas/:id` y el array `items` de una venta.
 *
 * Regla del DTO de respuesta: la API expone **sólo** lo que declara este tipo.
 * `ProductSale` no guarda campos internos, por eso el contrato coincide hoy con
 * el modelo. Si mañana aparece uno, la proyección se vuelve explícita aquí
 * (`Omit<ProductSaleI, "...">`) y el mapper lo omite.
 */
export type ProductSaleResponseDto = ProductSaleI;

/** Mapper modelo -> DTO de respuesta (objeto plano, sin métodos de Sequelize). */
export function toProductSaleResponse(productSale: ProductSale): ProductSaleResponseDto {
  return productSale.toJSON() as ProductSaleResponseDto;
}
EOF

dto/index.ts

: > src/features/business/product-sales/dto/index.ts
cat >> src/features/business/product-sales/dto/index.ts << 'EOF'
export * from "./create-product-sale.dto";
export * from "./update-product-sale.dto";
export * from "./patch-product-sale.dto";
export * from "./product-sale-response.dto";
EOF
  • product-sales.repository.ts → acceso a ProductSale (líneas de venta).
  • product-sales.service.ts → reglas de negocio: valida venta/producto activos, controla stock, restaura stock al borrar y recalcula subtotal/total de la venta; todo con withTransaction.
  • product-sales.controller.ts → solo HTTP.

Repository

: > src/features/business/product-sales/product-sales.repository.ts
cat >> src/features/business/product-sales/product-sales.repository.ts << 'EOF'
import { CreationAttributes, Transaction } from "sequelize";
import { ProductSale, ProductSaleI } from "./product-sale.model";

/**
 * Capa Repository del feature ProductSales (tabla `product_sales`).
 *
 * Única responsable de hablar con Sequelize (el modelo `ProductSale`).
 */
export class ProductSalesRepository {
  /** Todas las líneas activas. */
  public async findAllActive(): Promise<ProductSale[]> {
    return ProductSale.findAll({ where: { status: "active" } });
  }

  /** Una línea por PK (o `null`). */
  public async findById(
    id: number,
    transaction?: Transaction
  ): Promise<ProductSale | null> {
    return ProductSale.findByPk(id, { transaction });
  }

  /** Una línea por PK bloqueando la fila (`SELECT ... FOR UPDATE`). */
  public async findByIdForUpdate(
    id: number,
    transaction: Transaction
  ): Promise<ProductSale | null> {
    return ProductSale.findByPk(id, {
      transaction,
      lock: transaction.LOCK.UPDATE,
    });
  }

  /** Líneas activas de una venta (para recalcular subtotal/total). */
  public async findActiveBySaleId(
    sale_id: number,
    transaction?: Transaction
  ): Promise<ProductSale[]> {
    return ProductSale.findAll({
      where: { sale_id, status: "active" },
      transaction,
    });
  }

  /** Inserta una línea. */
  public async create(
    data: CreationAttributes<ProductSale>,
    transaction?: Transaction
  ): Promise<ProductSale> {
    return ProductSale.create(data, { transaction });
  }

  /** Persiste cambios sobre una instancia existente. */
  public async update(
    productSale: ProductSale,
    data: Partial<ProductSaleI>,
    transaction?: Transaction
  ): Promise<ProductSale> {
    return productSale.update(data, { transaction });
  }

  /** Elimina físicamente una instancia. */
  public async delete(productSale: ProductSale, transaction?: Transaction): Promise<void> {
    await productSale.destroy({ transaction });
  }

  /** Elimina físicamente todas las líneas de una venta. */
  public async deleteBySaleId(sale_id: number, transaction?: Transaction): Promise<number> {
    return ProductSale.destroy({ where: { sale_id }, transaction });
  }

  /** Desactiva (borrado lógico) todas las líneas de una venta. */
  public async deactivateBySaleId(
    sale_id: number,
    transaction?: Transaction
  ): Promise<number> {
    const [affected] = await ProductSale.update(
      { status: "inactive" },
      { where: { sale_id }, transaction }
    );
    return affected;
  }
}
EOF

Service

: > src/features/business/product-sales/product-sales.service.ts
cat >> src/features/business/product-sales/product-sales.service.ts << 'EOF'
import { Transaction } from "sequelize";
import {
  CreateProductSaleDto,
  PatchProductSaleDto,
  ProductSaleResponseDto,
  UpdateProductSaleDto,
  toProductSaleResponse,
} from "./dto";
import { ProductSale } from "./product-sale.model";
import { ProductSalesRepository } from "./product-sales.repository";
import { SalesRepository } from "../sales/sales.repository";
import { ProductsRepository } from "../products/products.repository";
import { Product } from "../products/product.model";
import { AppError } from "../../../shared/errors/app-error";
import { withTransaction } from "../../../shared/database/with-transaction";

/**
 * Capa Service del feature ProductSales (tabla `product_sales`).
 *
 * Reglas de negocio de una línea de venta: valida venta y producto activos,
 * controla stock, mantiene el stock del producto y recalcula los totales de la
 * venta; todo dentro de una transacción.
 *
 * **Orden canónico de locks**: `sales` -> `products` -> `product_sales`. Todos
 * los flujos del feature lo respetan para evitar deadlocks de InnoDB.
 *
 * Entrada y salida se expresan con **DTOs** (carpeta `dto/`): el service nunca
 * devuelve la instancia de Sequelize, sino un objeto plano de respuesta.
 */
export class ProductSalesService {
  public constructor(
    private readonly repository: ProductSalesRepository = new ProductSalesRepository(),
    private readonly salesRepository: SalesRepository = new SalesRepository(),
    private readonly productsRepository: ProductsRepository = new ProductsRepository()
  ) {}

  // ================== READ ==================
  public async getAll(): Promise<ProductSaleResponseDto[]> {
    const productSales = await this.repository.findAllActive();
    return productSales.map((productSale) => toProductSaleResponse(productSale));
  }

  public async getOne(id: number): Promise<ProductSaleResponseDto> {
    return toProductSaleResponse(await this.findOrFail(id));
  }

  // ================== CREATE ==================
  /** Agrega una línea a una venta existente (ajusta stock y totales). */
  public async create(body: CreateProductSaleDto): Promise<ProductSaleResponseDto> {
    if (!body.sale_id || !body.product_id || !body.quantity || body.quantity < 1) {
      throw new AppError(400, "sale_id, product_id and quantity (>=1) are required");
    }

    return withTransaction(async (t) => {
      // Orden canónico de locks del feature: `sales` -> `products` -> `product_sales`.
      // Bloquear la venta primero evita el deadlock por *upgrade* S->X del FK:
      // el INSERT en `product_sales` dejaría un S-lock en la fila padre `sales`
      // y `recalcSaleTotals` pediría luego un X-lock sobre ella.
      const sale = await this.salesRepository.findByIdForUpdate(body.sale_id, t);
      if (!sale) {
        throw new AppError(404, "Sale not found");
      }
      if (sale.status !== "active") {
        throw new AppError(400, "Sale must be active");
      }

      const product = await this.productsRepository.findByIdForUpdate(body.product_id, t);
      if (!product) {
        throw new AppError(404, "Product not found");
      }
      if (product.status !== "active") {
        throw new AppError(400, "Product must be active");
      }
      if (product.quantity < body.quantity) {
        throw new AppError(
          400,
          `Insufficient stock (available: ${product.quantity}, requested: ${body.quantity})`
        );
      }

      const unit_price = Number(product.price);
      const line_total = unit_price * body.quantity;

      const productSale = await this.repository.create(
        {
          sale_id: body.sale_id,
          product_id: body.product_id,
          quantity: body.quantity,
          unit_price,
          line_total,
          status: body.status ?? "active",
        },
        t
      );

      await this.productsRepository.update(product, { quantity: product.quantity - body.quantity }, t);
      await this.recalcSaleTotals(body.sale_id, t);

      return toProductSaleResponse(productSale);
    });
  }

  // ================== UPDATE ==================
  public async updatePut(
    id: number,
    body: UpdateProductSaleDto
  ): Promise<ProductSaleResponseDto> {
    return withTransaction(async (t) => {
      const { productSale, product } = await this.lockLine(id, t);

      const newQty = Number(body.quantity);
      if (!newQty || newQty < 1) {
        throw new AppError(400, "quantity (>=1) is required");
      }
      if (!product) {
        throw new AppError(404, "Product not found");
      }

      await this.applyQuantityDelta(productSale, product, newQty, t);

      await this.repository.update(
        productSale,
        { quantity: newQty, line_total: Number(productSale.unit_price) * newQty },
        t
      );
      await this.recalcSaleTotals(productSale.sale_id, t);

      return toProductSaleResponse(productSale);
    });
  }

  public async updatePatch(
    id: number,
    body: PatchProductSaleDto
  ): Promise<ProductSaleResponseDto> {
    return withTransaction(async (t) => {
      const { productSale, product } = await this.lockLine(id, t);

      if (body.quantity !== undefined) {
        const newQty = Number(body.quantity);
        if (!newQty || newQty < 1) {
          throw new AppError(400, "quantity must be >= 1");
        }
        if (!product) {
          throw new AppError(404, "Product not found");
        }

        await this.applyQuantityDelta(productSale, product, newQty, t);
        await this.repository.update(
          productSale,
          { quantity: newQty, line_total: Number(productSale.unit_price) * newQty },
          t
        );
      }

      await this.recalcSaleTotals(productSale.sale_id, t);
      return toProductSaleResponse(productSale);
    });
  }

  // ================== DELETE ==================
  /** Eliminación física: restaura stock y recalcula totales de la venta. */
  public async deletePhysical(id: number): Promise<void> {
    await withTransaction(async (t) => {
      // `onlyActive: false` -> también permite purgar líneas ya desactivadas.
      const { productSale, product } = await this.lockLine(id, t, false);

      if (productSale.status === "active" && product) {
        await this.productsRepository.update(
          product,
          { quantity: product.quantity + productSale.quantity },
          t
        );
      }

      const sale_id = productSale.sale_id;
      await this.repository.delete(productSale, t);
      await this.recalcSaleTotals(sale_id, t);
    });
  }

  /** Eliminación lógica -> `status = inactive` (restaura stock y recalcula). */
  public async deleteLogical(id: number): Promise<ProductSaleResponseDto> {
    return withTransaction(async (t) => {
      const { productSale, product } = await this.lockLine(id, t);

      // `lockLine` ya garantiza que la línea está activa, así que se restaura stock.
      if (product) {
        await this.productsRepository.update(
          product,
          { quantity: product.quantity + productSale.quantity },
          t
        );
      }

      await this.repository.update(productSale, { status: "inactive" }, t);
      await this.recalcSaleTotals(productSale.sale_id, t);
      return toProductSaleResponse(productSale);
    });
  }

  // ================== HELPERS DE NEGOCIO ==================
  /**
   * Busca la línea por PK y falla con 404 si no existe.
   *
   * `onlyActive` (por defecto `true`) aplica la política de borrado lógico: una
   * línea `inactive` deja de ser visible para la API, igual que en `getAll`.
   */
  private async findOrFail(id: number, onlyActive = true): Promise<ProductSale> {
    const productSale = await this.repository.findById(id);
    if (!productSale || (onlyActive && productSale.status !== "active")) {
      throw new AppError(404, "Product sale not found");
    }
    return productSale;
  }

  /**
   * Bloquea la venta, el producto y la línea en el **orden canónico del feature**:
   * `sales` -> `products` -> `product_sales`.
   *
   * Bloquear en un orden distinto en cada flujo produce interbloqueos (deadlock
   * de InnoDB) cuando dos peticiones simultáneas tocan las mismas filas. Además,
   * bloquear `sales` antes de insertar/actualizar en `product_sales` evita el
   * *upgrade* S->X que provoca el FK cuando luego se recalcula el total de la venta.
   *
   * `sale_id` y `product_id` son inmutables (el DTO de update solo expone
   * `quantity`), por eso se leen sin lock para descubrirlos antes de bloquear
   * las filas padre. `onlyActive` decide si una línea desactivada cuenta como
   * inexistente (404) o no.
   */
  private async lockLine(
    id: number,
    t: Transaction,
    onlyActive = true
  ): Promise<{ productSale: ProductSale; product: Product | null }> {
    const snapshot = await this.repository.findById(id, t);
    if (!snapshot) {
      throw new AppError(404, "Product sale not found");
    }

    await this.salesRepository.findByIdForUpdate(snapshot.sale_id, t);

    const product = await this.productsRepository.findByIdForUpdate(snapshot.product_id, t);

    const productSale = await this.repository.findByIdForUpdate(id, t);
    if (!productSale || (onlyActive && productSale.status !== "active")) {
      throw new AppError(404, "Product sale not found");
    }

    return { productSale, product };
  }

  /**
   * Ajusta el stock del producto según la diferencia entre la cantidad nueva
   * y la anterior. Falla si no hay stock suficiente para un aumento.
   */
  private async applyQuantityDelta(
    productSale: ProductSale,
    product: Product,
    newQty: number,
    t: Transaction
  ): Promise<void> {
    const delta = newQty - productSale.quantity;
    if (delta > 0 && product.quantity < delta) {
      throw new AppError(
        400,
        `Insufficient stock (available: ${product.quantity}, requested_extra: ${delta})`
      );
    }

    if (delta !== 0) {
      await this.productsRepository.update(product, { quantity: product.quantity - delta }, t);
    }
  }

  /** Recalcula `subtotal` y `total` de la venta a partir de sus líneas activas. */
  private async recalcSaleTotals(sale_id: number, t: Transaction): Promise<void> {
    const items = await this.repository.findActiveBySaleId(sale_id, t);
    const subtotal = items.reduce((sum, row) => sum + Number(row.line_total), 0);

    const sale = await this.salesRepository.findById(sale_id, t);
    if (!sale) {
      return;
    }

    const total = subtotal + Number(sale.tax) - Number(sale.discounts);
    await this.salesRepository.update(sale, { subtotal, total }, t);
  }
}
EOF

Controller

: > src/features/business/product-sales/product-sales.controller.ts
cat >> src/features/business/product-sales/product-sales.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import {
  CreateProductSaleDto,
  PatchProductSaleDto,
  UpdateProductSaleDto,
} from "./dto";
import { ProductSalesService } from "./product-sales.service";

/**
 * Capa Controller del feature ProductSales.
 * Solo HTTP: lee `req`, llama al service y arma la respuesta.
 * El manejo de errores se delega en `run()` (ver `BaseController`).
 */
export class ProductSalesController extends BaseController {
  public constructor(
    private readonly service: ProductSalesService = new ProductSalesService()
  ) {
    super();
  }

  // ================== READ ==================
  public async getAll(_req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product_sales = await this.service.getAll();
      res.status(200).json({ product_sales });
    });
  }

  public async getOne(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product_sale = await this.service.getOne(this.paramId(req));
      res.status(200).json({ product_sale });
    });
  }

  // ================== CREATE ==================
  public async create(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product_sale = await this.service.create(
        req.body as CreateProductSaleDto
      );
      res.status(201).json({ product_sale });
    });
  }

  // ================== UPDATE ==================
  public async updatePut(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product_sale = await this.service.updatePut(
        this.paramId(req),
        req.body as UpdateProductSaleDto
      );
      res.status(200).json({ product_sale });
    });
  }

  public async updatePatch(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product_sale = await this.service.updatePatch(
        this.paramId(req),
        req.body as PatchProductSaleDto
      );
      res.status(200).json({ product_sale });
    });
  }

  // ================== DELETE ==================
  /** Eliminación física (restaura stock). */
  public async deletePhysical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const id = this.paramId(req);
      await this.service.deletePhysical(id);
      res.status(200).json({ message: "Product sale permanently deleted", id });
    });
  }

  /** Eliminación lógica -> `status = inactive` (restaura stock). */
  public async deleteLogical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const product_sale = await this.service.deleteLogical(this.paramId(req));
      res.status(200).json({
        message: "Product sale deactivated (logical delete)",
        product_sale,
      });
    });
  }
}
EOF

13.3b Routes ProductSale (/api/detalle-ventas)

: > src/features/business/product-sales/product-sales.routes.ts
cat >> src/features/business/product-sales/product-sales.routes.ts << 'EOF'
import { Application } from "express";
import { ProductSalesController } from "./product-sales.controller";

export class ProductSalesRoutes {
  public productSalesController: ProductSalesController = new ProductSalesController();

  public routes(app: Application): void {
    // ================== RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT ==================

    // getAll
    app
      .route("/api/detalle-ventas")
      .get(this.productSalesController.getAll.bind(this.productSalesController));

    // getOne
    app
      .route("/api/detalle-ventas/:id")
      .get(this.productSalesController.getOne.bind(this.productSalesController));

    // create
    app
      .route("/api/detalle-ventas")
      .post(this.productSalesController.create.bind(this.productSalesController));

    // update (PUT / PATCH)
    app
      .route("/api/detalle-ventas/:id")
      .put(this.productSalesController.updatePut.bind(this.productSalesController))
      .patch(this.productSalesController.updatePatch.bind(this.productSalesController));

    // delete físico
    app
      .route("/api/detalle-ventas/:id")
      .delete(this.productSalesController.deletePhysical.bind(this.productSalesController));

    // delete lógico
    app
      .route("/api/detalle-ventas/:id/deactivate")
      .patch(this.productSalesController.deleteLogical.bind(this.productSalesController));
  }
}
EOF

13.4b Seeder ProductSale

: > src/features/business/product-sales/product-sales.seeder.ts
cat >> src/features/business/product-sales/product-sales.seeder.ts << 'EOF'
import { faker } from "@faker-js/faker";
import { sequelize } from "../../../database/db";
import { ProductSale } from "./product-sale.model";
import { Sale } from "../sales/sale.model";
import { Product } from "../products/product.model";

/**
 * Seeder del feature ProductSale (tabla `product_sales`).
 * Se invoca desde `src/database/seeders` (SeedersRunner), no desde la App.
 *
 * Requiere ventas y productos activos. Idempotente: si ya hay filas, omite.
 * Recalcula subtotal/total de cada venta afectada y reduce stock.
 */
export async function seedProductSales(count: number): Promise<number> {
  if (count <= 0) {
    console.log("⏭️  product_sales: count=0, se omite");
    return 0;
  }

  const existing = await ProductSale.count();
  if (existing > 0) {
    console.log(`⏭️  product_sales: ya hay ${existing} registro(s), se omite seeder`);
    return 0;
  }

  const sales = await Sale.findAll({ where: { status: "active" } });
  const products = await Product.findAll({ where: { status: "active" } });

  if (sales.length === 0 || products.length === 0) {
    console.log("⏭️  product_sales: faltan ventas o productos activos, se omite seeder");
    return 0;
  }

  let created = 0;
  let saleIndex = 0;

  while (created < count && saleIndex < sales.length * 3) {
    const sale = sales[saleIndex % sales.length];
    saleIndex += 1;

    const t = await sequelize.transaction();
    try {
      const product = products[Math.floor(Math.random() * products.length)];
      const fresh = await Product.findByPk(product.id, {
        transaction: t,
        lock: t.LOCK.UPDATE,
      });
      if (!fresh || fresh.quantity < 1) {
        await t.rollback();
        continue;
      }

      const quantity = Math.min(
        fresh.quantity,
        faker.number.int({ min: 1, max: Math.min(3, fresh.quantity) })
      );
      const unit_price = Number(fresh.price);
      const line_total = unit_price * quantity;

      await ProductSale.create(
        {
          sale_id: sale.id,
          product_id: fresh.id,
          quantity,
          unit_price,
          line_total,
          status: "active",
        },
        { transaction: t }
      );

      await fresh.update(
        { quantity: fresh.quantity - quantity },
        { transaction: t }
      );

      // El seeder escribe directo en la BD (no pasa por el controller/service), así que
      // replica la regla de `recalculateSaleTotals`: subtotal de líneas activas y
      // total = subtotal + tax - discounts.
      const items = await ProductSale.findAll({
        where: { sale_id: sale.id, status: "active" },
        transaction: t,
      });
      const subtotal = items.reduce((sum, row) => sum + Number(row.line_total), 0);
      const saleRow = await Sale.findByPk(sale.id, { transaction: t });
      if (saleRow) {
        const total = subtotal + Number(saleRow.tax) - Number(saleRow.discounts);
        await saleRow.update({ subtotal, total }, { transaction: t });
      }

      await t.commit();
      created += 1;
    } catch {
      await t.rollback();
    }
  }

  console.log(`✅ product_sales: insertados ${created} registro(s) falsos`);
  return created;
}
EOF

13.6b Swagger ProductSale

: > src/features/business/product-sales/product-sales.swagger.ts
cat >> src/features/business/product-sales/product-sales.swagger.ts << 'EOF'
import {
  bearerSecurity,
  forbiddenResponse,
  unauthorizedResponse,
} from "../../../shared/http/swagger-security";

/**
 * Documentación OpenAPI del feature ProductSale (tabla product_sales).
 * Se agrega desde `src/swagger` (registry externo), no se monta aquí.
 *
 * Leyenda: todos los endpoints son **JWT + RBAC** (`authenticate` + `authorize`).
 * La modalidad se declara por operación: aquí heredan el `security` global del documento.
 */

export const productSalesSwagger = {
  tags: [
    {
      name: "DetalleVentas",
      description:
        "CRUD de líneas Sale↔Product (tabla product_sales) — **JWT + RBAC** (authenticate + authorize)",
    },
  ],
  paths: {
    "/api/detalle-ventas": {
      get: {
        tags: ["DetalleVentas"],
        summary: "Listar detalles de venta activos",
        description: "JWT + RBAC — retorna product_sales con status=active",
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": {
            description: "Lista de detalles",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    product_sales: {
                      type: "array",
                      items: { $ref: "#/components/schemas/ProductSale" },
                    },
                  },
                },
              },
            },
          },
        },
      },
      post: {
        tags: ["DetalleVentas"],
        summary: "Agregar línea a una venta",
        description:
          "JWT + RBAC — valida venta/producto activos y stock; crea product_sale, reduce quantity y recalcula totales de la venta",
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/ProductSaleCreate" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "201": {
            description: "Línea creada",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    product_sale: { $ref: "#/components/schemas/ProductSale" },
                  },
                },
              },
            },
          },
          "400": { description: "Validación (venta/producto/stock)" },
          "404": { description: "Venta o producto no encontrado" },
        },
      },
    },
    "/api/detalle-ventas/{id}": {
      get: {
        tags: ["DetalleVentas"],
        summary: "Obtener detalle por id",
        description: "JWT + RBAC — 404 si no existe o tiene borrado lógico",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": {
            description: "Detalle encontrado",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    product_sale: { $ref: "#/components/schemas/ProductSale" },
                  },
                },
              },
            },
          },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
      put: {
        tags: ["DetalleVentas"],
        summary: "Actualizar cantidad de línea (PUT)",
        description: "JWT + RBAC — ajusta stock y recalcula totales",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/ProductSaleUpdate" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Actualizado" },
          "400": { description: "id inválido (entero positivo) o stock insuficiente" },
          "404": { description: "No encontrado" },
        },
      },
      patch: {
        tags: ["DetalleVentas"],
        summary: "Actualizar línea (PATCH — parcial)",
        description: "JWT + RBAC — quantity",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/ProductSalePatch" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Actualizado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
      delete: {
        tags: ["DetalleVentas"],
        summary: "Eliminar línea (físico)",
        description: "JWT + RBAC — restaura stock y recalcula (también si tiene borrado lógico)",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Eliminado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
    },
    "/api/detalle-ventas/{id}/deactivate": {
      patch: {
        tags: ["DetalleVentas"],
        summary: "Eliminar línea (lógico)",
        description: "JWT + RBAC — status = inactive; restaura stock y recalcula totales",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Desactivado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
    },
  },
  components: {
    schemas: {
      ProductSale: {
        type: "object",
        description:
          "Detalle N:M Sale↔Product (tabla product_sales). quantity, unit_price (snapshot), line_total = quantity × unit_price",
        properties: {
          id: { type: "integer", example: 1 },
          sale_id: { type: "integer", example: 1 },
          product_id: { type: "integer", example: 1 },
          quantity: { type: "integer", example: 2 },
          unit_price: { type: "number", example: 100.0 },
          line_total: { type: "number", example: 200.0 },
          status: { type: "string", enum: ["active", "inactive"], example: "active" },
          createdAt: { type: "string", format: "date-time" },
          updatedAt: { type: "string", format: "date-time" },
        },
      },
      ProductSaleCreate: {
        type: "object",
        required: ["sale_id", "product_id", "quantity"],
        properties: {
          sale_id: { type: "integer" },
          product_id: { type: "integer" },
          quantity: { type: "integer", minimum: 1 },
          status: { type: "string", enum: ["active", "inactive"], default: "active" },
        },
      },
      ProductSaleUpdate: {
        type: "object",
        required: ["quantity"],
        properties: {
          quantity: { type: "integer", minimum: 1 },
        },
      },
      ProductSalePatch: {
        type: "object",
        properties: {
          quantity: { type: "integer", minimum: 1 },
        },
      },
    },
  },
};
EOF

HTTP ProductSale get

: > src/features/business/product-sales/http/product-sales.get.http
cat >> src/features/business/product-sales/http/product-sales.get.http << 'EOF'
### Feature ProductSale — GET
### Modalidad JWT + RBAC (SELLER NO tiene concedido `GET /api/detalle-ventas`).
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "seller",
  "password": "Seller123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@sellerToken = {{loginSeller.response.body.$.access_token}}

# @name getProductSales
GET {{baseUrl}}/api/detalle-ventas
Authorization: Bearer {{token}}

###

# @name getProductSale
GET {{baseUrl}}/api/detalle-ventas/1
Authorization: Bearer {{token}}

### 403 — el SELLER no tiene concedido este recurso
GET {{baseUrl}}/api/detalle-ventas
Authorization: Bearer {{sellerToken}}

### 401 — sin token
GET {{baseUrl}}/api/detalle-ventas
EOF

HTTP ProductSale create

: > src/features/business/product-sales/http/product-sales.create.http
cat >> src/features/business/product-sales/http/product-sales.create.http << 'EOF'
### Feature ProductSale — CREATE
### Modalidad JWT + RBAC.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}

# @name createProductSale
POST {{baseUrl}}/api/detalle-ventas
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "sale_id": 1,
  "product_id": 1,
  "quantity": 2,
  "status": "active"
}
EOF

HTTP ProductSale update

: > src/features/business/product-sales/http/product-sales.update.http
cat >> src/features/business/product-sales/http/product-sales.update.http << 'EOF'
### Feature ProductSale — UPDATE
### Modalidad JWT + RBAC. Al desactivar hay que restaurar stock, y eso lo hace
### PATCH {{baseUrl}}/api/detalle-ventas/1/deactivate (no un PUT/PATCH normal).
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}

# @name updateProductSalePut
PUT {{baseUrl}}/api/detalle-ventas/1
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "quantity": 3
}

###

# @name updateProductSalePatch
PATCH {{baseUrl}}/api/detalle-ventas/1
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "quantity": 1
}
EOF

HTTP ProductSale delete

: > src/features/business/product-sales/http/product-sales.delete.http
cat >> src/features/business/product-sales/http/product-sales.delete.http << 'EOF'
### Feature ProductSale — DELETE
### Modalidad JWT + RBAC.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}

# @name deleteProductSalePhysical
DELETE {{baseUrl}}/api/detalle-ventas/1
Authorization: Bearer {{token}}

###

# @name deleteProductSaleLogical
PATCH {{baseUrl}}/api/detalle-ventas/1/deactivate
Authorization: Bearer {{token}}
EOF

13.2 DTO + Repository + Service + Controller + routes

Recordemos el flujo por capas del feature:

HTTP (routes) -> Controller -> Service -> Repository -> Model -> Sequelize -> BD

DTOs — carpeta dto/ (un archivo por operación)

mkdir -p src/features/business/sales/dto

dto/create-sale.dto.ts

: > src/features/business/sales/dto/create-sale.dto.ts
cat >> src/features/business/sales/dto/create-sale.dto.ts << 'EOF'
/** Una línea (producto + cantidad) dentro del `POST /api/ventas`. */
export interface SaleItemDto {
  product_id: number;
  quantity: number;
}

/** Datos de entrada de `POST /api/ventas` (cabecera + líneas, transaccional). */
export interface CreateSaleDto {
  client_id: number;
  tax?: number;
  discounts?: number;
  sale_date?: Date | string;
  /** Opcional: por defecto `active`. Tras crearla, el estado sólo cambia con el borrado lógico. */
  status?: "active" | "inactive";
  items: SaleItemDto[];
}
EOF

dto/update-sale.dto.ts

: > src/features/business/sales/dto/update-sale.dto.ts
cat >> src/features/business/sales/dto/update-sale.dto.ts << 'EOF'
/**
 * Datos de entrada de `PUT /api/ventas/:id` (reemplazo de la cabecera).
 *
 * Semántica de PUT: `tax` y `discounts` que no lleguen valen `0` (la cabecera
 * se reemplaza). En `PATCH` los omitidos se conservan. `subtotal` y `total` no
 * aparecen porque son derivados y los calcula el service.
 *
 * `status` **no** está aquí a propósito: la venta se desactiva (junto con sus
 * líneas) con `DELETE /api/ventas/:id/deactivate`.
 */
export interface UpdateSaleDto {
  /** Si no se envía, el service conserva el cliente actual. */
  client_id?: number;
  /** Si no se envía, se asume 0. */
  tax?: number;
  /** Si no se envía, se asume 0. */
  discounts?: number;
  sale_date?: Date | string;
}
EOF

dto/patch-sale.dto.ts

: > src/features/business/sales/dto/patch-sale.dto.ts
cat >> src/features/business/sales/dto/patch-sale.dto.ts << 'EOF'
import { UpdateSaleDto } from "./update-sale.dto";

/**
 * Datos de entrada de `PATCH /api/ventas/:id` (actualización parcial).
 *
 * Nota: `total` y `subtotal` **no** están aquí a propósito; son derivados y
 * los calcula el service a partir de las líneas.
 */
export type PatchSaleDto = Partial<UpdateSaleDto>;
EOF

dto/sale-response.dto.ts

: > src/features/business/sales/dto/sale-response.dto.ts
cat >> src/features/business/sales/dto/sale-response.dto.ts << 'EOF'
import { Sale, SaleI } from "../sale.model";
import { ProductSaleResponseDto } from "../../product-sales/dto";

/**
 * Respuesta HTTP de una venta. Lo usan `GET /api/ventas`,
 * `GET /api/ventas/:id` y la salida de update/delete lógico.
 *
 * `items` es opcional porque la venta puede leerse con o sin sus líneas
 * (el repository decide el `include`); cuando viaja, cada línea ya es un
 * `ProductSaleResponseDto`, nunca una instancia de Sequelize.
 */
export type SaleResponseDto = SaleI & { items?: ProductSaleResponseDto[] };

/** Salida de `POST /api/ventas`: la cabecera creada y sus líneas. */
export interface CreateSaleResultDto {
  sale: SaleResponseDto;
  items: ProductSaleResponseDto[];
}

/** Mapper modelo -> DTO de respuesta (objeto plano, sin métodos de Sequelize). */
export function toSaleResponse(sale: Sale): SaleResponseDto {
  return sale.toJSON() as SaleResponseDto;
}
EOF

dto/index.ts

: > src/features/business/sales/dto/index.ts
cat >> src/features/business/sales/dto/index.ts << 'EOF'
export * from "./create-sale.dto";
export * from "./update-sale.dto";
export * from "./patch-sale.dto";
export * from "./sale-response.dto";
EOF
  • sales.repository.ts → acceso a Sale (con líneas incluidas para lecturas).
  • sales.service.ts → reglas de negocio de la venta: al menos un ítem, cliente/productos activos, stock, subtotal/total y transacción.
  • sales.controller.ts → solo HTTP.

Repository

: > src/features/business/sales/sales.repository.ts
cat >> src/features/business/sales/sales.repository.ts << 'EOF'
import { CreationAttributes, Transaction } from "sequelize";
import { Sale, SaleI } from "./sale.model";
import { ProductSale } from "../product-sales/product-sale.model";

/**
 * Capa Repository del feature Sales.
 *
 * Única responsable de hablar con Sequelize (el modelo `Sale`).
 * Incluye la hidratación de líneas (`ProductSale`) para las lecturas.
 */
export class SalesRepository {
  /** Ventas activas con sus líneas. */
  public async findAllActiveWithItems(): Promise<Sale[]> {
    return Sale.findAll({
      where: { status: "active" },
      include: [{ model: ProductSale, as: "items" }],
    });
  }

  /**
   * Una venta con sus líneas (o `null`), sin filtrar por `status`.
   *
   * El filtro de borrado lógico es una regla de negocio y vive en el service;
   * por eso este método no lo aplica y lo reutilizan tanto las lecturas
   * (que sí lo exigen) como la purga (`deletePhysical`).
   */
  public async findWithItemsById(id: number, transaction?: Transaction): Promise<Sale | null> {
    return Sale.findByPk(id, {
      include: [{ model: ProductSale, as: "items" }],
      transaction,
    });
  }

  /** Una venta por PK sin líneas (o `null`). */
  public async findById(id: number, transaction?: Transaction): Promise<Sale | null> {
    return Sale.findByPk(id, { transaction });
  }

  /** Una venta por PK bloqueando la fila (`SELECT ... FOR UPDATE`). */
  public async findByIdForUpdate(id: number, transaction: Transaction): Promise<Sale | null> {
    return Sale.findByPk(id, {
      transaction,
      lock: transaction.LOCK.UPDATE,
    });
  }

  /** Inserta la cabecera de una venta. */
  public async create(
    data: CreationAttributes<Sale>,
    transaction?: Transaction
  ): Promise<Sale> {
    return Sale.create(data, { transaction });
  }

  /** Persiste cambios sobre una instancia existente. */
  public async update(
    sale: Sale,
    data: Partial<SaleI>,
    transaction?: Transaction
  ): Promise<Sale> {
    return sale.update(data, { transaction });
  }

  /** Elimina físicamente una instancia. */
  public async delete(sale: Sale, transaction?: Transaction): Promise<void> {
    await sale.destroy({ transaction });
  }
}
EOF

Service

SalesService orquesta cuatro repositories (SalesRepository, ProductSalesRepository, ProductsRepository, ClientsRepository) dentro de una sola transacción (withTransaction): es el ejemplo canónico de unit of work del lab.

: > src/features/business/sales/sales.service.ts
cat >> src/features/business/sales/sales.service.ts << 'EOF'
import { Transaction } from "sequelize";
import {
  CreateSaleDto,
  CreateSaleResultDto,
  PatchSaleDto,
  SaleResponseDto,
  UpdateSaleDto,
  toSaleResponse,
} from "./dto";
import { toProductSaleResponse } from "../product-sales/dto";
import { SalesRepository } from "./sales.repository";
import { ProductSalesRepository } from "../product-sales/product-sales.repository";
import { ProductsRepository } from "../products/products.repository";
import { ClientsRepository } from "../clients/clients.repository";
import { Product } from "../products/product.model";
import { ProductSale } from "../product-sales/product-sale.model";
import { Sale } from "./sale.model";
import { AppError } from "../../../shared/errors/app-error";
import { withTransaction } from "../../../shared/database/with-transaction";

/** Línea calculada en memoria antes de persistir la venta (tipo interno, no DTO). */
type SaleLine = {
  product_id: number;
  quantity: number;
  unit_price: number;
  line_total: number;
  product: Product;
};

/**
 * Capa Service del feature Sales.
 *
 * Reglas de negocio de una venta: exige al menos una línea, valida cliente y
 * productos activos, controla stock, calcula subtotal/total y garantiza
 * atomicidad con una transacción (unit of work).
 *
 * Persistencia delegada en los repositories de Sales, ProductSales, Products y
 * Clients. Entrada y salida se expresan con **DTOs** (carpeta `dto/`): nunca se
 * devuelve una instancia de Sequelize.
 */
export class SalesService {
  public constructor(
    private readonly repository: SalesRepository = new SalesRepository(),
    private readonly productSalesRepository: ProductSalesRepository = new ProductSalesRepository(),
    private readonly productsRepository: ProductsRepository = new ProductsRepository(),
    private readonly clientsRepository: ClientsRepository = new ClientsRepository()
  ) {}

  // ================== READ ==================
  public async getAll(): Promise<SaleResponseDto[]> {
    const sales = await this.repository.findAllActiveWithItems();
    return sales.map((sale) => toSaleResponse(sale));
  }

  public async getOne(id: number): Promise<SaleResponseDto> {
    return toSaleResponse(await this.findOrFail(id));
  }

  // ================== CREATE ==================
  /** Crea la venta completa (cabecera + líneas) en una sola transacción. */
  public async create(body: CreateSaleDto): Promise<CreateSaleResultDto> {
    if (!body.items || !Array.isArray(body.items) || body.items.length === 0) {
      throw new AppError(400, "Sale requires at least one item");
    }

    return withTransaction(async (t) => {
      await this.assertActiveClient(body.client_id, t);

      const lineRows: SaleLine[] = [];
      let subtotal = 0;

      // Orden canónico de locks: se bloquean los productos de menor a mayor
      // `product_id`. Si dos ventas simultáneas traen los mismos productos en
      // distinto orden, bloquear en orden ascendente evita el deadlock.
      const orderedItems = [...body.items].sort(
        (a, b) => Number(a.product_id) - Number(b.product_id)
      );

      for (const item of orderedItems) {
        const product = await this.productsRepository.findByIdForUpdate(item.product_id, t);
        if (!product) {
          throw new AppError(404, `Product not found: ${item.product_id}`);
        }
        if (product.status !== "active") {
          throw new AppError(400, `Product must be active: ${item.product_id}`);
        }
        if (product.quantity < item.quantity) {
          throw new AppError(
            400,
            `Insufficient stock for product ${item.product_id} (available: ${product.quantity}, requested: ${item.quantity})`
          );
        }

        const unit_price = Number(product.price);
        const line_total = unit_price * item.quantity;
        subtotal += line_total;
        lineRows.push({
          product_id: product.id,
          quantity: item.quantity,
          unit_price,
          line_total,
          product,
        });
      }

      const tax = Number(body.tax ?? 0);
      const discounts = Number(body.discounts ?? 0);
      const total = subtotal + tax - discounts;

      const sale = await this.repository.create(
        {
          sale_date: body.sale_date ?? new Date(),
          subtotal,
          tax,
          discounts,
          total,
          client_id: body.client_id,
          status: body.status ?? "active",
        },
        t
      );

      const items: ProductSale[] = [];
      for (const line of lineRows) {
        const productSale = await this.productSalesRepository.create(
          {
            sale_id: sale.id,
            product_id: line.product_id,
            quantity: line.quantity,
            unit_price: line.unit_price,
            line_total: line.line_total,
            status: "active",
          },
          t
        );
        await this.productsRepository.update(
          line.product,
          { quantity: line.product.quantity - line.quantity },
          t
        );
        items.push(productSale);
      }

      return {
        sale: toSaleResponse(sale),
        items: items.map((item) => toProductSaleResponse(item)),
      };
    });
  }

  // ================== UPDATE ==================
  /** PUT: reemplaza la cabecera (las líneas se gestionan en ProductSales). */
  public async updatePut(id: number, body: UpdateSaleDto): Promise<SaleResponseDto> {
    const sale = await this.findOrFail(id);

    if (body.client_id !== undefined) {
      await this.assertActiveClient(body.client_id);
    }

    const tax = Number(body.tax ?? 0);
    const discounts = Number(body.discounts ?? 0);
    const total = Number(sale.subtotal) + tax - discounts;

    await this.repository.update(sale, {
      sale_date: body.sale_date ?? sale.sale_date,
      tax,
      discounts,
      total,
      client_id: body.client_id ?? sale.client_id,
    });

    return toSaleResponse(sale);
  }

  /** PATCH: cambia sólo lo que llega (lo omitido se conserva). */
  public async updatePatch(id: number, body: PatchSaleDto): Promise<SaleResponseDto> {
    const sale = await this.findOrFail(id);

    if (body.client_id !== undefined) {
      await this.assertActiveClient(body.client_id);
    }

    const tax = body.tax !== undefined ? Number(body.tax) : Number(sale.tax);
    const discounts =
      body.discounts !== undefined ? Number(body.discounts) : Number(sale.discounts);
    const needsRecalc = body.tax !== undefined || body.discounts !== undefined;

    // Sólo se aplican los campos del DTO: `total` y `subtotal` siempre los
    // calcula el service, nunca llegan desde el cliente.
    await this.repository.update(sale, {
      sale_date: body.sale_date ?? sale.sale_date,
      client_id: body.client_id ?? sale.client_id,
      tax,
      discounts,
      total: needsRecalc ? Number(sale.subtotal) + tax - discounts : Number(sale.total),
    });

    return toSaleResponse(sale);
  }

  // ================== DELETE ==================
  /** Eliminación física: restaura stock de las líneas activas, líneas y venta. */
  public async deletePhysical(id: number): Promise<void> {
    await withTransaction(async (t) => {
      // Sin filtro de `status`: purga también ventas con borrado lógico.
      // Bloquear la fila de la venta primero respeta el orden canónico del
      // feature: `sales` -> `products` -> `product_sales`.
      const sale = await this.repository.findByIdForUpdate(id, t);
      if (!sale) {
        throw new AppError(404, "Sale not found");
      }

      await this.releaseStockOfActiveLines(id, t);
      await this.productSalesRepository.deleteBySaleId(id, t);
      await this.repository.delete(sale, t);
    });
  }

  /** Eliminación lógica -> `status = inactive` (venta + líneas, restaurando stock). */
  public async deleteLogical(id: number): Promise<SaleResponseDto> {
    return withTransaction(async (t) => {
      const sale = await this.findOrFail(id, t);

      await this.repository.findByIdForUpdate(id, t);
      await this.releaseStockOfActiveLines(id, t);

      await this.repository.update(sale, { status: "inactive" }, t);
      await this.productSalesRepository.deactivateBySaleId(id, t);

      // Relectura para que la respuesta muestre las líneas ya desactivadas.
      const updated = await this.repository.findWithItemsById(id, t);
      return toSaleResponse(updated ?? sale);
    });
  }

  // ================== HELPERS DE NEGOCIO ==================
  /**
   * Devuelve al stock el `quantity` de cada **línea activa** de la venta.
   *
   * Borrar o desactivar una venta equivale a borrar/desactivar sus líneas, así
   * que el stock se restaura igual que en `ProductSalesService`. Las líneas ya
   * inactivas no se tocan: su stock volvió cuando se desactivaron.
   *
   * Los productos se bloquean en orden ascendente de `product_id`, el mismo
   * orden que usa `create`, para que dos transacciones que tocan los mismos
   * productos no se interbloqueen. Es seguro leer las líneas antes de bloquear
   * los productos porque ya se tiene el X-lock de la fila `sales`, y todos los
   * flujos que modifican líneas bloquean antes esa fila.
   */
  private async releaseStockOfActiveLines(sale_id: number, t: Transaction): Promise<void> {
    const lines = await this.productSalesRepository.findActiveBySaleId(sale_id, t);
    const ordered = [...lines].sort((a, b) => a.product_id - b.product_id);

    for (const line of ordered) {
      const product = await this.productsRepository.findByIdForUpdate(line.product_id, t);
      if (!product) {
        continue;
      }
      await this.productsRepository.update(
        product,
        { quantity: product.quantity + line.quantity },
        t
      );
    }
  }

  /**
   * Busca la venta con sus líneas y falla con 404 si no existe o si tiene
   * borrado lógico.
   *
   * Es el único punto donde se aplica la **política de borrado lógico** del
   * feature, así que `getOne`, `updatePut`, `updatePatch` y `deleteLogical`
   * quedan automáticamente consistentes con el filtro de `getAll`.
   *
   * Dos diferencias con los demás features (deliberadas):
   *  - la venta se lee **con sus líneas** (`findWithItemsById`), así que el
   *    segundo parámetro es la `transaction`, no el flag `onlyActive`;
   *  - `deletePhysical` no lo usa: necesita un `SELECT ... FOR UPDATE` propio,
   *    porque debe poder purgar también ventas con borrado lógico.
   */
  private async findOrFail(id: number, transaction?: Transaction): Promise<Sale> {
    const sale = await this.repository.findWithItemsById(id, transaction);
    if (!sale || sale.status !== "active") {
      throw new AppError(404, "Sale not found");
    }
    return sale;
  }

  /** Regla: el cliente de la venta debe existir y estar activo. */
  private async assertActiveClient(
    client_id: number,
    transaction?: Transaction
  ): Promise<void> {
    const client = await this.clientsRepository.findById(client_id, transaction);
    if (!client) {
      throw new AppError(404, "Client not found");
    }
    if (client.status !== "active") {
      throw new AppError(400, "Client must be active");
    }
  }
}
EOF

Controller

: > src/features/business/sales/sales.controller.ts
cat >> src/features/business/sales/sales.controller.ts << 'EOF'
import { Request, Response } from "express";
import { BaseController } from "../../../shared/http/base-controller";
import { CreateSaleDto, PatchSaleDto, UpdateSaleDto } from "./dto";
import { SalesService } from "./sales.service";

/**
 * Capa Controller del feature Sales.
 * Solo HTTP: lee `req`, llama al service y arma la respuesta.
 * El manejo de errores se delega en `run()` (ver `BaseController`).
 */
export class SalesController extends BaseController {
  public constructor(private readonly service: SalesService = new SalesService()) {
    super();
  }

  // ================== READ ==================
  public async getAll(_req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const sales = await this.service.getAll();
      res.status(200).json({ sales });
    });
  }

  public async getOne(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const sale = await this.service.getOne(this.paramId(req));
      res.status(200).json({ sale });
    });
  }

  // ================== CREATE ==================
  public async create(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const { sale, items } = await this.service.create(req.body as CreateSaleDto);
      res.status(201).json({ sale, items });
    });
  }

  // ================== UPDATE ==================
  public async updatePut(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const sale = await this.service.updatePut(
        this.paramId(req),
        req.body as UpdateSaleDto
      );
      res.status(200).json({ sale });
    });
  }

  public async updatePatch(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const sale = await this.service.updatePatch(
        this.paramId(req),
        req.body as PatchSaleDto
      );
      res.status(200).json({ sale });
    });
  }

  // ================== DELETE ==================
  /** Eliminación física (venta + líneas). */
  public async deletePhysical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const id = this.paramId(req);
      await this.service.deletePhysical(id);
      res.status(200).json({ message: "Sale permanently deleted", id });
    });
  }

  /** Eliminación lógica -> `status = inactive` (venta + líneas). */
  public async deleteLogical(req: Request, res: Response): Promise<void> {
    await this.run(res, async () => {
      const sale = await this.service.deleteLogical(this.paramId(req));
      res.status(200).json({
        message: "Sale deactivated (logical delete)",
        sale,
      });
    });
  }
}
EOF
: > src/features/business/sales/sales.routes.ts
cat >> src/features/business/sales/sales.routes.ts << 'EOF'
import { Application } from "express";
import { SalesController } from "./sales.controller";

export class SalesRoutes {
  public salesController: SalesController = new SalesController();

  public routes(app: Application): void {
    // ================== RUTAS SIN AUTENTICACIÓN / SIN MIDDLEWARE JWT ==================

    // getAll
    app
      .route("/api/ventas")
      .get(this.salesController.getAll.bind(this.salesController));

    // getOne
    app
      .route("/api/ventas/:id")
      .get(this.salesController.getOne.bind(this.salesController));

    // create
    app
      .route("/api/ventas")
      .post(this.salesController.create.bind(this.salesController));

    // update (PUT / PATCH)
    app
      .route("/api/ventas/:id")
      .put(this.salesController.updatePut.bind(this.salesController))
      .patch(this.salesController.updatePatch.bind(this.salesController));

    // delete físico
    app
      .route("/api/ventas/:id")
      .delete(this.salesController.deletePhysical.bind(this.salesController));

    // delete lógico
    app
      .route("/api/ventas/:id/deactivate")
      .patch(this.salesController.deleteLogical.bind(this.salesController));
  }
}
EOF


13.3 HTTP

: > src/features/business/sales/http/sales.get.http
cat >> src/features/business/sales/http/sales.get.http << 'EOF'
### Feature Sale — GET ALL / GET ONE
### Modalidad JWT + RBAC (SELLER tiene concedidas ambas lecturas de ventas).
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "seller",
  "password": "Seller123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@sellerToken = {{loginSeller.response.body.$.access_token}}
@id = 1

# @name getAllSales
GET {{baseUrl}}/api/ventas
Authorization: Bearer {{token}}

###

# @name getOneSale
GET {{baseUrl}}/api/ventas/{{id}}
Authorization: Bearer {{token}}

### 200 — SELLER también tiene concedido `GET /api/ventas`
GET {{baseUrl}}/api/ventas
Authorization: Bearer {{sellerToken}}

### 401 — sin token
GET {{baseUrl}}/api/ventas
EOF

: > src/features/business/sales/http/sales.create.http
cat >> src/features/business/sales/http/sales.create.http << 'EOF'
### Feature Sale — CREATE
### Modalidad JWT + RBAC. `POST /api/ventas` la tiene concedida SELLER y ADMIN.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

# @name loginSeller
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "seller",
  "password": "Seller123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@sellerToken = {{loginSeller.response.body.$.access_token}}

### CREATE — admin
# @name createSale
POST {{baseUrl}}/api/ventas
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "client_id": 1,
  "tax": 19,
  "discounts": 5,
  "items": [
    { "product_id": 1, "quantity": 2 },
    { "product_id": 2, "quantity": 1 }
  ]
}

### CREATE — seller (también tiene la concesión) -> 201
POST {{baseUrl}}/api/ventas
Authorization: Bearer {{sellerToken}}
Content-Type: application/json

{
  "client_id": 1,
  "tax": 19,
  "discounts": 0,
  "items": [
    { "product_id": 1, "quantity": 1 }
  ]
}
EOF
: > src/features/business/sales/http/sales.update.http
cat >> src/features/business/sales/http/sales.update.http << 'EOF'
### Feature Sale — UPDATE (PUT) / UPDATE (PATCH) — solo cabecera
### Modalidad JWT + RBAC. `status` no se envía: la venta se desactiva (con sus
### líneas) con PATCH {{baseUrl}}/api/ventas/{{id}}/deactivate.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@id = 1

# @name updateSalePut
PUT {{baseUrl}}/api/ventas/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "client_id": 1,
  "tax": 20,
  "discounts": 10,
  "sale_date": "2026-09-16T12:00:00.000Z"
}

###

# @name updateSalePatch
PATCH {{baseUrl}}/api/ventas/{{id}}
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "tax": 15,
  "discounts": 0
}
EOF
: > src/features/business/sales/http/sales.delete.http
cat >> src/features/business/sales/http/sales.delete.http << 'EOF'
### Feature Sale — DELETE físico / DELETE lógico (status = inactive)
### Modalidad JWT + RBAC.
@baseUrl = http://localhost:4000

# @name loginAdmin
POST {{baseUrl}}/api/sesion/login
Content-Type: application/json

{
  "identifier": "admin",
  "password": "Admin123!"
}

@token = {{loginAdmin.response.body.$.access_token}}
@id = 1

# @name deleteSalePhysical
DELETE {{baseUrl}}/api/ventas/{{id}}
Authorization: Bearer {{token}}

###

# @name deleteSaleLogical
PATCH {{baseUrl}}/api/ventas/{{id}}/deactivate
Authorization: Bearer {{token}}
EOF


13.4 Cableado

PARCHE — src/routes/index.ts: import + salesRoutes.

PARCHE — src/config/index.ts:

  • Debajo de import product.model, añadir:
import "../features/business/sales/sale.model";
import "../features/business/product-sales/product-sale.model";
  • Dentro de routes(), añadir this.routePrv.salesRoutes.routes(this.app);

13.5 Relaciones Sale / ProductSale / Client / Product (obligatorio)

Norma FK: client_id, sale_id, product_id (singular de la tabla referenciada + _id).

: > src/features/business/sales/sales.associations.ts
cat >> src/features/business/sales/sales.associations.ts << 'EOF'
import { Sale } from "./sale.model";
import { Client } from "../clients/client.model";

Sale.belongsTo(Client, { foreignKey: "client_id", as: "client" });
Client.hasMany(Sale, { foreignKey: "client_id", as: "sales" });
EOF
PARCHE — src/config/index.ts ya existe.

Debajo de import "../features/business/products/products.associations";, añadir:

import "../features/business/sales/sales.associations";

Relaciones registradas:

  • Sale.belongsTo(Client) / Client.hasMany(Sale) — en sales.associations.ts
  • ProductSale.belongsTo(Sale|Product) / Sale.hasMany(items) / Product.hasMany(sale_items) — en product-sales.associations.ts

PARCHE — también importar side-effect:

import "../features/business/product-sales/product-sales.associations";

Verificación venta

curl -s -X POST http://localhost:4000/api/ventas -H 'Content-Type: application/json' \
  -d '{"client_id":1,"tax":0,"discounts":0,"items":[{"product_id":1,"quantity":2}],"status":"active"}'
curl -s http://localhost:4000/api/ventas
curl -s http://localhost:4000/api/detalle-ventas

13.6 Seeder + Swagger Sale

: > src/features/business/sales/sales.seeder.ts
cat >> src/features/business/sales/sales.seeder.ts << 'EOF'
import { faker } from "@faker-js/faker";
import { Sale } from "./sale.model";
import { Client } from "../clients/client.model";

/**
 * Seeder del feature Sale (cabeceras).
 * Las líneas `product_sales` las inserta `product-sales.seeder.ts`.
 * Idempotente: si ya hay ventas, no inserta.
 */
export async function seedSales(count: number): Promise<number> {
  if (count <= 0) {
    console.log("⏭️  sales: count=0, se omite");
    return 0;
  }

  const existing = await Sale.count();
  if (existing > 0) {
    console.log(`⏭️  sales: ya hay ${existing} registro(s), se omite seeder`);
    return 0;
  }

  const clients = await Client.findAll({ where: { status: "active" } });
  if (clients.length === 0) {
    console.log("⏭️  sales: faltan clientes activos, se omite seeder");
    return 0;
  }

  const rows = Array.from({ length: count }, () => {
    const client = clients[Math.floor(Math.random() * clients.length)];
    const tax = Number(faker.number.float({ min: 0, max: 20, fractionDigits: 2 }));
    const discounts = Number(faker.number.float({ min: 0, max: 10, fractionDigits: 2 }));
    return {
      sale_date: faker.date.recent({ days: 30 }),
      subtotal: 0,
      tax,
      discounts,
      // Misma regla que `recalculateSaleTotals` (product-sales.service) para una
      // venta sin ítems (`subtotal = 0`). El seeder de líneas la recalcula luego.
      total: tax - discounts,
      client_id: client.id,
      status: "active" as const,
    };
  });

  await Sale.bulkCreate(rows);
  console.log(`✅ sales: insertados ${count} registro(s) falsos (sin ítems)`);
  return count;
}
EOF
: > src/features/business/sales/sales.swagger.ts
cat >> src/features/business/sales/sales.swagger.ts << 'EOF'
import {
  bearerSecurity,
  forbiddenResponse,
  unauthorizedResponse,
} from "../../../shared/http/swagger-security";

/**
 * Documentación OpenAPI del feature Sale.
 * Se agrega desde `src/swagger` (registry externo), no se monta aquí.
 *
 * Leyenda: todos los endpoints son **JWT + RBAC** (`authenticate` + `authorize`).
 * La modalidad se declara por operación: aquí heredan el `security` global del documento.
 */

export const salesSwagger = {
  tags: [
    {
      name: "Ventas",
      description: "CRUD de ventas — **JWT + RBAC** (authenticate + authorize)",
    },
  ],
  paths: {
    "/api/ventas": {
      get: {
        tags: ["Ventas"],
        summary: "Listar ventas activas",
        description: "JWT + RBAC — retorna ventas con status=active e items (ProductSale)",
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": {
            description: "Lista de ventas",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    sales: {
                      type: "array",
                      items: { $ref: "#/components/schemas/SaleWithItems" },
                    },
                  },
                },
              },
            },
          },
        },
      },
      post: {
        tags: ["Ventas"],
        summary: "Crear venta (transaccional)",
        description:
          "JWT + RBAC — valida cliente/productos activos y stock; crea Sale + ProductSale y reduce quantity",
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/SaleCreate" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "201": {
            description: "Venta creada",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    sale: { $ref: "#/components/schemas/Sale" },
                    items: {
                      type: "array",
                      items: { $ref: "#/components/schemas/ProductSale" },
                    },
                  },
                },
              },
            },
          },
          "400": { description: "Validación (cliente/producto/stock)" },
          "404": { description: "Cliente o producto no encontrado" },
        },
      },
    },
    "/api/ventas/{id}": {
      get: {
        tags: ["Ventas"],
        summary: "Obtener venta por id",
        description: "JWT + RBAC — incluye items; 404 si no existe o tiene borrado lógico",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": {
            description: "Venta encontrada",
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: {
                    sale: { $ref: "#/components/schemas/SaleWithItems" },
                  },
                },
              },
            },
          },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
      put: {
        tags: ["Ventas"],
        summary: "Actualizar cabecera de venta (PUT)",
        description:
          "JWT + RBAC — solo tax, discounts, client_id, sale_date, status; recalcula total; no reescribe items",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/SaleUpdate" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Actualizado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
      patch: {
        tags: ["Ventas"],
        summary: "Actualizar cabecera de venta (PATCH)",
        description: "JWT + RBAC — parcial; recalcula total si cambian tax/discounts",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: { $ref: "#/components/schemas/SalePatch" },
            },
          },
        },
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Actualizado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
      delete: {
        tags: ["Ventas"],
        summary: "Eliminar venta (físico)",
        description: "JWT + RBAC — restaura stock de las líneas activas, borra product_sales y luego la venta (transacción; también si tiene borrado lógico)",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Eliminado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
    },
    "/api/ventas/{id}/deactivate": {
      patch: {
        tags: ["Ventas"],
        summary: "Eliminar venta (lógico)",
        description: "JWT + RBAC — status = inactive en venta e items, restaurando stock",
        parameters: [
          {
            name: "id",
            in: "path",
            required: true,
            description: "id numérico (> 0). Si no lo es, la API responde 400.",
            schema: { type: "integer", minimum: 1 },
          },
        ],
        security: bearerSecurity,
        responses: {
          "401": unauthorizedResponse,
          "403": forbiddenResponse,
          "200": { description: "Desactivado" },
          "400": { description: "id inválido (debe ser un entero positivo)" },
          "404": { description: "No encontrado" },
        },
      },
    },
  },
  components: {
    schemas: {
      // ProductSale schema vive en product-sales.swagger.ts (feature propio)
      Sale: {
        type: "object",
        properties: {
          id: { type: "integer", example: 1 },
          sale_date: { type: "string", format: "date-time" },
          subtotal: { type: "number", example: 200.0 },
          tax: { type: "number", example: 19.0 },
          discounts: { type: "number", example: 5.0 },
          total: { type: "number", example: 214.0 },
          client_id: { type: "integer", example: 1 },
          status: { type: "string", enum: ["active", "inactive"], example: "active" },
          createdAt: { type: "string", format: "date-time" },
          updatedAt: { type: "string", format: "date-time" },
        },
      },
      SaleWithItems: {
        allOf: [
          { $ref: "#/components/schemas/Sale" },
          {
            type: "object",
            properties: {
              items: {
                type: "array",
                items: { $ref: "#/components/schemas/ProductSale" },
              },
            },
          },
        ],
      },
      SaleCreate: {
        type: "object",
        required: ["client_id", "items"],
        properties: {
          client_id: { type: "integer" },
          tax: { type: "number", default: 0 },
          discounts: { type: "number", default: 0 },
          sale_date: { type: "string", format: "date-time" },
          status: { type: "string", enum: ["active", "inactive"], default: "active" },
          items: {
            type: "array",
            minItems: 1,
            items: {
              type: "object",
              required: ["product_id", "quantity"],
              properties: {
                product_id: { type: "integer" },
                quantity: { type: "integer", minimum: 1 },
              },
            },
          },
        },
      },
      SaleUpdate: {
        type: "object",
        properties: {
          sale_date: { type: "string", format: "date-time" },
          tax: { type: "number" },
          discounts: { type: "number" },
          client_id: { type: "integer" },
        },
      },
      SalePatch: {
        type: "object",
        properties: {
          sale_date: { type: "string", format: "date-time" },
          tax: { type: "number" },
          discounts: { type: "number" },
          client_id: { type: "integer" },
        },
      },
    },
  },
};
EOF


13.7 Estado final de agregadores (reemplazar / alinear)

Tras ISS-06…08, estos archivos quedan así (podés reemplazar el contenido completo con cat >> si preferís evitar parches acumulados):

src/routes/index.ts

: > src/routes/index.ts
cat >> src/routes/index.ts << 'EOF'
import { ClientsRoutes } from "../features/business/clients/clients.routes";
import { ProductTypesRoutes } from "../features/business/product-types/product-types.routes";
import { ProductsRoutes } from "../features/business/products/products.routes";
import { SalesRoutes } from "../features/business/sales/sales.routes";
import { ProductSalesRoutes } from "../features/business/product-sales/product-sales.routes";

export class Routes {
  public clientsRoutes: ClientsRoutes = new ClientsRoutes();
  public productTypesRoutes: ProductTypesRoutes = new ProductTypesRoutes();
  public productsRoutes: ProductsRoutes = new ProductsRoutes();
  public salesRoutes: SalesRoutes = new SalesRoutes();
  public productSalesRoutes: ProductSalesRoutes = new ProductSalesRoutes();
}
EOF

src/database/seeders/counts.ts

: > src/database/seeders/counts.ts
cat >> src/database/seeders/counts.ts << 'EOF'
/**
 * Cantidad de registros por tabla (snake_case = nombre de tabla BD).
 * Prioridad: CLI (--clients=N) > env (SEED_CLIENTS) > default de este archivo.
 *
 * Cuando agregues features, suma aquí la clave (nombre de tabla) y léela en el runner.
 */
export type SeedCounts = {
  clients: number;
  product_types: number;
  products: number;
  sales: number;
  product_sales: number;
  // users?: number;
  // roles?: number;
};

export const DEFAULT_SEED_COUNTS: SeedCounts = {
  clients: 10,
  product_types: 25,
  products: 15,
  sales: 5,
  product_sales: 12,
};

export function resolveSeedCounts(argv: string[] = process.argv.slice(2)): SeedCounts {
  const counts: SeedCounts = { ...DEFAULT_SEED_COUNTS };

  const envMap: Array<[keyof SeedCounts, string | undefined]> = [
    ["clients", process.env.SEED_CLIENTS],
    ["product_types", process.env.SEED_PRODUCT_TYPES],
    ["products", process.env.SEED_PRODUCTS],
    ["sales", process.env.SEED_SALES],
    ["product_sales", process.env.SEED_PRODUCT_SALES],
  ];
  for (const [key, value] of envMap) {
    if (value !== undefined && value !== "") {
      counts[key] = Number(value);
    }
  }

  for (const arg of argv) {
    const m = arg.match(/^--([a-zA-Z_]+)=(\d+)$/);
    if (!m) continue;
    const key = m[1] as keyof SeedCounts;
    const value = Number(m[2]);
    if (key in counts) {
      counts[key] = value;
    }
  }

  return counts;
}
EOF

src/database/seeders/index.ts

: > src/database/seeders/index.ts
cat >> src/database/seeders/index.ts << 'EOF'
import dotenv from "dotenv";
import { sequelize, testConnection } from "../db";
import "../../features/business/clients/client.model";
import "../../features/business/product-types/product-type.model";
import "../../features/business/products/product.model";
import "../../features/business/sales/sale.model";
import "../../features/business/product-sales/product-sale.model";
import "../../features/business/products/products.associations";
import "../../features/business/sales/sales.associations";
import "../../features/business/product-sales/product-sales.associations";
import { seedClients } from "../../features/business/clients/clients.seeder";
import { seedProductTypes } from "../../features/business/product-types/product-types.seeder";
import { seedProducts } from "../../features/business/products/products.seeder";
import { seedSales } from "../../features/business/sales/sales.seeder";
import { seedProductSales } from "../../features/business/product-sales/product-sales.seeder";
import { resolveSeedCounts } from "./counts";

dotenv.config();

/**
 * SeedersRunner — ejecuta los seeders de TODAS las tablas (features).
 *
 * Tablas actuales (orden padres → hijos):
 *   clients → product_types → products → sales → product_sales
 *
 * Ejecutar seeders de todas las tablas:
 *   npm run db:seed
 *
 * Variar cantidades (CLI o env; claves = nombre de tabla):
 *   npm run db:seed -- --clients=20 --product_types=5 --products=15 --sales=5 --product_sales=12
 *   SEED_CLIENTS=5 SEED_PRODUCT_TYPES=3 SEED_PRODUCTS=10 SEED_SALES=2 SEED_PRODUCT_SALES=6 npm run db:seed
 *
 * Defaults: ver `counts.ts`. Cada seeder es idempotente (si ya hay filas, omite).
 * Ubicación de cada seeder: `src/features/.../<plural>.seeder.ts`
 * Este archivo solo orquesta; no define datos.
 */
export async function runAllSeeders(): Promise<void> {
  const counts = resolveSeedCounts();
  console.log("🌱 Iniciando SeedersRunner...");
  console.log("📊 Conteos:", counts);

  const ok = await testConnection();
  if (!ok) {
    throw new Error("No hay conexión a la base de datos");
  }

  const isMysql =
    sequelize.getDialect() === "mysql" || sequelize.getDialect() === "mariadb";
  if (isMysql) {
    await sequelize.query("SET FOREIGN_KEY_CHECKS = 0");
  }
  try {
    await sequelize.sync({ force: false, alter: true });
  } finally {
    if (isMysql) {
      await sequelize.query("SET FOREIGN_KEY_CHECKS = 1");
    }
  }

  // Orden: business (padres → hijos)
  await seedClients(counts.clients);
  await seedProductTypes(counts.product_types);
  await seedProducts(counts.products);
  await seedSales(counts.sales);
  await seedProductSales(counts.product_sales);

  console.log("🌱 SeedersRunner finalizado");
}

if (require.main === module) {
  runAllSeeders()
    .then(async () => {
      await sequelize.close();
      process.exit(0);
    })
    .catch(async (err) => {
      console.error("❌ Error en seeders:", err);
      await sequelize.close();
      process.exit(1);
    });
}
EOF

src/swagger/index.ts

: > src/swagger/index.ts
cat >> src/swagger/index.ts << 'EOF'
import { Application } from "express";
import swaggerUi from "swagger-ui-express";
import { clientsSwagger } from "../features/business/clients/clients.swagger";
import { productTypesSwagger } from "../features/business/product-types/product-types.swagger";
import { productsSwagger } from "../features/business/products/products.swagger";
import { salesSwagger } from "../features/business/sales/sales.swagger";
import { productSalesSwagger } from "../features/business/product-sales/product-sales.swagger";

export type FeatureSwaggerModule = {
  tags: unknown[];
  paths: Record<string, unknown>;
  components?: { schemas?: Record<string, unknown> };
};

/**
 * Registry externo: importa la documentación OpenAPI de cada feature
 * (mismo patrón que SeedersRunner).
 */
const featureSwaggerModules: FeatureSwaggerModule[] = [
  clientsSwagger,
  productTypesSwagger,
  productsSwagger,
  salesSwagger,
  productSalesSwagger,
];

export function buildOpenApiDocument() {
  const tags: unknown[] = [];
  const paths: Record<string, unknown> = {};
  const schemas: Record<string, unknown> = {};

  for (const mod of featureSwaggerModules) {
    tags.push(...mod.tags);
    Object.assign(paths, mod.paths);
    if (mod.components?.schemas) {
      Object.assign(schemas, mod.components.schemas);
    }
  }

  return {
    openapi: "3.0.3",
    info: {
      title: "StoreLab API",
      version: "1.0.0",
      description:
        "API StoreLab (Express + Sequelize). Los endpoints de business están documentados como **SIN AUTH** (este lab no implementa autenticación).",
    },
    servers: [
      {
        url: `http://localhost:${process.env.PORT || 4000}`,
        description: "Local",
      },
    ],
    tags,
    paths,
    components: { schemas },
  };
}

/** Monta Swagger UI y el JSON OpenAPI */
export function setupSwagger(app: Application): void {
  const document = buildOpenApiDocument();
  app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(document));
  app.get("/api/docs.json", (_req, res) => {
    res.json(document);
  });
  console.log("📘 Swagger UI: /api/docs  |  OpenAPI JSON: /api/docs.json");
}
EOF

Estado final src/config/index.ts (consolida ISS-01…08)

: > src/config/index.ts
cat >> src/config/index.ts << 'EOF'
import dotenv from "dotenv";
import express, { Application, ErrorRequestHandler } from "express";
import morgan from "morgan";
var cors = require("cors");
import { sequelize, getDatabaseInfo, testConnection } from "../database/db";
import "../features/business/clients/client.model";
import "../features/business/product-types/product-type.model";
import "../features/business/products/product.model";
import "../features/business/sales/sale.model";
import "../features/business/product-sales/product-sale.model";
import "../features/business/products/products.associations";
import "../features/business/sales/sales.associations";
import "../features/business/product-sales/product-sales.associations";
// Fase II — Auth con RBAC: primero los seis modelos, después las asociaciones
// (las asociaciones referencian los modelos, no al revés).
import "../features/auth/users/user.model";
import "../features/auth/roles/role.model";
import "../features/auth/resources/resource.model";
import "../features/auth/role-users/role-user.model";
import "../features/auth/resource-roles/resource-role.model";
import "../features/auth/refresh-tokens/refresh-token.model";
import "../features/auth/rbac.associations";
import { Routes } from "../routes/index";
import { setupSwagger } from "../swagger/index";

dotenv.config();

export class App {
  public app: Application;
  public routePrv: Routes = new Routes();

  constructor(private port?: number | string) {
    this.app = express();
    this.settings();
    this.middlewares();
    this.routes();
    this.docs();
    this.errorHandling();
  }

  private settings(): void {
    this.app.set('port', this.port || process.env.PORT || 4000);
  }

  private middlewares(): void {
    this.app.use(morgan('dev'));
    this.app.use(cors());
    this.app.use(express.json());
    this.app.use(express.urlencoded({ extended: false }));
  }

  private routes(): void {
    // Fase I — Business (cada operación, modalidad JWT + RBAC)
    this.routePrv.clientsRoutes.routes(this.app);
    this.routePrv.productTypesRoutes.routes(this.app);
    this.routePrv.productsRoutes.routes(this.app);
    this.routePrv.salesRoutes.routes(this.app);
    this.routePrv.productSalesRoutes.routes(this.app);

    // Fase II — Auth con RBAC
    // `sessionRoutes` registra los endpoints OPEN/JWT (login, refresh, logout,
    // perfil, permisos); el resto son modalidad JWT + RBAC.
    this.routePrv.sessionRoutes.routes(this.app);
    this.routePrv.refreshTokensRoutes.routes(this.app);
    this.routePrv.usersRoutes.routes(this.app);
    this.routePrv.rolesRoutes.routes(this.app);
    this.routePrv.resourcesRoutes.routes(this.app);
    this.routePrv.roleUsersRoutes.routes(this.app);
    this.routePrv.resourceRolesRoutes.routes(this.app);
  }

  private docs(): void {
    setupSwagger(this.app);
  }

  /**
   * Errores que ocurren **antes** de llegar a un controller o middleware.
   *
   * El caso típico es un cuerpo JSON malformado: `express.json()` lanza un
   * `SyntaxError` que, sin manejador, cae en el de Express por defecto y responde
   * 400 con un HTML que incluye el **stack trace y rutas absolutas del servidor**
   * (fuga de información). Aquí se traduce a un 400 JSON limpio.
   *
   * Debe registrarse **después** de las rutas: Express reconoce un middleware de
   * error por su aridad de 4 argumentos.
   */
  private errorHandling(): void {
    const bodyErrorHandler: ErrorRequestHandler = (err, _req, res, next) => {
      if (err instanceof SyntaxError && "body" in err) {
        res.status(400).json({ error: "Malformed JSON body" });
        return;
      }
      next(err);
    };
    this.app.use(bodyErrorHandler);
  }

  private async dbConnection(): Promise<void> {
    try {
      const dbInfo = getDatabaseInfo();
      console.log(`🔗 Intentando conectar a: ${dbInfo.engine.toUpperCase()}`);

      const isConnected = await testConnection();
      if (!isConnected) {
        throw new Error(`No se pudo conectar a la base de datos ${dbInfo.engine.toUpperCase()}`);
      }

      // Lab: sync crea/altera tablas desde los modelos (BD limpia → snake_case desde cero).
      const force = process.env.DB_SYNC_FORCE === "true";
      const isMysql =
        sequelize.getDialect() === "mysql" || sequelize.getDialect() === "mariadb";

      if (isMysql) {
        await sequelize.query("SET FOREIGN_KEY_CHECKS = 0");
      }
      try {
        await sequelize.sync({ force, alter: !force });
      } finally {
        if (isMysql) {
          await sequelize.query("SET FOREIGN_KEY_CHECKS = 1");
        }
      }

      console.log(
        force
          ? "📦 Base de datos recreada (DB_SYNC_FORCE=true)"
          : "📦 Base de datos sincronizada exitosamente"
      );
    } catch (error) {
      console.error("❌ Error al conectar con la base de datos:", error);
      process.exit(1);
    }
  }

  async listen() {
    // Orden de arranque: primero la BD (conexión + `sync`), después abrir el puerto.
    // Si se abre el puerto antes de terminar `sync({ alter: true })`, las sentencias
    // DDL (ALTER TABLE, DROP/ADD FOREIGN KEY) compiten con las peticiones que ya
    // están entrando y provocan deadlocks y errores de FK intermitentes.
    await this.dbConnection();
    await this.app.listen(this.app.get('port'));
    console.log(`🚀 Servidor ejecutándose en puerto ${this.app.get('port')}`);
  }
}
EOF

PARCHE — src/config/index.ts: al cerrar ISS-08 el bloque de imports de modelos/asociaciones y routes() debe quedar como en el repo (models client→product-type→product→sale→product-sale; associations product + sale + product-sale; routes() registra las 5 features business).

Verificación ISS-08 / business completo

npx tsc --noEmit
npm run db:seed
curl -s http://localhost:4000/api/tipos-producto | head
curl -s http://localhost:4000/api/productos | head
curl -s http://localhost:4000/api/ventas | head
curl -s http://localhost:4000/api/detalle-ventas | head

Cierre del ISS

npm run dev

Swagger: http://localhost:4000/api/docs. El servidor debe arrancar sin error.

Diagnóstico

Síntoma Causa probable Solución
Deadlock found when trying to get lock Dos flujos bloquean sales, products y product_sales en orden distinto, o dos ventas traen los mismos productos en distinto orden Respetar el orden canónico sales → products → product_sales y ordenar los ítems por product_id ascendente antes de bloquear (lo hace create)
Cannot add or update a child row: a foreign key constraint fails (product_sales) sale_id o product_id inexistente, o el INSERT va fuera de la transacción y la fila padre aún no está confirmada Verificar que la venta/producto existen y que todos los repositories reciben la misma transaction t
Insufficient stock (available: …, requested: …) con stock de sobra en la BD Se está vendiendo dos veces la misma fila por una lectura sin lock Usar findByIdForUpdate (SELECT … FOR UPDATE) para leer el producto dentro de la transacción
La venta se crea pero sus líneas no aparecen El create de las líneas se hizo fuera de la transacción (sin pasar t) Pasar t a productSalesRepository.create(..., t) y al update de stock
La venta queda creada aunque una línea falló Falta el rollback: hay escrituras hechas con sequelize directo en lugar del helper Envolver todo el bloque en withTransaction y no capturar el error dentro de work
total no coincide con la suma de líneas recalcSaleTotals no se llamó tras cambiar las líneas, o subtotal/total llegaron desde el cliente Llamar a recalcSaleTotals tras cada cambio de línea; nunca aceptar subtotal/total en los DTO de entrada
El stock no se restaura al borrar una línea o una venta El flujo de delete no devuelve quantity al producto, o ignora las líneas activas Usar releaseStockOfActiveLines (en venta) o sumar productSale.quantity (en línea) dentro de la transacción
Una línea inactive sigue apareciendo en el listado Falta el filtro status: "active" en la lectura Revisar findAllActive y findActiveBySaleId; el borrado lógico debe ser invisible en la API
DELETE /api/ventas/:id responde error de FK La venta tiene líneas y se intentó borrar el padre primero Borrar en orden hijos → padre: primero product_sales, luego sales (lo hace deletePhysical)
El seeder de líneas no inserta nada No hay ventas o productos activos, o ya existen filas en product_sales (idempotencia) Sembrar primero sales/products; vaciar product_sales si quieres resembrar
sale_id, product_id and quantity (>=1) are required El body no trae quantity o es < 1 Enviar quantity entero ≥ 1

Pregunta que responde: si una venta falla o queda inconsistente, ¿por dónde empiezo a mirar?

Conexión con el resto del curso

Relación Qué aporta
Viene de ISS-03 — Feature Client El patrón completo Routes → Controller → Service → Repository → Model → DTO y el helper withTransaction (creado allí)
Viene de ISS-07 — Feature Product El modelo Product con price y quantity, y las asociaciones; sin productos no hay líneas de venta
Reutiliza clients SalesService valida el cliente con ClientsRepository (assertActiveClient)
Reutiliza products ProductSalesService y SalesService bloquean, descuentan y restauran stock con ProductsRepository
Reutiliza shared/ AppError, BaseController y withTransaction
Habilita Cierre del laboratorio (Fase I — Business) Cierra las 5 features de negocio y da paso a la Fase II
Prepara ISS-09 — Auth base Las rutas ya están documentadas como JWT + RBAC; en Fase II solo habrá que insertar los middlewares

Pregunta que responde: ¿qué piezas del curso reutiliza este ISS y a qué ISS deja paso?

Glosario

Término Significado en este ISS
Transacción Conjunto de operaciones sobre la BD que se confirman o se deshacen en bloque
Unit of work Patrón que agrupa varias escrituras en una sola unidad atómica; aquí, withTransaction
withTransaction Helper de src/shared/database/ que abre, confirma o revierte una transacción (creado en ISS-03, usado de verdad en ISS-08)
Atomicidad Propiedad por la cual la operación «todo o nada»: o se aplican todas las escrituras, o ninguna
COMMIT Confirma los cambios de la transacción
ROLLBACK Deshace todos los cambios de la transacción, como si nunca hubieran ocurrido
FOR UPDATE Bloqueo exclusivo de una fila durante la transacción (SELECT … FOR UPDATE)
Orden canónico de locks Orden fijo sales → products → product_sales para evitar deadlocks
Deadlock Interbloqueo: dos transacciones se esperan mutuamente; InnoDB mata una
Línea de venta Fila de product_sales: un producto dentro de una venta, con quantity, unit_price, line_total
unit_price Snapshot del precio aplicado al vender; no cambia aunque cambie products.price
line_total quantity × unit_price
subtotal Suma de los line_total de las líneas activas
total subtotal + tax − discounts
Borrado lógico Marcar status = inactive en lugar de borrar la fila
Borrado físico DELETE real de la fila, restaurando antes el stock
N:M Relación muchos-a-muchos entre ventas y productos, materializada en product_sales
Idempotencia (seeder) Propiedad de no volver a insertar si ya hay filas

Criterios de aceptación

Los del ISS, textuales, como checklist. No los cierres hasta que todos estén en verde:

  • [ ] Feature propio src/features/business/product-sales/ (model, repository, service, controller, routes, seeder, swagger, http, associations)
  • [ ] Rutas /api/detalle-ventas montadas en aggregators
  • [ ] 13.1 Modelos sale + feature product-sales/ (tabla product_sales)
  • [ ] 13.2 DTOs (dto/) + Repository + Service + Controller de Sale y ProductSale en orden getAll, getOne, create, update PUT/PATCH, delete físico y lógico (el create de Sale sigue siendo transaccional: cliente activo, stock, totales)
  • [ ] 13.3 Routes /api/ventas + /api/detalle-ventas + http/ en ese mismo orden
  • [ ] 13.4 Seeders: sales (cabeceras) → product_sales (líneas); clave product_sales en SeedCounts; swagger registry
  • [ ] 13.5 Relaciones Client↔Sale (sales.associations) y Sale↔ProductSale↔Product (product-sales.associations)
  • [ ] 13.6 Swagger Sale + ProductSale
  • [ ] 13.7 Estado final consolidado (config / routes / seeders / swagger)

Evaluación

Preguntas de comprensión

  1. ¿Por qué el create de una venta no puede ser un simple INSERT en sales? Porque una venta con ítems implica escribir en tres tablas: la cabecera (sales), una fila por línea (product_sales) y el descuento de stock (products). Esas escrituras solo tienen sentido juntas; si se hacen por separado y algo falla, la BD queda inconsistente (venta sin líneas, o stock descontado sin venta).

  2. ¿Qué es exactamente una unit of work y dónde vive en este proyecto? Es el patrón que agrupa varias escrituras en una sola operación atómica. En el lab se materializa en withTransaction, un helper de src/shared/database/ que abre la transacción, ejecuta el bloque, hace commit si todo va bien y rollback si algo lanza.

  3. Si una venta con dos ítems falla al procesar el segundo, ¿qué queda en la base de datos? Nada: withTransaction captura la excepción y ejecuta ROLLBACK, deshaciendo la cabecera y la primera línea que ya se habían insertado, y también el stock que se había descontado. Después re-lanza el error para que el controller responda 400.

  4. ¿Por qué todos los flujos bloquean sales antes que product_sales? Para evitar deadlocks. El orden canónico del feature es sales → products → product_sales. Si un flujo bloqueara en el orden inverso, dos peticiones simultáneas podrían esperarse en ciclo. Además, bloquear sales antes de tocar product_sales evita el upgrade de un S-lock (dejado por el FK) a un X-lock cuando luego se recalcula el total.

  5. ¿Por qué unit_price se guarda en la línea si el precio ya está en products.price? Porque es un snapshot histórico: si el precio del producto cambia mañana, la venta de ayer debe seguir mostrando el precio al que realmente se vendió. Renormalizarlo sería perder información contable.

  6. ¿Qué calcula recalcSaleTotals y por qué subtotal/total no están en los DTOs de entrada? Calcula subtotal como la suma de los line_total de las líneas activas, y total = subtotal + tax − discounts. No están en los DTOs de entrada porque son derivados: aceptarlos del cliente permitiría enviar una venta con totales falsos. El service los calcula siempre.

  7. ¿Por qué el borrado lógico de una venta (deactivate) también toca el stock? Porque desactivar una venta equivale a desactivar sus líneas, y una línea desactivada deja de consumir unidades. Para mantener el stock coherente, deleteLogical devuelve al producto la cantidad de cada línea activa antes de marcar todo como inactive.

  8. Las rutas de ventas ya no piden token, pero el Swagger muestra 401/403. ¿Es un error? No. El Swagger y los archivos .http documentan la modalidad futura (JWT + RBAC de la Fase II). En este ISS las rutas se montan explícitamente como «sin autenticación / sin middleware JWT». La autorización real llega en ISS-12/13.

Ejercicios

Ejercicio 1 — Traza una venta atómica. Escribe, en pasos numerados, qué sentencias se ejecutan en la BD al hacer POST /api/ventas con dos ítems válidos, desde el BEGIN hasta el COMMIT.

Respuesta razonada 1. `BEGIN` (lo abre `withTransaction`). 2. `SELECT` del cliente para validar que existe y está `active` (`assertActiveClient`). 3. Los ítems se ordenan por `product_id` ascendente. 4. Por cada ítem: `SELECT … FOR UPDATE` del producto; se valida `status = active` y stock suficiente; se calcula `unit_price` y `line_total`; se acumula el `subtotal`. 5. `INSERT` en `sales` con `subtotal`, `tax`, `discounts`, `total` y `client_id`. 6. Por cada línea: `INSERT` en `product_sales` y `UPDATE products` descontando la cantidad. 7. `COMMIT`: todos los cambios se hacen visibles de golpe. Si en el paso 4 fallara el stock del segundo ítem, el paso 7 sería `ROLLBACK` y ninguno de los pasos 5–6 sobreviviría.

Ejercicio 2 — Provoca y explica un ROLLBACK. Ejecuta un POST /api/ventas con un segundo ítem cuya quantity supere el stock. Describe la respuesta HTTP y comprueba, consultando la BD, que no quedó ni la venta ni el stock descontado del primer ítem.

Respuesta razonada La respuesta será `400` con un mensaje del estilo `Insufficient stock for product N (available: X, requested: Y)`. El `throw AppError(400, …)` interrumpe `work`, `withTransaction` hace `ROLLBACK` y re-lanza el error; `BaseController.run` lo traduce a ese `400`. La comprobación clave: `SELECT * FROM sales` no debe mostrar ninguna fila nueva, y el stock del primer producto debe seguir intacto. Eso demuestra que el `rollback` cubrió también las escrituras ya realizadas.

Ejercicio 3 — Justifica el orden de los locks. Explica qué podría pasar si ProductSalesService.deleteLogical bloqueara primero product_sales y después sales, mientras que create bloqueara en el orden canónico, con dos peticiones simultáneas implicadas.

Respuesta razonada Existiría **inversión de orden**: una transacción tendría el lock de `product_sales` y esperaría `sales`, y la otra tendría `sales` y esperaría `product_sales`. Ninguna puede avanzar: es un *deadlock*. InnoDB lo detecta y mata una de las dos transacciones con `Deadlock found when trying to get lock`, que desde la API se ve como un error inesperado. Por eso el ISS fuerza que **todos** los flujos pasen por `lockLine`, que bloquea siempre `sales → products → product_sales`, y que los productos se ordenen por `product_id` ascendente.

GATE

Para cerrar el ISS-08, ejecuta exactamente los comandos de verificación del ISS:

npx tsc --noEmit
npm run db:seed
curl -s http://localhost:4000/api/tipos-producto | head
curl -s http://localhost:4000/api/productos | head
curl -s http://localhost:4000/api/ventas | head
curl -s http://localhost:4000/api/detalle-ventas | head

Resultado esperado: tsc termina sin errores de tipos, el seeder inserta los registros de todas las tablas (incluidas sales y product_sales) y los cuatro curl devuelven JSON con datos.

Y para el cierre del laboratorio (Fase I — Business), arranca el servidor:

npm run dev

Resultado esperado: el servidor arranca sin error y deja disponible Swagger en http://localhost:4000/api/docs, con los tags Ventas y DetalleVentas.

Checklist de cierre:

  • [ ] npx tsc --noEmit → sin errores
  • [ ] npm run db:seed → inserta sales (cabeceras) y product_sales (líneas)
  • [ ] GET /api/ventas → lista de ventas activas con sus items
  • [ ] GET /api/detalle-ventas → lista de líneas activas
  • [ ] POST /api/ventas con ítems válidos → 201 con { sale, items }
  • [ ] POST /api/ventas con stock insuficiente → 400 sin dejar rastro en la BD (rollback)
  • [ ] PATCH /api/ventas/:id/deactivate → venta y líneas inactive, stock restaurado
  • [ ] Swagger http://localhost:4000/api/docs con los tags Ventas y DetalleVentas
  • [ ] npm run dev arranca sin errores

Con los nueve en verde, el ISS-08 está cumplido: el laboratorio cierra la Fase I — Business con cinco features y su primera transacción real. El siguiente paso natural es el cierre del laboratorio y, después, la Fase II de autenticación que empieza en ISS-09 — Auth base.


Navegación de la ruta: ← ISS-07 · 🛠 Construir · ↑ Ruta Express · → ISS-08 · 🛠 Construir