# 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 { // 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 = { '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 { // 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 { 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:** -