# Envío automático de OTs a POSTouch (Plan 1 de 3) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Cuando una OT llega a `TERMINADA` en un tenant con `facturacionPosHabilitada`, enviar automáticamente la orden a POSTouch (vía `POST /create_ordenes_edngt`, forma de pago 99/por_liquidar) sin que el cajero teclee nada, y reflejar el resultado en un nuevo estado `FACTURADA` de la OT.

**Architecture:** Nuevo módulo `facturacion-pos` (tenant-scoped) que expone `FacturacionPosService.enviarOrden()`, consumido por `OtsService.changeEstado()` en la transición a `TERMINADA`. Antes de enviar, un `MaepluMirrorService` dedicado inserta (INSERT-only, nunca UPDATE/DELETE) en `maeplu` cualquier producto local de una sola vez que la cotización use y que aún no exista en el ERP. La configuración (flag + credenciales) vive en `TenantConfig` (control-plane DB), expuesta por un nuevo `TenantConfigService` y un endpoint de administración de plataforma.

**Tech Stack:** NestJS, TypeORM, MySQL, `fetch` nativo de Node (mismo patrón que `whatsapp.service.ts`, sin dependencias nuevas), Jest.

**Spec:** `docs/superpowers/specs/2026-09-07-integracion-facturacion-postouch-design.md`

## Global Constraints

- `maeplu` sigue siendo de solo lectura para TODO excepto el nuevo `MaepluMirrorService`, que solo puede llamar `.insert(` — nunca `.save()`/`.update()`/`.delete()`/`.remove()`/`.upsert()`. Esto se hace cumplir con un test de guardia (Tarea 5), igual que `productos.readonly.spec.ts` ya hace para las lecturas.
- Ningún acceso a datos de tenant fuera de `TenantRepositoryAccessor.repositoryFor()` (política ya existente, verificada por `tenant-aware-access.spec.ts`).
- El estado `FACTURADA` de una OT **nunca** se asigna manualmente vía `PATCH /ots/:id/estado` — solo lo asigna `OtsService` tras un envío exitoso a POSTouch.
- Las credenciales de POSTouch se cifran con `CredentialsCryptoService` (mismo servicio que ya cifra `Tenant.dbPasswordEncrypted`), nunca en texto plano en `tenant_config`.
- Todos los textos de UI y mensajes de error van en español, siguiendo el idioma ya usado en todo el backend.

---

## File Structure

**Control-plane (configuración por tenant):**
- Modificar: `backend/src/control-plane/entities/tenant-config.entity.ts` — nuevas columnas de facturación POS.
- Crear: `backend/src/control-plane/migrations/1780000000000-AddFacturacionPosToTenantConfig.ts`
- Crear: `backend/src/control-plane/tenant-config.service.ts` — lectura/escritura de `TenantConfig`.
- Crear: `backend/src/control-plane/tenant-config.service.spec.ts`
- Modificar: `backend/src/control-plane/control-plane.module.ts` — registrar/exportar `TenantConfigService`.
- Crear: `backend/src/control-plane-admin/dto/configurar-facturacion-pos.dto.ts`
- Modificar: `backend/src/control-plane-admin/tenants-admin.controller.ts` — endpoint `PUT /admin/tenants/:id/facturacion-pos`.
- Modificar: `backend/src/control-plane-admin/tenants-admin.service.ts` — método `configurarFacturacionPos`.

**Espejo de productos de una sola vez:**
- Crear: `backend/src/productos/entities/maeplu-mirror.entity.ts`
- Crear: `backend/src/productos/maeplu-mirror.service.ts`
- Crear: `backend/src/productos/maeplu-mirror.service.spec.ts`
- Crear: `backend/src/productos/maeplu-mirror.guard.spec.ts`
- Modificar: `backend/src/productos/productos.module.ts` — registrar `MaepluMirrorService`.

**Integración POSTouch (nuevo módulo):**
- Crear: `backend/src/facturacion-pos/entities/ot-facturacion.entity.ts`
- Crear: `backend/src/migrations/1780100000000-CreateTallerOtFacturacion.ts`
- Crear: `backend/src/facturacion-pos/postouch-client.service.ts`
- Crear: `backend/src/facturacion-pos/postouch-client.service.spec.ts`
- Crear: `backend/src/facturacion-pos/facturacion-pos.service.ts`
- Crear: `backend/src/facturacion-pos/facturacion-pos.service.spec.ts`
- Crear: `backend/src/facturacion-pos/facturacion-pos.module.ts`

**Máquina de estados de la OT:**
- Modificar: `backend/src/ots/entities/orden-trabajo.entity.ts` — agregar `FACTURADA` a `EstadoOT`.
- Crear: `backend/src/migrations/1780200000000-AddFacturadaEstadoOt.ts`
- Modificar: `backend/src/ots/ots.service.ts` — transiciones permitidas, bloqueo de asignación manual, llamada a `FacturacionPosService`.
- Modificar: `backend/src/ots/ots.service.spec.ts` — casos nuevos.
- Modificar: `backend/src/ots/ots.module.ts` — importar `FacturacionPosModule`.
- Modificar: `backend/src/app.module.ts` — registrar `FacturacionPosModule`.

**Frontend (mínimo para que el nuevo estado sea visible):**
- Modificar: `frontend/src/lib/api.ts` (o donde viva el tipo `EstadoOT`/labels) — agregar `facturada` con su etiqueta.

---

### Task 1: Columnas de facturación POS en `TenantConfig`

**Files:**
- Modify: `backend/src/control-plane/entities/tenant-config.entity.ts`
- Create: `backend/src/control-plane/migrations/1780000000000-AddFacturacionPosToTenantConfig.ts`

**Interfaces:**
- Produces: columnas `facturacionPosHabilitada: boolean`, `facturacionPosUrl: string | null`, `facturacionPosUsername: string | null`, `facturacionPosPasswordEncrypted: string | null`, `facturacionPosComputadora: string | null`, `facturacionPosPrecioDecimales: number | null` en `TenantConfig`.

- [ ] **Step 1: Agregar las columnas a la entidad**

Editar `backend/src/control-plane/entities/tenant-config.entity.ts`, agregando al final de la clase (antes del cierre `}`):

```typescript
  @Column({
    name: "facturacion_pos_habilitada",
    type: "boolean",
    default: false,
  })
  facturacionPosHabilitada: boolean;

  @Column({ name: "facturacion_pos_url", type: "varchar", length: 500, nullable: true })
  facturacionPosUrl: string | null;

  @Column({ name: "facturacion_pos_username", type: "varchar", length: 128, nullable: true })
  facturacionPosUsername: string | null;

  // Cifrado con CredentialsCryptoService, igual que whatsappAccessTokenEncrypted.
  @Column({ name: "facturacion_pos_password_encrypted", type: "text", nullable: true })
  facturacionPosPasswordEncrypted: string | null;

  @Column({ name: "facturacion_pos_computadora", type: "varchar", length: 64, nullable: true })
  facturacionPosComputadora: string | null;

  // Decimales de redondeo de la tienda POSTouch destino (ParametrosGlobales.precioDecimales
  // leído manualmente una vez por un administrador — ver spec §11, punto 2).
  @Column({ name: "facturacion_pos_precio_decimales", type: "int", nullable: true })
  facturacionPosPrecioDecimales: number | null;
```

- [ ] **Step 2: Crear la migración de control-plane**

Crear `backend/src/control-plane/migrations/1780000000000-AddFacturacionPosToTenantConfig.ts`:

```typescript
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddFacturacionPosToTenantConfig1780000000000
  implements MigrationInterface
{
  name = "AddFacturacionPosToTenantConfig1780000000000";

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      ALTER TABLE tenant_config
        ADD COLUMN facturacion_pos_habilitada TINYINT(1) NOT NULL DEFAULT 0,
        ADD COLUMN facturacion_pos_url VARCHAR(500) NULL,
        ADD COLUMN facturacion_pos_username VARCHAR(128) NULL,
        ADD COLUMN facturacion_pos_password_encrypted TEXT NULL,
        ADD COLUMN facturacion_pos_computadora VARCHAR(64) NULL,
        ADD COLUMN facturacion_pos_precio_decimales INT NULL
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      ALTER TABLE tenant_config
        DROP COLUMN facturacion_pos_habilitada,
        DROP COLUMN facturacion_pos_url,
        DROP COLUMN facturacion_pos_username,
        DROP COLUMN facturacion_pos_password_encrypted,
        DROP COLUMN facturacion_pos_computadora,
        DROP COLUMN facturacion_pos_precio_decimales
    `);
  }
}
```

- [ ] **Step 3: Compilar para verificar que la entidad y la migración no tienen errores de tipos**

Run: `cd backend && npx tsc --noEmit`
Expected: sin errores relacionados a `tenant-config.entity.ts` ni a la nueva migración.

- [ ] **Step 4: Commit**

```bash
git add backend/src/control-plane/entities/tenant-config.entity.ts backend/src/control-plane/migrations/1780000000000-AddFacturacionPosToTenantConfig.ts
git commit -m "feat: agregar columnas de facturacion POS a tenant_config"
```

---

### Task 2: `TenantConfigService`

**Files:**
- Create: `backend/src/control-plane/tenant-config.service.ts`
- Create: `backend/src/control-plane/tenant-config.service.spec.ts`
- Modify: `backend/src/control-plane/control-plane.module.ts`

**Interfaces:**
- Consumes: `CredentialsCryptoService.encrypt(plaintext): string` / `.decrypt(blob): string` (ya existente, `backend/src/control-plane/credentials-crypto.service.ts`).
- Produces:
  - `TenantConfigService.findByTenantId(tenantId: number): Promise<TenantConfig | null>`
  - `TenantConfigService.getFacturacionPosConfig(tenantId: number): Promise<{ habilitada: boolean; url: string; username: string; password: string; computadora: string; precioDecimales: number } | null>` — retorna `null` si `facturacionPosHabilitada` es `false` o no hay fila; lanza si está habilitada pero falta algún campo requerido.
  - `TenantConfigService.upsertFacturacionPos(tenantId: number, dto: { habilitada: boolean; url?: string; username?: string; password?: string; computadora?: string; precioDecimales?: number }): Promise<TenantConfig>`

- [ ] **Step 1: Escribir el test que falla**

Crear `backend/src/control-plane/tenant-config.service.spec.ts`:

```typescript
import { TenantConfigService } from "./tenant-config.service";
import { TenantConfig } from "./entities/tenant-config.entity";

