feat(document-series): implement document series management API

- Add UpdateDocumentSeriesController for updating document series.
- Create documentSeriesApiErrorMapper for error handling.
- Define document series routes including CRUD operations.
- Implement Sequelize-based persistence layer for document series.
- Add DTOs for request and response schemas for document series operations.
- Establish common structures for document series data handling.
- Configure TypeScript settings for the document series module.
This commit is contained in:
David Arranz 2026-07-28 18:04:57 +02:00
parent 65015c8c4f
commit 5ed6556036
219 changed files with 4285 additions and 1098 deletions

View File

@ -40,6 +40,7 @@
"@erp/catalogs": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/document-series": "workspace:*",
"@erp/identity": "workspace:*",
"@erp/factuges": "workspace:*",
"@repo/rdx-logger": "workspace:*",

View File

@ -2,6 +2,7 @@ import catalogsAPIModule from "@erp/catalogs/api";
import companiesAPIModule from "@erp/companies/api";
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
import customersAPIModule from "@erp/customers/api";
import documentSeriesAPIModule from "@erp/document-series/api";
import factuGESAPIModule from "@erp/factuges/api";
import identityAPIModule from "@erp/identity/api";
@ -19,6 +20,7 @@ export const registerModules = () => {
registerModule(companiesAPIModule);
registerModule(catalogsAPIModule);
registerModule(customersAPIModule);
registerModule(documentSeriesAPIModule);
registerModule(customerInvoicesAPIModule);
registerModule(factuGESAPIModule);
//registerModule(suppliersAPIModule);

View File

@ -20,8 +20,9 @@ docs/
- [Estado de migración a identity](./architecture/identity-migration-status.md)
- [Flujo frontend de auth y selección de empresa](./frontend/auth-and-company-selection.md)
- [Seed local de admin](./dev/seed-local-admin.sql)
- [SQL manual de `customer_invoice_series`](./dev/customer-invoice-series.sql)
- [Decisión backend de `InvoiceSeries`](./customer-invoices/invoice-series.md)
- [SQL histórico de `customer_invoice_series`](./dev/customer-invoice-series.sql)
- [Migración desde `customer_invoice_series` a `document_series`](./document-series/migration-from-customer-invoice-series.md)
- [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md)
- [README de `modules/auth`](../modules/auth/README.md)
- [README de `modules/identity`](../modules/identity/README.md)
- [README de `modules/companies`](../modules/companies/README.md)

View File

@ -1,20 +1,23 @@
# InvoiceSeries
# InvoiceSeries (Histórico / Obsoleto)
> Documento mantenido solo como referencia histórica previa a la retirada de `/catalogs/invoice-series`.
> Estado actual: la API canónica es `/document-series` y la tabla canónica es `document_series`.
## Decisión consolidada
El recurso backend para las series de facturación de cliente se llama:
Antes de la migración, el recurso backend para las series de facturación de cliente se llamaba:
```txt
InvoiceSeries
```
Su tabla Sequelize asociada es:
Su tabla Sequelize asociada era:
```txt
customer_invoice_series
```
No se usa ya un recurso backend genérico `series`, ni `document-series`, ni `issued-invoice-series`.
Este contenido ya no describe el runtime activo del ERP.
## Alcance funcional

View File

@ -0,0 +1,81 @@
# Proforma Series Contract
## Objetivo
Normalizar el contrato publico de proformas para separar definitivamente la serie propia de la proforma de la serie futura de la factura emitida.
## Semantica correcta
En proformas:
- `document_series_id` identifica la serie documental propia de la proforma
- `proforma_number` guarda el numero propio de la proforma
- `proforma_reference` guarda la referencia visible de la proforma, por ejemplo `PF-0139`
- `proforma_series_code` es el codigo funcional que se usa solo en create para elegir una serie de `document_type = proforma`
- `target_invoice_series_code` identifica la serie futura que se usara al emitir una `issued_invoice`, por ejemplo `F26`
`target_invoice_series_code` nunca debe apuntar implicitamente a la serie documental de proforma. Un valor como `PF` solo seria valido si existiera tambien una serie activa `PF` de `document_type = issued_invoice`, lo cual no debe asumirse.
## Reglas funcionales
### Crear proforma
- se pide fecha
- se pide cliente
- `proforma_series_code` es opcional; si falta, se usa la serie default activa de `document_type = proforma`
- `target_invoice_series_code` es opcional; si falta, se persiste `NULL`
- la UI obtiene ambas listas desde `GET /document-series`, filtrando por `document_type`
### Editar draft
- se permiten cambios comerciales
- se permite cambiar `target_invoice_series_code`
- no se permite cambiar `proforma_series_code`, `document_series_id`, `proforma_number` ni `proforma_reference`
### Editar approved
- la edicion queda restringida
- solo se permite ajustar `target_invoice_series_code` antes de emitir
### Editar issued
- no se permite edicion funcional
## Contrato actual
### Requests
- create acepta `proforma_series_code`
- create acepta `target_invoice_series_code`
- update acepta `target_invoice_series_code`
- el flujo nuevo ya no necesita `series`
### Responses
- el backend emite `target_invoice_series_code` como nombre preferente
- `series` puede mantenerse solo como alias legacy de salida mientras existan consumidores antiguos
## Regla de emision
- si `target_invoice_series_code` tiene valor, la emision usa ese `seriesCode` para `document_type = issued_invoice`
- si `target_invoice_series_code` es `NULL`, `document-series` resuelve la serie default activa de `issued_invoice`
- la UI nunca debe consumir numeracion directa desde `POST /document-series/assign-next`
## Validacion defensiva
Si el request informa `target_invoice_series_code`:
- debe corresponder a una serie activa de `document_type = issued_invoice`
- no se permite volver a persistir series de `document_type = proforma` como target de factura
Si el request informa `proforma_series_code`:
- debe corresponder a una serie activa de `document_type = proforma`
- solo se usa para numerar la proforma en create
- despues queda congelado en `document_series_id`, `proforma_number` y `proforma_reference`
## Retirada futura de `series`
- `series` era ambiguo porque mezclaba dos conceptos distintos
- el contrato correcto usa `proforma_series_code` y `target_invoice_series_code`
- `series` debe retirarse por completo cuando ya no queden consumidores legacy de responses

View File

@ -1,7 +1,7 @@
# Split Proformas / Issued Invoices
Estado: diseno tecnico / fases 1A-1D
Estado del backend: persistencia V2 activada; SQL de desarrollo preparado; sin ejecucion confirmada de migracion DB
Estado: diseno tecnico / fases 1A-1D + 2A-2B
Estado del backend: persistencia V2 activada; integracion con `document-series` activa para numeracion nueva; sin ejecucion confirmada de migracion DB
## Objetivo
@ -19,6 +19,11 @@ Preparar el esquema fisico para separar:
- `proforma_reference` es el identificador visible canonico de proforma.
- `target_invoice_series_code` sustituye la semantica legacy de `series` en proformas.
- `document_series_id` identifica la serie documental usada para numerar la proforma.
- `proforma_number` guarda el numero propio asignado a la proforma.
- `target_invoice_series_code` es opcional y solo puede apuntar a una serie de `issued_invoice`.
- una serie `PF` o cualquier otra serie de `document_type = proforma` nunca debe persistirse en `target_invoice_series_code`.
- si `target_invoice_series_code` queda `NULL`, la emision de la factura usara la serie default activa de `issued_invoice`.
- `issued_invoices` usa `invoice_series_code`, `invoice_number`, `invoice_date` y `source_proforma_id`.
- `proformas` no guarda `issued_invoice_id`; la relacion se resuelve con `issued_invoices.source_proforma_id`.
- Los impuestos del esquema actual son por documento, no por linea: `proforma_taxes` y `issued_invoice_taxes`.
@ -78,7 +83,7 @@ Preparar el esquema fisico para separar:
- contratos de Application (`IProformaRepository`, `IIssuedInvoiceRepository`)
- use cases, servicios de dominio y assemblers
- `TransactionManager`
- `InvoiceSeriesNumberAssigner` para `IssuedInvoice`
- adaptadores legacy de `InvoiceSeriesNumberAssigner` para `IssuedInvoice`
- resolvers de catalogos
- servicios de documentos PDF/preview
@ -87,14 +92,66 @@ Preparar el esquema fisico para separar:
- `CustomerInvoiceModel` como storage real de Proforma e IssuedInvoice
- numerador de proformas sobre `customer_invoices.invoice_number` con `is_proforma = true`
- repositorios Sequelize legacy de `customer_invoices`
- `CustomerInvoiceSeriesModel` y `SequelizeInvoiceSeriesRepository` como wiring transicional no prioritario
### Exclusivas de V2
- tablas `proformas`, `proforma_items`, `proforma_taxes`
- tablas `issued_invoices`, `issued_invoice_items`, `issued_invoice_taxes`
- numerador V2 de proformas sobre `proformas.proforma_reference`
- numerador V2 de proformas sobre `document-series`, con snapshot en `proformas.document_series_id`, `proforma_number` y `proforma_reference`
- mappers y repositorios Sequelize V2
## Regla operativa actual de series en proformas
- al crear una proforma, `document-series` se invoca con `document_type = proforma` para resolver la numeracion visible de la proforma
- ese resultado solo alimenta `document_series_id`, `proforma_number` y `proforma_reference`
- `target_invoice_series_code` no se rellena automaticamente con la serie documental de proforma
- si el usuario no selecciona serie futura de factura, `target_invoice_series_code` se persiste en `NULL`
- si el usuario informa `target_invoice_series_code`, el backend lo conserva solo si corresponde a una serie activa de `issued_invoice`
- al emitir una proforma, la factura definitiva se numera con `document_type = issued_invoice`
- si la proforma trae `target_invoice_series_code`, se usa como `seriesCode`
- si `target_invoice_series_code` es `NULL`, `document-series` resuelve la serie default activa de `issued_invoice`
- `proforma_series_code` es un input de create para `document_type = proforma` y no vuelve a ser editable despues
## Estado Fase 2B
- `customer-invoices` ya no usa `customer_invoice_series` como fuente primaria para listar series activas ni para asignar numeracion nueva en `issued_invoice` y `proforma`
- `/catalogs/invoice-series` fue transicional y queda eliminado antes de produccion
- `document-series` queda como API canonica para listar series activas y asignar numeracion
- la DDL de `proformas` y la migracion/validacion SQL quedan documentadas, no ejecutadas
- la propagacion de `branchId` sigue pendiente porque el contexto de sucursal no llega todavia a los casos de uso activos
## Estado Fase 2C
- se detecto un entorno de desarrollo en `apps/server/.env.development`
- produccion quedo descartada para ejecucion en esta fase
- no se ejecuto SQL real desde esta terminal por bloqueo operativo del cliente de BD local
- la DDL de proformas se endurecio para compatibilidad MariaDB/MySQL mediante `information_schema` y SQL dinamico
- la validacion funcional y la prueba de concurrencia quedaron documentadas para ejecucion manual sobre la BD de desarrollo segura
## Estado Fase 2D
- el contrato publico de proformas ya soporta `target_invoice_series_code` en create y update
- `series` se mantiene como alias legacy temporal en requests
- si `series` y `target_invoice_series_code` llegan a la vez con distinto valor, el request falla por validacion
- las responses de proforma prefieren `target_invoice_series_code`
- `series` se mantiene temporalmente en responses como alias legacy con el mismo valor
- el cliente interno del modulo ya emite `target_invoice_series_code` en requests nuevos
## Estado Fase 2E
- create separa `proforma_series_code` de `target_invoice_series_code`
- `proforma_series_code` solo se usa para numerar la proforma
- update ya no usa `series` en el flujo nuevo ni permite cambiar la serie propia de proforma
- `target_invoice_series_code` sigue siendo el unico concepto de serie futura de factura emitida
- la UI nueva de proformas ya envia `proforma_series_code` y `target_invoice_series_code`
## Rollback recomendado
- no aplicar nada en produccion en esta fase
- si en desarrollo se ejecuta la DDL/migracion y hay que revertir, hacerlo con backup previo o clon de desarrollo, nunca con borrado destructivo improvisado
- conservar `customer_invoice_series` solo como legado historico hasta ejecutar la limpieza operativa documentada
## Tablas nuevas previstas
### proformas
@ -795,3 +852,20 @@ SELECT SUM(total_amount_value) FROM issued_invoices;
- levantar el backend contra esa BD y validar `create`, `update`, `get`, `list` e `issue proforma`
- validar numeracion de proformas V2 y asignacion de serie/numero de factura emitida
- revisar la ruta `VerifactuRecord -> issued_invoice_id` ya con persistencia V2 activa
## Nota document-series
`customer-invoices` debe migrar progresivamente desde `CustomerInvoiceSeriesModel` hacia `document-series`. Desde la Fase 2A:
- `issued_invoice` ya asigna numeración nueva mediante `document-series`
- `proforma` ya asigna referencia nueva mediante `document-series`
- la UI debe consumir `GET /document-series` filtrando por `document_type`
- `CustomerInvoiceSeriesModel` deja de existir como modelo runtime activo
## Fase 2B no incluida
- no ejecucion de SQL en una BD real
- no borrado de tablas/modelos/repositorios legacy
- no propagacion completa de `branchId`
- no cambios de frontend salvo compatibilidad ya existente
- no nueva infraestructura de tests automatizados para estos modulos

View File

@ -0,0 +1,103 @@
-- DDL conservador e idempotente para preparar proformas con document-series.
-- Compatible con MariaDB/MySQL sin depender de ALTER ... IF NOT EXISTS.
-- Ejecutar solo en una BD de desarrollo segura.
-- No ejecutar automaticamente en produccion.
SET @target_schema = DATABASE();
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE
table_schema = @target_schema
AND table_name = 'proformas'
AND column_name = 'document_series_id'
), 'SELECT ''document_series_id already exists''', 'ALTER TABLE proformas ADD COLUMN document_series_id CHAR(36) NULL AFTER company_id'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE
table_schema = @target_schema
AND table_name = 'proformas'
AND column_name = 'proforma_number'
), 'SELECT ''proforma_number already exists''', 'ALTER TABLE proformas ADD COLUMN proforma_number VARCHAR(64) NULL AFTER proforma_reference'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE
table_schema = @target_schema
AND table_name = 'proformas'
AND index_name = 'idx_proformas_company_document_series'
), 'SELECT ''idx_proformas_company_document_series already exists''', 'ALTER TABLE proformas ADD INDEX idx_proformas_company_document_series (company_id, document_series_id)'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET
@ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE
table_schema = @target_schema
AND table_name = 'proformas'
AND index_name = 'idx_proformas_company_proforma_reference'
), 'SELECT ''idx_proformas_company_proforma_reference already exists''', 'ALTER TABLE proformas ADD INDEX idx_proformas_company_proforma_reference (company_id, proforma_reference)'
)
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Validacion minima posterior a la DDL.
-- SELECT column_name
-- FROM information_schema.columns
-- WHERE table_schema = DATABASE()
-- AND table_name = 'proformas'
-- AND column_name IN ('document_series_id', 'proforma_number');
--
-- SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns
-- FROM information_schema.statistics
-- WHERE table_schema = DATABASE()
-- AND table_name = 'proformas'
-- AND index_name IN (
-- 'idx_proformas_company_document_series',
-- 'idx_proformas_company_proforma_reference'
-- )
-- GROUP BY index_name;

