erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-SISTEMA-SECUENCIAS.md

18 KiB

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

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

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')

-- 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

// 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

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

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

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:

-- 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:

-- 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

-- 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

# 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)

-- 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

// 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

-- 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

// 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

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

-- 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

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


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