function createService(overrides: Record<string, any> = {}) {
  const existing: TenantConfig = {
    id: 1,
    tenantId: 42,
    tenant: undefined as any,
    whatsappPhoneNumberId: null,
    whatsappAccessTokenEncrypted: null,
    whatsappTemplateLanguage: null,
    whatsappTemplateCotizacion: null,
    whatsappTemplateEstado: null,
    whatsappDefaultCountryCode: null,
    facturacionPosHabilitada: true,
    facturacionPosUrl: "http://132.226.40.48:3310/create_ordenes_edngt",
    facturacionPosUsername: "22",
    facturacionPosPasswordEncrypted: "ENC(5551)",
    facturacionPosComputadora: "5.5",
    facturacionPosPrecioDecimales: 2,
  };

  const repo = {
    findOne: jest.fn(async () => existing),
    create: jest.fn((data) => ({ ...existing, ...data })),
    save: jest.fn(async (data) => ({ ...existing, ...data })),
    ...overrides.repo,
  };

  const crypto = {
    encrypt: jest.fn((plain: string) => `ENC(${plain})`),
    decrypt: jest.fn((blob: string) => blob.replace(/^ENC\(|\)$/g, "")),
    ...overrides.crypto,
  };

  const service = new TenantConfigService(repo as any, crypto as any);
  return { service, repo, crypto, existing };
}

describe("TenantConfigService", () => {
  it("getFacturacionPosConfig descifra la contrasena y arma el config completo", async () => {
    const { service, crypto } = createService();

    const config = await service.getFacturacionPosConfig(42);

    expect(config).toEqual({
      habilitada: true,
      url: "http://132.226.40.48:3310/create_ordenes_edngt",
      username: "22",
      password: "5551",
      computadora: "5.5",
      precioDecimales: 2,
    });
    expect(crypto.decrypt).toHaveBeenCalledWith("ENC(5551)");
  });

  it("getFacturacionPosConfig retorna null si no esta habilitada", async () => {
    const { service } = createService({
      repo: {
        findOne: jest.fn(async () => ({
          facturacionPosHabilitada: false,
        })),
      },
    });

    const config = await service.getFacturacionPosConfig(42);

    expect(config).toBeNull();
  });

  it("getFacturacionPosConfig lanza si esta habilitada pero falta la URL", async () => {
    const { service } = createService({
      repo: {
        findOne: jest.fn(async () => ({
          facturacionPosHabilitada: true,
          facturacionPosUrl: null,
          facturacionPosUsername: "22",
          facturacionPosPasswordEncrypted: "ENC(5551)",
          facturacionPosComputadora: "5.5",
          facturacionPosPrecioDecimales: 2,
        })),
      },
    });

    await expect(service.getFacturacionPosConfig(42)).rejects.toThrow(
      /facturacion.*url/i,
    );
  });

  it("upsertFacturacionPos cifra la contrasena antes de guardar", async () => {
    const { service, repo, crypto } = createService();

    await service.upsertFacturacionPos(42, {
      habilitada: true,
      url: "http://nuevo",
      username: "1",
      password: "clave-nueva",
      computadora: "1.1",
      precioDecimales: 2,
    });

    expect(crypto.encrypt).toHaveBeenCalledWith("clave-nueva");
    expect(repo.save).toHaveBeenCalledWith(
      expect.objectContaining({
        facturacionPosPasswordEncrypted: "ENC(clave-nueva)",
      }),
    );
  });
});
```

- [ ] **Step 2: Correr el test para verificar que falla**

Run: `cd backend && npx jest src/control-plane/tenant-config.service.spec.ts`
Expected: FAIL — `Cannot find module './tenant-config.service'`

- [ ] **Step 3: Implementar `TenantConfigService`**

Crear `backend/src/control-plane/tenant-config.service.ts`:

```typescript
import { BadRequestException, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { CONTROL_PLANE_CONNECTION } from "./control-plane.module";
import { CredentialsCryptoService } from "./credentials-crypto.service";
import { TenantConfig } from "./entities/tenant-config.entity";

export type FacturacionPosConfig = {
  habilitada: boolean;
  url: string;
  username: string;
  password: string;
  computadora: string;
  precioDecimales: number;
};

export type UpsertFacturacionPosDto = {
  habilitada: boolean;
  url?: string;
  username?: string;
  password?: string;
  computadora?: string;
  precioDecimales?: number;
};

@Injectable()
export class TenantConfigService {
  constructor(
    @InjectRepository(TenantConfig, CONTROL_PLANE_CONNECTION)
    private readonly repository: Repository<TenantConfig>,
    private readonly crypto: CredentialsCryptoService,
  ) {}

  async findByTenantId(tenantId: number): Promise<TenantConfig | null> {
    return this.repository.findOne({ where: { tenantId } });
  }

  async getFacturacionPosConfig(
    tenantId: number,
  ): Promise<FacturacionPosConfig | null> {
    const config = await this.findByTenantId(tenantId);

    if (!config || !config.facturacionPosHabilitada) {
      return null;
    }

    if (!config.facturacionPosUrl) {
      throw new BadRequestException(
        `El tenant ${tenantId} tiene facturacionPosHabilitada pero no tiene facturacionPosUrl configurada`,
      );
    }
    if (!config.facturacionPosUsername) {
      throw new BadRequestException(
        `El tenant ${tenantId} tiene facturacionPosHabilitada pero no tiene facturacionPosUsername configurado`,
      );
    }
    if (!config.facturacionPosPasswordEncrypted) {
      throw new BadRequestException(
        `El tenant ${tenantId} tiene facturacionPosHabilitada pero no tiene contrasena configurada`,
      );
    }
    if (!config.facturacionPosComputadora) {
      throw new BadRequestException(
        `El tenant ${tenantId} tiene facturacionPosHabilitada pero no tiene facturacionPosComputadora configurada`,
      );
    }
    if (
      config.facturacionPosPrecioDecimales === null ||
      config.facturacionPosPrecioDecimales === undefined
    ) {
      throw new BadRequestException(
        `El tenant ${tenantId} tiene facturacionPosHabilitada pero no tiene facturacionPosPrecioDecimales configurado`,
      );
    }

    return {
      habilitada: true,
      url: config.facturacionPosUrl,
      username: config.facturacionPosUsername,
      password: this.crypto.decrypt(config.facturacionPosPasswordEncrypted),
      computadora: config.facturacionPosComputadora,
      precioDecimales: config.facturacionPosPrecioDecimales,
    };
  }

  async upsertFacturacionPos(
    tenantId: number,
    dto: UpsertFacturacionPosDto,
  ): Promise<TenantConfig> {
    const existing = await this.findByTenantId(tenantId);
    const base = existing ?? this.repository.create({ tenantId });

    base.facturacionPosHabilitada = dto.habilitada;
    if (dto.url !== undefined) base.facturacionPosUrl = dto.url;
    if (dto.username !== undefined) base.facturacionPosUsername = dto.username;
    if (dto.password !== undefined) {
      base.facturacionPosPasswordEncrypted = this.crypto.encrypt(dto.password);
    }
    if (dto.computadora !== undefined) {
      base.facturacionPosComputadora = dto.computadora;
    }
    if (dto.precioDecimales !== undefined) {
      base.facturacionPosPrecioDecimales = dto.precioDecimales;
    }

    return this.repository.save(base);
  }
}
```

- [ ] **Step 4: Correr el test para verificar que pasa**

Run: `cd backend && npx jest src/control-plane/tenant-config.service.spec.ts`
Expected: PASS (4 tests)

- [ ] **Step 5: Registrar el servicio en el módulo**

Editar `backend/src/control-plane/control-plane.module.ts`:

```typescript
import { TenantConfigService } from "./tenant-config.service";
// ... (mantener el resto de imports igual)

@Module({
  imports: [
    // ... (igual)
  ],
  providers: [
    CredentialsCryptoService,
    IdentityService,
    TenantRecordsService,
    TenantConfigService,
  ],
  exports: [
    TypeOrmModule,
    CredentialsCryptoService,
    IdentityService,
    TenantRecordsService,
    TenantConfigService,
  ],
})
export class ControlPlaneModule {}
```

- [ ] **Step 6: Commit**

```bash
git add backend/src/control-plane/tenant-config.service.ts backend/src/control-plane/tenant-config.service.spec.ts backend/src/control-plane/control-plane.module.ts
git commit -m "feat: agregar TenantConfigService para leer/escribir configuracion de facturacion POS"
```

---

### Task 3: Endpoint de administración para configurar facturación POS por tenant

**Files:**
- Create: `backend/src/control-plane-admin/dto/configurar-facturacion-pos.dto.ts`
- Modify: `backend/src/control-plane-admin/tenants-admin.service.ts`
- Modify: `backend/src/control-plane-admin/tenants-admin.controller.ts`
- Modify: `backend/src/control-plane-admin/control-plane-admin.module.ts` (importar `ControlPlaneModule` si aún no lo hace)

**Interfaces:**
- Consumes: `TenantConfigService.upsertFacturacionPos(tenantId, dto)` (Task 2).
- Produces: `TenantsAdminService.configurarFacturacionPos(tenantId: number, dto: ConfigurarFacturacionPosDto): Promise<{ tenantId: number; habilitada: boolean }>`

- [ ] **Step 1: Verificar que `ControlPlaneModule` ya está importado**

Run: `cd backend && grep -n "ControlPlaneModule" src/control-plane-admin/control-plane-admin.module.ts`
Expected: aparece en `imports` (si no aparece, agregarlo antes de continuar — es el módulo que exporta `TenantConfigService`).

- [ ] **Step 2: Crear el DTO**

Crear `backend/src/control-plane-admin/dto/configurar-facturacion-pos.dto.ts`:

```typescript
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
  IsBoolean,
  IsInt,
  IsOptional,
  IsString,
  Min,
  ValidateIf,
} from "class-validator";