View File

@ -0,0 +1,15 @@
UPDATE proformas AS p
JOIN document_series AS ds
ON ds.company_id = p.company_id
AND ds.code = p.target_invoice_series_code
SET
p.target_invoice_series_code = NULL,
p.updated_at = NOW()
WHERE ds.document_type = 'proforma';
SELECT p.id, p.proforma_reference, p.target_invoice_series_code
FROM proformas AS p
JOIN document_series AS ds
ON ds.company_id = p.company_id
AND ds.code = p.target_invoice_series_code
WHERE ds.document_type = 'proforma';

View File

@ -0,0 +1,15 @@
UPDATE proformas AS p
JOIN document_series AS ds
ON ds.code = p.target_invoice_series_code
AND ds.document_type = 'proforma'
SET
p.target_invoice_series_code = NULL,
p.updated_at = NOW()
WHERE p.target_invoice_series_code IS NOT NULL;
SELECT p.id, p.proforma_reference, p.target_invoice_series_code
FROM proformas AS p
JOIN document_series AS ds
ON ds.company_id = p.company_id
AND ds.code = p.target_invoice_series_code
WHERE ds.document_type = 'proforma';

View File

@ -0,0 +1,74 @@
# document-series
Centraliza la gestion de series documentales y su numeracion transaccional para documentos ERP. A fecha de cierre V1, es la API canonica y la tabla canonica para series de `customer-invoices` en `issued_invoice` y `proforma`.
## Estructura
- `modules/document-series/src/api/domain`: agregado `DocumentSeries` y VOs
- `modules/document-series/src/api/application`: use cases, servicios y repositorio
- `modules/document-series/src/api/infrastructure`: Sequelize, Express, DI y servicios públicos
- `modules/document-series/src/common/dto`: contratos HTTP y de transporte con Zod
## Servicios disponibles
- `GET /document-series`
- `GET /document-series/:id`
- `POST /document-series`
- `PUT /document-series/:id`
- `PATCH /document-series/:id/disable`
- `POST /document-series/assign-next`
## Consumo desde otros módulos
`document-series:general` expone:
- `assignNextNumber(params)`
- `listActiveSeries(params)`
## Estado de integración
- `issued-invoices` ya asigna numeracion nueva mediante `document-series`
- `proformas` ya asigna referencia nueva mediante `document-series`
- las proformas V2 persisten snapshot explicito de serie con `document_series_id`, `proforma_number` y `proforma_reference`
- en create de proformas, `proforma_series_code` permite seleccionar la serie de `document_type = proforma`
- en emision de proformas, `target_invoice_series_code` apunta solo a `document_type = issued_invoice`
- el endpoint transicional `/catalogs/invoice-series` queda eliminado antes de produccion
- la UI debe consultar `GET /document-series` filtrando por `document_type`
- la UI no debe consumir `POST /document-series/assign-next` para previsualizar numeros
- `customer_invoice_series` queda como legado historico a retirar mediante limpieza operativa controlada, no como runtime activo
## SQL de soporte V1
- `docs/document-series/sql/migrate-customer-invoice-series-to-document-series.sql`
- `docs/document-series/sql/validate-document-series-migration.sql`
- `docs/document-series/sql/drop-customer-invoice-series-legacy.sql`
- `docs/customer-invoices/sql/add-proforma-document-series-columns.sql`
Estos scripts son conservadores e idempotentes. No se han ejecutado automaticamente en este workspace ni sustituyen una migracion productiva formal.
## Estado Fase 2C
- el entorno detectado para desarrollo es `apps/server/.env.development`
- el acceso apunta a `localhost` con `NODE_ENV=development`
- en esta terminal no se pudo ejecutar SQL real porque:
- no existe cliente `mysql` disponible
- la dependencia `mysql2` del workspace no resuelve una dependencia transitiva (`sql-escaper`), por lo que tampoco fue posible abrir conexion desde Node
- por tanto, la Fase 2C queda preparada y documentada, pero no ejecutada desde este workspace
## Pendientes conocidos
- propagacion real de `branchId` desde el contexto de negocio hasta `assignNextNumber(...)`
- validacion funcional contra una BD de desarrollo migrada
Ejemplo conceptual:
```ts
const documentSeries = getService<DocumentSeriesPublicServicesType>("document-series:general");
const result = await documentSeries.assignNextNumber({
companyId,
documentType: "issued_invoice",
seriesCode: "F",
transaction,
});
```

View File

@ -0,0 +1,91 @@
# Document Series Design
## Objetivo del módulo
`document-series` es un módulo propio porque la serie documental no es un catálogo estático: mantiene reglas de vigencia, flags de activación, defaults y estado transaccional de numeración.
## Por qué no vive en catalogs
Los catálogos del ERP resuelven valores de referencia. Aquí hay concurrencia, bloqueo de fila y mutación transaccional de `next_number`, así que el comportamiento es operativo y no meramente descriptivo.
## Por qué numeración y serie viven juntas
La numeración depende del estado de la serie:
- si está activa
- si está vigente
- si es la serie por defecto
- cuál es su prefijo, sufijo y padding
Separarlo introduciría doble escritura o lecturas no atómicas.
## Modelo conceptual
- `DocumentType`: tipo documental cerrado en V1
- `DocumentSeries`: agregado con configuración y contador
- `AssignedDocumentNumber`: resultado de asignación atómica
## Campos principales
- `companyId`, `branchId`
- `documentType`
- `code`, `name`, `description`
- `prefix`, `suffix`
- `nextNumber`, `padding`
- `validFrom`, `validTo`
- `isDefault`, `isActive`
## Reglas V1
- tipos soportados: `proforma`, `issued_invoice`
- no asigna si la serie está inactiva
- no asigna si la serie está fuera de vigencia
- default por empresa + tipo, con prioridad de `branch_id` cuando aplica
- formato de referencia: `prefix + padded(next_number) + suffix`
## Concurrencia
La asignación usa transacción obligatoria y bloqueo de fila con `LOCK.UPDATE` sobre `document_series`. El incremento de `next_number` se persiste dentro de la misma transacción y con comprobación optimista del valor previo para evitar dobles consumos.
## Integracion con customer-invoices
En V1:
- `issued-invoices` delega la asignacion de numero en `document-series`
- `proformas` delega la asignacion de referencia en `document-series`
- `proformas` persiste `document_series_id` y `proforma_number` como snapshot tecnico adicional a `proforma_reference`
- `proformas.document_series_id` identifica la serie documental usada para numerar la proforma
- `proformas.proforma_number` guarda el numero propio de la proforma
- `proformas.proforma_reference` guarda la referencia visible de la proforma
- `proforma_series_code` es el input funcional de create para elegir la serie de `document_type = proforma`
- `proformas.target_invoice_series_code` es opcional y, cuando existe, apunta a una serie de `issued_invoice`, nunca a una serie de `proforma`
- si `proformas.target_invoice_series_code` es `NULL`, la emision resuelve la serie default activa de `issued_invoice`
- en Fase 2E, `series` deja de ser el contrato nuevo para create/update de proformas
- la UI consulta `GET /document-series` con filtro por `document_type` e idealmente `is_active = true`
- `/catalogs/invoice-series` deja de existir como endpoint runtime
- `customer_invoice_series` no participa ya en flujos activos de lectura o numeracion
## Migracion conservadora
- la migracion historica de `issued_invoice` parte de `customer_invoice_series`
- la creacion de series default de `proforma` usa `companies` como fuente
- las validaciones SQL se mantienen separadas del script de migracion
- no hay ejecucion automatica ni cutover productivo en esta fase
- la DDL de `proformas` usa `information_schema` + SQL dinamico para mantener compatibilidad MariaDB/MySQL sin depender de `IF NOT EXISTS` en `ALTER TABLE`
## Branch scope pendiente
El modelo ya contempla `branchId`, y la resolucion default da prioridad a `branch_id` cuando existe. Aun asi, `customer-invoices` no propaga un contexto real de sucursal hasta `assignNextNumber(...)`, asi que en esta fase toda la integracion sigue operando con alcance de empresa.
## Límites V1
- sin `format_pattern`
- sin reseteos anuales automáticos
- sin reservas de numeración
- sin huecos justificados
- sin migración productiva automática de legacy
## Extensiones futuras
- nuevos `DocumentType`
- seeds/migraciones controladas de series heredadas

View File

@ -0,0 +1,87 @@
# Manual concurrency validation
Validacion manual de concurrencia para `document-series` sin introducir framework nuevo.
## Objetivo
Comprobar que varias asignaciones simultaneas sobre la misma serie:
- no generan duplicados
- consumen numeros consecutivos
- avanzan `next_number` exactamente `N` posiciones
## Precondiciones
- BD de desarrollo segura
- serie de `proforma` activa y conocida
- backend arrancado contra esa BD
- capacidad de lanzar varias peticiones HTTP casi simultaneas
## Caso base recomendado
Serie inicial:
- `document_type = 'proforma'`
- `code = 'PF'`
- `next_number = 100`
Numero de asignaciones concurrentes:
- `N = 10`
Esperado:
- referencias unicas `PF-000100` a `PF-000109`
- `document_series.next_number = 110`
- cero duplicados
## Preparacion SQL
```sql
SELECT id, company_id, document_type, code, prefix, suffix, next_number, padding
FROM document_series
WHERE document_type = 'proforma'
AND code = 'PF';
```
Si hace falta preparar la serie manualmente en desarrollo, hacerlo de forma conservadora y reversible.
## Ejecucion HTTP manual
1. Preparar 10 peticiones de creacion de proforma para la misma empresa y misma serie destino.
2. Lanzarlas en paralelo desde el cliente HTTP disponible.
3. Registrar `proforma_reference`, `proforma_number` y `document_series_id` devueltos.
## Verificaciones SQL
```sql
SELECT proforma_reference, proforma_number, document_series_id
FROM proformas
WHERE document_series_id = '<document_series_id>'
ORDER BY created_at DESC
LIMIT 20;
```
```sql
SELECT proforma_reference, COUNT(*) AS duplicates
FROM proformas
WHERE document_series_id = '<document_series_id>'
GROUP BY proforma_reference
HAVING COUNT(*) > 1;
```
```sql
SELECT id, next_number
FROM document_series
WHERE id = '<document_series_id>';
```
## Criterio de aceptacion
- no hay filas en la query de duplicados
- aparecen `N` nuevas referencias distintas
- `next_number_final = next_number_inicial + N`
## Bloqueo actual en este workspace
En este workspace no se pudo ejecutar la prueba porque la terminal no pudo abrir conexion operativa a la BD de desarrollo. La configuracion detectada es de desarrollo, pero falta cliente `mysql` y el conector `mysql2` local no resuelve todas sus dependencias transitivas.

View File

@ -0,0 +1,57 @@
# Manual validation
Guia minima de validacion manual para el cierre V1 de `document-series`. No requiere nueva infraestructura de tests automatizados.
## Objetivo
Confirmar que `document-series` es la fuente operativa unica para series y numeracion nueva de `customer-invoices` y que las restricciones funcionales V1 siguen activas.
## Precondiciones
- BD de desarrollo con tablas `document_series`, `proformas` e `issued_invoices`
- DDL de `docs/customer-invoices/sql/add-proforma-document-series-columns.sql` aplicada si `proformas` aun no tiene las columnas nuevas
- seed o migracion conservadora de `docs/document-series/sql/migrate-customer-invoice-series-to-document-series.sql` aplicada de forma manual si procede
- backend arrancado con `apps/server/.env.development` o equivalente seguro de desarrollo
## Casos recomendados
1. Asignacion de `issued_invoice` desde `document-series`
- crear o dejar activa una serie `issued_invoice`
- emitir una factura desde el flujo normal
- comprobar que la referencia/numero resultante coincide con `prefix + padded(next_number) + suffix`
- comprobar que `document_series.next_number` se incrementa exactamente en 1
2. Asignacion de `proforma` desde `document-series`
- crear o dejar activa una serie `proforma`
- crear una proforma desde el flujo normal
- comprobar que `proformas.document_series_id`, `proformas.proforma_number` y `proformas.proforma_reference` quedan informados
- comprobar que `proforma_reference` usa el formato de la serie asignada
3. Serie inactiva
- desactivar una serie
- intentar asignar numeracion con esa serie
- comprobar que el caso falla y no incrementa `next_number`
4. Vigencia invalida
- configurar una serie con ventana fuera de fecha
- intentar asignar numeracion
- comprobar que el caso falla y no incrementa `next_number`
5. API canonica de series documentales
- llamar a `/document-series` filtrando `document_type = proforma`
- llamar a `/document-series` filtrando `document_type = issued_invoice`
- comprobar que la respuesta refleja filas activas de `document_series`
6. Endpoint legacy retirado
- llamar a `/catalogs/invoice-series`
- comprobar `404` o endpoint no registrado
## Pendiente conocido
`branchId` aun no se propaga desde `customer-invoices` hasta `assignNextNumber(...)`. Toda la validacion de esta fase debe asumirse en alcance de empresa.
## Estado Fase 2C en este workspace
- configuracion de desarrollo detectada: si
- ejecucion real de SQL desde Codex: no
- motivo: falta cliente `mysql` en la terminal y la dependencia `mysql2` del workspace no pudo abrirse por una dependencia transitiva ausente

