# Especificación Técnica: Sistema de Secuencias

**Código:** SPEC-TRANS-001
**Versión:** 1.0
**Fecha:** 2025-12-08
**Estado:** Especificado
**Basado en:** Odoo ir.sequence (v18.0)

---

## 1. Resumen Ejecutivo

### 1.1 Propósito
El Sistema de Secuencias proporciona numeración automática y configurable para todos los documentos del ERP (facturas, órdenes de compra, órdenes de venta, pickings, etc.).

### 1.2 Alcance
- Generación de números únicos secuenciales
- Soporte para prefijos/sufijos con interpolación de fechas
- Reinicio automático (anual, mensual, nunca)
- Multi-empresa
- Subsecuencias por rango de fechas

### 1.3 Módulos Afectados
| Módulo | Uso |
|--------|-----|
| MGN-004 (Financiero) | Facturas, asientos, pagos |
| MGN-006 (Compras) | Órdenes de compra, RFQ |
| MGN-007 (Ventas) | Cotizaciones, órdenes de venta |
| MGN-005 (Inventario) | Pickings, lotes, series |
| MGN-011 (Proyectos) | Proyectos, tareas |

---

## 2. Modelo de Datos

### 2.1 Tabla Principal: `core_system.sequences`

```sql
CREATE TABLE core_system.sequences (
    -- Identificación
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    code VARCHAR(64) NOT NULL,           -- Código único (ej: 'sale.order')
    name VARCHAR(255) NOT NULL,          -- Nombre descriptivo

    -- Configuración de numeración
    prefix VARCHAR(64),                   -- Prefijo con interpolación
    suffix VARCHAR(64),                   -- Sufijo con interpolación
    padding INTEGER NOT NULL DEFAULT 5,   -- Dígitos con ceros (00001)
    number_next INTEGER NOT NULL DEFAULT 1, -- Próximo número
    number_increment INTEGER NOT NULL DEFAULT 1, -- Paso de incremento

    -- Tipo de implementación
    implementation VARCHAR(20) NOT NULL DEFAULT 'standard'
        CHECK (implementation IN ('standard', 'no_gap')),

    -- Reinicio automático
    reset_period VARCHAR(20) NOT NULL DEFAULT 'year'
        CHECK (reset_period IN ('never', 'year', 'month', 'day')),
    use_date_range BOOLEAN NOT NULL DEFAULT FALSE,

    -- Multi-empresa
    company_id UUID REFERENCES core_auth.companies(id),
    tenant_id UUID NOT NULL,

    -- Auditoría
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID REFERENCES core_auth.users(id),
    updated_by UUID REFERENCES core_auth.users(id),

    -- Constraints
    CONSTRAINT uq_sequence_code_company
        UNIQUE (code, company_id, tenant_id)
);

-- Índices
CREATE INDEX idx_sequences_code ON core_system.sequences(code);
CREATE INDEX idx_sequences_company ON core_system.sequences(company_id);
CREATE INDEX idx_sequences_tenant ON core_system.sequences(tenant_id);

-- Comentarios
COMMENT ON TABLE core_system.sequences IS 'Configuración de secuencias automáticas';
COMMENT ON COLUMN core_system.sequences.code IS 'Código único para identificar la secuencia (ej: sale.order, purchase.order)';
COMMENT ON COLUMN core_system.sequences.implementation IS 'standard=PostgreSQL sequences (rápido, permite gaps), no_gap=sin gaps (lento)';
```

### 2.2 Tabla de Rangos de Fecha: `core_system.sequence_date_ranges`

```sql
CREATE TABLE core_system.sequence_date_ranges (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sequence_id UUID NOT NULL REFERENCES core_system.sequences(id) ON DELETE CASCADE,

    -- Rango de fechas
    date_from DATE NOT NULL,
    date_to DATE NOT NULL,

    -- Numeración para este rango
    number_next INTEGER NOT NULL DEFAULT 1,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Constraints
    CONSTRAINT uq_sequence_date_range
        UNIQUE (sequence_id, date_from, date_to),
    CONSTRAINT chk_date_range
        CHECK (date_from <= date_to)
);

-- Índices
CREATE INDEX idx_seq_date_range_sequence ON core_system.sequence_date_ranges(sequence_id);
CREATE INDEX idx_seq_date_range_dates ON core_system.sequence_date_ranges(date_from, date_to);

COMMENT ON TABLE core_system.sequence_date_ranges IS 'Subsecuencias por período (anual, mensual)';
```