export class ConfigurarFacturacionPosDto {
  @ApiProperty({ description: "Activa o desactiva el envio automatico a POSTouch" })
  @IsBoolean()
  habilitada: boolean;

  @ApiPropertyOptional({ description: "URL de create_ordenes_edngt en POSTouch" })
  @ValidateIf((o) => o.habilitada)
  @IsString()
  url?: string;

  @ApiPropertyOptional({ description: "_username de la sesion de servicio en POSTouch" })
  @ValidateIf((o) => o.habilitada)
  @IsString()
  username?: string;

  @ApiPropertyOptional({ description: "_password de la sesion de servicio en POSTouch" })
  @IsOptional()
  @IsString()
  password?: string;

  @ApiPropertyOptional({ description: "Identificador de computadora requerido por POSTouch" })
  @ValidateIf((o) => o.habilitada)
  @IsString()
  computadora?: string;

  @ApiPropertyOptional({ description: "Decimales de redondeo de esa tienda (ParametrosGlobales.precioDecimales)" })
  @ValidateIf((o) => o.habilitada)
  @IsInt()
  @Min(0)
  precioDecimales?: number;
}
```

- [ ] **Step 3: Agregar el método al servicio**

Editar `backend/src/control-plane-admin/tenants-admin.service.ts`, agregar el import de `TenantConfigService` y `ConfigurarFacturacionPosDto`, inyectarlo en el constructor, y agregar el método:

```typescript
  async configurarFacturacionPos(
    tenantId: number,
    dto: ConfigurarFacturacionPosDto,
  ): Promise<{ tenantId: number; habilitada: boolean }> {
    const saved = await this.tenantConfigService.upsertFacturacionPos(tenantId, {
      habilitada: dto.habilitada,
      url: dto.url,
      username: dto.username,
      password: dto.password,
      computadora: dto.computadora,
      precioDecimales: dto.precioDecimales,
    });

    return { tenantId, habilitada: saved.facturacionPosHabilitada };
  }
```

- [ ] **Step 4: Agregar el endpoint al controlador**

Editar `backend/src/control-plane-admin/tenants-admin.controller.ts`, agregar el import de `ConfigurarFacturacionPosDto` y el endpoint (después de `activar`):

```typescript
  @Put(":id/facturacion-pos")
  @ApiOperation({ summary: "Configurar (o desactivar) el envio automatico de OTs a POSTouch para este tenant" })
  async configurarFacturacionPos(
    @Param("id", ParseIntPipe) id: number,
    @Body() dto: ConfigurarFacturacionPosDto,
  ) {
    return this.tenantsAdminService.configurarFacturacionPos(id, dto);
  }
```

Agregar `Put` al import de `@nestjs/common` si no está ya presente.

- [ ] **Step 5: Compilar**

Run: `cd backend && npx tsc --noEmit`
Expected: sin errores.

- [ ] **Step 6: Commit**

```bash
git add backend/src/control-plane-admin/dto/configurar-facturacion-pos.dto.ts backend/src/control-plane-admin/tenants-admin.service.ts backend/src/control-plane-admin/tenants-admin.controller.ts backend/src/control-plane-admin/control-plane-admin.module.ts
git commit -m "feat: endpoint admin para configurar facturacion POS por tenant"
```

---

### Task 4: Entidad `MaepluMirror` (insertable)

**Files:**
- Create: `backend/src/productos/entities/maeplu-mirror.entity.ts`

**Interfaces:**
- Produces: clase `MaepluMirror` mapeada a la tabla `maeplu` (sin `insert:false`/`update:false`), con columnas `plu, desclarga, precio, costo, iva, pagaiva, es_servicio, usainventario, es_taller, es_producto`.

- [ ] **Step 1: Crear la entidad**

Crear `backend/src/productos/entities/maeplu-mirror.entity.ts`:

```typescript
// Espejo INSERTABLE de maeplu — usado UNICAMENTE por MaepluMirrorService
// para dar de alta productos "de una sola vez" (prefijo TLLP-) que el
// taller crea localmente y que necesitan existir en el ERP para poder
// facturarse en POSTouch (Facturacion.php valida el PLU contra maeplu).
//
// A diferencia de Producto (producto.entity.ts), esta entidad NO tiene
// insert:false/update:false: es la unica excepcion deliberada a la regla
// "maeplu es de solo lectura", y solo permite INSERT (nunca UPDATE/DELETE
// — ver maeplu-mirror.guard.spec.ts, que falla si MaepluMirrorService
// llama algo distinto de .insert() sobre este repositorio).
import { Column, Entity, PrimaryColumn } from "typeorm";
import { charBooleanTransformer } from "../../common/transformers/char-boolean.transformer";

@Entity("maeplu")
export class MaepluMirror {
  @PrimaryColumn({ length: 24 })
  plu: string;

  @Column({ length: 255, nullable: true })
  desclarga: string;

  @Column({ type: "decimal", precision: 12, scale: 2, default: 0 })
  precio: number;

  @Column({ type: "decimal", precision: 12, scale: 2, default: 0 })
  costo: number;

  @Column({ type: "decimal", precision: 5, scale: 2, default: 0 })
  iva: number;

  @Column({
    type: "char",
    length: 1,
    nullable: true,
    transformer: charBooleanTransformer,
  })
  pagaiva: boolean;

  @Column({
    name: "es_servicio",
    type: "char",
    length: 1,
    nullable: true,
    transformer: charBooleanTransformer,
  })
  es_servicio: boolean;

  @Column({
    name: "usainventario",
    type: "char",
    length: 1,
    nullable: true,
    transformer: charBooleanTransformer,
  })
  usainventario: boolean;

  @Column({
    name: "es_taller",
    type: "char",
    length: 1,
    nullable: true,
    transformer: charBooleanTransformer,
  })
  es_taller: boolean;

  @Column({
    name: "es_producto",
    type: "char",
    length: 1,
    nullable: true,
    transformer: charBooleanTransformer,
  })
  es_producto: boolean;
}
```

- [ ] **Step 2: Compilar**

Run: `cd backend && npx tsc --noEmit`
Expected: sin errores.

- [ ] **Step 3: Commit**

```bash
git add backend/src/productos/entities/maeplu-mirror.entity.ts
git commit -m "feat: agregar entidad insertable MaepluMirror para productos de una sola vez"
```

---

### Task 5: `MaepluMirrorService` + test de guardia INSERT-only

**Files:**
- Create: `backend/src/productos/maeplu-mirror.service.ts`
- Create: `backend/src/productos/maeplu-mirror.service.spec.ts`
- Create: `backend/src/productos/maeplu-mirror.guard.spec.ts`
- Modify: `backend/src/productos/productos.module.ts`

**Interfaces:**
- Consumes: `TenantRepositoryAccessor.repositoryFor(MaepluMirror): Repository<MaepluMirror>` (Task 4 entity).
- Produces: `MaepluMirrorService.insertIfMissing(producto: { plu: string; desclarga: string; precio: number; costo: number; iva: number; pagaiva: boolean; esServicio: boolean; usainventario: boolean }): Promise<{ inserted: boolean }>`

- [ ] **Step 1: Escribir el test de guardia (INSERT-only) que falla**

Crear `backend/src/productos/maeplu-mirror.guard.spec.ts`:

```typescript
import * as fs from "fs";
import * as path from "path";