View File

@ -0,0 +1,105 @@
# Migration from customer_invoice_series
## Equivalencia conceptual
- `customer_invoice_series.code` -> `document_series.code`
- `customer_invoice_series.company_id` -> `document_series.company_id`
- `customer_invoice_series.next_number` -> `document_series.next_number`
- `customer_invoice_series.padding_length` -> `document_series.padding`
- `customer_invoice_series.is_default` -> `document_series.is_default`
- `customer_invoice_series.is_active` -> `document_series.is_active`
- `document_series.document_type` para legacy de facturas emitidas será `issued_invoice`
## Migración conservadora de issued invoices
```sql
INSERT INTO document_series (
id,
company_id,
branch_id,
document_type,
code,
name,
description,
prefix,
suffix,
next_number,
padding,
valid_from,
valid_to,
is_default,
is_active,
created_at,
updated_at
)
SELECT
cis.id,
cis.company_id,
NULL,
'issued_invoice',
cis.code,
cis.code,
NULL,
CONCAT(cis.code, '-'),
NULL,
cis.next_number,
cis.padding_length,
NULL,
NULL,
cis.is_default,
cis.is_active,
cis.created_at,
cis.updated_at
FROM customer_invoice_series cis
WHERE NOT EXISTS (
SELECT 1
FROM document_series ds
WHERE ds.company_id = cis.company_id
AND ds.document_type = 'issued_invoice'
AND ds.code = cis.code
);
```
## Series de proformas
Crear una serie por defecto por empresa con:
- `document_type = 'proforma'`
- `code = 'PF'`
- `prefix = 'PF-'`
- `next_number = 1`
- `padding = 6`
En Fase 2B la fuente preferida para este seed pasa a ser `companies`, no `customer_invoice_series`, para cubrir empresas activas sin series legacy previas.
## Cambio de assigner
- antes: `InvoiceSeriesNumberAssigner`
- después: `DocumentSeriesNumberAssigner` o `document-series:general.assignNextNumber`
## Estado actual
- `document_series` es la tabla canonica
- `GET /document-series` es la API canonica
- `/catalogs/invoice-series` ha sido retirado del runtime
- `CustomerInvoiceSeriesModel` y su repositorio legacy ya no participan en runtime
## Pendiente
- migración controlada de otros tipos documentales
- ejecucion manual y controlada del SQL conservador de migracion
- DDL previa de `proformas.document_series_id` y `proformas.proforma_number`
- propagacion real de `branchId` desde `customer-invoices`
## Validacion recomendada
- ejecutar `docs/document-series/sql/validate-document-series-migration.sql`
- revisar que no existan duplicados logicos por `company_id + document_type + code`
- revisar que cada empresa activa tenga una default de `proforma`
- revisar que ninguna proforma quede sin `proforma_reference`
## Estado de ejecucion Fase 2C
- se detecto una configuracion de desarrollo en `apps/server/.env.development`
- no se ejecuto SQL real desde esta terminal por bloqueo operativo del cliente/conector local
- produccion no se considero objetivo de ejecucion

View File

@ -0,0 +1,9 @@
-- Limpieza operativa V1.
-- NO ejecutar automaticamente desde codigo ni pipelines.
-- Ejecutar solo cuando:
-- 1) document_series contiene las series migradas necesarias
-- 2) /document-series funciona como API canonica
-- 3) no queda codigo runtime leyendo customer_invoice_series
-- 4) se ha validado create proforma e issue proforma
DROP TABLE customer_invoice_series;

View File

@ -0,0 +1,112 @@
-- Migracion conservadora e idempotente de customer_invoice_series a document_series.
-- No ejecutar automaticamente en produccion.
-- Revisar primero nombres de columnas y existencia de tabla document_series en el entorno destino.
-- 1. Migrar series legacy de facturas emitidas si no existen ya en document_series.
INSERT INTO document_series (
id,
company_id,
branch_id,
document_type,
code,
name,
description,
prefix,
suffix,
next_number,
padding,
valid_from,
valid_to,
is_default,
is_active,
created_at,
updated_at,
deleted_at
)
SELECT
cis.id,
cis.company_id,
NULL,
'issued_invoice',
cis.code,
cis.code,
NULL,
CONCAT(cis.code, '-'),
NULL,
cis.next_number,
cis.padding_length,
NULL,
NULL,
cis.is_default,
cis.is_active,
cis.created_at,
cis.updated_at,
NULL
FROM customer_invoice_series cis
WHERE NOT EXISTS (
SELECT 1
FROM document_series ds
WHERE ds.company_id = cis.company_id
AND ds.document_type = 'issued_invoice'
AND ds.code = cis.code
);
-- 2. Crear una serie por defecto de proformas por empresa solo si no existe ninguna.
-- Fuente preferida: companies. Esto evita depender de que exista customer_invoice_series
-- para empresas nuevas o para empresas sin series legacy de facturas emitidas.
INSERT INTO document_series (
id,
company_id,
branch_id,
document_type,
code,
name,
description,
prefix,
suffix,
next_number,
padding,
valid_from,
valid_to,
is_default,
is_active,
created_at,
updated_at,
deleted_at
)
SELECT
UUID(),
source.id,
NULL,
'proforma',
'PF',
'Proformas',
NULL,
'PF-',
NULL,
1,
6,
NULL,
NULL,
TRUE,
TRUE,
NOW(),
NOW(),
NULL
FROM (
SELECT c.id
FROM companies c
WHERE c.status = 'active'
) AS source
WHERE NOT EXISTS (
SELECT 1
FROM document_series ds
WHERE ds.company_id = source.id
AND ds.document_type = 'proforma'
AND ds.code = 'PF'
);
-- 3. Validaciones manuales recomendadas.
-- SELECT company_id, document_type, code, next_number, padding, is_default, is_active
-- FROM document_series
-- ORDER BY company_id, document_type, code;

View File

@ -0,0 +1,72 @@
-- Validaciones manuales para la migracion conservadora a document_series.
-- No modifica datos.
-- 1. Series duplicadas de issued_invoice tras la migracion legacy.
SELECT
company_id,
document_type,
code,
COUNT(*) AS duplicated_rows
FROM document_series
WHERE document_type = 'issued_invoice'
GROUP BY company_id, document_type, code
HAVING COUNT(*) > 1;
-- 2. Empresas activas sin serie default de proforma.
SELECT c.id AS company_id
FROM companies c
LEFT JOIN document_series ds
ON ds.company_id = c.id
AND ds.document_type = 'proforma'
AND ds.is_default = TRUE
AND ds.deleted_at IS NULL
WHERE c.status = 'active'
GROUP BY c.id
HAVING COUNT(ds.id) = 0;
-- 3. Duplicados logicos por company/type/code.
SELECT
company_id,
document_type,
code,
COUNT(*) AS duplicated_rows
FROM document_series
WHERE deleted_at IS NULL
GROUP BY company_id, document_type, code
HAVING COUNT(*) > 1;
-- 4. Contadores invalidos.
SELECT
id,
company_id,
document_type,
code,
next_number,
padding
FROM document_series
WHERE next_number < 1
OR padding < 1;
-- 5. Ventanas de vigencia incoherentes.
SELECT
id,
company_id,
document_type,
code,
valid_from,
valid_to
FROM document_series
WHERE valid_from IS NOT NULL
AND valid_to IS NOT NULL
AND valid_from > valid_to;
-- 6. Proformas sin referencia visible.
SELECT
id,
company_id,
document_series_id,
proforma_number,
proforma_reference
FROM proformas
WHERE proforma_reference IS NULL
OR TRIM(proforma_reference) = '';

View File

@ -43,6 +43,7 @@
"@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/document-series": "workspace:*",
"@erp/identity": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@lglab/react-qr-code": "^1.4.10",
@ -68,4 +69,4 @@
"sequelize": "^6.37.8",
"zod": "^4.3.6"
}
}
}

View File

@ -1,4 +0,0 @@
export * from "./invoice-series-finder.di";
export * from "./invoice-series-number-assigner.di";
export * from "./invoice-series-snapshot-builders.di";
export * from "./invoice-series-use-cases.di";

View File

@ -1,8 +0,0 @@
import type { IInvoiceSeriesRepository } from "../repositories";
import { type IInvoiceSeriesFinder, InvoiceSeriesFinder } from "../services";
export function buildInvoiceSeriesFinder(
repository: IInvoiceSeriesRepository
): IInvoiceSeriesFinder {
return new InvoiceSeriesFinder(repository);
}

View File

@ -1,9 +0,0 @@
import type { IInvoiceSeriesRepository } from "../repositories";
import {
type IInvoiceSeriesNumberAssigner,
InvoiceSeriesNumberAssigner,
} from "../services";
export const buildInvoiceSeriesNumberAssigner = (
repository: IInvoiceSeriesRepository
): IInvoiceSeriesNumberAssigner => new InvoiceSeriesNumberAssigner(repository);

View File