### 2.3 Tabla de Secuencias PostgreSQL (para implementation='standard')

```sql
-- Se crean dinámicamente por cada sequence con implementation='standard'
-- Nombre: seq_{sequence_id}

-- Ejemplo de creación:
CREATE SEQUENCE core_system.seq_550e8400_e29b_41d4_a716_446655440000
    INCREMENT BY 1
    START WITH 1
    NO MINVALUE
    NO MAXVALUE
    CACHE 1;
```

---

## 3. Variables de Interpolación

### 3.1 Variables de Fecha Disponibles

| Variable | Formato | Ejemplo | Descripción |
|----------|---------|---------|-------------|
| `%(year)s` | YYYY | 2025 | Año completo |
| `%(y)s` | YY | 25 | Año 2 dígitos |
| `%(month)s` | MM | 03 | Mes con ceros |
| `%(day)s` | DD | 15 | Día con ceros |
| `%(doy)s` | DDD | 074 | Día del año |
| `%(woy)s` | WW | 11 | Semana del año |
| `%(h24)s` | HH | 14 | Hora 24h |
| `%(h12)s` | HH | 02 | Hora 12h |
| `%(min)s` | MM | 45 | Minutos |
| `%(sec)s` | SS | 30 | Segundos |

### 3.2 Variables de Rango (cuando use_date_range=true)

| Variable | Descripción |
|----------|-------------|
| `%(range_year)s` | Año del inicio del rango |
| `%(range_month)s` | Mes del inicio del rango |
| `%(current_year)s` | Año actual del sistema |

### 3.3 Ejemplos de Patrones

| Patrón | Resultado |
|--------|-----------|
| `INV/%(year)s/` | INV/2025/00001 |
| `%(year)s-%(month)s-` | 2025-03-00001 |
| `FAC-%(y)s-` | FAC-25-00001 |
| `PO/%(year)s/%(month)s/` | PO/2025/03/00001 |
| `DOC-` | DOC-00001 (sin reinicio) |

---

## 4. Lógica de Negocio

### 4.1 Generación de Número

```typescript
// Pseudocódigo del algoritmo
async function nextByCode(
  code: string,
  sequenceDate?: Date,
  companyId?: string
): Promise<string> {

  // 1. Buscar secuencia por código
  const sequence = await findSequence(code, companyId);
  if (!sequence) throw new Error(`Sequence ${code} not found`);

  // 2. Obtener fecha de referencia
  const refDate = sequenceDate || new Date();

  // 3. Si usa date_range, obtener/crear subsecuencia
  let numberSource = sequence;
  if (sequence.useDateRange) {
    numberSource = await getOrCreateDateRange(sequence, refDate);
  }

  // 4. Obtener siguiente número según implementación
  let nextNumber: number;
  if (sequence.implementation === 'standard') {
    nextNumber = await getNextFromPostgresSequence(sequence.id);
  } else {
    nextNumber = await getNextNoGap(numberSource);
  }

  // 5. Formatear con prefijo, padding y sufijo
  const formattedNumber = formatSequence(
    sequence.prefix,
    nextNumber,
    sequence.padding,
    sequence.suffix,
    refDate
  );

  return formattedNumber;
}
```

### 4.2 Interpolación de Variables

```typescript
function interpolateVariables(
  template: string,
  date: Date
): string {
  const vars: Record<string, string> = {
    'year': date.getFullYear().toString(),
    'y': date.getFullYear().toString().slice(-2),
    'month': (date.getMonth() + 1).toString().padStart(2, '0'),
    'day': date.getDate().toString().padStart(2, '0'),
    'doy': getDayOfYear(date).toString().padStart(3, '0'),
    'woy': getWeekOfYear(date).toString().padStart(2, '0'),
    'h24': date.getHours().toString().padStart(2, '0'),
    'h12': (date.getHours() % 12 || 12).toString().padStart(2, '0'),
    'min': date.getMinutes().toString().padStart(2, '0'),
    'sec': date.getSeconds().toString().padStart(2, '0'),
  };

  let result = template;
  for (const [key, value] of Object.entries(vars)) {
    result = result.replace(new RegExp(`%\\(${key}\\)s`, 'g'), value);
  }

  return result;
}
```

### 4.3 Detección de Reinicio Automático