// maeplu-mirror.service.ts es la UNICA excepcion permitida a "maeplu es de
// solo lectura" — y aun asi, solo puede INSERTAR filas nuevas. Esta prueba
// falla si el archivo alguna vez llama a otro metodo de escritura sobre su
// propio repositorio.
describe("MaepluMirrorService solo puede INSERTAR en maeplu, nunca actualizar/borrar", () => {
  const FORBIDDEN = ["save", "update", "delete", "remove", "upsert"];

  it("maeplu-mirror.service.ts no llama a metodos de escritura distintos de insert", () => {
    const source = fs.readFileSync(
      path.join(__dirname, "maeplu-mirror.service.ts"),
      "utf8",
    );

    for (const method of FORBIDDEN) {
      const pattern = new RegExp(`repository\\s*\\.\\s*${method}\\s*\\(`);
      expect(source).not.toMatch(pattern);
    }

    expect(source).toMatch(/repository\s*\.\s*insert\s*\(/);
  });
});
```

- [ ] **Step 2: Correr el test de guardia para verificar que falla**

Run: `cd backend && npx jest src/productos/maeplu-mirror.guard.spec.ts`
Expected: FAIL — `ENOENT: no such file or directory, ... 'maeplu-mirror.service.ts'`

- [ ] **Step 3: Escribir el test unitario del servicio que falla**

Crear `backend/src/productos/maeplu-mirror.service.spec.ts`:

```typescript
import { MaepluMirrorService } from "./maeplu-mirror.service";

function createService(overrides: Record<string, any> = {}) {
  const repository = {
    findOne: jest.fn(async () => null),
    insert: jest.fn(async () => ({ identifiers: [{ plu: "TLLP-00000001" }] })),
    ...overrides.repository,
  };

  const tenantAccessor = {
    repositoryFor: jest.fn(() => repository),
  };

  const service = new MaepluMirrorService(tenantAccessor as any);
  return { service, repository, tenantAccessor };
}

describe("MaepluMirrorService", () => {
  const producto = {
    plu: "TLLP-00000001",
    desclarga: "Mano de obra - cambio de balatas",
    precio: 250,
    costo: 0,
    iva: 12,
    pagaiva: true,
    esServicio: true,
    usainventario: false,
  };

  it("inserta el producto si el PLU no existe todavia en maeplu", async () => {
    const { service, repository } = createService();

    const result = await service.insertIfMissing(producto);

    expect(result).toEqual({ inserted: true });
    expect(repository.insert).toHaveBeenCalledWith({
      plu: "TLLP-00000001",
      desclarga: "Mano de obra - cambio de balatas",
      precio: 250,
      costo: 0,
      iva: 12,
      pagaiva: true,
      es_servicio: true,
      usainventario: false,
      es_taller: true,
      es_producto: true,
    });
  });

  it("no inserta si el PLU ya existe (idempotente)", async () => {
    const { service, repository } = createService({
      repository: { findOne: jest.fn(async () => ({ plu: "TLLP-00000001" })) },
    });

    const result = await service.insertIfMissing(producto);

    expect(result).toEqual({ inserted: false });
    expect(repository.insert).not.toHaveBeenCalled();
  });

  it("usa el precio como costo si el costo es 0 y no es servicio, para evitar el bloqueo de venta bajo costo", async () => {
    const { service, repository } = createService();

    await service.insertIfMissing({ ...producto, esServicio: false, costo: 0 });

    expect(repository.insert).toHaveBeenCalledWith(
      expect.objectContaining({ costo: 250 }),
    );
  });
});
```

- [ ] **Step 4: Correr el test unitario para verificar que falla**

Run: `cd backend && npx jest src/productos/maeplu-mirror.service.spec.ts`
Expected: FAIL — `Cannot find module './maeplu-mirror.service'`

- [ ] **Step 5: Implementar `MaepluMirrorService`**

Crear `backend/src/productos/maeplu-mirror.service.ts`:

```typescript
import { Injectable } from "@nestjs/common";
import { Repository } from "typeorm";
import { TenantRepositoryAccessor } from "../tenancy/tenant-repository.accessor";
import { MaepluMirror } from "./entities/maeplu-mirror.entity";

export type ProductoParaEspejar = {
  plu: string;
  desclarga: string;
  precio: number;
  costo: number;
  iva: number;
  pagaiva: boolean;
  esServicio: boolean;
  usainventario: boolean;
};

@Injectable()
export class MaepluMirrorService {
  constructor(private readonly tenantAccessor: TenantRepositoryAccessor) {}

  private get repository(): Repository<MaepluMirror> {
    return this.tenantAccessor.repositoryFor(MaepluMirror);
  }

  async insertIfMissing(
    producto: ProductoParaEspejar,
  ): Promise<{ inserted: boolean }> {
    const existing = await this.repository.findOne({
      where: { plu: producto.plu },
    });

    if (existing) {
      return { inserted: false };
    }

    // Evita el bloqueo de POSTouch "venta por debajo de costo" en productos
    // que no son servicio y no traen costo definido (Transaccion.php:1988).
    const costo =
      !producto.esServicio && producto.costo <= 0
        ? producto.precio
        : producto.costo;

    await this.repository.insert({
      plu: producto.plu,
      desclarga: producto.desclarga,
      precio: producto.precio,
      costo,
      iva: producto.iva,
      pagaiva: producto.pagaiva,
      es_servicio: producto.esServicio,
      usainventario: producto.usainventario,
      es_taller: true,
      es_producto: true,
    });

    return { inserted: true };
  }
}
```

- [ ] **Step 6: Correr ambos tests para verificar que pasan**

Run: `cd backend && npx jest src/productos/maeplu-mirror.service.spec.ts src/productos/maeplu-mirror.guard.spec.ts`
Expected: PASS (4 tests)

- [ ] **Step 7: Registrar el servicio en `productos.module.ts`**

Editar `backend/src/productos/productos.module.ts`, agregar `MaepluMirrorService` a `providers` y `exports` (import correspondiente al inicio del archivo).

- [ ] **Step 8: Commit**

```bash
git add backend/src/productos/maeplu-mirror.service.ts backend/src/productos/maeplu-mirror.service.spec.ts backend/src/productos/maeplu-mirror.guard.spec.ts backend/src/productos/productos.module.ts
git commit -m "feat: agregar MaepluMirrorService (insert-only) para productos de una sola vez"
```

---

### Task 6: Tabla de seguimiento `taller_ot_facturacion`

**Files:**
- Create: `backend/src/facturacion-pos/entities/ot-facturacion.entity.ts`
- Create: `backend/src/migrations/1780100000000-CreateTallerOtFacturacion.ts`

**Interfaces:**
- Produces: entidad `OtFacturacion` con columnas `id, otId, tienda, caja, tipo, serie, transac, estadoFel ('pendiente'|'firmada'|'contingencia'), fechaCreacion, fechaLiquidacion`.

- [ ] **Step 1: Crear la migración**

Crear `backend/src/migrations/1780100000000-CreateTallerOtFacturacion.ts`:

```typescript
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";

export class CreateTallerOtFacturacion1780100000000
  implements MigrationInterface
{
  name = "CreateTallerOtFacturacion1780100000000";

  public async up(queryRunner: QueryRunner): Promise<void> {
    const exists = await queryRunner.hasTable("taller_ot_facturacion");
    if (exists) {
      return;
    }

    await queryRunner.createTable(
      new Table({
        name: "taller_ot_facturacion",
        columns: [
          {
            name: "id",
            type: "int",
            isPrimary: true,
            isGenerated: true,
            generationStrategy: "increment",
          },
          { name: "ot_id", type: "int" },
          { name: "tienda", type: "int" },
          { name: "caja", type: "int" },
          { name: "tipo", type: "varchar", length: "10" },
          { name: "serie", type: "varchar", length: "10" },
          { name: "transac", type: "decimal", precision: 18, scale: 0 },
          {
            name: "estado_fel",
            type: "enum",
            enum: ["pendiente", "firmada", "contingencia"],
            default: "'pendiente'",
          },
          {
            name: "fecha_creacion",
            type: "datetime",
            default: "CURRENT_TIMESTAMP",
          },
          { name: "fecha_liquidacion", type: "datetime", isNullable: true },
        ],
      }),
      true,
    );

    await queryRunner.createIndex(
      "taller_ot_facturacion",
      new TableIndex({ name: "IDX_ot_facturacion_ot_id", columnNames: ["ot_id"] }),
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable("taller_ot_facturacion", true);
  }
}
```

- [ ] **Step 2: Crear la entidad**

Crear `backend/src/facturacion-pos/entities/ot-facturacion.entity.ts`:

```typescript
import {
  Column,
  CreateDateColumn,
  Entity,
  Index,
  PrimaryGeneratedColumn,
} from "typeorm";

export enum EstadoFelOtFacturacion {
  PENDIENTE = "pendiente",
  FIRMADA = "firmada",
  CONTINGENCIA = "contingencia",
}

@Entity("taller_ot_facturacion")
export class OtFacturacion {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ name: "ot_id" })
  @Index()
  otId: number;

  @Column()
  tienda: number;

  @Column()
  caja: number;

  @Column({ length: 10 })
  tipo: string;

  @Column({ length: 10 })
  serie: string;

  @Column({ type: "decimal", precision: 18, scale: 0 })
  transac: number;

  @Column({
    name: "estado_fel",
    type: "enum",
    enum: EstadoFelOtFacturacion,
    default: EstadoFelOtFacturacion.PENDIENTE,
  })
  estadoFel: EstadoFelOtFacturacion;

  @CreateDateColumn({ name: "fecha_creacion" })
  fechaCreacion: Date;

  @Column({ name: "fecha_liquidacion", type: "datetime", nullable: true })
  fechaLiquidacion: Date | null;
}
```

- [ ] **Step 3: Compilar**

Run: `cd backend && npx tsc --noEmit`
Expected: sin errores.

- [ ] **Step 4: Commit**

```bash
git add backend/src/facturacion-pos/entities/ot-facturacion.entity.ts backend/src/migrations/1780100000000-CreateTallerOtFacturacion.ts
git commit -m "feat: agregar tabla de seguimiento taller_ot_facturacion"
```

---

### Task 7: `PostouchClientService` — construir payload y llamar `create_ordenes_edngt`

**Files:**
- Create: `backend/src/facturacion-pos/postouch-client.service.ts`
- Create: `backend/src/facturacion-pos/postouch-client.service.spec.ts`

**Interfaces:**
- Consumes: `FacturacionPosConfig` (Task 2: `{ url, username, password, computadora, precioDecimales }`).
- Produces:
  - Tipo `OrdenPosLinea = { plu: string; cantidad: number; precio: number }`
  - Tipo `OrdenPosCliente = { tarjeta?: string; nombre: string; nit: string; telefono?: string; email?: string; direccion?: string }`
  - `PostouchClientService.enviarOrden(config: FacturacionPosConfig, orden: { id: string; cliente: OrdenPosCliente; lineas: OrdenPosLinea[] }): Promise<{ tienda: number; caja: number; tipo: string; serie: string; transac: number }>` — lanza `BadRequestException` con el mensaje de POSTouch si la respuesta no es exitosa.
  - Función pura exportada `redondear(valor: number, decimales: number): number`.

- [ ] **Step 1: Escribir el test que falla**

Crear `backend/src/facturacion-pos/postouch-client.service.spec.ts`:

```typescript
import { BadRequestException } from "@nestjs/common";
import { PostouchClientService, redondear } from "./postouch-client.service";