@ -1,7 +0,0 @@
import { InvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders";
export function buildInvoiceSeriesSnapshotBuilders() {
return {
summary: new InvoiceSeriesSummarySnapshotBuilder(),
};
}

View File

@ -1,17 +0,0 @@
import type { ITransactionManager } from "@erp/core/api";
import type { IInvoiceSeriesFinder } from "../services";
import type { IInvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders";
import { ListInvoiceSeriesUseCase } from "../use-cases";
export function buildListInvoiceSeriesUseCase(deps: {
finder: IInvoiceSeriesFinder;
summarySnapshotBuilder: IInvoiceSeriesSummarySnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new ListInvoiceSeriesUseCase(
deps.finder,
deps.summarySnapshotBuilder,
deps.transactionManager
);
}

View File

@ -1,6 +1,2 @@
export * from "./di";
export * from "./errors";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";
export * from "./use-cases";

View File

@ -1 +0,0 @@
export * from "./invoice-series-repository.interface";

View File

@ -1,25 +0,0 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { InvoiceSeries, InvoiceSeriesCode } from "../../../domain";
export interface IInvoiceSeriesRepository {
findActiveByCompany(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<Collection<InvoiceSeries>, Error>>;
findByCodeInCompany(params: {
companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode;
transaction?: unknown;
}): Promise<Result<Maybe<InvoiceSeries>, Error>>;
findByCodeInCompanyForUpdate(params: {
companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode;
transaction: unknown;
}): Promise<Result<Maybe<InvoiceSeries>, Error>>;
update(invoiceSeries: InvoiceSeries, transaction?: unknown): Promise<Result<void, Error>>;
}

View File

@ -1,2 +1 @@
export * from "./invoice-series-finder";
export * from "./invoice-series-number-assigner";

View File

@ -1,23 +0,0 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { InvoiceSeries } from "../../../domain";
import type { IInvoiceSeriesRepository } from "../repositories";
export interface IInvoiceSeriesFinder {
findActiveByCompany(
companyId: UniqueID,
transaction?: unknown
): Promise<Result<Collection<InvoiceSeries>, Error>>;
}
export class InvoiceSeriesFinder implements IInvoiceSeriesFinder {
public constructor(private readonly repository: IInvoiceSeriesRepository) {}
public findActiveByCompany(companyId: UniqueID, transaction?: unknown) {
return this.repository.findActiveByCompany({
companyId,
transaction,
});
}
}

View File

@ -1,24 +1,18 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { type Maybe, Result } from "@repo/rdx-utils";
import { type InvoiceNumber, type InvoiceSeriesCode } from "../../../domain";
import {
InvoiceSeriesInactiveError,
InvoiceSeriesNotFoundError,
InvoiceSeriesTransactionRequiredError,
} from "../errors";
import type { IInvoiceSeriesRepository } from "../repositories";
import type { InvoiceNumber, InvoiceSerie } from "../../../domain";
export interface InvoiceSeriesAssignment {
invoiceNumber: InvoiceNumber;
formattedNumber: string;
invoiceSeriesId: string;
invoiceSeriesCode: string;
invoiceSeriesCode: InvoiceSerie;
}
export interface InvoiceSeriesNumberAssignerParams {
companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode;
invoiceSeriesCode: Maybe<InvoiceSerie>;
transaction: unknown;
}
@ -27,50 +21,3 @@ export interface IInvoiceSeriesNumberAssigner {
params: InvoiceSeriesNumberAssignerParams
): Promise<Result<InvoiceSeriesAssignment, Error>>;
}
export class InvoiceSeriesNumberAssigner implements IInvoiceSeriesNumberAssigner {
public constructor(private readonly repository: IInvoiceSeriesRepository) {}
public async assignNextNumber(
params: InvoiceSeriesNumberAssignerParams
): Promise<Result<InvoiceSeriesAssignment, Error>> {
if (!params.transaction) {
return Result.fail(new InvoiceSeriesTransactionRequiredError());
}
const invoiceSeriesResult = await this.repository.findByCodeInCompanyForUpdate(params);
if (invoiceSeriesResult.isFailure) {
return Result.fail(invoiceSeriesResult.error);
}
if (invoiceSeriesResult.data.isNone()) {
return Result.fail(new InvoiceSeriesNotFoundError());
}
const invoiceSeries = invoiceSeriesResult.data.unwrap();
if (!invoiceSeries.isActive) {
return Result.fail(new InvoiceSeriesInactiveError(invoiceSeries.code.toPrimitive()));
}
const invoiceNumberResult = invoiceSeries.assignNextInvoiceNumber();
if (invoiceNumberResult.isFailure) {
return Result.fail(invoiceNumberResult.error);
}
const updateResult = await this.repository.update(invoiceSeries, params.transaction);
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok({
invoiceNumber: invoiceNumberResult.data,
formattedNumber: invoiceSeries.formatInvoiceNumber(invoiceNumberResult.data),
invoiceSeriesId: invoiceSeries.id.toString(),
invoiceSeriesCode: invoiceSeries.code.toPrimitive(),
});
}
}

View File

@ -1 +0,0 @@
export * from "./invoice-series-summary.snapshot-builder";

View File

@ -1,21 +0,0 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import type { InvoiceSeriesSummaryDTO } from "../../../../../common";
import type { InvoiceSeries } from "../../../../domain";
export interface IInvoiceSeriesSummarySnapshotBuilder
extends ISnapshotBuilder<InvoiceSeries, InvoiceSeriesSummaryDTO> {}
export class InvoiceSeriesSummarySnapshotBuilder
implements IInvoiceSeriesSummarySnapshotBuilder
{
public toOutput(invoiceSeries: InvoiceSeries): InvoiceSeriesSummaryDTO {
return {
id: invoiceSeries.id.toString(),
code: invoiceSeries.code.toPrimitive(),
next_number: invoiceSeries.nextNumber.toPrimitive(),
padding_length: invoiceSeries.paddingLength.toPrimitive(),
is_default: invoiceSeries.isDefault,
};
}
}

View File

@ -1 +0,0 @@
export * from "./list-invoice-series.use-case";

View File

@ -1,40 +0,0 @@
import type { ITransactionManager } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IInvoiceSeriesFinder } from "../services";
import type { IInvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders";
type ListInvoiceSeriesUseCaseInput = {
companyId: UniqueID;
};
export class ListInvoiceSeriesUseCase {
public constructor(
private readonly finder: IInvoiceSeriesFinder,
private readonly summarySnapshotBuilder: IInvoiceSeriesSummarySnapshotBuilder,
private readonly transactionManager: ITransactionManager
) {}
public execute(params: ListInvoiceSeriesUseCaseInput) {
const { companyId } = params;
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const result = await this.finder.findActiveByCompany(companyId, transaction);
if (result.isFailure) {
return Result.fail(result.error);
}
const items = result.data.map((invoiceSeries) =>
this.summarySnapshotBuilder.toOutput(invoiceSeries)
);
return Result.ok({ items });
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -2,17 +2,16 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceCreateProps,
InvoiceSeriesCode,
IssuedInvoice,
} from "../../../domain";
import type { IInvoiceSeriesNumberAssigner } from "../../invoice-series";
import type { IIssuedInvoiceRepository } from "../repositories";
import type { ProformaToIssuedInvoiceCreateProps } from "./proforma-to-issued-invoice-props-converter";
export interface IIssuedInvoiceCreatorParams {
companyId: UniqueID;
id: UniqueID;
props: Omit<IIssuedInvoiceCreateProps, "invoiceNumber">;
props: ProformaToIssuedInvoiceCreateProps;
transaction: unknown;
}
@ -37,15 +36,9 @@ export class IssuedInvoiceCreator implements IIssuedInvoiceCreator {
async create(params: IIssuedInvoiceCreatorParams): Promise<Result<IssuedInvoice, Error>> {
const { companyId, id, props, transaction } = params;
const invoiceSeriesCodeResult = InvoiceSeriesCode.create(props.series.toPrimitive());
if (invoiceSeriesCodeResult.isFailure) {
return Result.fail(invoiceSeriesCodeResult.error);
}
const numberResult = await this.invoiceSeriesNumberAssigner.assignNextNumber({
companyId,
invoiceSeriesCode: invoiceSeriesCodeResult.data,
invoiceSeriesCode: props.series,
transaction,
});
@ -55,7 +48,15 @@ export class IssuedInvoiceCreator implements IIssuedInvoiceCreator {
const invoiceNumber = numberResult.data.invoiceNumber;
const invoiceResult = IssuedInvoice.create({ ...props, invoiceNumber, companyId }, id);
const invoiceResult = IssuedInvoice.create(
{
...props,
series: numberResult.data.invoiceSeriesCode,
invoiceNumber,
companyId,
},
id
);
if (invoiceResult.isFailure) {
return Result.fail(invoiceResult.error);

View File

@ -6,6 +6,7 @@ import { Maybe, Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceCreateProps,
InvoicePaymentMethod,
type InvoiceSerie,
InvoiceStatus,
InvoiceTaxRegime,
IssuedInvoiceItem,
@ -16,7 +17,12 @@ import {
} from "../../../domain";
import type { ProformaIssueReadModel } from "../models";
export type ProformaToIssuedInvoiceCreateProps = Omit<IIssuedInvoiceCreateProps, "invoiceNumber">;
export type ProformaToIssuedInvoiceCreateProps = Omit<
IIssuedInvoiceCreateProps,
"invoiceNumber" | "series"
> & {
series: Maybe<InvoiceSerie>;
};
export interface IProformaToIssuedInvoiceConverter {
toCreateProps(source: ProformaIssueReadModel): Result<ProformaToIssuedInvoiceCreateProps, Error>;
@ -70,7 +76,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
companyId: proforma.companyId,
status: InvoiceStatus.issued(),
series: proforma.series.getOrUndefined()!,
series: proforma.series,
linkedProformaId: proforma.id,
// La fecha de factura debe reflejar la emisión, no la fecha original de la proforma.

View File

@ -4,6 +4,8 @@ import {
type IProformaCreator,
type IProformaNumberGenerator,
ProformaCreator,
type ProformaSeriesValidator,
type ProformaTargetInvoiceSeriesValidator,
type ProformaTaxResolver,
} from "../services";
@ -12,6 +14,8 @@ export const buildProformaCreator = (params: {
paymentResolver: ProformaPaymentResolver;
numberService: IProformaNumberGenerator;
repository: IProformaRepository;
proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
}): IProformaCreator => {
return new ProformaCreator(params);
};

View File

@ -1,12 +1,18 @@
// modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts
import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "../services";
import { type IProformaUpdater, type ProformaTaxResolver, ProformaUpdater } from "../services";
import {
type IProformaUpdater,
type ProformaTargetInvoiceSeriesValidator,
type ProformaTaxResolver,
ProformaUpdater,
} from "../services";
export const buildProformaUpdater = (params: {
repository: IProformaRepository;
taxResolver: ProformaTaxResolver;
paymentResolver: ProformaPaymentResolver;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
}): IProformaUpdater => {
return new ProformaUpdater(params);
};

View File

@ -58,9 +58,15 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
errors
);
const series = extractOrPushError(
maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)),
"series",
const proformaSeriesCode = extractOrPushError(
maybeFromNullableResult(dto.proforma_series_code, (value) => InvoiceSerie.create(value)),
"proforma_series_code",
errors
);
const targetInvoiceSeriesCode = extractOrPushError(
maybeFromNullableResult(dto.target_invoice_series_code, (value) => InvoiceSerie.create(value)),
"target_invoice_series_code",
errors
);
@ -126,7 +132,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
errors
);
const items = this.mapItemsProps(dto.items, {
const items = this.mapItemsProps(dto.items ?? [], {
languageCode: languageCode!,
currencyCode: currencyCode!,
globalDiscountPercentage: globalDiscountPercentage!,
@ -139,8 +145,8 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
companyId: params.companyId,
status: InvoiceStatus.draft(),
//invoiceNumber: invoiceNumber!,
series: series!,
proformaSeriesCode: proformaSeriesCode!,
targetInvoiceSeriesCode: targetInvoiceSeriesCode!,
proformaDate: proformaDate!,
operationDate: operationDate!,
@ -283,4 +289,5 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
throw new ValidationErrorCollection("Proforma props mapping failed", errors);
}
}
}

View File

@ -55,10 +55,10 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
const errors: ValidationErrorDetail[] = [];
const proformaPatchProps: ProformaPatchInputProps = {};
toPatchField(dto.series).ifSet((series) => {
proformaPatchProps.series = extractOrPushError(
maybeFromNullableResult(series, (value) => InvoiceSerie.create(value)),
"series",
toPatchField(dto.target_invoice_series_code).ifSet((targetInvoiceSeriesCode) => {
proformaPatchProps.targetInvoiceSeriesCode = extractOrPushError(
maybeFromNullableResult(targetInvoiceSeriesCode, (value) => InvoiceSerie.create(value)),
"target_invoice_series_code",
errors
);
});
@ -262,4 +262,5 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
throw new ValidationErrorCollection("Proforma props mapping failed", errors);
}
}
}

View File

@ -1,4 +1,6 @@
import type { IProformaCreateProps, IProformaItemCreateProps } from "../../../domain";
import type { Maybe } from "@repo/rdx-utils";
import type { InvoiceSerie } from "../../../domain";
import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.model";
@ -12,7 +14,12 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.
* - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio.
*/
export type ProformaCreateInputProps = Omit<IProformaCreateProps, "items" | "proformaReference"> & {
export type ProformaCreateInputProps = Omit<
IProformaCreateProps,
"items" | "proformaReference" | "proformaNumber" | "documentSeriesId" | "series"
> & {
proformaSeriesCode: Maybe<InvoiceSerie>;
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
items: ProformaItemCreateInputProps[];
};

View File

@ -1,4 +1,6 @@
import type { ProformaItemPatchProps, ProformaPatchProps } from "../../../domain";
import type { Maybe } from "@repo/rdx-utils";
import type { InvoiceSerie } from "../../../domain";
import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.model";
@ -12,7 +14,8 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.
* - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio.
*/
export type ProformaPatchInputProps = Omit<ProformaPatchProps, "items"> & {
export type ProformaPatchInputProps = Omit<ProformaPatchProps, "items" | "series"> & {
targetInvoiceSeriesCode?: Maybe<InvoiceSerie>;
items?: ProformaItemPatchInputProps[];
};

View File

@ -8,5 +8,7 @@ export * from "./proforma-finder";
export * from "./proforma-issuer";
export * from "./proforma-number-generator.interface";
export * from "./proforma-public-services.interface";
export * from "./proforma-series-validator.interface";
export * from "./proforma-status-changer";
export * from "./proforma-target-invoice-series-validator.interface";
export * from "./proforma-updater";

View File

@ -4,12 +4,15 @@ import { Maybe, Result } from "@repo/rdx-utils";
import {
type IProformaCreateProps,
type IProformaItemCreateProps,
InvoiceNumber,
Proforma,
} from "../../../domain";
import type { ProformaCreateInputProps } from "../models";
import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
import type { ProformaSeriesValidator } from "./proforma-series-validator.interface";
import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface";
import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver";
import type { IProformaNumberGenerator } from "./proforma-number-generator.interface";
@ -31,6 +34,8 @@ export class ProformaCreator implements IProformaCreator {
numberService: IProformaNumberGenerator;
taxResolver: ProformaTaxResolver;
paymentResolver: ProformaPaymentResolver;
proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
}
) {}
@ -43,11 +48,21 @@ export class ProformaCreator implements IProformaCreator {
return Result.fail(resolvedProps.error);
}
const proformaSeriesValidationResult =
await this.deps.proformaSeriesValidator.ensureValidProformaSeries({
companyId,
proformaSeriesCode: resolvedProps.data.proformaSeriesCode,
transaction,
});
if (proformaSeriesValidationResult.isFailure) {
return Result.fail(proformaSeriesValidationResult.error);
}
// 1. Obtener siguiente número
const { series } = props;
const numberResult = await this.deps.numberService.getNextForCompany(
companyId,
series,
resolvedProps.data.proformaSeriesCode,
transaction
);
@ -55,11 +70,41 @@ export class ProformaCreator implements IProformaCreator {
return Result.fail(numberResult.error);
}
const proformaReference = numberResult.data;
const targetInvoiceSeriesValidationResult =
await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({
companyId,
targetInvoiceSeriesCode: resolvedProps.data.targetInvoiceSeriesCode,
transaction,
});
if (targetInvoiceSeriesValidationResult.isFailure) {
return Result.fail(targetInvoiceSeriesValidationResult.error);
}
const proformaReferenceResult = InvoiceNumber.create(numberResult.data.reference);
if (proformaReferenceResult.isFailure) {
return Result.fail(proformaReferenceResult.error);
}
const proformaNumberResult = InvoiceNumber.create(numberResult.data.number);
if (proformaNumberResult.isFailure) {
return Result.fail(proformaNumberResult.error);
}
const proformaReference = proformaReferenceResult.data;
// 2. Crear agregado
const proformaResult = Proforma.create(
{ ...resolvedProps.data, proformaReference, companyId },
{
...resolvedProps.data,
companyId,
documentSeriesId: Maybe.some(numberResult.data.documentSeriesId),
proformaNumber: Maybe.some(proformaNumberResult.data),
proformaReference,
series: resolvedProps.data.targetInvoiceSeriesCode,
},
id
);
@ -88,7 +133,9 @@ export class ProformaCreator implements IProformaCreator {
private async resolveCreateProps(
props: ProformaCreateInputProps
): Promise<Result<Omit<IProformaCreateProps, "proformaReference">, Error>> {
): Promise<
Result<Omit<IProformaCreateProps, "proformaReference" | "proformaNumber" | "documentSeriesId">, Error>
> {
// TODO: Esto hay que cambiarlo en el futuro para buscar valores por defecto, desde la empresa o desde el cliente
const _newProps = {
...props,

View File

@ -1,7 +1,14 @@
import type { InvoiceNumber, InvoiceSerie } from "@erp/customer-invoices/api/domain";
import type { InvoiceSerie } from "@erp/customer-invoices/api/domain";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
export type ProformaNumberAssignment = {
documentSeriesId: string;
documentSeriesCode: string;
number: string;
reference: string;
};
/**
* Servicio de dominio que define cómo se genera el siguiente número de factura.
*/
@ -15,7 +22,7 @@ export interface IProformaNumberGenerator {
*/
getNextForCompany(
companyId: UniqueID,
series: Maybe<InvoiceSerie>,
proformaSeriesCode: Maybe<InvoiceSerie>,
transaction: any
): Promise<Result<InvoiceNumber, Error>>;
): Promise<Result<ProformaNumberAssignment, Error>>;
}

View File

@ -0,0 +1,14 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { InvoiceSerie } from "../../../domain";
export interface EnsureValidProformaSeriesParams {
companyId: UniqueID;
proformaSeriesCode: Maybe<InvoiceSerie>;
transaction?: unknown;
}
export interface ProformaSeriesValidator {
ensureValidProformaSeries(params: EnsureValidProformaSeriesParams): Promise<Result<void, Error>>;
}

View File

@ -0,0 +1,16 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { InvoiceSerie } from "../../../domain";
export interface EnsureValidIssuedInvoiceSeriesParams {
companyId: UniqueID;
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
transaction?: unknown;
}
export interface ProformaTargetInvoiceSeriesValidator {
ensureValidIssuedInvoiceSeries(
params: EnsureValidIssuedInvoiceSeriesParams
): Promise<Result<void, Error>>;
}

View File

@ -1,4 +1,4 @@
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Proforma, ProformaItemPatchProps, ProformaPatchProps } from "../../../domain";
@ -7,6 +7,7 @@ import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver";
import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface";
export interface IProformaUpdater {
update(params: {
@ -23,6 +24,7 @@ export class ProformaUpdater implements IProformaUpdater {
repository: IProformaRepository;
taxResolver: ProformaTaxResolver;
paymentResolver: ProformaPaymentResolver;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
}
) {}
@ -43,10 +45,35 @@ export class ProformaUpdater implements IProformaUpdater {
const proforma = existingResult.data;
if (proforma.status.isIssued()) {
return Result.fail(
new DomainValidationError(
"PROFORMA_ALREADY_ISSUED",
"status",
"Issued proformas cannot be edited"
)
);
}
if (proforma.status.isApproved()) {
const allowedApprovedPatch = this.isApprovedPatchAllowed(patchProps);
if (!allowedApprovedPatch) {
return Result.fail(
new DomainValidationError(
"APPROVED_PROFORMA_RESTRICTED_UPDATE",
"status",
"Approved proformas only allow target invoice series changes before issuing"
)
);
}
}
const resolvedPatch = await this.resolvePatchProps({
companyId,
currentInvoiceDate: proforma.invoiceDate,
patch: patchProps,
transaction,
});
if (resolvedPatch.isFailure) {
@ -81,8 +108,9 @@ export class ProformaUpdater implements IProformaUpdater {
companyId: UniqueID;
currentInvoiceDate: UtcDate;
patch: ProformaPatchInputProps;
transaction: unknown;
}): Promise<Result<ProformaPatchProps, Error>> {
const { patch, companyId, currentInvoiceDate } = params;
const { patch, companyId, currentInvoiceDate, transaction } = params;
if (patch.taxRegimeCode !== undefined) {
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
@ -106,8 +134,24 @@ export class ProformaUpdater implements IProformaUpdater {
}
}
if (patch.targetInvoiceSeriesCode !== undefined) {
const targetInvoiceSeriesValidationResult =
await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({
companyId,
targetInvoiceSeriesCode: patch.targetInvoiceSeriesCode,
transaction,
});
if (targetInvoiceSeriesValidationResult.isFailure) {
return Result.fail(targetInvoiceSeriesValidationResult.error);
}
}
if (patch.items === undefined) {
return Result.ok(patch as ProformaPatchProps);
return Result.ok({
...patch,
series: patch.targetInvoiceSeriesCode,
} as ProformaPatchProps);
}
const effectiveInvoiceDate = patch.proformaDate ?? currentInvoiceDate;
@ -132,7 +176,14 @@ export class ProformaUpdater implements IProformaUpdater {
return Result.ok({
...patch,
series: patch.targetInvoiceSeriesCode,
items: resolvedItems,
});
}
private isApprovedPatchAllowed(patch: ProformaPatchInputProps): boolean {
const allowedKeys = new Set(["targetInvoiceSeriesCode"]);
return Object.keys(patch).every((key) => allowedKeys.has(key));
}
}

View File

@ -36,11 +36,13 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
const allTotals = proforma.totals();
return {
// `series` se mantiene como alias legacy temporal del contrato publico.
id: proforma.id.toString(),
company_id: proforma.companyId.toString(),
proforma_reference: proforma.proformaReference.toString(),
status: proforma.status.toPrimitive() as ProformaFullSnapshot["status"],
target_invoice_series_code: maybeToNullable(proforma.series, (value) => value.toString()),
series: maybeToNullable(proforma.series, (value) => value.toString()),
proforma_date: proforma.invoiceDate.toDateString(),

View File

@ -17,6 +17,7 @@ export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotB
proforma_reference: proforma.proformaReference.toString(),
status: proforma.status.toPrimitive() as ProformaSummaryDTO["status"],
target_invoice_series_code: maybeToNullable(proforma.series, (value) => value.toString()),
series: maybeToNullable(proforma.series, (value) => value.toString()),
proforma_date: proforma.proformaDate.toDateString(),

View File

@ -19,7 +19,6 @@ import {
InvoiceStatus,
type ItemAmount,
} from "../../common/value-objects";
import { InvalidInvoiceSeriesCodeError } from "../../invoice-series";
import {
type IProformaItemCreateProps,
type IProformaItems,
@ -36,6 +35,8 @@ export interface IProformaCreateProps {
companyId: UniqueID;
status: InvoiceStatus;
documentSeriesId: Maybe<string>;
proformaNumber: Maybe<InvoiceNumber>;
proformaReference: InvoiceNumber;
series: Maybe<InvoiceSerie>;
@ -86,6 +87,8 @@ export interface IProforma {
companyId: UniqueID;
status: InvoiceStatus;
documentSeriesId: Maybe<string>;
proformaNumber: Maybe<InvoiceNumber>;
series: Maybe<InvoiceSerie>;
proformaReference: InvoiceNumber;
@ -234,6 +237,14 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return this.props.series;
}
public get documentSeriesId(): Maybe<string> {
return this.props.documentSeriesId;
}
public get proformaNumber(): Maybe<InvoiceNumber> {
return this.props.proformaNumber;
}
public get proformaReference() {
return this.props.proformaReference;
}
@ -363,10 +374,6 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
}
private validateCanBeIssued(): Result<void, Error> {
if (this.series.isNone()) {
return Result.fail(InvalidInvoiceSeriesCodeError.required());
}
if (this.description.isNone()) {
return Result.fail(
new DomainValidationError(

View File

@ -1,12 +1,10 @@
import type { IModuleServer } from "@erp/core/api";
import {
buildInvoiceSeriesDependencies,
buildIssuedInvoicePublicServices,
buildIssuedInvoicesDependencies,
buildProformaPublicServices,
buildProformasDependencies,
invoiceSeriesRouter,
issuedInvoicesRouter,
models,
proformasRouter,
@ -17,7 +15,7 @@ export type { IProformaPublicServices } from "./application";
export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices",
version: "1.0.0",
dependencies: ["catalogs", "customers", "identity", "companies"],
dependencies: ["catalogs", "customers", "identity", "companies", "document-series"],
/**
* Fase de SETUP
@ -27,10 +25,9 @@ export const customerInvoicesAPIModule: IModuleServer = {
* - NO conecta infraestructura
*/
async setup(params) {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
const { logger } = params;
// 1) Dominio interno
const invoiceSeriesInternal = buildInvoiceSeriesDependencies(params);
const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params);
const proformasInternal = buildProformasDependencies(params);
@ -52,7 +49,6 @@ export const customerInvoicesAPIModule: IModuleServer = {
// Implementación privada del módulo
internal: {
invoiceSeries: invoiceSeriesInternal,
issuedInvoices: issuedInvoicesInternal,
proformas: proformasInternal,
},
@ -67,10 +63,9 @@ export const customerInvoicesAPIModule: IModuleServer = {
* - NO construye dominio
*/
async start(params) {
const { app, baseRoutePath, logger, getInternal, getService } = params;
const { logger } = params;
// Registro de rutas HTTP
invoiceSeriesRouter(params);
issuedInvoicesRouter(params);
proformasRouter(params);

View File

@ -1,6 +1,5 @@
import customerInvoiceModelInit from "./models/customer-invoice.model";
import customerInvoiceItemModelInit from "./models/customer-invoice-item.model";
import customerInvoiceSeriesModelInit from "./models/customer-invoice-series.model";
import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
import issuedInvoiceModelInit from "./models/sequelize-issued-invoice.model";
import issuedInvoiceItemModelInit from "./models/sequelize-issued-invoice-item.model";
@ -12,12 +11,10 @@ import verifactuRecordModelInit from "./models/verifactu-record.model";
export * from "./models";
// Array de inicializadores para que registerModels() lo use
// Array de inicializadores para que registerModels() lo use.
export const models = [
customerInvoiceModelInit,
customerInvoiceItemModelInit,
customerInvoiceSeriesModelInit,
customerInvoiceTaxesModelInit,
proformaModelInit,
proformaItemModelInit,

View File

@ -1,112 +0,0 @@
import {
type CreationOptional,
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type Sequelize,
} from "sequelize";
export class CustomerInvoiceSeriesModel extends Model<
InferAttributes<CustomerInvoiceSeriesModel>,
InferCreationAttributes<CustomerInvoiceSeriesModel>
> {
declare id: string;
declare company_id: string;
declare code: string;
declare next_number: number;
declare padding_length: number;
declare is_default: CreationOptional<boolean>;
declare is_active: CreationOptional<boolean>;
declare created_at: CreationOptional<Date>;
declare updated_at: CreationOptional<Date>;
}
export default (database: Sequelize) => {
CustomerInvoiceSeriesModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
charset: "utf8mb4",
collate: "utf8mb4_bin",
} as any,
company_id: {
type: DataTypes.UUID,
allowNull: false,
charset: "utf8mb4",
collate: "utf8mb4_bin",
} as any,
code: {
type: DataTypes.STRING(10),
allowNull: false,
},
next_number: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
},
padding_length: {
type: DataTypes.SMALLINT.UNSIGNED,
allowNull: false,
},
is_default: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
},
},
{
sequelize: database,
modelName: "CustomerInvoiceSeriesModel",
tableName: "customer_invoice_series",
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
underscored: true,
paranoid: false,
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{
name: "idx_customer_invoice_series_company_id",
fields: ["company_id"],
},
{
name: "idx_customer_invoice_series_company_active",
fields: ["company_id", "is_active"],
},
{
name: "uq_customer_invoice_series_company_code",
fields: ["company_id", "code"],
unique: true,
},
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return CustomerInvoiceSeriesModel;
};

View File

@ -1,6 +1,5 @@
export * from "./customer-invoice.model";
export * from "./customer-invoice-item.model";
export * from "./customer-invoice-series.model";
export * from "./customer-invoice-tax.model";
export * from "./sequelize-proforma.model";
export * from "./sequelize-proforma-item.model";

View File

@ -35,6 +35,8 @@ export class ProformaModel extends Model<
declare company_id: string;
declare status: string;
declare document_series_id: CreationOptional<string | null>;
declare proforma_number: CreationOptional<string | null>;
declare proforma_reference: string;
declare proforma_date: string;
declare operation_date: CreationOptional<string | null>;
@ -171,6 +173,18 @@ export default (database: Sequelize) => {
allowNull: false,
defaultValue: "draft",
},
document_series_id: {
type: DataTypes.UUID,
allowNull: true,
defaultValue: null,
charset: "utf8mb4",
collate: "utf8mb4_bin",
} as any,
proforma_number: {
type: new DataTypes.STRING(32),
allowNull: true,
defaultValue: null,
},
proforma_reference: {
type: new DataTypes.STRING(32),
allowNull: false,
@ -414,6 +428,10 @@ export default (database: Sequelize) => {
name: "idx_proformas_company",
fields: ["company_id"],
},
{
name: "idx_proformas_company_document_series",
fields: ["company_id", "document_series_id"],
},
{
name: "uq_proformas_company_reference",
fields: ["company_id", "proforma_reference"],

View File

@ -1,4 +1,3 @@
export * from "./common";
export * from "./invoice-series";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -1,2 +0,0 @@
export * from "./invoice-series.di";
export * from "./invoice-series-repository.di";

View File

@ -1,7 +0,0 @@
import type { Sequelize } from "sequelize";
import { SequelizeInvoiceSeriesRepository } from "../persistence";
import { InvoiceSeriesDomainMapper } from "../persistence/sequelize/mappers";
export const buildInvoiceSeriesRepository = (database: Sequelize) =>
new SequelizeInvoiceSeriesRepository(new InvoiceSeriesDomainMapper(), database);

View File

@ -1,36 +0,0 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import {
type ListInvoiceSeriesUseCase,
buildInvoiceSeriesFinder,
buildInvoiceSeriesSnapshotBuilders,
buildListInvoiceSeriesUseCase,
} from "../../../application";
import { buildInvoiceSeriesRepository } from "./invoice-series-repository.di";
export type InvoiceSeriesInternalDeps = {
useCases: {
listInvoiceSeries: () => ListInvoiceSeriesUseCase;
};
};
export function buildInvoiceSeriesDependencies(params: ModuleParams): InvoiceSeriesInternalDeps {
const { database } = params;
const transactionManager = buildTransactionManager(database);
const repository = buildInvoiceSeriesRepository(database);
const finder = buildInvoiceSeriesFinder(repository);
const snapshotBuilders = buildInvoiceSeriesSnapshotBuilders();
return {
useCases: {
listInvoiceSeries: () =>
buildListInvoiceSeriesUseCase({
finder,
summarySnapshotBuilder: snapshotBuilders.summary,
transactionManager,
}),
},
};
}

View File

@ -1 +0,0 @@
export * from "./list-invoice-series.controller";

View File

@ -1,41 +0,0 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import { ListInvoiceSeriesResponseSchema } from "../../../../../common";
import type { ListInvoiceSeriesUseCase } from "../../../../application";
import { invoiceSeriesApiErrorMapper } from "../invoice-series-api-error-mapper";
export class ListInvoiceSeriesController extends ExpressController {
public constructor(private readonly useCase: ListInvoiceSeriesUseCase) {
super();
this.errorMapper = invoiceSeriesApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId"),
forbidQueryFieldGuard("company_id")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const result = await this.useCase.execute({ companyId });
return result.match(
(data) => {
const dto = ListInvoiceSeriesResponseSchema.parse(data);
return this.ok(dto);
},
(error) => this.handleError(error)
);
}
}

View File

@ -1,3 +0,0 @@
export * from "./controllers";
export * from "./invoice-series-api-error-mapper";
export * from "./invoice-series.routes";

View File

@ -1,87 +0,0 @@
import {
ApiErrorMapper,
type ErrorToApiRule,
InternalApiError,
ValidationApiError,
} from "@erp/core/api";
import {
InvoiceSeriesConcurrencyError,
InvoiceSeriesLockRequiredError,
InvoiceSeriesTransactionRequiredError,
} from "../../../application";
import {
type InvalidInvoiceSeriesCodeError,
type InvalidInvoiceSeriesNextNumberError,
type InvalidInvoiceSeriesPaddingLengthError,
isInvalidInvoiceSeriesCodeError,
isInvalidInvoiceSeriesNextNumberError,
isInvalidInvoiceSeriesPaddingLengthError,
} from "../../../domain";
const invalidInvoiceSeriesCodeRule: ErrorToApiRule = {
priority: 130,
matches: (error) => isInvalidInvoiceSeriesCodeError(error),
build: (error) =>
new ValidationApiError(
(error as InvalidInvoiceSeriesCodeError).message || "Invoice series code is invalid."
),
};
const invalidInvoiceSeriesNextNumberRule: ErrorToApiRule = {
priority: 130,
matches: (error) => isInvalidInvoiceSeriesNextNumberError(error),
build: (error) =>
new ValidationApiError(
(error as InvalidInvoiceSeriesNextNumberError).message ||
"Invoice series next number is invalid."
),
};
const invalidInvoiceSeriesPaddingLengthRule: ErrorToApiRule = {
priority: 130,
matches: (error) => isInvalidInvoiceSeriesPaddingLengthError(error),
build: (error) =>
new ValidationApiError(
(error as InvalidInvoiceSeriesPaddingLengthError).message ||
"Invoice series padding length is invalid."
),
};
const invoiceSeriesTransactionRequiredRule: ErrorToApiRule = {
priority: 130,
matches: (error) => error instanceof InvoiceSeriesTransactionRequiredError,
build: (error) =>
new InternalApiError(
(error as InvoiceSeriesTransactionRequiredError).message ||
"Invoice series operations require an active transaction."
),
};
const invoiceSeriesLockRequiredRule: ErrorToApiRule = {
priority: 130,
matches: (error) => error instanceof InvoiceSeriesLockRequiredError,
build: (error) =>
new InternalApiError(
(error as InvoiceSeriesLockRequiredError).message ||
"Invoice series row lock requires an active transaction."
),
};
const invoiceSeriesConcurrencyRule: ErrorToApiRule = {
priority: 130,
matches: (error) => error instanceof InvoiceSeriesConcurrencyError,
build: (error) =>
new InternalApiError(
(error as InvoiceSeriesConcurrencyError).message ||
"Invoice series persistence ended in an unexpected concurrency state."
),
};
export const invoiceSeriesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(invoiceSeriesConcurrencyRule)
.register(invoiceSeriesLockRequiredRule)
.register(invoiceSeriesTransactionRequiredRule)
.register(invalidInvoiceSeriesPaddingLengthRule)
.register(invalidInvoiceSeriesNextNumberRule)
.register(invalidInvoiceSeriesCodeRule);

View File

@ -1,25 +0,0 @@
import type { StartParams } from "@erp/core/api";
import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import type { InvoiceSeriesInternalDeps } from "../di/invoice-series.di";
import { ListInvoiceSeriesController } from "./controllers";
export const invoiceSeriesRouter = (params: StartParams) => {
const { app, config, getInternal } = params;
const deps = getInternal<InvoiceSeriesInternalDeps>("customer-invoices", "invoiceSeries");
const router: Router = Router({ mergeParams: true });
router.use(...requireIdentityTenant(params));
router.get("/", (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.listInvoiceSeries();
const controller = new ListInvoiceSeriesController(useCase);
return controller.execute(req, res, next);
});
app.use(`${config.server.apiBasePath}/catalogs/invoice-series`, router);
};

View File

@ -1 +0,0 @@
export * from "./invoice-series-domain.mapper";

View File

@ -1,82 +0,0 @@
import {
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
type InvalidInvoiceSeriesCodeError,
type InvalidInvoiceSeriesNextNumberError,
type InvalidInvoiceSeriesPaddingLengthError,
InvoiceSeries,
InvoiceSeriesCode,
type InvoiceSeriesInternalProps,
InvoiceSeriesNextNumber,
InvoiceSeriesPaddingLength,
} from "../../../../../domain";
import type { CustomerInvoiceSeriesModel } from "../../../../common";
export class InvoiceSeriesDomainMapper {
public mapToDomain(raw: CustomerInvoiceSeriesModel): Result<InvoiceSeries, Error> {
const errors: ValidationErrorDetail[] = [];
const id = extractOrPushError(UniqueID.create(raw.id), "id", errors);
const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors);
const codeResult = InvoiceSeriesCode.create(raw.code);
if (codeResult.isFailure) {
return Result.fail(codeResult.error as InvalidInvoiceSeriesCodeError);
}
const nextNumberResult = InvoiceSeriesNextNumber.create(raw.next_number);
if (nextNumberResult.isFailure) {
return Result.fail(nextNumberResult.error as InvalidInvoiceSeriesNextNumberError);
}
const paddingLengthResult = InvoiceSeriesPaddingLength.create(raw.padding_length);
if (paddingLengthResult.isFailure) {
return Result.fail(paddingLengthResult.error as InvalidInvoiceSeriesPaddingLengthError);
}
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Invoice series mapping failed [mapToDomain]", errors)
);
}
const props: InvoiceSeriesInternalProps = {
companyId: companyId!,
code: codeResult.data,
nextNumber: nextNumberResult.data,
paddingLength: paddingLengthResult.data,
isDefault: raw.is_default,
isActive: raw.is_active,
};
return Result.ok(InvoiceSeries.rehydrate(props, id!));
}
public mapToPersistence(source: InvoiceSeries) {
return Result.ok({
id: source.id.toString(),
company_id: source.companyId.toString(),
code: source.code.toPrimitive(),
next_number: source.nextNumber.toPrimitive(),
padding_length: source.paddingLength.toPrimitive(),
is_default: source.isDefault,
is_active: source.isActive,
});
}
public mapOptionalToDomain(
raw: CustomerInvoiceSeriesModel | null
): Result<Maybe<InvoiceSeries>, Error> {
if (!raw) {
return Result.ok(Maybe.none<InvoiceSeries>());
}
return this.mapToDomain(raw).map((invoiceSeries) => Maybe.some(invoiceSeries));
}
}

View File

@ -1 +0,0 @@
export * from "./invoice-series.repository";

View File

@ -1,136 +0,0 @@
import { SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import type { IInvoiceSeriesRepository } from "../../../../../application";
import {
InvoiceSeriesConcurrencyError,
InvoiceSeriesLockRequiredError,
InvoiceSeriesTransactionRequiredError,
} from "../../../../../application";
import type { InvoiceSeries, InvoiceSeriesCode } from "../../../../../domain";
import { CustomerInvoiceSeriesModel } from "../../../../common";
import type { InvoiceSeriesDomainMapper } from "../mappers";
export class SequelizeInvoiceSeriesRepository
extends SequelizeRepository<InvoiceSeries>
implements IInvoiceSeriesRepository
{
public constructor(
private readonly domainMapper: InvoiceSeriesDomainMapper,
database: Sequelize
) {
super({ database });
}
public async findActiveByCompany(params: { companyId: UniqueID; transaction?: unknown }) {
try {
const rows = await CustomerInvoiceSeriesModel.findAll({
where: {
company_id: params.companyId.toString(),
is_active: true,
},
order: [
["is_default", "DESC"],
["code", "ASC"],
],
transaction: params.transaction as Transaction | undefined,
});
const invoiceSeriesRows: InvoiceSeries[] = [];
for (const row of rows) {
const invoiceSeriesResult = this.domainMapper.mapToDomain(row);
if (invoiceSeriesResult.isFailure) {
return Result.fail(invoiceSeriesResult.error);
}
invoiceSeriesRows.push(invoiceSeriesResult.data);
}
return Result.ok(new Collection(invoiceSeriesRows));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findByCodeInCompany(params: {
companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode;
transaction?: unknown;
}) {
try {
const row = await CustomerInvoiceSeriesModel.findOne({
where: {
company_id: params.companyId.toString(),
code: params.invoiceSeriesCode.toPrimitive(),
},
transaction: params.transaction as Transaction | undefined,
});
return this.domainMapper.mapOptionalToDomain(row);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findByCodeInCompanyForUpdate(params: {
companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode;
transaction: unknown;
}) {
try {
const transaction = params.transaction as Transaction;
if (!transaction) {
return Result.fail(new InvoiceSeriesLockRequiredError());
}
const row = await CustomerInvoiceSeriesModel.findOne({
where: {
company_id: params.companyId.toString(),
code: params.invoiceSeriesCode.toPrimitive(),
},
transaction,
lock: transaction.LOCK.UPDATE,
});
return this.domainMapper.mapOptionalToDomain(row);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async update(
invoiceSeries: InvoiceSeries,
transaction?: unknown
): Promise<Result<void, Error>> {
try {
if (!transaction) {
return Result.fail(new InvoiceSeriesTransactionRequiredError());
}
const dtoResult = this.domainMapper.mapToPersistence(invoiceSeries);
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const { id, ...payload } = dtoResult.data;
const [affectedRows] = await CustomerInvoiceSeriesModel.update(payload, {
where: { id },
transaction: transaction as Transaction | undefined,
});
if (affectedRows !== 1) {
return Result.fail(new InvoiceSeriesConcurrencyError());
}
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
}

View File

@ -1,5 +1,6 @@
import type { SetupParams } from "@erp/core/api";
import { buildCatalogs } from "@erp/core/api";
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
@ -9,9 +10,9 @@ import {
type IIssuedInvoiceServicesContext,
buildIssuedInvoiceCreator,
} from "../../../application/issued-invoices";
import { buildInvoiceSeriesNumberAssigner } from "../../../application/invoice-series";
import type { IInvoiceSeriesNumberAssigner } from "../../../application/invoice-series";
import { InvoiceNumber, InvoiceSerie } from "../../../domain";
import { buildInvoiceSeriesRepository } from "../../invoice-series";
import { buildV2IssuedInvoicePersistence } from "./issued-invoice-persistence.di";
import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di";
@ -23,11 +24,46 @@ export function buildIssuedInvoicePublicServices(
// Infrastructure
const catalogs = buildCatalogs();
const documentSeriesServices =
params.getService<DocumentSeriesPublicServicesType>("document-series:general");
// Fase 1C: la emision ya persiste en `issued_invoices` y relaciona Verifactu por `issued_invoice_id`.
const persistence = buildV2IssuedInvoicePersistence({ catalogs, database });
const { repository } = persistence;
const invoiceSeriesRepository = buildInvoiceSeriesRepository(database);
const invoiceSeriesNumberAssigner = buildInvoiceSeriesNumberAssigner(invoiceSeriesRepository);
const invoiceSeriesNumberAssigner: IInvoiceSeriesNumberAssigner = {
assignNextNumber: async ({ companyId, invoiceSeriesCode, transaction }) => {
const requestedSeriesCode = invoiceSeriesCode
.map((series) => series.toPrimitive())
.unwrapOr(null);
const assignResult = await documentSeriesServices.assignNextNumber({
companyId: companyId.toString(),
documentType: "issued_invoice",
seriesCode: requestedSeriesCode,
transaction,
});
if (assignResult.isFailure) {
return Result.fail(assignResult.error);
}
const invoiceNumberResult = InvoiceNumber.create(assignResult.data.number);
if (invoiceNumberResult.isFailure) {
return Result.fail(invoiceNumberResult.error);
}
const assignedSeriesResult = InvoiceSerie.create(assignResult.data.series_code);
if (assignedSeriesResult.isFailure) {
return Result.fail(assignedSeriesResult.error);
}
return Result.ok({
invoiceNumber: invoiceNumberResult.data,
formattedNumber: assignResult.data.reference,
invoiceSeriesId: assignResult.data.series_id,
invoiceSeriesCode: assignedSeriesResult.data,
});
},
};
// Application helpers
const creator = buildIssuedInvoiceCreator({ invoiceSeriesNumberAssigner, repository });

View File

@ -1,5 +1,8 @@
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import type { IProformaNumberGenerator } from "../../../application";
import { SequelizeProformaV2NumberGenerator } from "../persistence";
export const buildV2ProformaNumberGenerator = (): IProformaNumberGenerator =>
new SequelizeProformaV2NumberGenerator();
export const buildV2ProformaNumberGenerator = (
documentSeriesServices: DocumentSeriesPublicServicesType
): IProformaNumberGenerator => new SequelizeProformaV2NumberGenerator(documentSeriesServices);

View File

@ -1,3 +1,4 @@
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import type { Sequelize } from "sequelize";
import { buildV2ProformaNumberGenerator } from "./proforma-number-generator-v2.di";
@ -9,6 +10,7 @@ import { buildProformaRepositoryV2 } from "./proforma-repositories-v2.di";
type BuildProformaPersistenceParams = {
database: Sequelize;
documentSeriesServices: DocumentSeriesPublicServicesType;
};
/*export type LegacyProformaPersistence = {
@ -53,6 +55,6 @@ export const buildV2ProformaPersistence = (
database: params.database,
mappers,
}),
numberGenerator: buildV2ProformaNumberGenerator(),
numberGenerator: buildV2ProformaNumberGenerator(params.documentSeriesServices),
};
};

View File

@ -1,4 +1,5 @@
import type { SetupParams } from "@erp/core/api";
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
@ -17,6 +18,8 @@ import type { Proforma } from "../../../domain";
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
import type { ProformasInternalDeps } from "./proformas.di";
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
type ProformaServicesContext = {
transaction: Transaction;
@ -48,6 +51,8 @@ export function buildProformaPublicServices(
const { database } = params;
const catalogs = resolveProformaCatalogsDeps(params);
const documentSeriesServices =
params.getService<DocumentSeriesPublicServicesType>("document-series:general");
/**
* Fase 1C: los servicios publicos ya consumen Proforma V2.
@ -55,6 +60,7 @@ export function buildProformaPublicServices(
*/
const persistence = buildV2ProformaPersistence({
database,
documentSeriesServices,
});
const { repository, numberGenerator: numberService } = persistence;
const finder = buildProformaFinder(repository);
@ -72,12 +78,20 @@ export function buildProformaPublicServices(
});
const snapshotBuilders = buildProformaSnapshotBuilders();
const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(
documentSeriesServices
);
const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator(
documentSeriesServices
);
const creator = buildProformaCreator({
repository,
numberService,
taxResolver: catalogResolvers.taxResolver,
paymentResolver: catalogResolvers.paymentResolver,
proformaSeriesValidator,
targetInvoiceSeriesValidator,
});
return {

View File

@ -1,4 +1,5 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import type { ICompanyPublicServices } from "../../../../../../companies/src/api";
import {
@ -32,6 +33,8 @@ import {
buildUpdateProformaUseCase,
} from "../../../application";
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
import { buildProformaDocumentServices } from "./proforma-documents.di";
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
@ -62,6 +65,8 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const catalogs = resolveProformaCatalogsDeps(params);
const companiesServices = params.getService<ICompanyPublicServices>("companies:general");
const documentSeriesServices =
params.getService<DocumentSeriesPublicServicesType>("document-series:general");
const transactionManager = buildTransactionManager(database);
/**
@ -70,6 +75,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
*/
const persistence = buildV2ProformaPersistence({
database,
documentSeriesServices,
});
const { repository, numberGenerator: proformaNumberService } = persistence;
@ -89,18 +95,27 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
});
const finder = buildProformaFinder(repository);
const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(
documentSeriesServices
);
const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator(
documentSeriesServices
);
const creator = buildProformaCreator({
taxResolver: catalogResolvers.taxResolver,
paymentResolver: catalogResolvers.paymentResolver,
numberService: proformaNumberService,
repository,
proformaSeriesValidator,
targetInvoiceSeriesValidator,
});
const updater = buildProformaUpdater({
repository,
taxResolver: catalogResolvers.taxResolver,
paymentResolver: catalogResolvers.paymentResolver,
targetInvoiceSeriesValidator,
});
const statusChanger = buildProformaStatusChanger({

View File

@ -11,7 +11,7 @@ import {
maybeFromNullableResult,
maybeToNullable,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import {
InvoiceNumber,
@ -244,6 +244,8 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
companyId: attributes.companyId!,
status: attributes.status!,
documentSeriesId: Maybe.none(),
proformaNumber: Maybe.some(attributes.invoiceNumber!),
series: attributes.series!,
proformaReference: attributes.invoiceNumber!,
proformaDate: attributes.invoiceDate!,

View File

@ -50,6 +50,11 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors);
const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors);
const documentSeriesId = extractOrPushError(
maybeFromNullableResult(raw.document_series_id, (value) => Result.ok(String(value))),
"document_series_id",
errors
);
const series = extractOrPushError(
maybeFromNullableResult(raw.target_invoice_series_code, (value) =>
InvoiceSerie.create(value)
@ -57,6 +62,11 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
"target_invoice_series_code",
errors
);
const proformaNumber = extractOrPushError(
maybeFromNullableResult(raw.proforma_number, (value) => InvoiceNumber.create(value)),
"proforma_number",
errors
);
const invoiceNumber = extractOrPushError(
InvoiceNumber.create(raw.proforma_reference),
"proforma_reference",
@ -125,6 +135,8 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
companyId,
customerId,
status,
documentSeriesId,
proformaNumber,
series,
invoiceNumber,
invoiceDate,
@ -173,6 +185,8 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
const invoiceProps: ProformaInternalProps = {
companyId: attributes.companyId!,
status: attributes.status!,
documentSeriesId: attributes.documentSeriesId!,
proformaNumber: attributes.proformaNumber!,
series: attributes.series!,
proformaReference: attributes.invoiceNumber!,
proformaDate: attributes.invoiceDate!,
@ -235,6 +249,8 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
// Flags / estado / serie / número
status: source.status.toPrimitive(),
document_series_id: maybeToNullable(source.documentSeriesId, (value) => value),
proforma_number: maybeToNullable(source.proformaNumber, (value) => value.toPrimitive()),
proforma_reference: source.proformaReference.toPrimitive(),
proforma_date: source.invoiceDate.toPrimitive(),
operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()),

View File

@ -1,74 +1,40 @@
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { Transaction, WhereOptions } from "sequelize";
import type { Transaction } from "sequelize";
import type { IProformaNumberGenerator } from "../../../../../application/proformas";
import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain";
import { ProformaModel } from "../../../../common/persistence";
import type { InvoiceSerie } from "../../../../../domain";
/**
* Numeracion V2 basada en la tabla fisica `proformas`.
* Queda preparada para el cutover, pero no debe activarse mientras legacy siga en produccion.
* Adapter V2 para delegar la numeracion de proformas en document-series.
*/
export class SequelizeProformaV2NumberGenerator implements IProformaNumberGenerator {
private static readonly PREFIX = "PF-";
private static readonly DIGITS = 4;
public constructor(
private readonly documentSeriesServices: DocumentSeriesPublicServicesType
) {}
public async getNextForCompany(
companyId: UniqueID,
_series: Maybe<InvoiceSerie>,
proformaSeriesCode: Maybe<InvoiceSerie>,
transaction: Transaction
): Promise<Result<InvoiceNumber, Error>> {
const where: WhereOptions = {
company_id: companyId.toString(),
};
try {
const existingInvoices = await ProformaModel.findAll({
attributes: ["proforma_reference"],
where,
transaction,
raw: true,
lock: transaction.LOCK.UPDATE,
});
const maxSequence = existingInvoices.reduce((max, currentInvoice) => {
const currentSequence = this.extractSequence(currentInvoice.proforma_reference);
return Math.max(max, currentSequence);
}, 0);
const nextValue = this.formatSequence(maxSequence + 1);
const numberResult = InvoiceNumber.create(nextValue);
if (numberResult.isFailure) {
return Result.fail(numberResult.error);
) {
return this.documentSeriesServices.assignNextNumber({
companyId: companyId.toString(),
documentType: "proforma",
seriesCode: proformaSeriesCode.getOrUndefined()?.toPrimitive() ?? null,
transaction,
}).then((assignmentResult) => {
if (assignmentResult.isFailure) {
return Result.fail(assignmentResult.error);
}
return Result.ok(numberResult.data);
} catch (error) {
return Result.fail(
new Error(
`Error generating proforma number for company ${companyId}: ${(error as Error).message}`
)
);
}
}
private extractSequence(invoiceNumber: string): number {
const normalized = invoiceNumber.trim().toUpperCase();
if (normalized.startsWith(SequelizeProformaV2NumberGenerator.PREFIX)) {
const suffix = normalized.slice(SequelizeProformaV2NumberGenerator.PREFIX.length);
const parsed = Number(suffix);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
private formatSequence(sequence: number): string {
const padded = String(sequence).padStart(SequelizeProformaV2NumberGenerator.DIGITS, "0");
return `${SequelizeProformaV2NumberGenerator.PREFIX}${padded}`;
return Result.ok({
documentSeriesId: assignmentResult.data.series_id,
documentSeriesCode: assignmentResult.data.series_code,
number: assignmentResult.data.number,
reference: assignmentResult.data.reference,
});
});
}
}

View File

@ -0,0 +1,51 @@
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import { DomainValidationError } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type {
EnsureValidProformaSeriesParams,
ProformaSeriesValidator,
} from "../../../application";
import type { InvoiceSerie } from "../../../domain";
export class DocumentSeriesProformaSeriesValidator implements ProformaSeriesValidator {
public constructor(private readonly documentSeriesServices: DocumentSeriesPublicServicesType) {}
public async ensureValidProformaSeries(
params: EnsureValidProformaSeriesParams
): Promise<Result<void, Error>> {
const proformaSeriesCode = this.normalize(params.proformaSeriesCode);
if (proformaSeriesCode === null) {
return Result.ok();
}
const listResult = await this.documentSeriesServices.listActiveSeries({
companyId: params.companyId.toString(),
documentType: "proforma",
transaction: params.transaction,
});
if (listResult.isFailure) {
return Result.fail(listResult.error);
}
const exists = listResult.data.some((series) => series.code === proformaSeriesCode);
if (!exists) {
return Result.fail(
new DomainValidationError(
"INVALID_PROFORMA_SERIES_CODE",
"proforma_series_code",
"Proforma series code must reference an active proforma series"
)
);
}
return Result.ok();
}
private normalize(proformaSeriesCode: Maybe<InvoiceSerie>): string | null {
return proformaSeriesCode.isSome() ? proformaSeriesCode.unwrap().toPrimitive() : null;
}
}

View File

@ -0,0 +1,53 @@
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import { DomainValidationError } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type {
EnsureValidIssuedInvoiceSeriesParams,
ProformaTargetInvoiceSeriesValidator,
} from "../../../application";
import type { InvoiceSerie } from "../../../domain";
export class DocumentSeriesProformaTargetInvoiceSeriesValidator
implements ProformaTargetInvoiceSeriesValidator
{
public constructor(private readonly documentSeriesServices: DocumentSeriesPublicServicesType) {}
public async ensureValidIssuedInvoiceSeries(
params: EnsureValidIssuedInvoiceSeriesParams
): Promise<Result<void, Error>> {
const targetInvoiceSeriesCode = this.normalize(params.targetInvoiceSeriesCode);
if (targetInvoiceSeriesCode === null) {
return Result.ok();
}
const listResult = await this.documentSeriesServices.listActiveSeries({
companyId: params.companyId.toString(),
documentType: "issued_invoice",
transaction: params.transaction,
});
if (listResult.isFailure) {
return Result.fail(listResult.error);
}
const exists = listResult.data.some((series) => series.code === targetInvoiceSeriesCode);
if (!exists) {
return Result.fail(
new DomainValidationError(
"INVALID_TARGET_INVOICE_SERIES_CODE",
"target_invoice_series_code",
"Target invoice series code must reference an active issued_invoice series"
)
);
}
return Result.ok();
}
private normalize(targetInvoiceSeriesCode: Maybe<InvoiceSerie>): string | null {
return targetInvoiceSeriesCode.isSome() ? targetInvoiceSeriesCode.unwrap().toPrimitive() : null;
}
}

View File

@ -29,7 +29,8 @@ export const CreateProformaRequestSchema = z.object({
id: z.uuid(),
proforma_reference: z.string().nullable().optional(),
series: z.string().nullable(),
proforma_series_code: z.string().nullable().optional(),
target_invoice_series_code: z.string().nullable().optional(),
proforma_date: IsoDateSchema,
operation_date: IsoDateSchema.nullable().optional(),
@ -49,7 +50,7 @@ export const CreateProformaRequestSchema = z.object({
payment_term_id: z.uuid().nullable().optional(),
tax_regime_code: z.string().nullable().optional(),
items: z.array(CreateProformaItemRequestSchema),
items: z.array(CreateProformaItemRequestSchema).optional(),
});
export type CreateProformaRequestDTO = z.infer<typeof CreateProformaRequestSchema>;

View File

@ -35,7 +35,7 @@ export const UpdateProformaByIdParamsRequestSchema = z.object({
});
export const UpdateProformaByIdRequestSchema = z.object({
series: z.string().nullable().optional(),
target_invoice_series_code: z.string().nullable().optional(),
proforma_date: IsoDateSchema.optional(),
operation_date: IsoDateSchema.nullable().optional(),

View File

@ -1,3 +1,2 @@
export * from "./invoice-series";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -1 +0,0 @@
export * from "./list-invoice-series.response.dto";

View File

@ -1,9 +0,0 @@
import { z } from "zod/v4";
import { InvoiceSeriesSummarySchema } from "../../shared/invoice-series";
export const ListInvoiceSeriesResponseSchema = z.object({
items: z.array(InvoiceSeriesSummarySchema),
});
export type ListInvoiceSeriesResponseDTO = z.infer<typeof ListInvoiceSeriesResponseSchema>;

View File

@ -26,6 +26,7 @@ export const GetProformaByIdResponseSchema = z.object({
proforma_reference: z.string(),
status: ProformaStatusSchema,
target_invoice_series_code: z.string().nullable(),
series: z.string().nullable(),
proforma_date: IsoDateSchema,

View File

@ -1,5 +1,4 @@
export * from "./issued-invoices";
export * from "./invoice-series";
export * from "./item-position.dto";
export * from "./payment-method-ref.dto";
export * from "./payment-term-ref.dto";

View File

@ -1 +0,0 @@
export * from "./invoice-series-summary.dto";

View File

@ -1,11 +0,0 @@
import { z } from "zod/v4";
export const InvoiceSeriesSummarySchema = z.object({
id: z.uuid(),
code: z.string(),
next_number: z.number().int().nonnegative(),
padding_length: z.number().int().nonnegative(),
is_default: z.boolean(),
});
export type InvoiceSeriesSummaryDTO = z.infer<typeof InvoiceSeriesSummarySchema>;

View File

@ -10,6 +10,7 @@ export const ProformaSummarySchema = z.object({
proforma_reference: z.string(),
status: ProformaStatusSchema,
target_invoice_series_code: z.string().nullable(),
series: z.string().nullable(),
proforma_date: IsoDateSchema,

View File

@ -267,7 +267,7 @@
"placeholder": "",
"description": ""
},
"invoice_date": {
"proforma_date": {
"label": "Proforma date",
"placeholder": "Select a date",
"description": "Proforma date"

View File

@ -259,8 +259,8 @@
"placeholder": "",
"description": ""
},
"invoice_date": {
"label": "Fecha",
"proforma_date": {
"label": "Fecha de la proforma",
"placeholder": "Selecciona una fecha",
"description": "Fecha de emisión de la proforma"
},

View File

@ -12,7 +12,8 @@ export const buildCreateProformaParams = (
data: {
id: proformaId,
proforma_reference: "",
series: formData.series.trim() || null,
proforma_series_code: formData.proformaSeriesCode.trim() || null,
target_invoice_series_code: formData.targetInvoiceSeriesCode.trim() || null,
proforma_date: formData.proformaDate,
customer_id: formData.customerId,
language_code: formData.languageCode,
@ -21,7 +22,6 @@ export const buildCreateProformaParams = (
payment_method_id: null,
payment_term_id: null,
tax_regime_code: null,
items: [],
},
};
};

View File

@ -19,8 +19,8 @@ import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import type { Proforma } from "../../shared";
import {
buildInvoiceSeriesSelectItems,
useInvoiceSeriesQuery,
buildDocumentSeriesSelectItems,
useDocumentSeriesQuery,
useProformaCreateMutation,
} from "../../shared";
import { buildCreateProformaParams } from "../adapters";
@ -55,7 +55,8 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
error: createError,
} = useProformaCreateMutation();
const seriesQuery = useInvoiceSeriesQuery();
const proformaSeriesQuery = useDocumentSeriesQuery("proforma");
const targetInvoiceSeriesQuery = useDocumentSeriesQuery("issued_invoice");
const initialValues = useMemo<ProformaCreateForm>(() => buildProformaCreateDefault(), []);
@ -65,27 +66,35 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
disabled: isCreating,
});
const seriesOptions = useMemo(
() => buildInvoiceSeriesSelectItems(seriesQuery.data ?? []),
[seriesQuery.data]
const proformaSeriesOptions = useMemo(
() => buildDocumentSeriesSelectItems(proformaSeriesQuery.data ?? []),
[proformaSeriesQuery.data]
);
const defaultSeries = useMemo(() => {
const invoiceSeries = seriesQuery.data ?? [];
const targetInvoiceSeriesOptions = useMemo(
() => [
{ value: "", label: "Automática" },
...buildDocumentSeriesSelectItems(targetInvoiceSeriesQuery.data ?? []),
],
[targetInvoiceSeriesQuery.data]
);
const defaultProformaSeries = useMemo(() => {
const invoiceSeries = proformaSeriesQuery.data ?? [];
return invoiceSeries.find((series) => series.isDefault)?.code ?? invoiceSeries[0]?.code ?? "";
}, [seriesQuery.data]);
}, [proformaSeriesQuery.data]);
useEffect(() => {
if (form.getValues("series")) return;
if (!defaultSeries) return;
if (form.getValues("proformaSeriesCode")) return;
if (!defaultProformaSeries) return;
form.setValue("series", defaultSeries, {
form.setValue("proformaSeriesCode", defaultProformaSeries, {
shouldDirty: false,
shouldTouch: false,
shouldValidate: true,
});
}, [defaultSeries, form]);
}, [defaultProformaSeries, form]);
const setCustomer = (customer: CustomerSelectionOption) => {
setSelectedCustomer(customer);
@ -128,7 +137,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
form.reset(
{
...buildProformaCreateDefault(),
series: formData.series,
proformaSeriesCode: formData.proformaSeriesCode,
},
{ keepDirty: false }
);
@ -179,9 +188,11 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
selectedCustomer,
setCustomer,
clearCustomer,
seriesOptions,
isSeriesLoading: seriesQuery.isLoading,
isSeriesLoadError: seriesQuery.isError,
seriesLoadError: seriesQuery.error,
proformaSeriesOptions,
targetInvoiceSeriesOptions,
isProformaSeriesLoading: proformaSeriesQuery.isLoading,
isTargetInvoiceSeriesLoading: targetInvoiceSeriesQuery.isLoading,
isSeriesLoadError: proformaSeriesQuery.isError || targetInvoiceSeriesQuery.isError,
seriesLoadError: proformaSeriesQuery.error ?? targetInvoiceSeriesQuery.error,
};
};

View File

@ -1,7 +1,8 @@
export interface ProformaCreateForm {
customerId: string;
proformaDate: string;
series: string;
proformaSeriesCode: string;
targetInvoiceSeriesCode: string;
languageCode: string;
currencyCode: string;
}

View File

@ -8,7 +8,8 @@ export const ProformaCreateFormSchema = z.object({
.string()
.min(1, "Introduce una fecha válida.")
.regex(ISO_DATE_PATTERN, "Introduce una fecha válida."),
series: z.string(),
proformaSeriesCode: z.string(),
targetInvoiceSeriesCode: z.string(),
languageCode: z.string().min(1),
currencyCode: z.string().min(1),
});

View File

@ -7,10 +7,10 @@ export const ProformaCreateAutoAppliedInfo = () => {
<AlertCircleIcon className="mt-0.5 size-5 text-blue-600" />
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold">Se aplicará automáticamente</h2>
<h2 className="text-lg font-semibold">Se preparará automáticamente</h2>
<p className="text-sm text-slate-600">
Los siguientes datos se tomarán del cliente o de la configuración de empresa y
podrás modificarlos después en la edición.
Estos valores iniciales se tomarán del cliente o de la configuración de empresa.
Podrás revisarlos y modificarlos después en la edición.
</p>
</div>
@ -45,8 +45,8 @@ export const ProformaCreateAutoAppliedInfo = () => {
<div>
<p className="font-medium">Fiscalidad inicial</p>
<p className="text-sm text-slate-600">
La fiscalidad se tomará del cliente o de la configuración de empresa y podrás
modificarla después en la edición.
Se tomará del cliente o de la configuración de empresa. Los impuestos no se
calcularán hasta que añadas líneas a la proforma.
</p>
</div>
</div>

View File

@ -8,6 +8,7 @@ import type { ProformaCreateForm } from "../../entities";
interface ProformaCreateCustomerFieldProps {
disabled?: boolean;
className?: string;
selectedCustomer?: CustomerSelectionOption | null;
onSelectCustomer: () => void;
onClearCustomer: () => void;
@ -15,6 +16,7 @@ interface ProformaCreateCustomerFieldProps {
export const ProformaCreateCustomerField = ({
disabled = false,
className,
selectedCustomer,
onSelectCustomer,
onClearCustomer,
@ -28,7 +30,7 @@ export const ProformaCreateCustomerField = ({
control={control}
name="customerId"
render={({ fieldState }) => (
<Field className="gap-1" data-invalid={fieldState.invalid}>
<Field className={cn("gap-1", className)} data-invalid={fieldState.invalid}>
<label className="text-sm font-medium leading-none" htmlFor={triggerId}>
Cliente <span className="text-destructive">*</span>
</label>
@ -46,7 +48,9 @@ export const ProformaCreateCustomerField = ({
variant="outline"
>
<span className="truncate">
{selectedCustomer?.name || selectedCustomer?.tradeName || "Buscar o seleccionar cliente..."}
{selectedCustomer?.name ||
selectedCustomer?.tradeName ||
"Buscar o seleccionar cliente..."}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-60" />
</Button>

View File

@ -9,24 +9,28 @@ import { ProformaCreateInitialInfoCard } from "./proforma-create-initial-info-ca
interface ProformaCreateFormProps {
formId: string;
isSubmitting: boolean;
isSeriesLoading?: boolean;
isProformaSeriesLoading?: boolean;
isTargetInvoiceSeriesLoading?: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
selectedCustomer?: CustomerSelectionOption | null;
onSelectCustomer: () => void;
onClearCustomer: () => void;
seriesOptions: { value: string; label: string }[];
proformaSeriesOptions: { value: string; label: string }[];
targetInvoiceSeriesOptions: { value: string; label: string }[];
className?: string;
}
export const ProformaCreateForm = ({
formId,
isSubmitting,
isSeriesLoading = false,
isProformaSeriesLoading = false,
isTargetInvoiceSeriesLoading = false,
onSubmit,
selectedCustomer,
onSelectCustomer,
onClearCustomer,
seriesOptions,
proformaSeriesOptions,
targetInvoiceSeriesOptions,
className,
}: ProformaCreateFormProps) => {
return (
@ -35,11 +39,13 @@ export const ProformaCreateForm = ({
<div className="space-y-6">
<ProformaCreateInitialInfoCard
disabled={isSubmitting}
isSeriesLoading={isSeriesLoading}
isProformaSeriesLoading={isProformaSeriesLoading}
isTargetInvoiceSeriesLoading={isTargetInvoiceSeriesLoading}
onClearCustomer={onClearCustomer}
onSelectCustomer={onSelectCustomer}
proformaSeriesOptions={proformaSeriesOptions}
selectedCustomer={selectedCustomer}
seriesOptions={seriesOptions}
targetInvoiceSeriesOptions={targetInvoiceSeriesOptions}
/>
<ProformaCreateAutoAppliedInfo />

View File

@ -14,35 +14,38 @@ import { ProformaCreateCustomerField } from "./proforma-create-customer-field";
interface ProformaCreateInitialInfoCardProps {
disabled?: boolean;
isSeriesLoading?: boolean;
isProformaSeriesLoading?: boolean;
isTargetInvoiceSeriesLoading?: boolean;
selectedCustomer?: CustomerSelectionOption | null;
onSelectCustomer: () => void;
onClearCustomer: () => void;
seriesOptions: SelectFieldItem[];
proformaSeriesOptions: SelectFieldItem[];
targetInvoiceSeriesOptions: SelectFieldItem[];
}
export const ProformaCreateInitialInfoCard = ({
disabled = false,
isSeriesLoading = false,
isProformaSeriesLoading = false,
isTargetInvoiceSeriesLoading = false,
selectedCustomer,
onSelectCustomer,
onClearCustomer,
seriesOptions,
proformaSeriesOptions,
targetInvoiceSeriesOptions,
}: ProformaCreateInitialInfoCardProps) => {
return (
<FormSectionCard icon={<FileTextIcon className="size-5" />} title="Información inicial">
<FormSectionGrid>
<div className="md:col-span-5">
<ProformaCreateCustomerField
disabled={disabled}
onClearCustomer={onClearCustomer}
onSelectCustomer={onSelectCustomer}
selectedCustomer={selectedCustomer}
/>
</div>
<FormSectionGrid className="md:grid-cols-6 2xl:grid-cols-12">
<ProformaCreateCustomerField
className="md:col-span-4"
disabled={disabled}
onClearCustomer={onClearCustomer}
onSelectCustomer={onSelectCustomer}
selectedCustomer={selectedCustomer}
/>
<DatePickerField<ProformaCreateForm>
className="md:col-span-3"
className="md:col-span-2"
disabled={disabled}
label="Fecha"
name="proformaDate"
@ -51,19 +54,28 @@ export const ProformaCreateInitialInfoCard = ({
/>
<SelectField<ProformaCreateForm>
className="md:col-span-4"
disabled={disabled || isSeriesLoading}
items={seriesOptions}
label="Serie"
name="series"
className="md:col-span-2"
disabled={disabled || isProformaSeriesLoading}
items={proformaSeriesOptions}
label="Serie de esta proforma"
name="proformaSeriesCode"
placeholder={
isSeriesLoading
isProformaSeriesLoading
? "Cargando series..."
: seriesOptions.length > 0
: proformaSeriesOptions.length > 0
? "Selecciona una serie"
: "Sin serie"
}
/>
<SelectField<ProformaCreateForm>
className="md:col-span-2"
disabled={disabled || isTargetInvoiceSeriesLoading}
items={targetInvoiceSeriesOptions}
label="Serie de la factura"
name="targetInvoiceSeriesCode"
placeholder={isTargetInvoiceSeriesLoading ? "Cargando series..." : "Automática"}
/>
</FormSectionGrid>
</FormSectionCard>
);

View File

@ -54,13 +54,15 @@ export const ProformaCreatePage = () => {
<div className="flex-1 overflow-y-auto">
<ProformaCreateForm
formId={createCtrl.formId}
isSeriesLoading={createCtrl.isSeriesLoading}
isProformaSeriesLoading={createCtrl.isProformaSeriesLoading}
isSubmitting={createCtrl.isCreating}
isTargetInvoiceSeriesLoading={createCtrl.isTargetInvoiceSeriesLoading}
onClearCustomer={createCtrl.clearCustomer}
onSelectCustomer={selectCustomerCtrl.selectCtrl.openDialog}
onSubmit={createCtrl.onSubmit}
proformaSeriesOptions={createCtrl.proformaSeriesOptions}
selectedCustomer={createCtrl.selectedCustomer}
seriesOptions={createCtrl.seriesOptions}
targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions}
/>
</div>

Some files were not shown because too many files have changed in this diff Show More