18 KiB
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
- Identificar último número usado en sistema legacy
- Crear secuencia con
number_next = último + 1 - 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
- 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: -