const config = {
  habilitada: true as const,
  url: "http://132.226.40.48:3310/create_ordenes_edngt",
  username: "22",
  password: "5551",
  computadora: "5.5",
  precioDecimales: 2,
};

const orden = {
  id: "OT-1234",
  cliente: {
    tarjeta: "2031",
    nombre: "Juan Perez",
    nit: "CF",
    telefono: "5085-4657",
  },
  lineas: [{ plu: "2205", cantidad: 2, precio: 100.005 }],
};

describe("redondear", () => {
  it("redondea al numero de decimales indicado", () => {
    expect(redondear(100.005, 2)).toBe(100.01);
    expect(redondear(100.004, 2)).toBe(100);
    expect(redondear(100, 0)).toBe(100);
  });
});

describe("PostouchClientService.enviarOrden", () => {
  let originalFetch: typeof fetch;

  beforeEach(() => {
    originalFetch = global.fetch;
  });

  afterEach(() => {
    global.fetch = originalFetch;
  });

  it("arma el payload con formasPago=99 por el total exacto y parsea la respuesta", async () => {
    const fetchMock = jest.fn(async () => ({
      ok: true,
      text: async () =>
        JSON.stringify({
          procesadas: 1,
          facturas: [
            { tienda: 1, caja: 2, tipo: "FAC", serie: "A", transac: 555 },
          ],
        }),
    }));
    global.fetch = fetchMock as any;

    const service = new PostouchClientService();
    const result = await service.enviarOrden(config, orden);

    expect(result).toEqual({ tienda: 1, caja: 2, tipo: "FAC", serie: "A", transac: 555 });

    const [url, init] = fetchMock.mock.calls[0];
    expect(url).toBe(config.url);
    const body = JSON.parse((init as any).body);
    expect(body._username).toBe("22");
    expect(body._password).toBe("5551");
    expect(body.computadora).toBe("5.5");
    expect(body.ordenes).toHaveLength(1);
    const [ordenEnviada] = body.ordenes;
    expect(ordenEnviada.id).toBe("OT-1234");
    expect(ordenEnviada.productos).toEqual([
      { plu: "2205", cantidad: 2, precio: 100.01, monto: 200.02, descuentoItemPorcentaje: 0, comboNumero: 0, pluPadre: "" },
    ]);
    expect(ordenEnviada.formasPago).toEqual([
      { media: 99, emisor: 0, codigo: 0, monto: 200.02 },
    ]);
  });

  it("lanza BadRequestException si POSTouch responde con error", async () => {
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ error: "No existe el PLU[2205]." }),
    })) as any;

    const service = new PostouchClientService();

    await expect(service.enviarOrden(config, orden)).rejects.toThrow(
      BadRequestException,
    );
    await expect(service.enviarOrden(config, orden)).rejects.toThrow(
      /No existe el PLU/,
    );
  });

  it("lanza BadRequestException si la respuesta HTTP no es ok", async () => {
    global.fetch = jest.fn(async () => ({
      ok: false,
      text: async () => "Internal Server Error",
    })) as any;

    const service = new PostouchClientService();

    await expect(service.enviarOrden(config, orden)).rejects.toThrow(
      BadRequestException,
    );
  });
});
```

- [ ] **Step 2: Correr el test para verificar que falla**

Run: `cd backend && npx jest src/facturacion-pos/postouch-client.service.spec.ts`
Expected: FAIL — `Cannot find module './postouch-client.service'`

- [ ] **Step 3: Implementar `PostouchClientService`**

Crear `backend/src/facturacion-pos/postouch-client.service.ts`:

```typescript
import { BadRequestException, Injectable } from "@nestjs/common";
import { FacturacionPosConfig } from "../control-plane/tenant-config.service";

export type OrdenPosLinea = {
  plu: string;
  cantidad: number;
  precio: number;
};

export type OrdenPosCliente = {
  tarjeta?: string;
  nombre: string;
  nit: string;
  telefono?: string;
  email?: string;
  direccion?: string;
};

export type OrdenPosInput = {
  id: string;
  cliente: OrdenPosCliente;
  lineas: OrdenPosLinea[];
};

export type OrdenPosCreada = {
  tienda: number;
  caja: number;
  tipo: string;
  serie: string;
  transac: number;
};

// Redondeo a los decimales exactos configurados para la tienda destino
// (ParametrosGlobales.getPrecioDecimales() en POSTouch) — la tolerancia de
// esa validacion es de solo Q0.005 por linea (Transaccion.php:1955-1977),
// asi que un redondeo distinto al de la tienda hace que se rechace la
// orden completa antes de facturar.
export function redondear(valor: number, decimales: number): number {
  const factor = 10 ** decimales;
  return Math.round(valor * factor) / factor;
}

const MEDIA_POR_LIQUIDAR = 99;

@Injectable()
export class PostouchClientService {
  async enviarOrden(
    config: FacturacionPosConfig,
    orden: OrdenPosInput,
  ): Promise<OrdenPosCreada> {
    const productos = orden.lineas.map((linea) => {
      const precio = redondear(linea.precio, config.precioDecimales);
      const monto = redondear(precio * linea.cantidad, config.precioDecimales);
      return {
        plu: linea.plu,
        cantidad: linea.cantidad,
        precio,
        monto,
        descuentoItemPorcentaje: 0,
        comboNumero: 0,
        pluPadre: "",
      };
    });

    const total = redondear(
      productos.reduce((sum, p) => sum + p.monto, 0),
      config.precioDecimales,
    );

    const payload = {
      _username: config.username,
      _password: config.password,
      computadora: config.computadora,
      ordenes: [
        {
          id: orden.id,
          clienteTarjeta: orden.cliente.tarjeta ?? "",
          clienteNombre: orden.cliente.nombre,
          clienteTelefono: orden.cliente.telefono ?? "",
          clienteEmail: orden.cliente.email ?? "",
          clienteNIT: orden.cliente.nit,
          clienteDireccion2: orden.cliente.direccion ?? "",
          clienteCiudad: "",
          clienteDepartamen: "0",
          formasPago: [
            { media: MEDIA_POR_LIQUIDAR, emisor: 0, codigo: 0, monto: total },
          ],
          productos,
        },
      ],
    };

    const response = await fetch(config.url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });

    const text = await response.text();
    let data: any = {};
    try {
      data = text ? JSON.parse(text) : {};
    } catch {
      data = {};
    }

    if (!response.ok) {
      throw new BadRequestException(
        `POSTouch respondio con error al crear la orden: ${text.slice(0, 300)}`,
      );
    }

    if (data?.error) {
      throw new BadRequestException(`POSTouch rechazo la orden: ${data.error}`);
    }

    const factura = data?.facturas?.[0];
    if (!factura) {
      throw new BadRequestException(
        `POSTouch no devolvio la factura creada: ${text.slice(0, 300)}`,
      );
    }