```typescript
function deduceResetPeriod(lastSequence: string): ResetPeriod {
  // Patrones de detección
  const patterns = {
    year: /^(.*)(\d{4})(.*)(\d+)(.*)$/,      // INV/2025/001
    month: /^(.*)(\d{4})(\d{2})(.*)(\d+)(.*)$/, // INV/202503/001
    never: /^(.*)(\d+)(.*)$/,                 // DOC001
  };

  if (patterns.month.test(lastSequence)) return 'month';
  if (patterns.year.test(lastSequence)) return 'year';
  return 'never';
}
```

### 4.4 Creación Automática de Date Range

```typescript
async function getOrCreateDateRange(
  sequence: Sequence,
  date: Date
): Promise<SequenceDateRange> {
  // Buscar rango existente
  const existing = await findDateRange(sequence.id, date);
  if (existing) return existing;

  // Crear nuevo rango según período de reinicio
  const range = calculateDateRange(date, sequence.resetPeriod);

  return await createDateRange({
    sequenceId: sequence.id,
    dateFrom: range.from,
    dateTo: range.to,
    numberNext: 1,
  });
}

function calculateDateRange(
  date: Date,
  period: ResetPeriod
): { from: Date; to: Date } {
  switch (period) {
    case 'year':
      return {
        from: new Date(date.getFullYear(), 0, 1),
        to: new Date(date.getFullYear(), 11, 31),
      };
    case 'month':
      return {
        from: new Date(date.getFullYear(), date.getMonth(), 1),
        to: new Date(date.getFullYear(), date.getMonth() + 1, 0),
      };
    case 'never':
    default:
      return {
        from: new Date(1970, 0, 1),
        to: new Date(9999, 11, 31),
      };
  }
}
```

---

## 5. Implementaciones de Secuencia

### 5.1 Standard (PostgreSQL Sequences)

**Características:**
- Usa secuencias nativas de PostgreSQL
- Muy rápido y escalable
- Permite gaps (si transacción falla, número se pierde)
- Ideal para alto volumen

**Operaciones:**
```sql
-- Crear secuencia
CREATE SEQUENCE core_system.seq_{uuid}
    INCREMENT BY 1
    START WITH 1;

-- Obtener siguiente
SELECT nextval('core_system.seq_{uuid}');

-- Reiniciar
ALTER SEQUENCE core_system.seq_{uuid} RESTART WITH 1;

-- Predecir sin consumir
SELECT last_value, is_called FROM core_system.seq_{uuid};
```

### 5.2 No Gap (Sin Gaps)

**Características:**
- Usa tabla de secuencias con bloqueo FOR UPDATE
- Garantiza números consecutivos sin gaps
- Más lento (bloqueo de fila)
- Requerido para documentos fiscales en algunos países

**Operaciones:**
```sql
-- Obtener siguiente con bloqueo
UPDATE core_system.sequences
SET number_next = number_next + number_increment,
    updated_at = NOW()
WHERE id = '{uuid}'
RETURNING number_next - number_increment AS current_value;

-- Con date_range
UPDATE core_system.sequence_date_ranges
SET number_next = number_next + 1,
    updated_at = NOW()
WHERE sequence_id = '{uuid}'
  AND date_from <= '{date}'
  AND date_to >= '{date}'
RETURNING number_next - 1 AS current_value;
```

---

## 6. Multi-Empresa

### 6.1 Búsqueda de Secuencia

```sql
-- Prioridad: secuencia específica > secuencia global
SELECT * FROM core_system.sequences
WHERE code = 'sale.order'
  AND tenant_id = '{tenant_id}'
  AND (company_id = '{company_id}' OR company_id IS NULL)
ORDER BY company_id NULLS LAST
LIMIT 1;
```

### 6.2 Escenarios

| company_id | Comportamiento |
|------------|----------------|
| NULL | Secuencia global para todas las empresas |
| UUID específico | Secuencia solo para esa empresa |

**Ejemplo:**
```
Secuencia A: code='sale.order', company_id=NULL (global)
Secuencia B: code='sale.order', company_id='empresa-1'

Usuario en empresa-1 → usa Secuencia B (S00001, S00002...)
Usuario en empresa-2 → usa Secuencia A (S00001, S00002...)
```

---

## 7. API REST

### 7.1 Endpoints

```yaml
# Obtener siguiente número
POST /api/v1/sequences/next
Content-Type: application/json
Authorization: Bearer {token}

Request:
{
  "code": "sale.order",
  "sequence_date": "2025-03-15",  // opcional
  "company_id": "uuid"            // opcional
}

Response:
{
  "success": true,
  "data": {
    "sequence": "S00001",
    "sequence_id": "uuid",
    "date_range": {
      "from": "2025-01-01",
      "to": "2025-12-31"
    }
  }
}

# Listar secuencias
GET /api/v1/sequences
Authorization: Bearer {token}

# Obtener secuencia por código
GET /api/v1/sequences/by-code/{code}
Authorization: Bearer {token}

# Crear secuencia
POST /api/v1/sequences
Content-Type: application/json
Authorization: Bearer {token}

Request:
{
  "code": "custom.document",
  "name": "Custom Document Sequence",
  "prefix": "DOC/%(year)s/",
  "padding": 5,
  "reset_period": "year",
  "implementation": "standard"
}

# Actualizar secuencia
PUT /api/v1/sequences/{id}

# Reiniciar secuencia
POST /api/v1/sequences/{id}/reset
Request:
{
  "number_next": 1
}
```

### 7.2 Permisos Requeridos

| Endpoint | Permiso |
|----------|---------|
| GET /sequences | `sequences:read` |
| POST /sequences/next | `sequences:use` |
| POST /sequences | `sequences:create` |
| PUT /sequences/{id} | `sequences:update` |
| POST /sequences/{id}/reset | `sequences:admin` |

---

## 8. Secuencias Predefinidas

### 8.1 Datos Iniciales (Seeds)

```sql
-- Ventas
INSERT INTO core_system.sequences (code, name, prefix, padding, reset_period)
VALUES
  ('sale.quotation', 'Cotizaciones', 'COT/%(year)s/', 5, 'year'),
  ('sale.order', 'Órdenes de Venta', 'OV/%(year)s/', 5, 'year');

-- Compras
INSERT INTO core_system.sequences (code, name, prefix, padding, reset_period)
VALUES
  ('purchase.rfq', 'Solicitudes de Cotización', 'RFQ/%(year)s/', 5, 'year'),
  ('purchase.order', 'Órdenes de Compra', 'OC/%(year)s/', 5, 'year');

-- Contabilidad
INSERT INTO core_system.sequences (code, name, prefix, padding, reset_period, implementation)
VALUES
  ('account.invoice.out', 'Facturas Cliente', 'FAC/%(year)s/', 5, 'year', 'no_gap'),
  ('account.invoice.in', 'Facturas Proveedor', 'FACPROV/%(year)s/', 5, 'year', 'standard'),
  ('account.payment', 'Pagos', 'PAG/%(year)s/', 5, 'year', 'standard'),
  ('account.move', 'Asientos Contables', 'AST/%(year)s/%(month)s/', 6, 'month', 'standard');

-- Inventario
INSERT INTO core_system.sequences (code, name, prefix, padding, reset_period)
VALUES
  ('stock.picking.in', 'Recepciones', 'REC/', 5, 'never'),
  ('stock.picking.out', 'Entregas', 'ENT/', 5, 'never'),
  ('stock.picking.internal', 'Transferencias', 'INT/', 5, 'never'),
  ('stock.lot', 'Lotes', 'LOT', 7, 'never'),
  ('stock.serial', 'Números de Serie', 'SN', 10, 'never');

-- Proyectos
INSERT INTO core_system.sequences (code, name, prefix, padding, reset_period)
VALUES
  ('project.project', 'Proyectos', 'PRJ/%(year)s/', 4, 'year'),
  ('project.task', 'Tareas', 'TASK/', 6, 'never');
```

---

## 9. Consideraciones de Rendimiento

### 9.1 Caching

```typescript
// Cache de secuencias en memoria (Redis recomendado)
interface SequenceCache {
  sequence: Sequence;
  lastAccess: Date;
  ttl: number; // segundos
}

// Cache key: `sequence:${code}:${companyId || 'global'}`
```

### 9.2 Índices Recomendados

```sql
-- Ya incluidos en el DDL
CREATE INDEX idx_sequences_code ON core_system.sequences(code);
CREATE INDEX idx_sequences_company ON core_system.sequences(company_id);

-- Índice compuesto para búsqueda frecuente
CREATE INDEX idx_sequences_lookup
  ON core_system.sequences(code, tenant_id, company_id);

-- Índice para date_ranges
CREATE INDEX idx_seq_date_range_lookup
  ON core_system.sequence_date_ranges(sequence_id, date_from, date_to);
```