    return {
      tienda: factura.tienda,
      caja: factura.caja,
      tipo: factura.tipo,
      serie: factura.serie,
      transac: factura.transac,
    };
  }
}
```

- [ ] **Step 4: Correr el test para verificar que pasa**

Run: `cd backend && npx jest src/facturacion-pos/postouch-client.service.spec.ts`
Expected: PASS (4 tests)

- [ ] **Step 5: Commit**

```bash
git add backend/src/facturacion-pos/postouch-client.service.ts backend/src/facturacion-pos/postouch-client.service.spec.ts
git commit -m "feat: agregar PostouchClientService para enviar ordenes a create_ordenes_edngt"
```

---

### Task 8: `FacturacionPosService` — orquestación completa

**Files:**
- Create: `backend/src/facturacion-pos/facturacion-pos.service.ts`
- Create: `backend/src/facturacion-pos/facturacion-pos.service.spec.ts`

**Interfaces:**
- Consumes:
  - `TenantConfigService.getFacturacionPosConfig(tenantId): Promise<FacturacionPosConfig | null>` (Task 2)
  - `MaepluMirrorService.insertIfMissing(producto): Promise<{ inserted: boolean }>` (Task 5)
  - `PostouchClientService.enviarOrden(config, orden): Promise<OrdenPosCreada>` (Task 7)
  - `TenantRepositoryAccessor.repositoryFor(OtFacturacion)` (Task 6)
  - `TenantRepositoryAccessor.getCurrentTenantId(): number` (ya existente)
- Produces: `FacturacionPosService.enviarOrdenTerminada(ot: { id: number; numeroOt: string; clienteTarjeta: string; cliente: { nombre: string; nit: string; telefono?: string; email?: string; direccion?: string }; cotizacion: { lineas: Array<{ plu: string; descripcion: string; precioUnitario: number; cantidad: number; esServicio: boolean; origenLocal?: boolean }> } }): Promise<{ exito: true } | { exito: false; error: string }>`

- [ ] **Step 1: Escribir el test que falla**

Crear `backend/src/facturacion-pos/facturacion-pos.service.spec.ts`:

```typescript
import { FacturacionPosService } from "./facturacion-pos.service";

function createService(overrides: Record<string, any> = {}) {
  const otFacturacionRepository = {
    save: jest.fn(async (data) => ({ id: 1, ...data })),
    ...overrides.otFacturacionRepository,
  };

  const tenantAccessor = {
    getCurrentTenantId: jest.fn(() => 42),
    repositoryFor: jest.fn(() => otFacturacionRepository),
  };

  const tenantConfigService = {
    getFacturacionPosConfig: jest.fn(async () => ({
      habilitada: true,
      url: "http://postouch/create_ordenes_edngt",
      username: "22",
      password: "5551",
      computadora: "5.5",
      precioDecimales: 2,
    })),
    ...overrides.tenantConfigService,
  };

  const maepluMirrorService = {
    insertIfMissing: jest.fn(async () => ({ inserted: true })),
    ...overrides.maepluMirrorService,
  };

  const postouchClientService = {
    enviarOrden: jest.fn(async () => ({
      tienda: 1,
      caja: 2,
      tipo: "FAC",
      serie: "A",
      transac: 555,
    })),
    ...overrides.postouchClientService,
  };

  const service = new FacturacionPosService(
    tenantAccessor as any,
    tenantConfigService as any,
    maepluMirrorService as any,
    postouchClientService as any,
  );

  return {
    service,
    tenantAccessor,
    tenantConfigService,
    maepluMirrorService,
    postouchClientService,
    otFacturacionRepository,
  };
}

const otBase = {
  id: 7,
  numeroOt: "OT-1234",
  clienteTarjeta: "2031",
  cliente: { nombre: "Juan Perez", nit: "CF", telefono: "5085-4657" },
  cotizacion: {
    lineas: [
      { plu: "2205", descripcion: "Aceite", precioUnitario: 100, cantidad: 2, esServicio: false },
      { plu: "TLLP-00000001", descripcion: "Mano de obra", precioUnitario: 250, cantidad: 1, esServicio: true, origenLocal: true },
    ],
  },
};

describe("FacturacionPosService.enviarOrdenTerminada", () => {
  it("retorna exito:false sin llamar a POSTouch si el tenant no tiene facturacion habilitada", async () => {
    const { service, postouchClientService } = createService({
      tenantConfigService: { getFacturacionPosConfig: jest.fn(async () => null) },
    });

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({ exito: false, error: "Facturacion POS no habilitada para este tenant" });
    expect(postouchClientService.enviarOrden).not.toHaveBeenCalled();
  });

  it("espeja los productos locales antes de enviar, y guarda la referencia al facturar con exito", async () => {
    const { service, maepluMirrorService, postouchClientService, otFacturacionRepository } =
      createService();

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({ exito: true });
    expect(maepluMirrorService.insertIfMissing).toHaveBeenCalledTimes(1);
    expect(maepluMirrorService.insertIfMissing).toHaveBeenCalledWith({
      plu: "TLLP-00000001",
      desclarga: "Mano de obra",
      precio: 250,
      costo: 0,
      iva: 0,
      pagaiva: true,
      esServicio: true,
      usainventario: false,
    });
    expect(postouchClientService.enviarOrden).toHaveBeenCalledWith(
      expect.objectContaining({ url: "http://postouch/create_ordenes_edngt" }),
      expect.objectContaining({
        id: "OT-1234",
        cliente: { tarjeta: "2031", nombre: "Juan Perez", nit: "CF", telefono: "5085-4657" },
        lineas: [
          { plu: "2205", cantidad: 2, precio: 100 },
          { plu: "TLLP-00000001", cantidad: 1, precio: 250 },
        ],
      }),
    );
    expect(otFacturacionRepository.save).toHaveBeenCalledWith(
      expect.objectContaining({
        otId: 7,
        tienda: 1,
        caja: 2,
        tipo: "FAC",
        serie: "A",
        transac: 555,
      }),
    );
  });

  it("retorna exito:false con el mensaje de error si POSTouch rechaza la orden", async () => {
    const { service, otFacturacionRepository } = createService({
      postouchClientService: {
        enviarOrden: jest.fn(async () => {
          throw new Error("POSTouch rechazo la orden: No existe el PLU[2205].");
        }),
      },
    });

    const result = await service.enviarOrdenTerminada(otBase);

    expect(result).toEqual({
      exito: false,
      error: "POSTouch rechazo la orden: No existe el PLU[2205].",
    });
    expect(otFacturacionRepository.save).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Correr el test para verificar que falla**

Run: `cd backend && npx jest src/facturacion-pos/facturacion-pos.service.spec.ts`
Expected: FAIL — `Cannot find module './facturacion-pos.service'`

- [ ] **Step 3: Implementar `FacturacionPosService`**

Crear `backend/src/facturacion-pos/facturacion-pos.service.ts`:

```typescript
import { Injectable, Logger } from "@nestjs/common";
import { Repository } from "typeorm";
import { TenantRepositoryAccessor } from "../tenancy/tenant-repository.accessor";
import { TenantConfigService } from "../control-plane/tenant-config.service";
import { MaepluMirrorService } from "../productos/maeplu-mirror.service";
import { PostouchClientService } from "./postouch-client.service";
import { OtFacturacion } from "./entities/ot-facturacion.entity";

export type OtParaFacturar = {
  id: number;
  numeroOt: string;
  clienteTarjeta: string;
  cliente: {
    nombre: string;
    nit: string;
    telefono?: string;
    email?: string;
    direccion?: string;
  };
  cotizacion: {
    lineas: Array<{
      plu: string;
      descripcion: string;
      precioUnitario: number;
      cantidad: number;
      esServicio: boolean;
      origenLocal?: boolean;
    }>;
  };
};

export type ResultadoEnvioPos =
  | { exito: true }
  | { exito: false; error: string };

@Injectable()
export class FacturacionPosService {
  private readonly logger = new Logger(FacturacionPosService.name);

  constructor(
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private readonly tenantConfigService: TenantConfigService,
    private readonly maepluMirrorService: MaepluMirrorService,
    private readonly postouchClientService: PostouchClientService,
  ) {}

  private get otFacturacionRepository(): Repository<OtFacturacion> {
    return this.tenantAccessor.repositoryFor(OtFacturacion);
  }

  async enviarOrdenTerminada(ot: OtParaFacturar): Promise<ResultadoEnvioPos> {
    const tenantId = this.tenantAccessor.getCurrentTenantId();
    const config = await this.tenantConfigService.getFacturacionPosConfig(tenantId);

    if (!config) {
      return { exito: false, error: "Facturacion POS no habilitada para este tenant" };
    }

    try {
      for (const linea of ot.cotizacion.lineas) {
        if (linea.origenLocal) {
          await this.maepluMirrorService.insertIfMissing({
            plu: linea.plu,
            desclarga: linea.descripcion,
            precio: linea.precioUnitario,
            costo: 0,
            iva: 0,
            pagaiva: true,
            esServicio: linea.esServicio,
            usainventario: false,
          });
        }
      }

      const factura = await this.postouchClientService.enviarOrden(config, {
        id: ot.numeroOt,
        cliente: {
          tarjeta: ot.clienteTarjeta,
          nombre: ot.cliente.nombre,
          nit: ot.cliente.nit,
          telefono: ot.cliente.telefono,
          email: ot.cliente.email,
          direccion: ot.cliente.direccion,
        },
        lineas: ot.cotizacion.lineas.map((linea) => ({
          plu: linea.plu,
          cantidad: linea.cantidad,
          precio: linea.precioUnitario,
        })),
      });

      await this.otFacturacionRepository.save(
        this.otFacturacionRepository.create({
          otId: ot.id,
          tienda: factura.tienda,
          caja: factura.caja,
          tipo: factura.tipo,
          serie: factura.serie,
          transac: factura.transac,
        }),
      );

      return { exito: true };
    } catch (error: any) {
      this.logger.error(
        `Error enviando OT ${ot.numeroOt} a POSTouch: ${error?.message ?? error}`,
      );
      return { exito: false, error: error?.message ?? "Error desconocido enviando a POSTouch" };
    }
  }
}
```

- [ ] **Step 4: Correr el test para verificar que pasa**

Run: `cd backend && npx jest src/facturacion-pos/facturacion-pos.service.spec.ts`
Expected: PASS (3 tests)

- [ ] **Step 5: Commit**

```bash
git add backend/src/facturacion-pos/facturacion-pos.service.ts backend/src/facturacion-pos/facturacion-pos.service.spec.ts
git commit -m "feat: agregar FacturacionPosService que orquesta el envio de una OT a POSTouch"
```

---

### Task 9: `FacturacionPosModule`

**Files:**
- Create: `backend/src/facturacion-pos/facturacion-pos.module.ts`

**Interfaces:**
- Consumes: `ControlPlaneModule` (exporta `TenantConfigService`), `ProductosModule` (exporta `MaepluMirrorService`), `TenancyModule` (exporta `TenantRepositoryAccessor`, ya importado globalmente — verificar si `TenancyModule` es `@Global()`).
- Produces: exporta `FacturacionPosService`.

- [ ] **Step 1: Verificar si `TenancyModule` es global**

Run: `cd backend && grep -n "@Global" src/tenancy/tenancy.module.ts`
Expected: si aparece `@Global()`, `TenantRepositoryAccessor` ya está disponible en todos los módulos sin importarlo explícitamente — omitir su import en el módulo nuevo. Si NO aparece, agregar `TenancyModule` a los `imports` del paso siguiente.

- [ ] **Step 2: Crear el módulo**

Crear `backend/src/facturacion-pos/facturacion-pos.module.ts` (ejemplo asumiendo `TenancyModule` global — ajustar `imports` según el resultado del Step 1):

```typescript
import { Module } from "@nestjs/common";
import { ControlPlaneModule } from "../control-plane/control-plane.module";
import { ProductosModule } from "../productos/productos.module";
import { FacturacionPosService } from "./facturacion-pos.service";
import { PostouchClientService } from "./postouch-client.service";

@Module({
  imports: [ControlPlaneModule, ProductosModule],
  providers: [FacturacionPosService, PostouchClientService],
  exports: [FacturacionPosService],
})
export class FacturacionPosModule {}
```

- [ ] **Step 3: Compilar**

Run: `cd backend && npx tsc --noEmit`
Expected: sin errores. Si hay un error de dependencia circular entre `ProductosModule` y `FacturacionPosModule`, revisar que `ProductosModule` no importe `FacturacionPosModule` en ningún punto.

- [ ] **Step 4: Commit**

```bash
git add backend/src/facturacion-pos/facturacion-pos.module.ts
git commit -m "feat: agregar FacturacionPosModule"
```

---

### Task 10: Nuevo estado `FACTURADA` en `EstadoOT`

**Files:**
- Modify: `backend/src/ots/entities/orden-trabajo.entity.ts`
- Create: `backend/src/migrations/1780200000000-AddFacturadaEstadoOt.ts`

**Interfaces:**
- Produces: `EstadoOT.FACTURADA = "facturada"`.

- [ ] **Step 1: Agregar el valor al enum**

Editar `backend/src/ots/entities/orden-trabajo.entity.ts`:

```typescript
export enum EstadoOT {
  BORRADOR = "borrador",
  EN_PROCESO = "en_proceso",
  COTIZADA = "cotizada",
  APROBADA = "aprobada",
  TERMINADA = "terminada",
  FACTURADA = "facturada",
  ENTREGADA = "entregada",
}
```

- [ ] **Step 2: Crear la migración**

Crear `backend/src/migrations/1780200000000-AddFacturadaEstadoOt.ts`:

```typescript
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddFacturadaEstadoOt1780200000000 implements MigrationInterface {
  name = "AddFacturadaEstadoOt1780200000000";

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE taller_ordenes_trabajo MODIFY COLUMN estado ENUM('borrador','en_proceso','cotizada','aprobada','terminada','facturada','entregada') NOT NULL DEFAULT 'borrador'`,
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    // Antes de revertir el enum hay que mover cualquier fila en 'facturada'
    // a 'terminada' — si no, el ALTER siguiente la trunca a '' y MySQL la
    // rechaza (columna NOT NULL sin default vacio permitido).
    await queryRunner.query(
      `UPDATE taller_ordenes_trabajo SET estado = 'terminada' WHERE estado = 'facturada'`,
    );
    await queryRunner.query(
      `ALTER TABLE taller_ordenes_trabajo MODIFY COLUMN estado ENUM('borrador','en_proceso','cotizada','aprobada','terminada','entregada') NOT NULL DEFAULT 'borrador'`,
    );
  }
}
```

- [ ] **Step 3: Compilar**

Run: `cd backend && npx tsc --noEmit`
Expected: sin errores.

- [ ] **Step 4: Commit**

```bash
git add backend/src/ots/entities/orden-trabajo.entity.ts backend/src/migrations/1780200000000-AddFacturadaEstadoOt.ts
git commit -m "feat: agregar estado FACTURADA a la maquina de estados de la OT"
```

---

### Task 11: `OtsService.changeEstado` — disparar el envío y bloquear asignación manual

**Files:**
- Modify: `backend/src/ots/ots.service.ts`
- Modify: `backend/src/ots/ots.service.spec.ts`
- Modify: `backend/src/ots/ots.module.ts`

**Interfaces:**
- Consumes: `FacturacionPosService.enviarOrdenTerminada(ot: OtParaFacturar): Promise<ResultadoEnvioPos>` (Task 8).
- Produces: `OtsService.changeEstado` ahora, al recibir `estado === EstadoOT.TERMINADA` con éxito, intenta el envío automático y puede terminar en `TERMINADA` (si falla o el tenant no lo tiene habilitado) o `FACTURADA` (si tuvo éxito); rechaza cualquier solicitud de `PATCH` directa a `FACTURADA`.

- [ ] **Step 1: Escribir los tests que fallan**

Agregar a `backend/src/ots/ots.service.spec.ts` (junto a los tests existentes de `changeEstado`, reutilizando el `createService`/fixtures ya presentes en ese archivo):

```typescript
  describe("changeEstado — integracion POSTouch", () => {
    it("rechaza un PATCH manual a FACTURADA", async () => {
      const { service } = createService();

      await expect(
        service.changeEstado(7, EstadoOT.FACTURADA, undefined, 1),
      ).rejects.toThrow(/no se puede establecer manualmente/i);
    });

    it("al pasar a TERMINADA con facturacion habilitada y envio exitoso, queda en FACTURADA", async () => {
      const { service, otRepository, facturacionPosService } = createService({
        facturacionPosService: {
          enviarOrdenTerminada: jest.fn(async () => ({ exito: true })),
        },
      });

      const ot = await service.changeEstado(7, EstadoOT.TERMINADA, undefined, 1);

      expect(facturacionPosService.enviarOrdenTerminada).toHaveBeenCalled();
      expect(otRepository.save).toHaveBeenLastCalledWith(
        expect.objectContaining({ estado: EstadoOT.FACTURADA }),
      );
      expect(ot).toBeDefined();
    });

    it("al pasar a TERMINADA y el envio falla, queda en TERMINADA con un evento de error", async () => {
      const { service, otRepository, otEventoRepository, facturacionPosService } =
        createService({
          facturacionPosService: {
            enviarOrdenTerminada: jest.fn(async () => ({
              exito: false,
              error: "POSTouch no responde",
            })),
          },
        });

      await service.changeEstado(7, EstadoOT.TERMINADA, undefined, 1);

      expect(otRepository.save).toHaveBeenLastCalledWith(
        expect.objectContaining({ estado: EstadoOT.TERMINADA }),
      );
      expect(otEventoRepository.save).toHaveBeenCalledWith(
        expect.objectContaining({
          descripcion: expect.stringContaining("POSTouch no responde"),
        }),
      );
    });
  });
```

**Nota para quien implemente este paso:** el archivo `ots.service.spec.ts` ya tiene su propio `createService(overrides)` con fixtures de `otRepository`/`otEventoRepository` — extenderlo para aceptar un `facturacionPosService` inyectable (con un mock por defecto `{ enviarOrdenTerminada: jest.fn(async () => ({ exito: false, error: "tenant sin facturacion habilitada" })) }`) y pasarlo al constructor de `OtsService` en el mismo lugar donde hoy se instancian `mecanicosService`/`vehiculosService`.

- [ ] **Step 2: Correr los tests para verificar que fallan**

Run: `cd backend && npx jest src/ots/ots.service.spec.ts -t "integracion POSTouch"`
Expected: FAIL (el constructor de `OtsService` todavía no acepta `FacturacionPosService`, o `changeEstado` no tiene la lógica nueva).

- [ ] **Step 3: Inyectar `FacturacionPosService` y actualizar `changeEstado`**

Editar `backend/src/ots/ots.service.ts`:

1. Agregar el import: `import { FacturacionPosService, OtParaFacturar } from "../facturacion-pos/facturacion-pos.service";`
2. Agregar `FacturacionPosService` al constructor:

```typescript
  constructor(
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private mecanicosService: MecanicosService,
    private vehiculosService: VehiculosService,
    private facturacionPosService: FacturacionPosService,
  ) {}
```

3. Reemplazar el cuerpo de `changeEstado` (a partir de la validación de transición) por:

```typescript
  async changeEstado(
    id: number,
    estado: EstadoOT,
    fechaSalida?: string,
    userId?: number,
    user?: any,
  ): Promise<OrdenTrabajo> {
    if (estado === EstadoOT.FACTURADA) {
      throw new BadRequestException(
        "FACTURADA se asigna automaticamente al facturar en POSTouch, no se puede establecer manualmente",
      );
    }

    const actor = user || (await this.getUsuarioScope(userId));
    const ot = await this.findById(id, actor);
    const nextStates = ALLOWED_ESTADO_TRANSITIONS[ot.estado] || [];

    if (estado !== ot.estado && !nextStates.includes(estado)) {
      throw new BadRequestException(
        `Transicion invalida: ${ot.estado} -> ${estado}`,
      );
    }

    if (estado === EstadoOT.ENTREGADA && !fechaSalida && !ot.fechaSalida) {
      throw new BadRequestException(
        "Se requiere la fecha de salida para marcar como entregada",
      );
    }

    if (estado === EstadoOT.ENTREGADA) {
      this.ensureReadyForDelivery(ot);
    }

    ot.estado = estado;
    if (fechaSalida) {
      ot.fechaSalida = new Date(fechaSalida);
    }
    ot.version = ot.version + 1;
    ot.updatedBy = userId ?? null;

    await this.otRepository.save(ot);

    await this.logEvento(
      id,
      TipoEventoOT.CAMBIO_ESTADO,
      `Estado cambiado a: ${estado}`,
      userId,
    );

    if (estado === EstadoOT.TERMINADA) {
      await this.intentarEnvioAutomaticoAPostouch(id, userId);
    }

    return this.findById(id, actor);
  }

  private async intentarEnvioAutomaticoAPostouch(
    otId: number,
    userId?: number,
  ): Promise<void> {
    const otParaFacturar = await this.buildOtParaFacturar(otId);
    const resultado = await this.facturacionPosService.enviarOrdenTerminada(
      otParaFacturar,
    );

    if (!resultado.exito) {
      await this.logEvento(
        otId,
        TipoEventoOT.OTRO,
        `No se pudo enviar a POSTouch automaticamente: ${resultado.error}`,
        userId,
      );
      return;
    }

    const ot = await this.otRepository.findOne({ where: { id: otId } });
    if (!ot) {
      return;
    }

    ot.estado = EstadoOT.FACTURADA;
    ot.version = ot.version + 1;
    ot.updatedBy = userId ?? null;
    await this.otRepository.save(ot);

    await this.logEvento(
      otId,
      TipoEventoOT.OTRO,
      "Orden enviada automaticamente a POSTouch (pendiente de pago)",
      userId,
    );
  }
```

Agregar también el método `buildOtParaFacturar`, reutilizando `findById` (que ya carga `cotizaciones.lineas` y adjunta `(ot as any).cliente` desde `clienteRepository` — ver `ots.service.ts:246-276`) y tomando la cotización en estado `APROBADA` de esa OT:

```typescript
  private async buildOtParaFacturar(otId: number): Promise<OtParaFacturar> {
    const ot = await this.findById(otId);
    const cliente = (ot as any).cliente as ClienteLocal | null;

    const cotizacionAprobada = (ot.cotizaciones || []).find(
      (c) => c.estado === EstadoCotizacion.APROBADA,
    );

    if (!cotizacionAprobada) {
      throw new BadRequestException(
        `La OT ${ot.numeroOt} no tiene una cotizacion aprobada, no se puede facturar`,
      );
    }

    return {
      id: ot.id,
      numeroOt: ot.numeroOt,
      clienteTarjeta: ot.clienteTarjeta,
      cliente: {
        nombre: cliente?.nombre || "CONSUMIDOR FINAL",
        nit: cliente?.nit || "CF",
        telefono: cliente?.telefono,
        email: cliente?.email,
        direccion: cliente?.direccion,
      },
      cotizacion: {
        lineas: cotizacionAprobada.lineas.map((linea) => ({
          plu: linea.plu,
          descripcion: linea.descripcion,
          precioUnitario: Number(linea.precioUnitario),
          cantidad: Number(linea.cantidad),
          esServicio: linea.esServicio,
          origenLocal: linea.plu.startsWith("TLLP-"),
        })),
      },
    };
  }
```

Agregar los imports que este método necesita en `ots.service.ts`: `import { EstadoCotizacion } from "../cotizaciones/entities/cotizacion.entity";` (junto a los imports existentes de `ClienteLocal` y `EstadoOT`, ya presentes en el archivo).

4. Actualizar `ALLOWED_ESTADO_TRANSITIONS`:

```typescript
const ALLOWED_ESTADO_TRANSITIONS: Record<EstadoOT, EstadoOT[]> = {
  [EstadoOT.BORRADOR]: [EstadoOT.COTIZADA],
  [EstadoOT.COTIZADA]: [EstadoOT.APROBADA],
  [EstadoOT.APROBADA]: [EstadoOT.EN_PROCESO],
  [EstadoOT.EN_PROCESO]: [EstadoOT.TERMINADA],
  [EstadoOT.TERMINADA]: [EstadoOT.FACTURADA, EstadoOT.ENTREGADA],
  [EstadoOT.FACTURADA]: [EstadoOT.ENTREGADA],
  [EstadoOT.ENTREGADA]: [],
};
```

(`TERMINADA → ENTREGADA` se mantiene directo para tenants sin facturación POS habilitada — `intentarEnvioAutomaticoAPostouch` simplemente no mueve el estado si `enviarOrdenTerminada` devuelve `exito:false` por tenant no habilitado, dejando la OT en `TERMINADA` como hoy.)

- [ ] **Step 4: Correr los tests para verificar que pasan**

Run: `cd backend && npx jest src/ots/ots.service.spec.ts`
Expected: PASS (todos, incluyendo los 3 nuevos)

- [ ] **Step 5: Importar `FacturacionPosModule` en `OtsModule`**

Editar `backend/src/ots/ots.module.ts`, agregar `FacturacionPosModule` a `imports` (import correspondiente al inicio del archivo).

- [ ] **Step 6: Compilar todo el backend**

Run: `cd backend && npx tsc --noEmit`
Expected: sin errores.

- [ ] **Step 7: Commit**

```bash
git add backend/src/ots/ots.service.ts backend/src/ots/ots.service.spec.ts backend/src/ots/ots.module.ts
git commit -m "feat: disparar envio automatico a POSTouch al terminar una OT"
```

---

### Task 12: Registrar `FacturacionPosModule` en `AppModule`

**Files:**
- Modify: `backend/src/app.module.ts`

- [ ] **Step 1: Agregar el import**

Run: `cd backend && grep -n "OtsModule" src/app.module.ts`

Agregar `FacturacionPosModule` al arreglo de `imports` de `AppModule`, junto a `OtsModule` (import correspondiente al inicio del archivo).

- [ ] **Step 2: Levantar el backend en modo desarrollo para verificar que arranca sin errores de DI**

Run: `cd backend && npm run start:dev`
Expected: el log muestra `Nest application successfully started` sin errores de `UnknownDependenciesException`. Detener el proceso (Ctrl+C) una vez confirmado.

- [ ] **Step 3: Commit**

```bash
git add backend/src/app.module.ts
git commit -m "feat: registrar FacturacionPosModule en AppModule"
```

---

### Task 13: Reflejar `FACTURADA` en el frontend

**Files:**
- Modify: archivo(s) donde el frontend define las etiquetas/colores de `EstadoOT` (localizar con el Step 1).

- [ ] **Step 1: Localizar dónde el frontend mapea estados a etiquetas**

Run: `cd frontend && grep -rln "entregada.*Entregada\|ESTADO_LABELS\|estadoLabel" src --include="*.ts" --include="*.tsx"`

- [ ] **Step 2: Agregar la entrada para `facturada`**

En el archivo(s) localizado en el Step 1, agregar `facturada: "Facturada"` (y su color/badge siguiendo el mismo patrón que las demás entradas, ej. un tono distinto al de `terminada` y al de `entregada` para que se distinga visualmente en las listas de OTs).

- [ ] **Step 3: Verificar visualmente**

Run: `cd frontend && npm run dev`

Abrir el listado de OTs en el navegador y confirmar que el filtro/badge de estado no rompe con el nuevo valor (aunque todavía no haya ninguna OT en `facturada` en datos de prueba, el build/tipo no debe fallar). Detener el proceso una vez confirmado.

- [ ] **Step 4: Commit**

```bash
git add frontend/src
git commit -m "feat: agregar etiqueta de estado FACTURADA en el frontend"
```

---

## Notas para quien ejecute este plan

- Este plan **no** implementa el job de detección de pago/FEL ni el bloqueo de `FACTURADA → ENTREGADA` hasta confirmar pago — eso es el Plan 2 (`docs/superpowers/specs/2026-09-07-integracion-facturacion-postouch-design.md`, §6-7), que depende de que este plan ya esté en producción (necesita OTs reales en estado `FACTURADA` con fila en `taller_ot_facturacion` para poder probarse). Tampoco incluye la consolidación de la bitácora (Plan 3, independiente).
- Antes de activar `facturacionPosHabilitada` en un tenant real, completar el spike de la sección 11 del spec contra el sandbox `autocentro` (confirmar comportamiento real de `media=99`, `precioDecimales` real de esa tienda, y credenciales de servicio) — las Tareas 1-13 de este plan son seguras de mergear sin ese spike porque el flag por defecto es `false` y no afecta a ningún tenant hasta que un admin lo active explícitamente vía el endpoint de la Tarea 3.