### 9.3 Bloqueo y Concurrencia

```typescript
// Para implementation='no_gap', usar transacción con timeout
async function getNextNoGap(sequence: Sequence): Promise<number> {
  return await db.transaction(async (trx) => {
    // Timeout de 5 segundos para evitar deadlocks
    await trx.raw('SET LOCAL lock_timeout = 5000');

    const result = await trx('core_system.sequences')
      .where('id', sequence.id)
      .forUpdate()
      .increment('number_next', sequence.numberIncrement)
      .returning('number_next');

    return result[0].number_next - sequence.numberIncrement;
  });
}
```

---

## 10. Validaciones

### 10.1 Reglas de Negocio

| Regla | Descripción |
|-------|-------------|
| RN-SEQ-001 | El código de secuencia debe ser único por empresa/tenant |
| RN-SEQ-002 | El incremento no puede ser 0 |
| RN-SEQ-003 | El padding debe ser >= 0 |
| RN-SEQ-004 | date_from debe ser <= date_to en rangos |
| RN-SEQ-005 | No se pueden solapar rangos de fechas |

### 10.2 Validación de Prefijos/Sufijos

```typescript
function validateTemplate(template: string): boolean {
  // Variables permitidas
  const allowedVars = [
    'year', 'y', 'month', 'day', 'doy', 'woy',
    'h24', 'h12', 'min', 'sec',
    'range_year', 'range_month', 'current_year'
  ];

  // Extraer variables del template
  const matches = template.matchAll(/%\((\w+)\)s/g);

  for (const match of matches) {
    if (!allowedVars.includes(match[1])) {
      return false;
    }
  }

  return true;
}
```

---

## 11. Migración desde Sistemas Legacy

### 11.1 Estrategia

1. **Identificar último número** usado en sistema legacy
2. **Crear secuencia** con `number_next = último + 1`
3. **Si hay date_ranges**, crear rangos históricos

### 11.2 Script de Migración

```sql
-- Ejemplo: migrar facturas con último número 5432 en 2024
INSERT INTO core_system.sequences (code, name, prefix, padding, number_next)
VALUES ('account.invoice.legacy', 'Facturas Migradas', 'FAC/', 5, 5433);

-- Con date_range para mantener continuidad
INSERT INTO core_system.sequence_date_ranges (sequence_id, date_from, date_to, number_next)
SELECT id, '2024-01-01', '2024-12-31', 5433
FROM core_system.sequences WHERE code = 'account.invoice.legacy';
```

---

## 12. Testing

### 12.1 Casos de Prueba

| ID | Caso | Input | Expected |
|----|------|-------|----------|
| TC-001 | Generación básica | code='test', padding=5 | 00001 |
| TC-002 | Con prefijo | prefix='INV/' | INV/00001 |
| TC-003 | Con año | prefix='%(year)s/' | 2025/00001 |
| TC-004 | Reinicio anual | Llamar en 2026 | 2026/00001 |
| TC-005 | No gap | 2 llamadas paralelas | 00001, 00002 (sin gaps) |
| TC-006 | Multi-empresa | company_id diferente | Secuencias independientes |

### 12.2 Tests de Concurrencia

```typescript
describe('Sequence Concurrency', () => {
  it('should handle 100 parallel requests without gaps (no_gap)', async () => {
    const promises = Array(100).fill(null).map(() =>
      sequenceService.nextByCode('test.no_gap')
    );

    const results = await Promise.all(promises);
    const numbers = results.map(r => parseInt(r.match(/\d+$/)[0]));

    // Verificar que sean consecutivos
    const sorted = [...numbers].sort((a, b) => a - b);
    for (let i = 1; i < sorted.length; i++) {
      expect(sorted[i]).toBe(sorted[i-1] + 1);
    }
  });
});
```

---

## 13. Referencias

- **Odoo ir.sequence:** `odoo/addons/base/models/ir_sequence.py`
- **Odoo SequenceMixin:** `odoo/addons/account/models/sequence_mixin.py`
- **PostgreSQL Sequences:** https://www.postgresql.org/docs/current/sql-createsequence.html
- **ADR-007:** Database Design Standards

---

**Documento creado por:** Requirements-Analyst
**Revisado por:** -
**Aprobado por:** -
