diff --git a/apps/server/package.json b/apps/server/package.json index 94364893..2a722238 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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:*", diff --git a/apps/server/src/register-modules.ts b/apps/server/src/register-modules.ts index 16744e8c..52189439 100644 --- a/apps/server/src/register-modules.ts +++ b/apps/server/src/register-modules.ts @@ -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); diff --git a/docs/README.md b/docs/README.md index 1945180c..44363527 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/customer-invoices/invoice-series.md b/docs/customer-invoices/invoice-series.md index b77b6fc6..c498ed21 100644 --- a/docs/customer-invoices/invoice-series.md +++ b/docs/customer-invoices/invoice-series.md @@ -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 diff --git a/docs/customer-invoices/proforma-series-contract.md b/docs/customer-invoices/proforma-series-contract.md new file mode 100644 index 00000000..f9ed97df --- /dev/null +++ b/docs/customer-invoices/proforma-series-contract.md @@ -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 diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md index e515015f..466c3d98 100644 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ b/docs/customer-invoices/split-proformas-issued-invoices-migration.md @@ -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 diff --git a/docs/customer-invoices/sql/add-proforma-document-series-columns.sql b/docs/customer-invoices/sql/add-proforma-document-series-columns.sql new file mode 100644 index 00000000..002fdc89 --- /dev/null +++ b/docs/customer-invoices/sql/add-proforma-document-series-columns.sql @@ -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; \ No newline at end of file diff --git a/docs/customer-invoices/sql/fix-proforma-series-semantics.sql b/docs/customer-invoices/sql/fix-proforma-series-semantics.sql new file mode 100644 index 00000000..bb05c729 --- /dev/null +++ b/docs/customer-invoices/sql/fix-proforma-series-semantics.sql @@ -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'; diff --git a/docs/customer-invoices/sql/fix-proforma-target-invoice-series-code.sql b/docs/customer-invoices/sql/fix-proforma-target-invoice-series-code.sql new file mode 100644 index 00000000..bb27be85 --- /dev/null +++ b/docs/customer-invoices/sql/fix-proforma-target-invoice-series-code.sql @@ -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'; diff --git a/docs/document-series/README.md b/docs/document-series/README.md new file mode 100644 index 00000000..06118693 --- /dev/null +++ b/docs/document-series/README.md @@ -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("document-series:general"); + +const result = await documentSeries.assignNextNumber({ + companyId, + documentType: "issued_invoice", + seriesCode: "F", + transaction, +}); +``` diff --git a/docs/document-series/document-series-design.md b/docs/document-series/document-series-design.md new file mode 100644 index 00000000..6118ab3b --- /dev/null +++ b/docs/document-series/document-series-design.md @@ -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 diff --git a/docs/document-series/manual-concurrency-validation.md b/docs/document-series/manual-concurrency-validation.md new file mode 100644 index 00000000..b1fbc3fe --- /dev/null +++ b/docs/document-series/manual-concurrency-validation.md @@ -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 = '' +ORDER BY created_at DESC +LIMIT 20; +``` + +```sql +SELECT proforma_reference, COUNT(*) AS duplicates +FROM proformas +WHERE document_series_id = '' +GROUP BY proforma_reference +HAVING COUNT(*) > 1; +``` + +```sql +SELECT id, next_number +FROM document_series +WHERE 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. diff --git a/docs/document-series/manual-validation.md b/docs/document-series/manual-validation.md new file mode 100644 index 00000000..41281ba4 --- /dev/null +++ b/docs/document-series/manual-validation.md @@ -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 diff --git a/docs/document-series/migration-from-customer-invoice-series.md b/docs/document-series/migration-from-customer-invoice-series.md new file mode 100644 index 00000000..3f659d4e --- /dev/null +++ b/docs/document-series/migration-from-customer-invoice-series.md @@ -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 diff --git a/docs/document-series/sql/drop-customer-invoice-series-legacy.sql b/docs/document-series/sql/drop-customer-invoice-series-legacy.sql new file mode 100644 index 00000000..13c33c42 --- /dev/null +++ b/docs/document-series/sql/drop-customer-invoice-series-legacy.sql @@ -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; diff --git a/docs/document-series/sql/migrate-customer-invoice-series-to-document-series.sql b/docs/document-series/sql/migrate-customer-invoice-series-to-document-series.sql new file mode 100644 index 00000000..a39d9b49 --- /dev/null +++ b/docs/document-series/sql/migrate-customer-invoice-series-to-document-series.sql @@ -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; diff --git a/docs/document-series/sql/validate-document-series-migration.sql b/docs/document-series/sql/validate-document-series-migration.sql new file mode 100644 index 00000000..0052bae9 --- /dev/null +++ b/docs/document-series/sql/validate-document-series-migration.sql @@ -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) = ''; diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index 6a10122f..fa140a0d 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/index.ts b/modules/customer-invoices/src/api/application/invoice-series/di/index.ts deleted file mode 100644 index 4704cea8..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/di/index.ts +++ /dev/null @@ -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"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-finder.di.ts b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-finder.di.ts deleted file mode 100644 index 77937961..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-finder.di.ts +++ /dev/null @@ -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); -} diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-number-assigner.di.ts b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-number-assigner.di.ts deleted file mode 100644 index fd1089a2..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-number-assigner.di.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { IInvoiceSeriesRepository } from "../repositories"; -import { - type IInvoiceSeriesNumberAssigner, - InvoiceSeriesNumberAssigner, -} from "../services"; - -export const buildInvoiceSeriesNumberAssigner = ( - repository: IInvoiceSeriesRepository -): IInvoiceSeriesNumberAssigner => new InvoiceSeriesNumberAssigner(repository); diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-snapshot-builders.di.ts b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-snapshot-builders.di.ts deleted file mode 100644 index dfb297a2..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-snapshot-builders.di.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { InvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders"; - -export function buildInvoiceSeriesSnapshotBuilders() { - return { - summary: new InvoiceSeriesSummarySnapshotBuilder(), - }; -} diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-use-cases.di.ts b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-use-cases.di.ts deleted file mode 100644 index 7304be3d..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-use-cases.di.ts +++ /dev/null @@ -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 - ); -} diff --git a/modules/customer-invoices/src/api/application/invoice-series/index.ts b/modules/customer-invoices/src/api/application/invoice-series/index.ts index 70fee2b8..9dea50d8 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/index.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/index.ts @@ -1,6 +1,2 @@ -export * from "./di"; export * from "./errors"; -export * from "./repositories"; export * from "./services"; -export * from "./snapshot-builders"; -export * from "./use-cases"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/repositories/index.ts b/modules/customer-invoices/src/api/application/invoice-series/repositories/index.ts deleted file mode 100644 index 4dd2f740..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/repositories/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./invoice-series-repository.interface"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts b/modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts deleted file mode 100644 index 5d600a66..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts +++ /dev/null @@ -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, Error>>; - - findByCodeInCompany(params: { - companyId: UniqueID; - invoiceSeriesCode: InvoiceSeriesCode; - transaction?: unknown; - }): Promise, Error>>; - - findByCodeInCompanyForUpdate(params: { - companyId: UniqueID; - invoiceSeriesCode: InvoiceSeriesCode; - transaction: unknown; - }): Promise, Error>>; - - update(invoiceSeries: InvoiceSeries, transaction?: unknown): Promise>; -} diff --git a/modules/customer-invoices/src/api/application/invoice-series/services/index.ts b/modules/customer-invoices/src/api/application/invoice-series/services/index.ts index c7cc7e50..46387972 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/services/index.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/services/index.ts @@ -1,2 +1 @@ -export * from "./invoice-series-finder"; export * from "./invoice-series-number-assigner"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-finder.ts b/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-finder.ts deleted file mode 100644 index 7f1d96b6..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-finder.ts +++ /dev/null @@ -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, Error>>; -} - -export class InvoiceSeriesFinder implements IInvoiceSeriesFinder { - public constructor(private readonly repository: IInvoiceSeriesRepository) {} - - public findActiveByCompany(companyId: UniqueID, transaction?: unknown) { - return this.repository.findActiveByCompany({ - companyId, - transaction, - }); - } -} diff --git a/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts b/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts index 3bf652c8..5c6489bd 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts @@ -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; transaction: unknown; } @@ -27,50 +21,3 @@ export interface IInvoiceSeriesNumberAssigner { params: InvoiceSeriesNumberAssignerParams ): Promise>; } - -export class InvoiceSeriesNumberAssigner implements IInvoiceSeriesNumberAssigner { - public constructor(private readonly repository: IInvoiceSeriesRepository) {} - - public async assignNextNumber( - params: InvoiceSeriesNumberAssignerParams - ): Promise> { - 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(), - }); - } -} diff --git a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/index.ts b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/index.ts deleted file mode 100644 index f1eb3e1e..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./summary"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/index.ts b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/index.ts deleted file mode 100644 index cf458449..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./invoice-series-summary.snapshot-builder"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/invoice-series-summary.snapshot-builder.ts b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/invoice-series-summary.snapshot-builder.ts deleted file mode 100644 index 00241b52..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/invoice-series-summary.snapshot-builder.ts +++ /dev/null @@ -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 {} - -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, - }; - } -} diff --git a/modules/customer-invoices/src/api/application/invoice-series/use-cases/index.ts b/modules/customer-invoices/src/api/application/invoice-series/use-cases/index.ts deleted file mode 100644 index 8c8a3dfd..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/use-cases/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./list-invoice-series.use-case"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/use-cases/list-invoice-series.use-case.ts b/modules/customer-invoices/src/api/application/invoice-series/use-cases/list-invoice-series.use-case.ts deleted file mode 100644 index 1baa99f3..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/use-cases/list-invoice-series.use-case.ts +++ /dev/null @@ -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); - } - }); - } -} diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-creator.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-creator.ts index 47f5eb63..9514bbbf 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-creator.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-creator.ts @@ -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; + props: ProformaToIssuedInvoiceCreateProps; transaction: unknown; } @@ -37,15 +36,9 @@ export class IssuedInvoiceCreator implements IIssuedInvoiceCreator { async create(params: IIssuedInvoiceCreatorParams): Promise> { 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); diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts index 7dd71673..4d3adcd5 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts @@ -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; +export type ProformaToIssuedInvoiceCreateProps = Omit< + IIssuedInvoiceCreateProps, + "invoiceNumber" | "series" +> & { + series: Maybe; +}; export interface IProformaToIssuedInvoiceConverter { toCreateProps(source: ProformaIssueReadModel): Result; @@ -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. diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts index 26c8e18f..e1553382 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts @@ -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); }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts index 18e0ec65..7dfaaa54 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts @@ -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); }; diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts index bd168e35..8766cfa1 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts @@ -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); } } + } diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts index b69a7a9c..c5253176 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts @@ -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); } } + } diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts index decef7a1..bcf4e2ea 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts @@ -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 & { +export type ProformaCreateInputProps = Omit< + IProformaCreateProps, + "items" | "proformaReference" | "proformaNumber" | "documentSeriesId" | "series" +> & { + proformaSeriesCode: Maybe; + targetInvoiceSeriesCode: Maybe; items: ProformaItemCreateInputProps[]; }; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts index 8978e52d..46b1acd3 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts @@ -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 & { +export type ProformaPatchInputProps = Omit & { + targetInvoiceSeriesCode?: Maybe; items?: ProformaItemPatchInputProps[]; }; diff --git a/modules/customer-invoices/src/api/application/proformas/services/index.ts b/modules/customer-invoices/src/api/application/proformas/services/index.ts index c8817337..985d2ea7 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts index fdc37dfa..1dc51da1 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts @@ -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, Error>> { + ): Promise< + Result, Error> + > { // TODO: Esto hay que cambiarlo en el futuro para buscar valores por defecto, desde la empresa o desde el cliente const _newProps = { ...props, diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-number-generator.interface.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-number-generator.interface.ts index 9b73cde2..00ef6eaa 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-number-generator.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-number-generator.interface.ts @@ -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, + proformaSeriesCode: Maybe, transaction: any - ): Promise>; + ): Promise>; } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-series-validator.interface.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-series-validator.interface.ts new file mode 100644 index 00000000..682e122f --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-series-validator.interface.ts @@ -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; + transaction?: unknown; +} + +export interface ProformaSeriesValidator { + ensureValidProformaSeries(params: EnsureValidProformaSeriesParams): Promise>; +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-target-invoice-series-validator.interface.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-target-invoice-series-validator.interface.ts new file mode 100644 index 00000000..d416077e --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-target-invoice-series-validator.interface.ts @@ -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; + transaction?: unknown; +} + +export interface ProformaTargetInvoiceSeriesValidator { + ensureValidIssuedInvoiceSeries( + params: EnsureValidIssuedInvoiceSeriesParams + ): Promise>; +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts index a59ea16a..221e0913 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts @@ -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> { - 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)); + } } diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts index 972b02da..fb577d1b 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts @@ -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(), diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts index a575d051..65e79750 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts @@ -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(), diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index 3a3adfa0..f5523e89 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -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; + proformaNumber: Maybe; proformaReference: InvoiceNumber; series: Maybe; @@ -86,6 +87,8 @@ export interface IProforma { companyId: UniqueID; status: InvoiceStatus; + documentSeriesId: Maybe; + proformaNumber: Maybe; series: Maybe; proformaReference: InvoiceNumber; @@ -234,6 +237,14 @@ export class Proforma extends AggregateRoot implements IP return this.props.series; } + public get documentSeriesId(): Maybe { + return this.props.documentSeriesId; + } + + public get proformaNumber(): Maybe { + return this.props.proformaNumber; + } + public get proformaReference() { return this.props.proformaReference; } @@ -363,10 +374,6 @@ export class Proforma extends AggregateRoot implements IP } private validateCanBeIssued(): Result { - if (this.series.isNone()) { - return Result.fail(InvalidInvoiceSeriesCodeError.required()); - } - if (this.description.isNone()) { return Result.fail( new DomainValidationError( diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index cbdfff88..ea8915eb 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -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); diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts index 5ff33a6d..09a823dd 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts @@ -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, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-series.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-series.model.ts deleted file mode 100644 index 7502e85e..00000000 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-series.model.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - type CreationOptional, - DataTypes, - type InferAttributes, - type InferCreationAttributes, - Model, - type Sequelize, -} from "sequelize"; - -export class CustomerInvoiceSeriesModel extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: string; - declare company_id: string; - declare code: string; - declare next_number: number; - declare padding_length: number; - declare is_default: CreationOptional; - declare is_active: CreationOptional; - declare created_at: CreationOptional; - declare updated_at: CreationOptional; -} - -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; -}; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts index eae0cf9f..bcd385f5 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts index ce9f0b04..bc6ff9b6 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts @@ -35,6 +35,8 @@ export class ProformaModel extends Model< declare company_id: string; declare status: string; + declare document_series_id: CreationOptional; + declare proforma_number: CreationOptional; declare proforma_reference: string; declare proforma_date: string; declare operation_date: CreationOptional; @@ -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"], diff --git a/modules/customer-invoices/src/api/infrastructure/index.ts b/modules/customer-invoices/src/api/infrastructure/index.ts index 90501b87..cece438d 100644 --- a/modules/customer-invoices/src/api/infrastructure/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/index.ts @@ -1,4 +1,3 @@ export * from "./common"; -export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts deleted file mode 100644 index f18b0791..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./invoice-series.di"; -export * from "./invoice-series-repository.di"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series-repository.di.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series-repository.di.ts deleted file mode 100644 index b2738be5..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series-repository.di.ts +++ /dev/null @@ -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); diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series.di.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series.di.ts deleted file mode 100644 index c2a6638b..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series.di.ts +++ /dev/null @@ -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, - }), - }, - }; -} diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/index.ts deleted file mode 100644 index e3460916..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./list-invoice-series.controller"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/list-invoice-series.controller.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/list-invoice-series.controller.ts deleted file mode 100644 index 21579783..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/list-invoice-series.controller.ts +++ /dev/null @@ -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) - ); - } -} diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/index.ts deleted file mode 100644 index df8a16c7..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./controllers"; -export * from "./invoice-series-api-error-mapper"; -export * from "./invoice-series.routes"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series-api-error-mapper.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series-api-error-mapper.ts deleted file mode 100644 index dbd272c4..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series-api-error-mapper.ts +++ /dev/null @@ -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); diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series.routes.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series.routes.ts deleted file mode 100644 index 8950905d..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series.routes.ts +++ /dev/null @@ -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("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); -}; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/index.ts deleted file mode 100644 index 368fe8ca..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./invoice-series-domain.mapper"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts deleted file mode 100644 index 15e2c8ef..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts +++ /dev/null @@ -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 { - 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, Error> { - if (!raw) { - return Result.ok(Maybe.none()); - } - - return this.mapToDomain(raw).map((invoiceSeries) => Maybe.some(invoiceSeries)); - } -} diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/index.ts deleted file mode 100644 index 758a4658..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./invoice-series.repository"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts deleted file mode 100644 index f2793f3a..00000000 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts +++ /dev/null @@ -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 - 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> { - 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)); - } - } -} diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts index aac3b3ce..e5ed8701 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts @@ -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("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 }); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-number-generator-v2.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-number-generator-v2.di.ts index 022af2a7..f29829aa 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-number-generator-v2.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-number-generator-v2.di.ts @@ -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); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence.di.ts index cbd35dc1..8ac76206 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence.di.ts @@ -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), }; }; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts index ecd1bbd3..cb3eabe9 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts @@ -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("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 { diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index e6aca674..9fb10f02 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -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("companies:general"); + const documentSeriesServices = + params.getService("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({ diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts index 743cae18..0c3ff51b 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts @@ -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!, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts index 7551fa2b..9758a32e 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts @@ -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()), diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-v2-number-generator.service.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-v2-number-generator.service.ts index 6a4e22ee..e6429b1e 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-v2-number-generator.service.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/services/sequelize-proforma-v2-number-generator.service.ts @@ -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, + proformaSeriesCode: Maybe, transaction: Transaction - ): Promise> { - 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, + }); + }); } } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/services/document-series-proforma-series-validator.ts b/modules/customer-invoices/src/api/infrastructure/proformas/services/document-series-proforma-series-validator.ts new file mode 100644 index 00000000..e5b50058 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/services/document-series-proforma-series-validator.ts @@ -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> { + 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): string | null { + return proformaSeriesCode.isSome() ? proformaSeriesCode.unwrap().toPrimitive() : null; + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/services/document-series-proforma-target-invoice-series-validator.ts b/modules/customer-invoices/src/api/infrastructure/proformas/services/document-series-proforma-target-invoice-series-validator.ts new file mode 100644 index 00000000..3acd4cbe --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/services/document-series-proforma-target-invoice-series-validator.ts @@ -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> { + 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): string | null { + return targetInvoiceSeriesCode.isSome() ? targetInvoiceSeriesCode.unwrap().toPrimitive() : null; + } +} diff --git a/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts index c796065f..10b442c9 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts @@ -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; diff --git a/modules/customer-invoices/src/common/dto/request/proformas/update-proforma-by-id.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/update-proforma-by-id.request.dto.ts index 5592eb4b..36303249 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/update-proforma-by-id.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/update-proforma-by-id.request.dto.ts @@ -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(), diff --git a/modules/customer-invoices/src/common/dto/response/index.ts b/modules/customer-invoices/src/common/dto/response/index.ts index e4a0d309..de2b09ca 100644 --- a/modules/customer-invoices/src/common/dto/response/index.ts +++ b/modules/customer-invoices/src/common/dto/response/index.ts @@ -1,3 +1,2 @@ -export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; diff --git a/modules/customer-invoices/src/common/dto/response/invoice-series/index.ts b/modules/customer-invoices/src/common/dto/response/invoice-series/index.ts deleted file mode 100644 index e77badcc..00000000 --- a/modules/customer-invoices/src/common/dto/response/invoice-series/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./list-invoice-series.response.dto"; diff --git a/modules/customer-invoices/src/common/dto/response/invoice-series/list-invoice-series.response.dto.ts b/modules/customer-invoices/src/common/dto/response/invoice-series/list-invoice-series.response.dto.ts deleted file mode 100644 index e45a7309..00000000 --- a/modules/customer-invoices/src/common/dto/response/invoice-series/list-invoice-series.response.dto.ts +++ /dev/null @@ -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; diff --git a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts index d1d3ea20..d7d59850 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts @@ -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, diff --git a/modules/customer-invoices/src/common/dto/shared/index.ts b/modules/customer-invoices/src/common/dto/shared/index.ts index dbe6afd9..33adbd78 100644 --- a/modules/customer-invoices/src/common/dto/shared/index.ts +++ b/modules/customer-invoices/src/common/dto/shared/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/common/dto/shared/invoice-series/index.ts b/modules/customer-invoices/src/common/dto/shared/invoice-series/index.ts deleted file mode 100644 index 9857cb68..00000000 --- a/modules/customer-invoices/src/common/dto/shared/invoice-series/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./invoice-series-summary.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/invoice-series/invoice-series-summary.dto.ts b/modules/customer-invoices/src/common/dto/shared/invoice-series/invoice-series-summary.dto.ts deleted file mode 100644 index 4867e202..00000000 --- a/modules/customer-invoices/src/common/dto/shared/invoice-series/invoice-series-summary.dto.ts +++ /dev/null @@ -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; diff --git a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-summary.dto.ts b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-summary.dto.ts index b6c47611..fe740ada 100644 --- a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-summary.dto.ts +++ b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-summary.dto.ts @@ -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, diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 04f98532..05230b04 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -267,7 +267,7 @@ "placeholder": "", "description": "" }, - "invoice_date": { + "proforma_date": { "label": "Proforma date", "placeholder": "Select a date", "description": "Proforma date" diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index ba6c8b3c..a6eb245d 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -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" }, diff --git a/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.ts b/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.ts index 6fe110e9..c7fb5b52 100644 --- a/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.ts +++ b/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.ts @@ -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: [], }, }; }; diff --git a/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts index 19b3a21c..21b7dba8 100644 --- a/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts @@ -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(() => 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, }; }; diff --git a/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.entity.ts b/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.entity.ts index bb1b2eb4..abcf3536 100644 --- a/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.entity.ts +++ b/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.entity.ts @@ -1,7 +1,8 @@ export interface ProformaCreateForm { customerId: string; proformaDate: string; - series: string; + proformaSeriesCode: string; + targetInvoiceSeriesCode: string; languageCode: string; currencyCode: string; } diff --git a/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.schema.ts b/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.schema.ts index 10306626..f157d1e8 100644 --- a/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.schema.ts +++ b/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.schema.ts @@ -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), }); diff --git a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-auto-applied-info.tsx b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-auto-applied-info.tsx index c04cef4f..3b4d790f 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-auto-applied-info.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-auto-applied-info.tsx @@ -7,10 +7,10 @@ export const ProformaCreateAutoAppliedInfo = () => {
-

Se aplicará automáticamente

+

Se preparará automáticamente

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

@@ -45,8 +45,8 @@ export const ProformaCreateAutoAppliedInfo = () => {

Fiscalidad inicial

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

diff --git a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-customer-field.tsx b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-customer-field.tsx index 4f7d1fa1..64d98fd3 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-customer-field.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-customer-field.tsx @@ -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 }) => ( - + @@ -46,7 +48,9 @@ export const ProformaCreateCustomerField = ({ variant="outline" > - {selectedCustomer?.name || selectedCustomer?.tradeName || "Buscar o seleccionar cliente..."} + {selectedCustomer?.name || + selectedCustomer?.tradeName || + "Buscar o seleccionar cliente..."} diff --git a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-form.tsx b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-form.tsx index 12b71eb1..2f4b5548 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-form.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-form.tsx @@ -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; 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 = ({
diff --git a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-initial-info-card.tsx b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-initial-info-card.tsx index 7d7f3817..d2996044 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-initial-info-card.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-initial-info-card.tsx @@ -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 ( } title="Información inicial"> - -
- -
+ + - className="md:col-span-3" + className="md:col-span-2" disabled={disabled} label="Fecha" name="proformaDate" @@ -51,19 +54,28 @@ export const ProformaCreateInitialInfoCard = ({ /> - 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" } /> + + + className="md:col-span-2" + disabled={disabled || isTargetInvoiceSeriesLoading} + items={targetInvoiceSeriesOptions} + label="Serie de la factura" + name="targetInvoiceSeriesCode" + placeholder={isTargetInvoiceSeriesLoading ? "Cargando series..." : "Automática"} + />
); diff --git a/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx b/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx index 23d50adb..209d0415 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx @@ -54,13 +54,15 @@ export const ProformaCreatePage = () => {
diff --git a/modules/customer-invoices/src/web/proformas/create/utils/build-proforma-create-default.ts b/modules/customer-invoices/src/web/proformas/create/utils/build-proforma-create-default.ts index a5d38f97..ee6c55d9 100644 --- a/modules/customer-invoices/src/web/proformas/create/utils/build-proforma-create-default.ts +++ b/modules/customer-invoices/src/web/proformas/create/utils/build-proforma-create-default.ts @@ -6,7 +6,8 @@ export const buildProformaCreateDefault = (): ProformaCreateForm => { return { customerId: "", proformaDate: DateHelper.buildTodayIsoDate(), - series: "", + proformaSeriesCode: "", + targetInvoiceSeriesCode: "", languageCode: "es", currencyCode: "EUR", }; diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts index 3683d84d..2398e8c2 100644 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts @@ -27,7 +27,8 @@ export const GetProformaByIdAdapter = { proformaReference: dto.proforma_reference, status: dto.status as ProformaStatus, - series: dto.series, + targetInvoiceSeriesCode: dto.target_invoice_series_code, + series: dto.target_invoice_series_code ?? dto.series, proformaDate: dto.proforma_date, operationDate: dto.operation_date, diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/index.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/index.ts index 5783a072..fb52771a 100644 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/index.ts @@ -1,4 +1,4 @@ export * from "./get-proforma-by-id.adapter"; -export * from "./list-invoice-series.adapter"; +export * from "./list-document-series.adapter"; export * from "./list-proformas.adapter"; export * from "./proforma-to-list-row-patch.adapter"; diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/list-document-series.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/list-document-series.adapter.ts new file mode 100644 index 00000000..8d735418 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/list-document-series.adapter.ts @@ -0,0 +1,21 @@ +import type { ListDocumentSeriesResponseDTO } from "@erp/document-series"; + +import type { DocumentSeriesSummary } from "../entities"; + +export const mapDocumentSeriesSummaryDTOToDocumentSeriesSummary = ( + dto: ListDocumentSeriesResponseDTO["items"][number] +): DocumentSeriesSummary => ({ + id: dto.id, + documentType: dto.document_type, + code: dto.code, + nextNumber: dto.next_number, + padding: dto.padding, + isDefault: dto.is_default, + isActive: dto.is_active, +}); + +export const ListDocumentSeriesAdapter = { + fromDto(dto: ListDocumentSeriesResponseDTO): DocumentSeriesSummary[] { + return dto.items.map(mapDocumentSeriesSummaryDTOToDocumentSeriesSummary); + }, +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/list-invoice-series.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/list-invoice-series.adapter.ts deleted file mode 100644 index 5e80e32e..00000000 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/list-invoice-series.adapter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ListInvoiceSeriesResponseDTO } from "../../../../common"; -import type { InvoiceSeriesSummary } from "../entities"; - -export const mapInvoiceSeriesSummaryDTOToInvoiceSeriesSummary = ( - dto: ListInvoiceSeriesResponseDTO["items"][number] -): InvoiceSeriesSummary => ({ - id: dto.id, - code: dto.code, - nextNumber: dto.next_number, - paddingLength: dto.padding_length, - isDefault: dto.is_default, -}); - -export const ListInvoiceSeriesAdapter = { - fromDto(dto: ListInvoiceSeriesResponseDTO): InvoiceSeriesSummary[] { - return dto.items.map(mapInvoiceSeriesSummaryDTOToInvoiceSeriesSummary); - }, -}; diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/list-proformas.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/list-proformas.adapter.ts index c854a8ab..27075719 100644 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/list-proformas.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/list-proformas.adapter.ts @@ -50,7 +50,8 @@ const ProformaListRowAdapter = { proformaReference: dto.proforma_reference, status: dto.status as ProformaStatus, - series: dto.series, + targetInvoiceSeriesCode: dto.target_invoice_series_code, + series: dto.target_invoice_series_code ?? dto.series, proformaDate: dto.proforma_date, operationDate: dto.operation_date, diff --git a/modules/customer-invoices/src/web/proformas/shared/api/index.ts b/modules/customer-invoices/src/web/proformas/shared/api/index.ts index 1168573c..14ff1d56 100644 --- a/modules/customer-invoices/src/web/proformas/shared/api/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/api/index.ts @@ -3,6 +3,6 @@ export * from "./create-proforma.api"; export * from "./delete-proforma-by-id.api"; export * from "./get-proforma-by-id.api"; export * from "./issue-proforma-by-id.api"; -export * from "./list-invoice-series.api"; +export * from "./list-document-series.api"; export * from "./list-proformas-by-criteria.api"; export * from "./update-proforma-by-id.api"; diff --git a/modules/customer-invoices/src/web/proformas/shared/api/list-document-series.api.ts b/modules/customer-invoices/src/web/proformas/shared/api/list-document-series.api.ts new file mode 100644 index 00000000..bde63c2b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/api/list-document-series.api.ts @@ -0,0 +1,25 @@ +import type { IDataSource } from "@erp/core/client"; +import type { ListDocumentSeriesResponseDTO } from "@erp/document-series"; + +export const listDocumentSeries = ( + dataSource: IDataSource, + signal?: AbortSignal, + documentType: "proforma" | "issued_invoice" = "proforma" +): Promise => { + return dataSource.getList("document-series", { + signal, + filters: [ + { + field: "document_type", + operator: "EQUALS", + value: documentType, + }, + { + field: "is_active", + operator: "EQUALS", + value: "true", + }, + ], + pageSize: 999, + }); +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/api/list-invoice-series.api.ts b/modules/customer-invoices/src/web/proformas/shared/api/list-invoice-series.api.ts deleted file mode 100644 index 6f194824..00000000 --- a/modules/customer-invoices/src/web/proformas/shared/api/list-invoice-series.api.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IDataSource } from "@erp/core/client"; - -import type { ListInvoiceSeriesResponseDTO } from "../../../../common"; - -export const listInvoiceSeries = ( - dataSource: IDataSource, - signal?: AbortSignal -): Promise => { - return dataSource.getList("catalogs/invoice-series", { - signal, - }); -}; diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/document-series-summary.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/document-series-summary.entity.ts new file mode 100644 index 00000000..d9620cea --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/entities/document-series-summary.entity.ts @@ -0,0 +1,9 @@ +export interface DocumentSeriesSummary { + id: string; + documentType: "proforma" | "issued_invoice"; + code: string; + nextNumber: number; + padding: number; + isDefault: boolean; + isActive: boolean; +} diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/index.ts b/modules/customer-invoices/src/web/proformas/shared/entities/index.ts index e1e25f31..cc7e58f7 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/index.ts @@ -1,5 +1,5 @@ export * from "./forms/proforma-item-form.entity"; -export * from "./invoice-series-summary.entity"; +export * from "./document-series-summary.entity"; export * from "./proforma.entity"; export * from "./proforma-item.entity"; export * from "./proforma-list.entity"; diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/invoice-series-summary.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/invoice-series-summary.entity.ts deleted file mode 100644 index 30362bba..00000000 --- a/modules/customer-invoices/src/web/proformas/shared/entities/invoice-series-summary.entity.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface InvoiceSeriesSummary { - id: string; - code: string; - nextNumber: number; - paddingLength: number; - isDefault: boolean; -} diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/proforma-list-row.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-list-row.entity.ts index 779acca9..ad112e9f 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/proforma-list-row.entity.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-list-row.entity.ts @@ -14,6 +14,7 @@ export interface ProformaListRow { proformaReference: string; status: ProformaStatus; + targetInvoiceSeriesCode: string | null; series: string | null; proformaDate: string; diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts index 0405e09a..25a9b417 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts @@ -16,6 +16,7 @@ export interface Proforma { proformaReference: string; status: ProformaStatus; + targetInvoiceSeriesCode: string | null; series: string | null; proformaDate: string; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts index 08d283a2..8b262037 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts @@ -1,5 +1,5 @@ export * from "./keys"; -export * from "./use-invoice-series-query"; +export * from "./use-document-series-query"; export * from "./use-proforma-change-status-mutation"; export * from "./use-proforma-create-mutation"; export * from "./use-proforma-delete-mutation"; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts index 2c19cd4e..fdbf136d 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts @@ -6,7 +6,7 @@ import type { ProformasListRequestDTO } from "../../../../common"; * Prefijo base para listados */ export const LIST_PROFORMAS_QUERY_KEY_PREFIX = ["proformas"] as const; -export const INVOICE_SERIES_QUERY_KEY = ["customer-invoices", "invoice-series"] as const; +export const DOCUMENT_SERIES_QUERY_KEY = ["customer-invoices", "document-series"] as const; /** * Query key para listado de proformas diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-document-series-query.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-document-series-query.ts new file mode 100644 index 00000000..e55232b2 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-document-series-query.ts @@ -0,0 +1,22 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; + +import { ListDocumentSeriesAdapter } from "../adapters"; +import { listDocumentSeries } from "../api"; +import type { DocumentSeriesSummary } from "../entities"; + +import { DOCUMENT_SERIES_QUERY_KEY } from "./keys"; + +export const useDocumentSeriesQuery = ( + documentType: "proforma" | "issued_invoice" = "proforma" +): UseQueryResult => { + const dataSource = useDataSource(); + + return useQuery({ + queryKey: [...DOCUMENT_SERIES_QUERY_KEY, documentType], + queryFn: async ({ signal }) => { + const dto = await listDocumentSeries(dataSource, signal, documentType); + return ListDocumentSeriesAdapter.fromDto(dto); + }, + }); +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-invoice-series-query.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-invoice-series-query.ts deleted file mode 100644 index db4a12e8..00000000 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-invoice-series-query.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useDataSource } from "@erp/core/hooks"; -import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; - -import { ListInvoiceSeriesAdapter } from "../adapters"; -import { listInvoiceSeries } from "../api"; -import type { InvoiceSeriesSummary } from "../entities"; - -import { INVOICE_SERIES_QUERY_KEY } from "./keys"; - -export const useInvoiceSeriesQuery = (): UseQueryResult => { - const dataSource = useDataSource(); - - return useQuery({ - queryKey: INVOICE_SERIES_QUERY_KEY, - queryFn: async ({ signal }) => { - const dto = await listInvoiceSeries(dataSource, signal); - return ListInvoiceSeriesAdapter.fromDto(dto); - }, - }); -}; diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/build-document-series-select-items.ts b/modules/customer-invoices/src/web/proformas/shared/utils/build-document-series-select-items.ts new file mode 100644 index 00000000..1d52586b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/utils/build-document-series-select-items.ts @@ -0,0 +1,12 @@ +import type { SelectFieldItem } from "@repo/rdx-ui/components"; + +import type { DocumentSeriesSummary } from "../entities"; + +export const buildDocumentSeriesSelectItems = ( + documentSeries: DocumentSeriesSummary[] +): SelectFieldItem[] => { + return documentSeries.map((series) => ({ + value: series.code, + label: series.code, + })); +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/build-invoice-series-select-items.ts b/modules/customer-invoices/src/web/proformas/shared/utils/build-invoice-series-select-items.ts deleted file mode 100644 index 6876a83f..00000000 --- a/modules/customer-invoices/src/web/proformas/shared/utils/build-invoice-series-select-items.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SelectFieldItem } from "@repo/rdx-ui/components"; - -import type { InvoiceSeriesSummary } from "../entities"; - -export const buildInvoiceSeriesSelectItems = ( - invoiceSeries: InvoiceSeriesSummary[] -): SelectFieldItem[] => { - return invoiceSeries.map((series) => ({ - value: series.code, - label: series.code, - })); -}; diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/index.ts b/modules/customer-invoices/src/web/proformas/shared/utils/index.ts index 3fbccf65..38bfae76 100644 --- a/modules/customer-invoices/src/web/proformas/shared/utils/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/utils/index.ts @@ -1,4 +1,4 @@ -export * from "./build-invoice-series-select-items"; +export * from "./build-document-series-select-items"; export * from "./calculate-proforma-due-dates"; export * from "./ensure-current-value-in-select-items"; export * from "./proforma-fiscal-options.utils"; diff --git a/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-proforma-update-form.adapter.ts b/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-proforma-update-form.adapter.ts index 196a5d88..4f217a8e 100644 --- a/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-proforma-update-form.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-proforma-update-form.adapter.ts @@ -27,7 +27,8 @@ export const mapProformaToProformaUpdateForm = (proforma: Proforma): ProformaUpd return { proformaReference: proforma.proformaReference, - series: proforma.series ?? proformaDefaults.series, + targetInvoiceSeriesCode: + proforma.targetInvoiceSeriesCode ?? proforma.series ?? proformaDefaults.targetInvoiceSeriesCode, proformaDate: proforma.proformaDate ?? proformaDefaults.proformaDate, operationDate: proforma.operationDate ?? proformaDefaults.operationDate, diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts index 21ed529c..231ea908 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts @@ -16,9 +16,9 @@ import type { FieldErrors } from "react-hook-form"; import { useTranslation } from "../../../i18n"; import type { UpdateProformaByIdParams } from "../../shared"; import { - buildInvoiceSeriesSelectItems, + buildDocumentSeriesSelectItems, ensureCurrentValueInSelectItems, - useInvoiceSeriesQuery, + useDocumentSeriesQuery, useProformaGetQuery, useProformaUpdateMutation, } from "../../shared"; @@ -79,7 +79,7 @@ export const useUpdateProformaController = ( error: updateError, } = useProformaUpdateMutation(); - const invoiceSeriesQuery = useInvoiceSeriesQuery(); + const targetInvoiceSeriesQuery = useDocumentSeriesQuery("issued_invoice"); const initialValues = useMemo(() => { if (!proformaData) return buildProformaUpdateDefault(); @@ -249,12 +249,15 @@ export const useUpdateProformaController = ( const currencyCode = form.watch("currencyCode"); const languageCode = form.watch("languageCode"); - const currentSeries = form.watch("series"); + const currentSeries = form.watch("targetInvoiceSeriesCode"); const seriesOptions = useMemo(() => { - const activeSeriesItems = buildInvoiceSeriesSelectItems(invoiceSeriesQuery.data ?? []); + const activeSeriesItems = [ + { value: "", label: "Automática" }, + ...buildDocumentSeriesSelectItems(targetInvoiceSeriesQuery.data ?? []), + ]; return ensureCurrentValueInSelectItems(activeSeriesItems, currentSeries); - }, [currentSeries, invoiceSeriesQuery.data]); + }, [currentSeries, targetInvoiceSeriesQuery.data]); const taxCtrl = useUpdateProformaTaxController({ form, @@ -280,9 +283,9 @@ export const useUpdateProformaController = ( totalsCtrl, paymentCtrl, seriesOptions, - isSeriesLoading: invoiceSeriesQuery.isLoading, - isSeriesLoadError: invoiceSeriesQuery.isError, - seriesLoadError: invoiceSeriesQuery.error, + isSeriesLoading: targetInvoiceSeriesQuery.isLoading, + isSeriesLoadError: targetInvoiceSeriesQuery.isError, + seriesLoadError: targetInvoiceSeriesQuery.error, // currencyCode, diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts index 6024f37b..1a0bb2a5 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts @@ -20,7 +20,7 @@ export type ProformaTaxMode = "single" | "perLine"; export interface ProformaUpdateForm { proformaReference: string; - series: string; + targetInvoiceSeriesCode: string; proformaDate: string; operationDate: string; diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts index 80c9ba17..e0866da2 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts @@ -20,7 +20,7 @@ import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema export const ProformaUpdateFormSchema = z .object({ proformaReference: z.string(), - series: z.string(), + targetInvoiceSeriesCode: z.string(), proformaDate: z.string().min(1), operationDate: z.string(), diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-patch.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-patch.entity.ts index 6947a8f0..9fc18694 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-patch.entity.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-patch.entity.ts @@ -12,7 +12,7 @@ import type { ProformaItemUpdatePatch } from "./proforma-item-update-patch.entity"; export type ProformaUpdatePatch = { - series?: string | null; + targetInvoiceSeriesCode?: string | null; proformaDate?: string; operationDate?: string | null; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx index 61db6a12..0e34faf6 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx @@ -58,15 +58,13 @@ export const ProformaUpdateEditorForm = ({ return (
-
+
-
- -
+ } title={t("form_groups.proformas.basic_info.title")} > - + } /> - - - + + + + { return { proformaReference: "", - series: "", + targetInvoiceSeriesCode: "", proformaDate: "", operationDate: "", diff --git a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts index b5014582..c31ca51d 100644 --- a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts +++ b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts @@ -40,8 +40,8 @@ export const buildUpdateProformaByIdParams = ( const data: UpdateProformaByIdParams["data"] = {}; - if (ObjectHelper.hasOwn(patch, "series")) { - data.series = patch.series; + if (ObjectHelper.hasOwn(patch, "targetInvoiceSeriesCode")) { + data.target_invoice_series_code = patch.targetInvoiceSeriesCode; } if (ObjectHelper.hasOwn(patch, "proformaDate")) { diff --git a/modules/document-series/package.json b/modules/document-series/package.json new file mode 100644 index 00000000..c146b18b --- /dev/null +++ b/modules/document-series/package.json @@ -0,0 +1,34 @@ +{ + "name": "@erp/document-series", + "description": "Document series module", + "version": "0.9.4", + "private": true, + "type": "module", + "sideEffects": false, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "check": "biome check .", + "lint": "biome lint .", + "clean": "rimraf .turbo node_modules dist" + }, + "exports": { + ".": "./src/common/index.ts", + "./common": "./src/common/index.ts", + "./api": "./src/api/index.ts" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "rimraf": "^6.1.3", + "typescript": "^6.0.2" + }, + "dependencies": { + "@erp/core": "workspace:*", + "@erp/identity": "workspace:*", + "@repo/rdx-criteria": "workspace:*", + "@repo/rdx-ddd": "workspace:*", + "@repo/rdx-utils": "workspace:*", + "express": "^4.22.1", + "sequelize": "^6.37.8", + "zod": "^4.3.6" + } +} diff --git a/modules/document-series/src/api/application/commands/assign-next-document-number.command.ts b/modules/document-series/src/api/application/commands/assign-next-document-number.command.ts new file mode 100644 index 00000000..3d2e6f6b --- /dev/null +++ b/modules/document-series/src/api/application/commands/assign-next-document-number.command.ts @@ -0,0 +1,7 @@ +export type AssignNextDocumentNumberCommand = { + companyId: string; + branchId?: string | null; + documentType: string; + seriesCode?: string | null; + transaction: unknown; +}; diff --git a/modules/document-series/src/api/application/commands/create-document-series.command.ts b/modules/document-series/src/api/application/commands/create-document-series.command.ts new file mode 100644 index 00000000..72bfe2fb --- /dev/null +++ b/modules/document-series/src/api/application/commands/create-document-series.command.ts @@ -0,0 +1,17 @@ +export type CreateDocumentSeriesCommand = { + id?: string; + companyId: string; + branchId?: string | null; + documentType: string; + code: string; + name: string; + description?: string | null; + prefix?: string | null; + suffix?: string | null; + nextNumber: number; + padding: number; + validFrom?: string | null; + validTo?: string | null; + isDefault: boolean; + isActive: boolean; +}; diff --git a/modules/document-series/src/api/application/commands/index.ts b/modules/document-series/src/api/application/commands/index.ts new file mode 100644 index 00000000..e2e89798 --- /dev/null +++ b/modules/document-series/src/api/application/commands/index.ts @@ -0,0 +1,3 @@ +export * from "./assign-next-document-number.command"; +export * from "./create-document-series.command"; +export * from "./update-document-series.command"; diff --git a/modules/document-series/src/api/application/commands/update-document-series.command.ts b/modules/document-series/src/api/application/commands/update-document-series.command.ts new file mode 100644 index 00000000..02fdcec2 --- /dev/null +++ b/modules/document-series/src/api/application/commands/update-document-series.command.ts @@ -0,0 +1,17 @@ +export type UpdateDocumentSeriesCommand = { + companyId: string; + id: string; + branchId?: string | null; + documentType?: string; + code?: string; + name?: string; + description?: string | null; + prefix?: string | null; + suffix?: string | null; + nextNumber?: number; + padding?: number; + validFrom?: string | null; + validTo?: string | null; + isDefault?: boolean; + isActive?: boolean; +}; diff --git a/modules/document-series/src/api/application/di/document-series-use-cases.di.ts b/modules/document-series/src/api/application/di/document-series-use-cases.di.ts new file mode 100644 index 00000000..ead28589 --- /dev/null +++ b/modules/document-series/src/api/application/di/document-series-use-cases.di.ts @@ -0,0 +1,67 @@ +import type { ITransactionManager } from "@erp/core/api"; + +import { CreateDocumentSeriesCommandMapper, UpdateDocumentSeriesCommandMapper } from "../mappers"; +import type { + DocumentSeriesFinder, + DocumentSeriesNumberAssigner, + DocumentSeriesWriter, +} from "../services"; +import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; +import { + AssignNextDocumentNumberUseCase, + CreateDocumentSeriesUseCase, + DisableDocumentSeriesUseCase, + GetDocumentSeriesByIdUseCase, + ListDocumentSeriesUseCase, + UpdateDocumentSeriesUseCase, +} from "../use-cases"; + +export function buildDocumentSeriesUseCases(deps: { + finder: DocumentSeriesFinder; + writer: DocumentSeriesWriter; + assigner: DocumentSeriesNumberAssigner; + transactionManager: ITransactionManager; +}) { + const snapshotBuilder = new DocumentSeriesSnapshotBuilder(); + const createMapper = new CreateDocumentSeriesCommandMapper(); + const updateMapper = new UpdateDocumentSeriesCommandMapper(); + + return { + createDocumentSeries: () => + new CreateDocumentSeriesUseCase({ + mapper: createMapper, + writer: deps.writer, + snapshotBuilder, + transactionManager: deps.transactionManager, + }), + updateDocumentSeries: () => + new UpdateDocumentSeriesUseCase({ + mapper: updateMapper, + writer: deps.writer, + finder: deps.finder, + snapshotBuilder, + transactionManager: deps.transactionManager, + }), + getDocumentSeriesById: () => + new GetDocumentSeriesByIdUseCase({ + finder: deps.finder, + snapshotBuilder, + }), + listDocumentSeries: () => + new ListDocumentSeriesUseCase({ + finder: deps.finder, + snapshotBuilder, + transactionManager: deps.transactionManager, + }), + assignNextDocumentNumber: () => + new AssignNextDocumentNumberUseCase({ + assigner: deps.assigner, + snapshotBuilder, + }), + disableDocumentSeries: () => + new DisableDocumentSeriesUseCase({ + writer: deps.writer, + transactionManager: deps.transactionManager, + }), + }; +} diff --git a/modules/document-series/src/api/application/di/index.ts b/modules/document-series/src/api/application/di/index.ts new file mode 100644 index 00000000..b9c879aa --- /dev/null +++ b/modules/document-series/src/api/application/di/index.ts @@ -0,0 +1 @@ +export * from "./document-series-use-cases.di"; diff --git a/modules/document-series/src/api/application/errors/document-series-concurrency.error.ts b/modules/document-series/src/api/application/errors/document-series-concurrency.error.ts new file mode 100644 index 00000000..ab5f9819 --- /dev/null +++ b/modules/document-series/src/api/application/errors/document-series-concurrency.error.ts @@ -0,0 +1,6 @@ +export class DocumentSeriesConcurrencyError extends Error { + public constructor() { + super("Document series could not be updated consistently during number assignment."); + this.name = "DocumentSeriesConcurrencyError"; + } +} diff --git a/modules/document-series/src/api/application/errors/document-series-lock-required.error.ts b/modules/document-series/src/api/application/errors/document-series-lock-required.error.ts new file mode 100644 index 00000000..ddc8c68c --- /dev/null +++ b/modules/document-series/src/api/application/errors/document-series-lock-required.error.ts @@ -0,0 +1,6 @@ +export class DocumentSeriesLockRequiredError extends Error { + public constructor() { + super("Document series row lock requires an active transaction."); + this.name = "DocumentSeriesLockRequiredError"; + } +} diff --git a/modules/document-series/src/api/application/errors/index.ts b/modules/document-series/src/api/application/errors/index.ts new file mode 100644 index 00000000..b468a14f --- /dev/null +++ b/modules/document-series/src/api/application/errors/index.ts @@ -0,0 +1,2 @@ +export * from "./document-series-concurrency.error"; +export * from "./document-series-lock-required.error"; diff --git a/modules/document-series/src/api/application/index.ts b/modules/document-series/src/api/application/index.ts new file mode 100644 index 00000000..31034643 --- /dev/null +++ b/modules/document-series/src/api/application/index.ts @@ -0,0 +1,9 @@ +export * from "./commands"; +export * from "./di"; +export * from "./errors"; +export * from "./mappers"; +export * from "./public"; +export * from "./repositories"; +export * from "./services"; +export * from "./snapshot-builders"; +export * from "./use-cases"; diff --git a/modules/document-series/src/api/application/mappers/create-document-series-command.mapper.ts b/modules/document-series/src/api/application/mappers/create-document-series-command.mapper.ts new file mode 100644 index 00000000..6105d1b4 --- /dev/null +++ b/modules/document-series/src/api/application/mappers/create-document-series-command.mapper.ts @@ -0,0 +1,95 @@ +import { Name, TextValue, UniqueID, UtcDate, extractOrPushError, maybeFromNullableResult, type ValidationErrorDetail, ValidationErrorCollection } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { CreateDocumentSeriesRequestDTO } from "../../../common"; +import { DocumentSeries, DocumentSeriesCode, DocumentSeriesNumber, DocumentType } from "../../domain"; +import type { CreateDocumentSeriesCommand } from "../commands"; + +export class CreateDocumentSeriesCommandMapper { + public map(dto: CreateDocumentSeriesRequestDTO, companyId: UniqueID) { + const command: CreateDocumentSeriesCommand = { + id: dto.id, + companyId: companyId.toString(), + branchId: dto.branch_id ?? null, + documentType: dto.document_type, + code: dto.code, + name: dto.name, + description: dto.description ?? null, + prefix: dto.prefix ?? null, + suffix: dto.suffix ?? null, + nextNumber: dto.next_number, + padding: dto.padding, + validFrom: dto.valid_from ?? null, + validTo: dto.valid_to ?? null, + isDefault: dto.is_default ?? false, + isActive: dto.is_active ?? true, + }; + + return this.toAggregate(command); + } + + public toAggregate(command: CreateDocumentSeriesCommand) { + const errors: ValidationErrorDetail[] = []; + const id = extractOrPushError(UniqueID.create(command.id, true), "id", errors); + const companyId = extractOrPushError(UniqueID.create(command.companyId), "companyId", errors); + const branchId = extractOrPushError( + maybeFromNullableResult(command.branchId, (value) => UniqueID.create(value)), + "branchId", + errors + ); + const documentType = extractOrPushError( + DocumentType.create(command.documentType), + "documentType", + errors + ); + const code = extractOrPushError(DocumentSeriesCode.create(command.code), "code", errors); + const name = extractOrPushError(Name.create(command.name), "name", errors); + const description = extractOrPushError( + maybeFromNullableResult(command.description, (value) => TextValue.create(value)), + "description", + errors + ); + const nextNumber = extractOrPushError( + DocumentSeriesNumber.create(command.nextNumber), + "nextNumber", + errors + ); + const validFrom = extractOrPushError( + maybeFromNullableResult(command.validFrom, (value) => UtcDate.createFromISO(value)), + "validFrom", + errors + ); + const validTo = extractOrPushError( + maybeFromNullableResult(command.validTo, (value) => UtcDate.createFromISO(value)), + "validTo", + errors + ); + + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection("Document series mapping failed", errors)); + } + + return DocumentSeries.create( + { + companyId: companyId!, + branchId: branchId ?? Maybe.none(), + documentType: documentType!, + code: code!, + name: name!, + description: description ?? Maybe.none(), + prefix: Maybe.fromNullable(command.prefix ?? undefined), + suffix: Maybe.fromNullable(command.suffix ?? undefined), + nextNumber: nextNumber!, + padding: command.padding, + validFrom: validFrom ?? Maybe.none(), + validTo: validTo ?? Maybe.none(), + isDefault: command.isDefault, + isActive: command.isActive, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: Maybe.none(), + }, + id + ); + } +} diff --git a/modules/document-series/src/api/application/mappers/index.ts b/modules/document-series/src/api/application/mappers/index.ts new file mode 100644 index 00000000..44eef27c --- /dev/null +++ b/modules/document-series/src/api/application/mappers/index.ts @@ -0,0 +1,2 @@ +export * from "./create-document-series-command.mapper"; +export * from "./update-document-series-command.mapper"; diff --git a/modules/document-series/src/api/application/mappers/update-document-series-command.mapper.ts b/modules/document-series/src/api/application/mappers/update-document-series-command.mapper.ts new file mode 100644 index 00000000..afc4f641 --- /dev/null +++ b/modules/document-series/src/api/application/mappers/update-document-series-command.mapper.ts @@ -0,0 +1,97 @@ +import { Name, TextValue, UniqueID, UtcDate, extractOrPushError, maybeFromNullableResult, type ValidationErrorDetail, ValidationErrorCollection } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { UpdateDocumentSeriesRequestDTO } from "../../../common"; +import { DocumentSeriesCode, DocumentSeriesNumber, DocumentType } from "../../domain"; +import type { UpdateDocumentSeriesCommand } from "../commands"; + +export class UpdateDocumentSeriesCommandMapper { + public map(dto: UpdateDocumentSeriesRequestDTO, companyId: UniqueID, id: string) { + return Result.ok({ + companyId: companyId.toString(), + id, + branchId: dto.branch_id, + documentType: dto.document_type, + code: dto.code, + name: dto.name, + description: dto.description, + prefix: dto.prefix, + suffix: dto.suffix, + nextNumber: dto.next_number, + padding: dto.padding, + validFrom: dto.valid_from, + validTo: dto.valid_to, + isDefault: dto.is_default, + isActive: dto.is_active, + }); + } + + public toPatch(command: UpdateDocumentSeriesCommand) { + const errors: ValidationErrorDetail[] = []; + + const branchId = extractOrPushError( + maybeFromNullableResult(command.branchId, (value) => UniqueID.create(value)), + "branchId", + errors + ); + const documentType = extractOrPushError( + maybeFromNullableResult(command.documentType, (value) => DocumentType.create(value)), + "documentType", + errors + ); + const code = extractOrPushError( + maybeFromNullableResult(command.code, (value) => DocumentSeriesCode.create(value)), + "code", + errors + ); + const name = extractOrPushError( + maybeFromNullableResult(command.name, (value) => Name.create(value)), + "name", + errors + ); + const description = extractOrPushError( + maybeFromNullableResult(command.description, (value) => TextValue.create(value)), + "description", + errors + ); + const nextNumber = extractOrPushError( + maybeFromNullableResult(command.nextNumber, (value) => DocumentSeriesNumber.create(value)), + "nextNumber", + errors + ); + const validFrom = extractOrPushError( + maybeFromNullableResult(command.validFrom, (value) => UtcDate.createFromISO(value)), + "validFrom", + errors + ); + const validTo = extractOrPushError( + maybeFromNullableResult(command.validTo, (value) => UtcDate.createFromISO(value)), + "validTo", + errors + ); + + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection("Document series patch mapping failed", errors)); + } + + return Result.ok({ + branchId: command.branchId === undefined ? undefined : branchId ?? Maybe.none(), + documentType: + command.documentType === undefined ? undefined : documentType ?? Maybe.none(), + code: command.code === undefined ? undefined : code ?? Maybe.none(), + name: command.name === undefined ? undefined : name ?? Maybe.none(), + description: + command.description === undefined ? undefined : description ?? Maybe.none(), + prefix: command.prefix === undefined ? undefined : Maybe.fromNullable(command.prefix ?? undefined), + suffix: command.suffix === undefined ? undefined : Maybe.fromNullable(command.suffix ?? undefined), + nextNumber: + command.nextNumber === undefined ? undefined : nextNumber ?? Maybe.none(), + padding: command.padding, + validFrom: command.validFrom === undefined ? undefined : validFrom ?? Maybe.none(), + validTo: command.validTo === undefined ? undefined : validTo ?? Maybe.none(), + isDefault: command.isDefault, + isActive: command.isActive, + updatedAt: new Date(), + }); + } +} diff --git a/modules/document-series/src/api/application/public/document-series-public-services.ts b/modules/document-series/src/api/application/public/document-series-public-services.ts new file mode 100644 index 00000000..777b3dd8 --- /dev/null +++ b/modules/document-series/src/api/application/public/document-series-public-services.ts @@ -0,0 +1,27 @@ +import type { Result } from "@repo/rdx-utils"; + +import type { AssignNextDocumentNumberResponseDTO, DocumentSeriesSummaryDTO } from "../../../common"; + +export type AssignNextDocumentNumberPublicParams = { + companyId: string; + branchId?: string | null; + documentType: string; + seriesCode?: string | null; + transaction: unknown; +}; + +export type ListActiveDocumentSeriesPublicParams = { + companyId: string; + branchId?: string | null; + documentType: string; + transaction?: unknown; +}; + +export interface IDocumentSeriesPublicServices { + assignNextNumber( + params: AssignNextDocumentNumberPublicParams + ): Promise>; + listActiveSeries( + params: ListActiveDocumentSeriesPublicParams + ): Promise>; +} diff --git a/modules/document-series/src/api/application/public/index.ts b/modules/document-series/src/api/application/public/index.ts new file mode 100644 index 00000000..d923d906 --- /dev/null +++ b/modules/document-series/src/api/application/public/index.ts @@ -0,0 +1 @@ +export * from "./document-series-public-services"; diff --git a/modules/document-series/src/api/application/repositories/document-series-repository.interface.ts b/modules/document-series/src/api/application/repositories/document-series-repository.interface.ts new file mode 100644 index 00000000..b1c20b91 --- /dev/null +++ b/modules/document-series/src/api/application/repositories/document-series-repository.interface.ts @@ -0,0 +1,57 @@ +import type { Criteria } from "@repo/rdx-criteria/server"; +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import type { DocumentSeries } from "../../domain"; +import type { AssignedDocumentNumber, DocumentSeriesCode, DocumentType } from "../../domain"; + +export type FindDocumentSeriesByCodeParams = { + companyId: UniqueID; + branchId?: UniqueID | null; + documentType: DocumentType; + code: DocumentSeriesCode; + transaction?: unknown; +}; + +export type FindDefaultDocumentSeriesParams = { + companyId: UniqueID; + branchId?: UniqueID | null; + documentType: DocumentType; + transaction?: unknown; +}; + +export type AssignNextNumberRepositoryParams = { + companyId: UniqueID; + branchId?: UniqueID | null; + documentType: DocumentType; + code?: DocumentSeriesCode | null; + transaction: unknown; +}; + +export interface IDocumentSeriesRepository { + save(documentSeries: DocumentSeries, transaction?: unknown): Promise>; + getByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise>; + findByCodeInCompany( + params: FindDocumentSeriesByCodeParams + ): Promise, Error>>; + findDefaultInCompany( + params: FindDefaultDocumentSeriesParams + ): Promise, Error>>; + findByCriteriaInCompany( + criteria: Criteria, + companyId: UniqueID, + transaction?: unknown + ): Promise, Error>>; + disableByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise>; + assignNextNumber( + params: AssignNextNumberRepositoryParams + ): Promise>; +} diff --git a/modules/document-series/src/api/application/repositories/index.ts b/modules/document-series/src/api/application/repositories/index.ts new file mode 100644 index 00000000..028ae84e --- /dev/null +++ b/modules/document-series/src/api/application/repositories/index.ts @@ -0,0 +1 @@ +export * from "./document-series-repository.interface"; diff --git a/modules/document-series/src/api/application/services/document-series-finder.ts b/modules/document-series/src/api/application/services/document-series-finder.ts new file mode 100644 index 00000000..6b36d257 --- /dev/null +++ b/modules/document-series/src/api/application/services/document-series-finder.ts @@ -0,0 +1,16 @@ +import type { Criteria } from "@repo/rdx-criteria/server"; +import type { UniqueID } from "@repo/rdx-ddd"; + +import type { IDocumentSeriesRepository } from "../repositories"; + +export class DocumentSeriesFinder { + public constructor(private readonly repository: IDocumentSeriesRepository) {} + + public findById(companyId: UniqueID, id: UniqueID, transaction?: unknown) { + return this.repository.getByIdInCompany(companyId, id, transaction); + } + + public findByCriteria(companyId: UniqueID, criteria: Criteria, transaction?: unknown) { + return this.repository.findByCriteriaInCompany(criteria, companyId, transaction); + } +} diff --git a/modules/document-series/src/api/application/services/document-series-number-assigner.ts b/modules/document-series/src/api/application/services/document-series-number-assigner.ts new file mode 100644 index 00000000..76c5bdc8 --- /dev/null +++ b/modules/document-series/src/api/application/services/document-series-number-assigner.ts @@ -0,0 +1,54 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { DocumentSeriesTransactionRequiredError, DocumentType, type AssignedDocumentNumber, DocumentSeriesCode } from "../../domain"; +import type { IDocumentSeriesRepository } from "../repositories"; +import type { AssignNextDocumentNumberCommand } from "../commands"; + +export interface AssignNextDocumentNumberParams extends AssignNextDocumentNumberCommand {} + +export interface IDocumentSeriesNumberAssigner { + assignNext(params: AssignNextDocumentNumberParams): Promise>; +} + +export class DocumentSeriesNumberAssigner implements IDocumentSeriesNumberAssigner { + public constructor(private readonly repository: IDocumentSeriesRepository) {} + + public async assignNext(params: AssignNextDocumentNumberParams) { + if (!params.transaction) { + return Result.fail(new DocumentSeriesTransactionRequiredError()); + } + + const companyIdResult = UniqueID.create(params.companyId); + if (companyIdResult.isFailure) { + return Result.fail(companyIdResult.error); + } + + const branchIdResult = params.branchId ? UniqueID.create(params.branchId) : null; + if (branchIdResult?.isFailure) { + return Result.fail(branchIdResult.error); + } + + const documentTypeResult = DocumentType.create(params.documentType); + if (documentTypeResult.isFailure) { + return Result.fail(documentTypeResult.error); + } + + const codeResult = + params.seriesCode === undefined || params.seriesCode === null + ? null + : DocumentSeriesCode.create(params.seriesCode); + + if (codeResult?.isFailure) { + return Result.fail(codeResult.error); + } + + return this.repository.assignNextNumber({ + companyId: companyIdResult.data, + branchId: branchIdResult?.data ?? null, + documentType: documentTypeResult.data, + code: codeResult?.data ?? null, + transaction: params.transaction, + }); + } +} diff --git a/modules/document-series/src/api/application/services/document-series-writer.ts b/modules/document-series/src/api/application/services/document-series-writer.ts new file mode 100644 index 00000000..daf396ac --- /dev/null +++ b/modules/document-series/src/api/application/services/document-series-writer.ts @@ -0,0 +1,50 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { DocumentSeries } from "../../domain"; +import { DocumentSeriesNotFoundError } from "../../domain"; +import type { IDocumentSeriesRepository } from "../repositories"; + +export class DocumentSeriesWriter { + public constructor(private readonly repository: IDocumentSeriesRepository) {} + + public create(documentSeries: DocumentSeries, transaction?: unknown) { + return this.repository.save(documentSeries, transaction); + } + + public async update( + companyId: UniqueID, + id: UniqueID, + patch: Parameters[0], + transaction?: unknown + ) { + const currentResult = await this.repository.getByIdInCompany(companyId, id, transaction); + + if (currentResult.isFailure) { + return Result.fail(currentResult.error); + } + + const updateResult = currentResult.data.update(patch); + + if (updateResult.isFailure) { + return Result.fail(updateResult.error); + } + + return this.repository.save(currentResult.data, transaction); + } + + public async disable(companyId: UniqueID, id: UniqueID, transaction?: unknown) { + const currentResult = await this.repository.getByIdInCompany(companyId, id, transaction); + + if (currentResult.isFailure) { + return Result.fail(currentResult.error); + } + + if (!currentResult.data) { + return Result.fail(new DocumentSeriesNotFoundError()); + } + + currentResult.data.disable(); + return this.repository.save(currentResult.data, transaction); + } +} diff --git a/modules/document-series/src/api/application/services/index.ts b/modules/document-series/src/api/application/services/index.ts new file mode 100644 index 00000000..c8770e9f --- /dev/null +++ b/modules/document-series/src/api/application/services/index.ts @@ -0,0 +1,3 @@ +export * from "./document-series-finder"; +export * from "./document-series-number-assigner"; +export * from "./document-series-writer"; diff --git a/modules/document-series/src/api/application/snapshot-builders/document-series.snapshot-builder.ts b/modules/document-series/src/api/application/snapshot-builders/document-series.snapshot-builder.ts new file mode 100644 index 00000000..c7fc6950 --- /dev/null +++ b/modules/document-series/src/api/application/snapshot-builders/document-series.snapshot-builder.ts @@ -0,0 +1,63 @@ +import type { Collection } from "@repo/rdx-utils"; + +import type { + AssignNextDocumentNumberResponseDTO, + DocumentSeriesResponseDTO, + DocumentSeriesSummaryDTO, + ListDocumentSeriesResponseDTO, +} from "../../../common"; +import type { AssignedDocumentNumber, DocumentSeries } from "../../domain"; + +export class DocumentSeriesSnapshotBuilder { + public toResponse(documentSeries: DocumentSeries): DocumentSeriesResponseDTO { + return { + id: documentSeries.id.toString(), + company_id: documentSeries.companyId.toString(), + branch_id: documentSeries.branchId.getOrUndefined()?.toString() ?? null, + document_type: documentSeries.documentType.toPrimitive(), + code: documentSeries.code.toPrimitive(), + name: documentSeries.name.toPrimitive(), + description: documentSeries.description.getOrUndefined()?.toPrimitive() ?? null, + prefix: documentSeries.prefix.getOrUndefined() ?? null, + suffix: documentSeries.suffix.getOrUndefined() ?? null, + next_number: documentSeries.nextNumber.toPrimitive(), + padding: documentSeries.padding, + valid_from: documentSeries.validFrom.getOrUndefined()?.toPrimitive() ?? null, + valid_to: documentSeries.validTo.getOrUndefined()?.toPrimitive() ?? null, + is_default: documentSeries.isDefault, + is_active: documentSeries.isActive, + created_at: documentSeries.createdAt.toISOString(), + updated_at: documentSeries.updatedAt.toISOString(), + deleted_at: documentSeries.deletedAt.getOrUndefined()?.toISOString() ?? null, + }; + } + + public toSummary(documentSeries: DocumentSeries): DocumentSeriesSummaryDTO { + return { + id: documentSeries.id.toString(), + code: documentSeries.code.toPrimitive(), + name: documentSeries.name.toPrimitive(), + document_type: documentSeries.documentType.toPrimitive(), + next_number: documentSeries.nextNumber.toPrimitive(), + padding: documentSeries.padding, + is_default: documentSeries.isDefault, + is_active: documentSeries.isActive, + }; + } + + public toList(collection: Collection): ListDocumentSeriesResponseDTO { + return { + items: collection.map((item) => this.toResponse(item)), + total: collection.total(), + }; + } + + public toAssignment(dto: AssignedDocumentNumber): AssignNextDocumentNumberResponseDTO { + return { + series_id: dto.seriesId, + series_code: dto.seriesCode, + number: dto.number, + reference: dto.reference, + }; + } +} diff --git a/modules/document-series/src/api/application/snapshot-builders/index.ts b/modules/document-series/src/api/application/snapshot-builders/index.ts new file mode 100644 index 00000000..ec70bf42 --- /dev/null +++ b/modules/document-series/src/api/application/snapshot-builders/index.ts @@ -0,0 +1 @@ +export * from "./document-series.snapshot-builder"; diff --git a/modules/document-series/src/api/application/use-cases/assign-next-document-number.use-case.ts b/modules/document-series/src/api/application/use-cases/assign-next-document-number.use-case.ts new file mode 100644 index 00000000..7cbca0e9 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/assign-next-document-number.use-case.ts @@ -0,0 +1,34 @@ +import { Result } from "@repo/rdx-utils"; + +import type { AssignNextDocumentNumberRequestDTO } from "../../../common"; +import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; +import { DocumentSeriesNumberAssigner } from "../services"; + +export class AssignNextDocumentNumberUseCase { + public constructor( + private readonly deps: { + assigner: DocumentSeriesNumberAssigner; + snapshotBuilder: DocumentSeriesSnapshotBuilder; + } + ) {} + + public async execute(params: { + companyId: string; + dto: AssignNextDocumentNumberRequestDTO; + transaction: unknown; + }) { + const result = await this.deps.assigner.assignNext({ + companyId: params.companyId, + branchId: params.dto.branch_id ?? null, + documentType: params.dto.document_type, + seriesCode: params.dto.series_code ?? null, + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(this.deps.snapshotBuilder.toAssignment(result.data)); + } +} diff --git a/modules/document-series/src/api/application/use-cases/create-document-series.use-case.ts b/modules/document-series/src/api/application/use-cases/create-document-series.use-case.ts new file mode 100644 index 00000000..e2618543 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/create-document-series.use-case.ts @@ -0,0 +1,37 @@ +import type { ITransactionManager } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { CreateDocumentSeriesRequestDTO } from "../../../common"; +import { CreateDocumentSeriesCommandMapper } from "../mappers"; +import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; +import { DocumentSeriesWriter } from "../services"; + +export class CreateDocumentSeriesUseCase { + public constructor( + private readonly deps: { + mapper: CreateDocumentSeriesCommandMapper; + writer: DocumentSeriesWriter; + snapshotBuilder: DocumentSeriesSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: { companyId: UniqueID; dto: CreateDocumentSeriesRequestDTO }) { + return this.deps.transactionManager.complete(async (transaction: unknown) => { + const aggregateResult = this.deps.mapper.map(params.dto, params.companyId); + + if (aggregateResult.isFailure) { + return Result.fail(aggregateResult.error); + } + + const createResult = await this.deps.writer.create(aggregateResult.data, transaction); + + if (createResult.isFailure) { + return Result.fail(createResult.error); + } + + return Result.ok(this.deps.snapshotBuilder.toResponse(aggregateResult.data)); + }); + } +} diff --git a/modules/document-series/src/api/application/use-cases/disable-document-series.use-case.ts b/modules/document-series/src/api/application/use-cases/disable-document-series.use-case.ts new file mode 100644 index 00000000..385b9f83 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/disable-document-series.use-case.ts @@ -0,0 +1,30 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { DocumentSeriesWriter } from "../services"; + +export class DisableDocumentSeriesUseCase { + public constructor( + private readonly deps: { + writer: DocumentSeriesWriter; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: { companyId: string; id: string }) { + const companyIdResult = UniqueID.create(params.companyId); + if (companyIdResult.isFailure) { + return Result.fail(companyIdResult.error); + } + + const idResult = UniqueID.create(params.id); + if (idResult.isFailure) { + return Result.fail(idResult.error); + } + + return this.deps.transactionManager.complete((transaction: unknown) => + this.deps.writer.disable(companyIdResult.data, idResult.data, transaction) + ); + } +} diff --git a/modules/document-series/src/api/application/use-cases/get-document-series-by-id.use-case.ts b/modules/document-series/src/api/application/use-cases/get-document-series-by-id.use-case.ts new file mode 100644 index 00000000..f63d7350 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/get-document-series-by-id.use-case.ts @@ -0,0 +1,34 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; +import { DocumentSeriesFinder } from "../services"; + +export class GetDocumentSeriesByIdUseCase { + public constructor( + private readonly deps: { + finder: DocumentSeriesFinder; + snapshotBuilder: DocumentSeriesSnapshotBuilder; + } + ) {} + + public async execute(params: { companyId: string; id: string }) { + const companyIdResult = UniqueID.create(params.companyId); + if (companyIdResult.isFailure) { + return Result.fail(companyIdResult.error); + } + + const idResult = UniqueID.create(params.id); + if (idResult.isFailure) { + return Result.fail(idResult.error); + } + + const result = await this.deps.finder.findById(companyIdResult.data, idResult.data); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(this.deps.snapshotBuilder.toResponse(result.data)); + } +} diff --git a/modules/document-series/src/api/application/use-cases/index.ts b/modules/document-series/src/api/application/use-cases/index.ts new file mode 100644 index 00000000..4ad2f195 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/index.ts @@ -0,0 +1,6 @@ +export * from "./assign-next-document-number.use-case"; +export * from "./create-document-series.use-case"; +export * from "./disable-document-series.use-case"; +export * from "./get-document-series-by-id.use-case"; +export * from "./list-document-series.use-case"; +export * from "./update-document-series.use-case"; diff --git a/modules/document-series/src/api/application/use-cases/list-document-series.use-case.ts b/modules/document-series/src/api/application/use-cases/list-document-series.use-case.ts new file mode 100644 index 00000000..3d8fc494 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/list-document-series.use-case.ts @@ -0,0 +1,38 @@ +import type { ITransactionManager } from "@erp/core/api"; +import type { Criteria } from "@repo/rdx-criteria/server"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; +import { DocumentSeriesFinder } from "../services"; + +export class ListDocumentSeriesUseCase { + public constructor( + private readonly deps: { + finder: DocumentSeriesFinder; + snapshotBuilder: DocumentSeriesSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: { companyId: string; criteria: Criteria }) { + const companyIdResult = UniqueID.create(params.companyId); + if (companyIdResult.isFailure) { + return Result.fail(companyIdResult.error); + } + + return this.deps.transactionManager.complete(async (transaction: unknown) => { + const result = await this.deps.finder.findByCriteria( + companyIdResult.data, + params.criteria, + transaction + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(this.deps.snapshotBuilder.toList(result.data)); + }); + } +} diff --git a/modules/document-series/src/api/application/use-cases/update-document-series.use-case.ts b/modules/document-series/src/api/application/use-cases/update-document-series.use-case.ts new file mode 100644 index 00000000..b700ab50 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/update-document-series.use-case.ts @@ -0,0 +1,122 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { UpdateDocumentSeriesRequestDTO } from "../../../common"; +import { UpdateDocumentSeriesCommandMapper } from "../mappers"; +import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; +import { DocumentSeriesFinder, DocumentSeriesWriter } from "../services"; + +export class UpdateDocumentSeriesUseCase { + public constructor( + private readonly deps: { + mapper: UpdateDocumentSeriesCommandMapper; + writer: DocumentSeriesWriter; + finder: DocumentSeriesFinder; + snapshotBuilder: DocumentSeriesSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: { companyId: string; id: string; dto: UpdateDocumentSeriesRequestDTO }) { + const companyIdResult = UniqueID.create(params.companyId); + if (companyIdResult.isFailure) { + return Result.fail(companyIdResult.error); + } + + const idResult = UniqueID.create(params.id); + if (idResult.isFailure) { + return Result.fail(idResult.error); + } + + const commandResult = this.deps.mapper.map(params.dto, companyIdResult.data, params.id); + if (commandResult.isFailure) { + return Result.fail(commandResult.error); + } + + const patchResult = this.deps.mapper.toPatch(commandResult.data); + if (patchResult.isFailure) { + return Result.fail(patchResult.error); + } + + return this.deps.transactionManager.complete(async (transaction: unknown) => { + const updateResult = await this.deps.writer.update( + companyIdResult.data, + idResult.data, + { + branchId: + patchResult.data.branchId === undefined + ? undefined + : patchResult.data.branchId.getOrUndefined() + ? patchResult.data.branchId + : patchResult.data.branchId, + documentType: + patchResult.data.documentType === undefined + ? undefined + : patchResult.data.documentType.getOrUndefined() + ? patchResult.data.documentType.unwrap() + : patchResult.data.documentType, + code: + patchResult.data.code === undefined + ? undefined + : patchResult.data.code.getOrUndefined() + ? patchResult.data.code.unwrap() + : patchResult.data.code, + name: + patchResult.data.name === undefined + ? undefined + : patchResult.data.name.getOrUndefined() + ? patchResult.data.name.unwrap() + : patchResult.data.name, + description: + patchResult.data.description === undefined + ? undefined + : patchResult.data.description.getOrUndefined() + ? patchResult.data.description.unwrap() + : patchResult.data.description, + prefix: patchResult.data.prefix, + suffix: patchResult.data.suffix, + nextNumber: + patchResult.data.nextNumber === undefined + ? undefined + : patchResult.data.nextNumber.getOrUndefined() + ? patchResult.data.nextNumber.unwrap() + : patchResult.data.nextNumber, + padding: patchResult.data.padding, + validFrom: + patchResult.data.validFrom === undefined + ? undefined + : patchResult.data.validFrom.getOrUndefined() + ? patchResult.data.validFrom.unwrap() + : patchResult.data.validFrom, + validTo: + patchResult.data.validTo === undefined + ? undefined + : patchResult.data.validTo.getOrUndefined() + ? patchResult.data.validTo.unwrap() + : patchResult.data.validTo, + isDefault: patchResult.data.isDefault, + isActive: patchResult.data.isActive, + updatedAt: patchResult.data.updatedAt, + }, + transaction + ); + + if (updateResult.isFailure) { + return Result.fail(updateResult.error); + } + + const getResult = await this.deps.finder.findById( + companyIdResult.data, + idResult.data, + transaction + ); + + if (getResult.isFailure) { + return Result.fail(getResult.error); + } + + return Result.ok(this.deps.snapshotBuilder.toResponse(getResult.data)); + }); + } +} diff --git a/modules/document-series/src/api/domain/aggregates/document-series.aggregate.ts b/modules/document-series/src/api/domain/aggregates/document-series.aggregate.ts new file mode 100644 index 00000000..435ad413 --- /dev/null +++ b/modules/document-series/src/api/domain/aggregates/document-series.aggregate.ts @@ -0,0 +1,213 @@ +import { AggregateRoot, Name, type TextValue, type UniqueID, type UtcDate } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + DocumentSeriesInactiveError, + DocumentSeriesOutOfValidityError, +} from "../errors"; +import { + type AssignedDocumentNumber, + DocumentSeriesCode, + DocumentSeriesNumber, + type DocumentType, +} from "../value-objects"; + +export type DocumentSeriesCreateProps = { + companyId: UniqueID; + branchId: Maybe; + documentType: DocumentType; + code: DocumentSeriesCode; + name: Name; + description: Maybe; + prefix: Maybe; + suffix: Maybe; + nextNumber: DocumentSeriesNumber; + padding: number; + validFrom: Maybe; + validTo: Maybe; + isDefault: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + deletedAt: Maybe; +}; + +export type DocumentSeriesInternalProps = DocumentSeriesCreateProps; + +export class DocumentSeries extends AggregateRoot { + protected constructor(props: DocumentSeriesInternalProps, id?: UniqueID) { + super(props, id); + } + + public static create(props: DocumentSeriesCreateProps, id?: UniqueID) { + if (props.padding < 0) { + return Result.fail(new Error("Document series padding must be greater than or equal to zero.")); + } + + if ( + props.validFrom.isSome() && + props.validTo.isSome() && + props.validFrom.unwrap().isAfter(props.validTo.unwrap()) + ) { + return Result.fail(new Error("Document series validFrom must be before or equal to validTo.")); + } + + return Result.ok(new DocumentSeries(props, id)); + } + + public static rehydrate(props: DocumentSeriesInternalProps, id: UniqueID) { + return new DocumentSeries(props, id); + } + + public get companyId() { + return this.props.companyId; + } + + public get branchId() { + return this.props.branchId; + } + + public get documentType() { + return this.props.documentType; + } + + public get code() { + return this.props.code; + } + + public get name() { + return this.props.name; + } + + public get description() { + return this.props.description; + } + + public get prefix() { + return this.props.prefix; + } + + public get suffix() { + return this.props.suffix; + } + + public get nextNumber() { + return this.props.nextNumber; + } + + public get padding() { + return this.props.padding; + } + + public get validFrom() { + return this.props.validFrom; + } + + public get validTo() { + return this.props.validTo; + } + + public get isDefault() { + return this.props.isDefault; + } + + public get isActive() { + return this.props.isActive; + } + + public get createdAt() { + return this.props.createdAt; + } + + public get updatedAt() { + return this.props.updatedAt; + } + + public get deletedAt() { + return this.props.deletedAt; + } + + public update(props: Partial>) { + const nextValidFrom = props.validFrom ?? this.props.validFrom; + const nextValidTo = props.validTo ?? this.props.validTo; + + if ( + nextValidFrom.isSome() && + nextValidTo.isSome() && + nextValidFrom.unwrap().isAfter(nextValidTo.unwrap()) + ) { + return Result.fail(new Error("Document series validFrom must be before or equal to validTo.")); + } + + this.props.branchId = props.branchId ?? this.props.branchId; + this.props.documentType = props.documentType ?? this.props.documentType; + this.props.code = props.code ?? this.props.code; + this.props.name = props.name ?? this.props.name; + this.props.description = props.description ?? this.props.description; + this.props.prefix = props.prefix ?? this.props.prefix; + this.props.suffix = props.suffix ?? this.props.suffix; + this.props.nextNumber = props.nextNumber ?? this.props.nextNumber; + this.props.padding = props.padding ?? this.props.padding; + this.props.validFrom = nextValidFrom; + this.props.validTo = nextValidTo; + this.props.isDefault = props.isDefault ?? this.props.isDefault; + this.props.isActive = props.isActive ?? this.props.isActive; + this.props.updatedAt = props.updatedAt ?? new Date(); + + return Result.ok(); + } + + public disable(updatedAt = new Date()) { + this.props.isActive = false; + this.props.updatedAt = updatedAt; + } + + public ensureCanAssign(currentDate: UtcDate) { + if (!this.props.isActive) { + return Result.fail(new DocumentSeriesInactiveError(this.code.toPrimitive())); + } + + if (this.validFrom.isSome() && this.validFrom.unwrap().isAfter(currentDate)) { + return Result.fail(new DocumentSeriesOutOfValidityError(this.code.toPrimitive())); + } + + if (this.validTo.isSome() && this.validTo.unwrap().isBefore(currentDate)) { + return Result.fail(new DocumentSeriesOutOfValidityError(this.code.toPrimitive())); + } + + return Result.ok(); + } + + public formatReference(number: DocumentSeriesNumber) { + const prefix = this.prefix.getOrUndefined() ?? ""; + const suffix = this.suffix.getOrUndefined() ?? ""; + const padded = String(number.toPrimitive()).padStart(this.padding, "0"); + return `${prefix}${padded}${suffix}`; + } + + public assignNext(currentDate: UtcDate): Result { + const canAssignResult = this.ensureCanAssign(currentDate); + + if (canAssignResult.isFailure) { + return Result.fail(canAssignResult.error); + } + + const currentNumber = this.nextNumber; + const reference = this.formatReference(currentNumber); + const nextNumberResult = currentNumber.increment(); + + if (nextNumberResult.isFailure) { + return Result.fail(nextNumberResult.error); + } + + this.props.nextNumber = nextNumberResult.data; + this.props.updatedAt = new Date(); + + return Result.ok({ + seriesId: this.id.toString(), + seriesCode: this.code.toPrimitive(), + number: String(currentNumber.toPrimitive()), + reference, + }); + } +} diff --git a/modules/document-series/src/api/domain/aggregates/index.ts b/modules/document-series/src/api/domain/aggregates/index.ts new file mode 100644 index 00000000..8dd58fcd --- /dev/null +++ b/modules/document-series/src/api/domain/aggregates/index.ts @@ -0,0 +1 @@ +export * from "./document-series.aggregate"; diff --git a/modules/document-series/src/api/domain/errors/document-series-inactive.error.ts b/modules/document-series/src/api/domain/errors/document-series-inactive.error.ts new file mode 100644 index 00000000..ab7604f1 --- /dev/null +++ b/modules/document-series/src/api/domain/errors/document-series-inactive.error.ts @@ -0,0 +1,10 @@ +export class DocumentSeriesInactiveError extends Error { + public constructor(code: string) { + super(`Document series "${code}" is inactive and cannot assign document numbers.`); + this.name = "DocumentSeriesInactiveError"; + } +} + +export const isDocumentSeriesInactiveError = ( + error: unknown +): error is DocumentSeriesInactiveError => error instanceof DocumentSeriesInactiveError; diff --git a/modules/document-series/src/api/domain/errors/document-series-not-found.error.ts b/modules/document-series/src/api/domain/errors/document-series-not-found.error.ts new file mode 100644 index 00000000..989f70b7 --- /dev/null +++ b/modules/document-series/src/api/domain/errors/document-series-not-found.error.ts @@ -0,0 +1,10 @@ +export class DocumentSeriesNotFoundError extends Error { + public constructor() { + super("Document series was not found for the provided company and criteria."); + this.name = "DocumentSeriesNotFoundError"; + } +} + +export const isDocumentSeriesNotFoundError = ( + error: unknown +): error is DocumentSeriesNotFoundError => error instanceof DocumentSeriesNotFoundError; diff --git a/modules/document-series/src/api/domain/errors/document-series-out-of-validity.error.ts b/modules/document-series/src/api/domain/errors/document-series-out-of-validity.error.ts new file mode 100644 index 00000000..857c1030 --- /dev/null +++ b/modules/document-series/src/api/domain/errors/document-series-out-of-validity.error.ts @@ -0,0 +1,10 @@ +export class DocumentSeriesOutOfValidityError extends Error { + public constructor(code: string) { + super(`Document series "${code}" is outside its validity window and cannot assign numbers.`); + this.name = "DocumentSeriesOutOfValidityError"; + } +} + +export const isDocumentSeriesOutOfValidityError = ( + error: unknown +): error is DocumentSeriesOutOfValidityError => error instanceof DocumentSeriesOutOfValidityError; diff --git a/modules/document-series/src/api/domain/errors/document-series-transaction-required.error.ts b/modules/document-series/src/api/domain/errors/document-series-transaction-required.error.ts new file mode 100644 index 00000000..cbb2acf7 --- /dev/null +++ b/modules/document-series/src/api/domain/errors/document-series-transaction-required.error.ts @@ -0,0 +1,11 @@ +export class DocumentSeriesTransactionRequiredError extends Error { + public constructor() { + super("Document series number assignment requires an active transaction."); + this.name = "DocumentSeriesTransactionRequiredError"; + } +} + +export const isDocumentSeriesTransactionRequiredError = ( + error: unknown +): error is DocumentSeriesTransactionRequiredError => + error instanceof DocumentSeriesTransactionRequiredError; diff --git a/modules/document-series/src/api/domain/errors/index.ts b/modules/document-series/src/api/domain/errors/index.ts new file mode 100644 index 00000000..b6b6917d --- /dev/null +++ b/modules/document-series/src/api/domain/errors/index.ts @@ -0,0 +1,4 @@ +export * from "./document-series-inactive.error"; +export * from "./document-series-not-found.error"; +export * from "./document-series-out-of-validity.error"; +export * from "./document-series-transaction-required.error"; diff --git a/modules/document-series/src/api/domain/index.ts b/modules/document-series/src/api/domain/index.ts new file mode 100644 index 00000000..ed09726f --- /dev/null +++ b/modules/document-series/src/api/domain/index.ts @@ -0,0 +1,3 @@ +export * from "./aggregates"; +export * from "./errors"; +export * from "./value-objects"; diff --git a/modules/document-series/src/api/domain/value-objects/assigned-document-number.vo.ts b/modules/document-series/src/api/domain/value-objects/assigned-document-number.vo.ts new file mode 100644 index 00000000..653ee14a --- /dev/null +++ b/modules/document-series/src/api/domain/value-objects/assigned-document-number.vo.ts @@ -0,0 +1,6 @@ +export type AssignedDocumentNumber = { + seriesId: string; + seriesCode: string; + number: string; + reference: string; +}; diff --git a/modules/document-series/src/api/domain/value-objects/document-series-code.vo.ts b/modules/document-series/src/api/domain/value-objects/document-series-code.vo.ts new file mode 100644 index 00000000..03a2bfec --- /dev/null +++ b/modules/document-series/src/api/domain/value-objects/document-series-code.vo.ts @@ -0,0 +1,33 @@ +import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +type DocumentSeriesCodeProps = { + value: string; +}; + +export class DocumentSeriesCode extends ValueObject { + public static create(value: string) { + const validation = z.string().trim().min(1).max(20).safeParse(value); + + if (!validation.success) { + return Result.fail( + new DomainValidationError( + "INVALID_DOCUMENT_SERIES_CODE", + "code", + validation.error.message + ) + ); + } + + return Result.ok(new DocumentSeriesCode({ value: validation.data })); + } + + public toPrimitive() { + return this.props.value; + } + + public toString() { + return this.props.value; + } +} diff --git a/modules/document-series/src/api/domain/value-objects/document-series-number.vo.ts b/modules/document-series/src/api/domain/value-objects/document-series-number.vo.ts new file mode 100644 index 00000000..2eb42759 --- /dev/null +++ b/modules/document-series/src/api/domain/value-objects/document-series-number.vo.ts @@ -0,0 +1,33 @@ +import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +type DocumentSeriesNumberProps = { + value: number; +}; + +export class DocumentSeriesNumber extends ValueObject { + public static create(value: number) { + const validation = z.number().int().min(1).safeParse(value); + + if (!validation.success) { + return Result.fail( + new DomainValidationError( + "INVALID_DOCUMENT_SERIES_NUMBER", + "nextNumber", + validation.error.message + ) + ); + } + + return Result.ok(new DocumentSeriesNumber({ value: validation.data })); + } + + public increment() { + return DocumentSeriesNumber.create(this.props.value + 1); + } + + public toPrimitive() { + return this.props.value; + } +} diff --git a/modules/document-series/src/api/domain/value-objects/document-type.vo.ts b/modules/document-series/src/api/domain/value-objects/document-type.vo.ts new file mode 100644 index 00000000..bc3c3318 --- /dev/null +++ b/modules/document-series/src/api/domain/value-objects/document-type.vo.ts @@ -0,0 +1,41 @@ +import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { z } from "zod/v4"; + +export const DOCUMENT_TYPE_VALUES = ["proforma", "issued_invoice"] as const; + +export type DocumentTypeValue = (typeof DOCUMENT_TYPE_VALUES)[number]; + +type DocumentTypeProps = { + value: DocumentTypeValue; +}; + +export class DocumentType extends ValueObject { + public static create(value: string) { + const validation = z.enum(DOCUMENT_TYPE_VALUES).safeParse(value); + + if (!validation.success) { + return Result.fail( + new DomainValidationError("INVALID_DOCUMENT_TYPE", "documentType", validation.error.message) + ); + } + + return Result.ok(new DocumentType({ value: validation.data })); + } + + public static proforma() { + return new DocumentType({ value: "proforma" }); + } + + public static issuedInvoice() { + return new DocumentType({ value: "issued_invoice" }); + } + + public toPrimitive(): DocumentTypeValue { + return this.props.value; + } + + public toString() { + return this.props.value; + } +} diff --git a/modules/document-series/src/api/domain/value-objects/index.ts b/modules/document-series/src/api/domain/value-objects/index.ts new file mode 100644 index 00000000..db0770f0 --- /dev/null +++ b/modules/document-series/src/api/domain/value-objects/index.ts @@ -0,0 +1,4 @@ +export * from "./assigned-document-number.vo"; +export * from "./document-series-code.vo"; +export * from "./document-series-number.vo"; +export * from "./document-type.vo"; diff --git a/modules/document-series/src/api/index.ts b/modules/document-series/src/api/index.ts new file mode 100644 index 00000000..ff8da32f --- /dev/null +++ b/modules/document-series/src/api/index.ts @@ -0,0 +1,38 @@ +import type { IModuleServer } from "@erp/core/api"; + +import type { IDocumentSeriesPublicServices } from "./application"; +import { documentSeriesRouter, models } from "./infrastructure"; +import { + buildDocumentSeriesDependencies, + buildDocumentSeriesPublicServices, +} from "./infrastructure/di"; + +export type { IDocumentSeriesPublicServices } from "./application"; +export * from "./infrastructure/persistence/sequelize"; + +export type DocumentSeriesPublicServicesType = IDocumentSeriesPublicServices; + +export const documentSeriesAPIModule: IModuleServer = { + name: "document-series", + version: "1.0.0", + dependencies: ["identity", "companies"], + + async setup(params) { + const internal = buildDocumentSeriesDependencies(params); + const services = buildDocumentSeriesPublicServices(params, internal); + + return { + models, + services: { + general: services, + }, + internal, + }; + }, + + async start(params) { + documentSeriesRouter(params); + }, +}; + +export default documentSeriesAPIModule; diff --git a/modules/document-series/src/api/infrastructure/common/index.ts b/modules/document-series/src/api/infrastructure/common/index.ts new file mode 100644 index 00000000..4a309c4d --- /dev/null +++ b/modules/document-series/src/api/infrastructure/common/index.ts @@ -0,0 +1 @@ +export * from "./persistence"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/index.ts b/modules/document-series/src/api/infrastructure/common/persistence/index.ts similarity index 100% rename from modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/index.ts rename to modules/document-series/src/api/infrastructure/common/persistence/index.ts diff --git a/modules/document-series/src/api/infrastructure/common/persistence/sequelize/index.ts b/modules/document-series/src/api/infrastructure/common/persistence/sequelize/index.ts new file mode 100644 index 00000000..ef2cafcf --- /dev/null +++ b/modules/document-series/src/api/infrastructure/common/persistence/sequelize/index.ts @@ -0,0 +1,5 @@ +import documentSeriesModelInit from "./models/sequelize-document-series.model"; + +export * from "./models"; + +export const models = [documentSeriesModelInit]; diff --git a/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/index.ts b/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/index.ts new file mode 100644 index 00000000..4ed87902 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-document-series.model"; diff --git a/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/sequelize-document-series.model.ts b/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/sequelize-document-series.model.ts new file mode 100644 index 00000000..6ee97169 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/sequelize-document-series.model.ts @@ -0,0 +1,145 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type Sequelize, +} from "sequelize"; + +export class DocumentSeriesModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare company_id: string; + declare branch_id: CreationOptional; + declare document_type: string; + declare code: string; + declare name: string; + declare description: CreationOptional; + declare prefix: CreationOptional; + declare suffix: CreationOptional; + declare next_number: number; + declare padding: number; + declare valid_from: CreationOptional; + declare valid_to: CreationOptional; + declare is_default: CreationOptional; + declare is_active: CreationOptional; + declare created_at: CreationOptional; + declare updated_at: CreationOptional; + declare deleted_at: CreationOptional; +} + +export default (database: Sequelize) => { + DocumentSeriesModel.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + } as any, + company_id: { + type: DataTypes.UUID, + allowNull: false, + } as any, + branch_id: { + type: DataTypes.UUID, + allowNull: true, + } as any, + document_type: { + type: DataTypes.STRING(30), + allowNull: false, + }, + code: { + type: DataTypes.STRING(20), + allowNull: false, + }, + name: { + type: DataTypes.STRING(255), + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + prefix: { + type: DataTypes.STRING(50), + allowNull: true, + }, + suffix: { + type: DataTypes.STRING(50), + allowNull: true, + }, + next_number: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + }, + padding: { + type: DataTypes.SMALLINT.UNSIGNED, + allowNull: false, + }, + valid_from: { + type: DataTypes.DATE, + allowNull: true, + }, + valid_to: { + type: DataTypes.DATE, + allowNull: true, + }, + 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, + }, + deleted_at: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize: database, + modelName: "DocumentSeriesModel", + tableName: "document_series", + underscored: true, + paranoid: true, + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", + deletedAt: "deleted_at", + indexes: [ + { + name: "uq_document_series_company_type_code", + unique: true, + fields: ["company_id", "document_type", "code"], + }, + { + name: "idx_document_series_company_type", + fields: ["company_id", "document_type"], + }, + { + name: "idx_document_series_company_branch_type", + fields: ["company_id", "branch_id", "document_type"], + }, + { + name: "idx_document_series_company_type_active", + fields: ["company_id", "document_type", "is_active"], + }, + ], + } + ); + + return DocumentSeriesModel; +}; diff --git a/modules/document-series/src/api/infrastructure/di/document-series-public-services.ts b/modules/document-series/src/api/infrastructure/di/document-series-public-services.ts new file mode 100644 index 00000000..9fb21016 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/di/document-series-public-services.ts @@ -0,0 +1,74 @@ +import type { SetupParams } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IDocumentSeriesPublicServices } from "../../application"; +import { + DocumentSeriesFinder, + DocumentSeriesNumberAssigner, + DocumentSeriesSnapshotBuilder, +} from "../../application"; +import { DocumentType } from "../../domain"; +import { SequelizeDocumentSeriesDomainMapper } from "../persistence/sequelize/mappers"; +import { SequelizeDocumentSeriesRepository } from "../persistence/sequelize/repositories"; + +import type { DocumentSeriesInternalDeps } from "./document-series.di"; + +export function buildDocumentSeriesPublicServices( + params: SetupParams, + _deps: DocumentSeriesInternalDeps +): IDocumentSeriesPublicServices { + const repository = new SequelizeDocumentSeriesRepository( + new SequelizeDocumentSeriesDomainMapper(), + params.database + ); + const finder = new DocumentSeriesFinder(repository); + const assigner = new DocumentSeriesNumberAssigner(repository); + const snapshotBuilder = new DocumentSeriesSnapshotBuilder(); + + return { + assignNextNumber: async (assignParams) => { + const result = await assigner.assignNext(assignParams); + if (result.isFailure) { + return Result.fail(result.error); + } + return Result.ok(snapshotBuilder.toAssignment(result.data)); + }, + + listActiveSeries: async (listParams) => { + const companyIdResult = UniqueID.create(listParams.companyId); + if (companyIdResult.isFailure) { + return Result.fail(companyIdResult.error); + } + + const documentTypeResult = DocumentType.create(listParams.documentType); + if (documentTypeResult.isFailure) { + return Result.fail(documentTypeResult.error); + } + + const criteriaModule = await import("@repo/rdx-criteria/server"); + const criteria = criteriaModule.Criteria.fromPrimitives( + [ + { field: "document_type", operator: "=", value: documentTypeResult.data.toPrimitive() }, + { field: "is_active", operator: "=", value: "true" }, + ], + "is_default", + "DESC", + null, + null, + null + ); + + const result = await finder.findByCriteria( + companyIdResult.data, + criteria, + listParams.transaction + ); + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(result.data.map((item) => snapshotBuilder.toSummary(item))); + }, + }; +} diff --git a/modules/document-series/src/api/infrastructure/di/document-series.di.ts b/modules/document-series/src/api/infrastructure/di/document-series.di.ts new file mode 100644 index 00000000..3219154f --- /dev/null +++ b/modules/document-series/src/api/infrastructure/di/document-series.di.ts @@ -0,0 +1,38 @@ +import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; + +import { + DocumentSeriesFinder, + DocumentSeriesNumberAssigner, + DocumentSeriesWriter, + type IDocumentSeriesRepository, + buildDocumentSeriesUseCases, +} from "../../application"; +import { SequelizeDocumentSeriesDomainMapper } from "../persistence/sequelize/mappers"; +import { SequelizeDocumentSeriesRepository } from "../persistence/sequelize/repositories"; + +export type DocumentSeriesInternalDeps = { + useCases: ReturnType; +}; + +export function buildDocumentSeriesDependencies(params: ModuleParams): DocumentSeriesInternalDeps { + const { database } = params; + + const transactionManager = buildTransactionManager(database); + const mapper = new SequelizeDocumentSeriesDomainMapper(); + const repository: IDocumentSeriesRepository = new SequelizeDocumentSeriesRepository( + mapper, + database + ); + const finder = new DocumentSeriesFinder(repository); + const writer = new DocumentSeriesWriter(repository); + const assigner = new DocumentSeriesNumberAssigner(repository); + + return { + useCases: buildDocumentSeriesUseCases({ + finder, + writer, + assigner, + transactionManager, + }), + }; +} diff --git a/modules/document-series/src/api/infrastructure/di/index.ts b/modules/document-series/src/api/infrastructure/di/index.ts new file mode 100644 index 00000000..e27b7cae --- /dev/null +++ b/modules/document-series/src/api/infrastructure/di/index.ts @@ -0,0 +1,2 @@ +export * from "./document-series.di"; +export * from "./document-series-public-services"; diff --git a/modules/document-series/src/api/infrastructure/express/controllers/assign-next-document-number.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/assign-next-document-number.controller.ts new file mode 100644 index 00000000..ddd35fe5 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/assign-next-document-number.controller.ts @@ -0,0 +1,41 @@ +import { + ExpressController, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; +import { buildTransactionManager } from "@erp/core/api"; + +import type { AssignNextDocumentNumberRequestDTO } from "../../../../common"; +import type { AssignNextDocumentNumberUseCase } from "../../../application"; +import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper"; + +export class AssignNextDocumentNumberController extends ExpressController { + public constructor( + private readonly useCase: AssignNextDocumentNumberUseCase, + private readonly transactionManager: ReturnType + ) { + super(); + this.errorMapper = documentSeriesApiErrorMapper; + this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard()); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const result = await this.transactionManager.complete((transaction: unknown) => + this.useCase.execute({ + companyId: companyId.toString(), + dto: this.req.body as AssignNextDocumentNumberRequestDTO, + transaction, + }) + ); + + return result.match( + (data) => this.ok(data), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/controllers/create-document-series.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/create-document-series.controller.ts new file mode 100644 index 00000000..0c240e6a --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/create-document-series.controller.ts @@ -0,0 +1,40 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { CreateDocumentSeriesRequestDTO } from "../../../../common"; +import type { CreateDocumentSeriesUseCase } from "../../../application"; +import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper"; + +export class CreateDocumentSeriesController extends ExpressController { + public constructor(private readonly useCase: CreateDocumentSeriesUseCase) { + super(); + this.errorMapper = documentSeriesApiErrorMapper; + 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, + dto: this.req.body as CreateDocumentSeriesRequestDTO, + }); + + return result.match( + (data) => this.created(data), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/controllers/disable-document-series.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/disable-document-series.controller.ts new file mode 100644 index 00000000..21dcfec5 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/disable-document-series.controller.ts @@ -0,0 +1,39 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { DisableDocumentSeriesUseCase } from "../../../application"; +import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper"; + +export class DisableDocumentSeriesController extends ExpressController { + public constructor(private readonly useCase: DisableDocumentSeriesUseCase) { + super(); + this.errorMapper = documentSeriesApiErrorMapper; + 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: companyId.toString(), + id: String(this.req.params.id), + }); + + return result.match( + () => this.noContent(), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/controllers/get-document-series-by-id.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/get-document-series-by-id.controller.ts new file mode 100644 index 00000000..537080b3 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/get-document-series-by-id.controller.ts @@ -0,0 +1,39 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { GetDocumentSeriesByIdUseCase } from "../../../application"; +import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper"; + +export class GetDocumentSeriesByIdController extends ExpressController { + public constructor(private readonly useCase: GetDocumentSeriesByIdUseCase) { + super(); + this.errorMapper = documentSeriesApiErrorMapper; + 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: companyId.toString(), + id: String(this.req.params.id), + }); + + return result.match( + (data) => this.ok(data), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/controllers/index.ts b/modules/document-series/src/api/infrastructure/express/controllers/index.ts new file mode 100644 index 00000000..db34cdc0 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/index.ts @@ -0,0 +1,6 @@ +export * from "./assign-next-document-number.controller"; +export * from "./create-document-series.controller"; +export * from "./disable-document-series.controller"; +export * from "./get-document-series-by-id.controller"; +export * from "./list-document-series.controller"; +export * from "./update-document-series.controller"; diff --git a/modules/document-series/src/api/infrastructure/express/controllers/list-document-series.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/list-document-series.controller.ts new file mode 100644 index 00000000..6f086c45 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/list-document-series.controller.ts @@ -0,0 +1,43 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; +import { Criteria } from "@repo/rdx-criteria/server"; + +import type { ListDocumentSeriesUseCase } from "../../../application"; +import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper"; + +export class ListDocumentSeriesController extends ExpressController { + public constructor(private readonly useCase: ListDocumentSeriesUseCase) { + super(); + this.errorMapper = documentSeriesApiErrorMapper; + 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 { filters, pageNumber, pageSize, quickSearch } = this.criteria; + const criteria = Criteria.fromPrimitives(filters.toPrimitives(), "code", "ASC", pageSize, pageNumber, quickSearch); + + const result = await this.useCase.execute({ + companyId: companyId.toString(), + criteria, + }); + + return result.match( + (data) => this.ok(data), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/controllers/update-document-series.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/update-document-series.controller.ts new file mode 100644 index 00000000..292b69d1 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/update-document-series.controller.ts @@ -0,0 +1,41 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { UpdateDocumentSeriesRequestDTO } from "../../../../common"; +import type { UpdateDocumentSeriesUseCase } from "../../../application"; +import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper"; + +export class UpdateDocumentSeriesController extends ExpressController { + public constructor(private readonly useCase: UpdateDocumentSeriesUseCase) { + super(); + this.errorMapper = documentSeriesApiErrorMapper; + 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: companyId.toString(), + id: String(this.req.params.id), + dto: this.req.body as UpdateDocumentSeriesRequestDTO, + }); + + return result.match( + (data) => this.ok(data), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/document-series-api-error-mapper.ts b/modules/document-series/src/api/infrastructure/express/document-series-api-error-mapper.ts new file mode 100644 index 00000000..da354391 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/document-series-api-error-mapper.ts @@ -0,0 +1,39 @@ +import { + ApiErrorMapper, + type ErrorToApiRule, + ConflictApiError, + NotFoundApiError, + ValidationApiError, +} from "@erp/core/api"; + +import { + type DocumentSeriesInactiveError, + type DocumentSeriesNotFoundError, + type DocumentSeriesOutOfValidityError, + isDocumentSeriesInactiveError, + isDocumentSeriesNotFoundError, + isDocumentSeriesOutOfValidityError, +} from "../../domain"; + +const notFoundRule: ErrorToApiRule = { + priority: 120, + matches: (error) => isDocumentSeriesNotFoundError(error), + build: (error) => new NotFoundApiError((error as DocumentSeriesNotFoundError).message), +}; + +const inactiveRule: ErrorToApiRule = { + priority: 120, + matches: (error) => isDocumentSeriesInactiveError(error), + build: (error) => new ConflictApiError((error as DocumentSeriesInactiveError).message), +}; + +const validityRule: ErrorToApiRule = { + priority: 120, + matches: (error) => isDocumentSeriesOutOfValidityError(error), + build: (error) => new ValidationApiError((error as DocumentSeriesOutOfValidityError).message), +}; + +export const documentSeriesApiErrorMapper = ApiErrorMapper.default() + .register(notFoundRule) + .register(inactiveRule) + .register(validityRule); diff --git a/modules/document-series/src/api/infrastructure/express/document-series.routes.ts b/modules/document-series/src/api/infrastructure/express/document-series.routes.ts new file mode 100644 index 00000000..c65e46cc --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/document-series.routes.ts @@ -0,0 +1,86 @@ +import { type StartParams, buildTransactionManager, validateRequest } from "@erp/core/api"; +import { requireIdentityTenant } from "@erp/identity/api"; +import { type NextFunction, type Request, type Response, Router } from "express"; + +import { + AssignNextDocumentNumberRequestSchema, + CreateDocumentSeriesRequestSchema, + GetDocumentSeriesByIdRequestSchema, + UpdateDocumentSeriesParamsRequestSchema, + UpdateDocumentSeriesRequestSchema, +} from "../../../common"; +import type { DocumentSeriesInternalDeps } from "../di"; + +import { + AssignNextDocumentNumberController, + CreateDocumentSeriesController, + DisableDocumentSeriesController, + GetDocumentSeriesByIdController, + ListDocumentSeriesController, + UpdateDocumentSeriesController, +} from "./controllers"; + +export const documentSeriesRouter = (params: StartParams) => { + const { app, config, getInternal, database } = params; + const deps = getInternal("document-series"); + const router: Router = Router({ mergeParams: true }); + const transactionManager = buildTransactionManager(database); + + router.use(...requireIdentityTenant(params)); + + router.get("/", (req: Request, res: Response, next: NextFunction) => { + const controller = new ListDocumentSeriesController(deps.useCases.listDocumentSeries()); + return controller.execute(req, res, next); + }); + + router.get( + "/:id", + validateRequest(GetDocumentSeriesByIdRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new GetDocumentSeriesByIdController(deps.useCases.getDocumentSeriesById()); + return controller.execute(req, res, next); + } + ); + + router.post( + "/", + validateRequest(CreateDocumentSeriesRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new CreateDocumentSeriesController(deps.useCases.createDocumentSeries()); + return controller.execute(req, res, next); + } + ); + + router.put( + "/:id", + validateRequest(UpdateDocumentSeriesParamsRequestSchema, "params"), + validateRequest(UpdateDocumentSeriesRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new UpdateDocumentSeriesController(deps.useCases.updateDocumentSeries()); + return controller.execute(req, res, next); + } + ); + + router.patch( + "/:id/disable", + validateRequest(GetDocumentSeriesByIdRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new DisableDocumentSeriesController(deps.useCases.disableDocumentSeries()); + return controller.execute(req, res, next); + } + ); + + router.post( + "/assign-next", + validateRequest(AssignNextDocumentNumberRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new AssignNextDocumentNumberController( + deps.useCases.assignNextDocumentNumber(), + transactionManager + ); + return controller.execute(req, res, next); + } + ); + + app.use(`${config.server.apiBasePath}/document-series`, router); +}; diff --git a/modules/document-series/src/api/infrastructure/express/index.ts b/modules/document-series/src/api/infrastructure/express/index.ts new file mode 100644 index 00000000..7977818e --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/index.ts @@ -0,0 +1,3 @@ +export * from "./document-series.routes"; +export * from "./document-series-api-error-mapper"; +export * from "./controllers"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts b/modules/document-series/src/api/infrastructure/index.ts similarity index 75% rename from modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts rename to modules/document-series/src/api/infrastructure/index.ts index 3e34d105..1e4f0816 100644 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts +++ b/modules/document-series/src/api/infrastructure/index.ts @@ -1,3 +1,4 @@ +export * from "./common"; export * from "./di"; export * from "./express"; export * from "./persistence"; diff --git a/modules/document-series/src/api/infrastructure/persistence/index.ts b/modules/document-series/src/api/infrastructure/persistence/index.ts new file mode 100644 index 00000000..62f8ac11 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/persistence/index.ts @@ -0,0 +1 @@ +export * from "./sequelize"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/index.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/index.ts similarity index 67% rename from modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/index.ts rename to modules/document-series/src/api/infrastructure/persistence/sequelize/index.ts index bad13e0e..0c2e706a 100644 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/index.ts +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/index.ts @@ -1,2 +1,3 @@ export * from "./mappers"; export * from "./repositories"; +export * from "./services"; diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/index.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/index.ts new file mode 100644 index 00000000..d5b92433 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-document-series-domain.mapper"; diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/sequelize-document-series-domain.mapper.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/sequelize-document-series-domain.mapper.ts new file mode 100644 index 00000000..eaf9a536 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/sequelize-document-series-domain.mapper.ts @@ -0,0 +1,101 @@ +import { Name, TextValue, UniqueID, UtcDate, ValidationErrorCollection, extractOrPushError, maybeFromNullableResult, type ValidationErrorDetail } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + DocumentSeries, + DocumentSeriesCode, + DocumentSeriesNumber, + DocumentType, + type DocumentSeriesInternalProps, +} from "../../../../domain"; +import type { DocumentSeriesModel } from "../../../common"; + +export class SequelizeDocumentSeriesDomainMapper { + public mapToDomain(raw: DocumentSeriesModel) { + const errors: ValidationErrorDetail[] = []; + + const id = extractOrPushError(UniqueID.create(raw.id), "id", errors); + const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); + const branchId = extractOrPushError( + maybeFromNullableResult(raw.branch_id, (value) => UniqueID.create(value)), + "branch_id", + errors + ); + const documentType = extractOrPushError(DocumentType.create(raw.document_type), "document_type", errors); + const code = extractOrPushError(DocumentSeriesCode.create(raw.code), "code", errors); + const name = extractOrPushError(Name.create(raw.name), "name", errors); + const description = extractOrPushError( + maybeFromNullableResult(raw.description, (value) => TextValue.create(value)), + "description", + errors + ); + const nextNumber = extractOrPushError( + DocumentSeriesNumber.create(raw.next_number), + "next_number", + errors + ); + const validFrom = extractOrPushError( + maybeFromNullableResult(raw.valid_from?.toISOString().slice(0, 10), (value) => + UtcDate.createFromISO(value) + ), + "valid_from", + errors + ); + const validTo = extractOrPushError( + maybeFromNullableResult(raw.valid_to?.toISOString().slice(0, 10), (value) => + UtcDate.createFromISO(value) + ), + "valid_to", + errors + ); + + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection("Document series mapping failed", errors)); + } + + const props: DocumentSeriesInternalProps = { + companyId: companyId!, + branchId: branchId ?? Maybe.none(), + documentType: documentType!, + code: code!, + name: name!, + description: description ?? Maybe.none(), + prefix: Maybe.fromNullable(raw.prefix ?? undefined), + suffix: Maybe.fromNullable(raw.suffix ?? undefined), + nextNumber: nextNumber!, + padding: raw.padding, + validFrom: validFrom ?? Maybe.none(), + validTo: validTo ?? Maybe.none(), + isDefault: raw.is_default, + isActive: raw.is_active, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + deletedAt: Maybe.fromNullable(raw.deleted_at ?? undefined), + }; + + return Result.ok(DocumentSeries.rehydrate(props, id!)); + } + + public mapToPersistence(source: DocumentSeries) { + return Result.ok({ + id: source.id.toString(), + company_id: source.companyId.toString(), + branch_id: source.branchId.getOrUndefined()?.toString() ?? null, + document_type: source.documentType.toPrimitive(), + code: source.code.toPrimitive(), + name: source.name.toPrimitive(), + description: source.description.getOrUndefined()?.toPrimitive() ?? null, + prefix: source.prefix.getOrUndefined() ?? null, + suffix: source.suffix.getOrUndefined() ?? null, + next_number: source.nextNumber.toPrimitive(), + padding: source.padding, + valid_from: source.validFrom.getOrUndefined()?.toDate() ?? null, + valid_to: source.validTo.getOrUndefined()?.toDate() ?? null, + is_default: source.isDefault, + is_active: source.isActive, + created_at: source.createdAt, + updated_at: source.updatedAt, + deleted_at: source.deletedAt.getOrUndefined() ?? null, + }); + } +} diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts new file mode 100644 index 00000000..73c1430d --- /dev/null +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts @@ -0,0 +1,308 @@ +import { SequelizeRepository, translateSequelizeError } from "@erp/core/api"; +import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; +import { Op, type Sequelize, type Transaction, type WhereOptions } from "sequelize"; + +import type { + AssignNextNumberRepositoryParams, + FindDefaultDocumentSeriesParams, + FindDocumentSeriesByCodeParams, + IDocumentSeriesRepository, +} from "../../../../application"; +import { + DocumentSeriesConcurrencyError, + DocumentSeriesLockRequiredError, +} from "../../../../application"; +import { + type AssignedDocumentNumber, + type DocumentSeries, + DocumentSeriesNotFoundError, +} from "../../../../domain"; +import { DocumentSeriesModel } from "../../../common"; +import type { SequelizeDocumentSeriesDomainMapper } from "../mappers"; + +export class SequelizeDocumentSeriesRepository + extends SequelizeRepository + implements IDocumentSeriesRepository +{ + public constructor( + private readonly domainMapper: SequelizeDocumentSeriesDomainMapper, + database: Sequelize + ) { + super({ database }); + } + + public async save( + documentSeries: DocumentSeries, + transaction?: Transaction + ): Promise> { + try { + const payloadResult = this.domainMapper.mapToPersistence(documentSeries); + if (payloadResult.isFailure) { + return Result.fail(payloadResult.error); + } + + await DocumentSeriesModel.upsert(payloadResult.data, { + transaction: transaction as Transaction | undefined, + }); + + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async getByIdInCompany(companyId: UniqueID, id: UniqueID, transaction?: unknown) { + try { + const row = await DocumentSeriesModel.findOne({ + where: { + id: id.toString(), + company_id: companyId.toString(), + }, + transaction: transaction as Transaction | undefined, + }); + + if (!row) { + return Result.fail(new DocumentSeriesNotFoundError()); + } + + return this.domainMapper.mapToDomain(row); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findByCodeInCompany(params: FindDocumentSeriesByCodeParams) { + try { + const row = await DocumentSeriesModel.findOne({ + where: this.buildSeriesWhere({ + companyId: params.companyId, + branchId: params.branchId ?? null, + documentType: params.documentType.toPrimitive(), + code: params.code.toPrimitive(), + }), + order: [ + ["branch_id", "DESC"], + ["is_default", "DESC"], + ], + transaction: params.transaction as Transaction | undefined, + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + return this.domainMapper.mapToDomain(row).map((series) => Maybe.some(series)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findDefaultInCompany(params: FindDefaultDocumentSeriesParams) { + try { + const row = await DocumentSeriesModel.findOne({ + where: { + ...this.buildSeriesWhere({ + companyId: params.companyId, + branchId: params.branchId ?? null, + documentType: params.documentType.toPrimitive(), + }), + is_default: true, + }, + order: [["branch_id", "DESC"]], + transaction: params.transaction as Transaction | undefined, + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + return this.domainMapper.mapToDomain(row).map((series) => Maybe.some(series)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findByCriteriaInCompany( + criteria: Criteria, + companyId: UniqueID, + transaction?: unknown + ) { + try { + const criteriaConverter = new CriteriaToSequelizeConverter(); + const options = criteriaConverter.convert(criteria, { + sortableFields: ["code", "name", "document_type", "is_active", "is_default"], + searchableFields: ["code", "name", "description"], + enableFullText: true, + database: this.database, + strictMode: true, // fuerza error si ORDER BY no permitido + }); + /*mappings: { + code: "code", + name: "name", + document_type: "document_type", + is_active: "is_active", + is_default: "is_default", + }, + enableFullText: true, + database: this.database, + strictMode: true, // fuerza error si ORDER BY no permitido + });*/ + + const rows = await DocumentSeriesModel.findAll({ + ...options, + where: { + ...(options.where as object), + company_id: companyId.toString(), + }, + transaction: transaction as Transaction | undefined, + }); + + const items: DocumentSeries[] = []; + for (const row of rows) { + const mappedResult = this.domainMapper.mapToDomain(row); + if (mappedResult.isFailure) { + return Result.fail(mappedResult.error); + } + items.push(mappedResult.data); + } + + return Result.ok(new Collection(items, items.length)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async disableByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: Transaction + ): Promise> { + try { + const [affectedRows] = await DocumentSeriesModel.update( + { is_active: false, updated_at: new Date() }, + { + where: { + company_id: companyId.toString(), + id: id.toString(), + }, + transaction: transaction as Transaction | undefined, + } + ); + + if (affectedRows !== 1) { + return Result.fail(new DocumentSeriesNotFoundError()); + } + + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async assignNextNumber(params: AssignNextNumberRepositoryParams) { + try { + const transaction = params.transaction as Transaction; + if (!transaction) { + return Result.fail(new DocumentSeriesLockRequiredError()); + } + + const row = await this.resolveSeriesForUpdate(params, transaction); + + if (!row) { + return Result.fail(new DocumentSeriesNotFoundError()); + } + + const mappedResult = this.domainMapper.mapToDomain(row); + if (mappedResult.isFailure) { + return Result.fail(mappedResult.error); + } + + const todayString = new Date().toISOString().slice(0, 10); + const todayResult = await import("@repo/rdx-ddd").then(({ UtcDate }) => + UtcDate.createFromISO(todayString) + ); + if (todayResult.isFailure) { + return Result.fail(todayResult.error); + } + + const assignmentResult = mappedResult.data.assignNext(todayResult.data); + if (assignmentResult.isFailure) { + return Result.fail(assignmentResult.error); + } + + const persistedResult = this.domainMapper.mapToPersistence(mappedResult.data); + if (persistedResult.isFailure) { + return Result.fail(persistedResult.error); + } + + const [affectedRows] = await DocumentSeriesModel.update( + { + next_number: persistedResult.data.next_number, + updated_at: persistedResult.data.updated_at, + }, + { + where: { + id: persistedResult.data.id, + next_number: row.next_number, + }, + transaction, + } + ); + + if (affectedRows !== 1) { + return Result.fail(new DocumentSeriesConcurrencyError()); + } + + return Result.ok(assignmentResult.data); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + private async resolveSeriesForUpdate( + params: AssignNextNumberRepositoryParams, + transaction: Transaction + ) { + const where = this.buildSeriesWhere({ + companyId: params.companyId, + branchId: params.branchId ?? null, + documentType: params.documentType.toPrimitive(), + code: params.code?.toPrimitive(), + isDefault: params.code ? undefined : true, + }); + + return DocumentSeriesModel.findOne({ + where, + order: [ + ["branch_id", "DESC"], + ["is_default", "DESC"], + ], + transaction, + lock: transaction.LOCK.UPDATE, + }); + } + + private buildSeriesWhere(params: { + companyId: UniqueID; + branchId?: UniqueID | null; + documentType: string; + code?: string; + isDefault?: boolean; + }): WhereOptions { + const branchWhere = params.branchId + ? { + [Op.or]: [{ branch_id: params.branchId.toString() }, { branch_id: null }], + } + : { branch_id: null }; + + return { + company_id: params.companyId.toString(), + document_type: params.documentType, + ...(params.code ? { code: params.code } : {}), + ...(params.isDefault === undefined ? {} : { is_default: params.isDefault }), + ...branchWhere, + }; + } +} diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/index.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/index.ts new file mode 100644 index 00000000..f40a49ff --- /dev/null +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/index.ts @@ -0,0 +1 @@ +export * from "./document-series.repository"; diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/services/index.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/services/index.ts new file mode 100644 index 00000000..51e7014e --- /dev/null +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/services/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-document-series-number-assigner.service"; diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/services/sequelize-document-series-number-assigner.service.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/services/sequelize-document-series-number-assigner.service.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/services/sequelize-document-series-number-assigner.service.ts @@ -0,0 +1 @@ +export {}; diff --git a/modules/document-series/src/common/dto/index.ts b/modules/document-series/src/common/dto/index.ts new file mode 100644 index 00000000..32497d2a --- /dev/null +++ b/modules/document-series/src/common/dto/index.ts @@ -0,0 +1,3 @@ +export * from "./request"; +export * from "./response"; +export * from "./shared"; diff --git a/modules/document-series/src/common/dto/request/assign-next-document-number.request.dto.ts b/modules/document-series/src/common/dto/request/assign-next-document-number.request.dto.ts new file mode 100644 index 00000000..eb419b89 --- /dev/null +++ b/modules/document-series/src/common/dto/request/assign-next-document-number.request.dto.ts @@ -0,0 +1,11 @@ +import { z } from "zod/v4"; + +export const AssignNextDocumentNumberRequestSchema = z.object({ + branch_id: z.uuid().nullable().optional(), + document_type: z.enum(["proforma", "issued_invoice"]), + series_code: z.string().trim().min(1).max(20).nullable().optional(), +}); + +export type AssignNextDocumentNumberRequestDTO = z.infer< + typeof AssignNextDocumentNumberRequestSchema +>; diff --git a/modules/document-series/src/common/dto/request/create-document-series.request.dto.ts b/modules/document-series/src/common/dto/request/create-document-series.request.dto.ts new file mode 100644 index 00000000..563ccb7f --- /dev/null +++ b/modules/document-series/src/common/dto/request/create-document-series.request.dto.ts @@ -0,0 +1,28 @@ +import { z } from "zod/v4"; + +export const CreateDocumentSeriesRequestSchema = z + .object({ + id: z.uuid().optional(), + branch_id: z.uuid().nullable().optional(), + document_type: z.enum(["proforma", "issued_invoice"]), + code: z.string().trim().min(1).max(20), + name: z.string().trim().min(1).max(255), + description: z.string().trim().max(4096).nullable().optional(), + prefix: z.string().trim().max(50).nullable().optional(), + suffix: z.string().trim().max(50).nullable().optional(), + next_number: z.number().int().min(1), + padding: z.number().int().min(0).max(20), + valid_from: z.iso.date().nullable().optional(), + valid_to: z.iso.date().nullable().optional(), + is_default: z.boolean().optional().default(false), + is_active: z.boolean().optional().default(true), + }) + .refine( + (value) => !value.valid_from || !value.valid_to || value.valid_from <= value.valid_to, + { + path: ["valid_to"], + message: "valid_to must be greater than or equal to valid_from", + } + ); + +export type CreateDocumentSeriesRequestDTO = z.infer; diff --git a/modules/document-series/src/common/dto/request/get-document-series-by-id.request.dto.ts b/modules/document-series/src/common/dto/request/get-document-series-by-id.request.dto.ts new file mode 100644 index 00000000..98b74119 --- /dev/null +++ b/modules/document-series/src/common/dto/request/get-document-series-by-id.request.dto.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const GetDocumentSeriesByIdRequestSchema = z.object({ + id: z.uuid(), +}); + +export type GetDocumentSeriesByIdRequestDTO = z.infer; diff --git a/modules/document-series/src/common/dto/request/index.ts b/modules/document-series/src/common/dto/request/index.ts new file mode 100644 index 00000000..780e4248 --- /dev/null +++ b/modules/document-series/src/common/dto/request/index.ts @@ -0,0 +1,5 @@ +export * from "./assign-next-document-number.request.dto"; +export * from "./create-document-series.request.dto"; +export * from "./get-document-series-by-id.request.dto"; +export * from "./list-document-series.request.dto"; +export * from "./update-document-series.request.dto"; diff --git a/modules/document-series/src/common/dto/request/list-document-series.request.dto.ts b/modules/document-series/src/common/dto/request/list-document-series.request.dto.ts new file mode 100644 index 00000000..880c238d --- /dev/null +++ b/modules/document-series/src/common/dto/request/list-document-series.request.dto.ts @@ -0,0 +1,5 @@ +import { CriteriaSchema } from "@erp/core"; + +export const ListDocumentSeriesRequestSchema = CriteriaSchema; + +export type ListDocumentSeriesRequestDTO = typeof ListDocumentSeriesRequestSchema._output; diff --git a/modules/document-series/src/common/dto/request/update-document-series.request.dto.ts b/modules/document-series/src/common/dto/request/update-document-series.request.dto.ts new file mode 100644 index 00000000..275928d2 --- /dev/null +++ b/modules/document-series/src/common/dto/request/update-document-series.request.dto.ts @@ -0,0 +1,34 @@ +import { z } from "zod/v4"; + +export const UpdateDocumentSeriesParamsRequestSchema = z.object({ + id: z.uuid(), +}); + +export const UpdateDocumentSeriesRequestSchema = z + .object({ + branch_id: z.uuid().nullable().optional(), + document_type: z.enum(["proforma", "issued_invoice"]).optional(), + code: z.string().trim().min(1).max(20).optional(), + name: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(4096).nullable().optional(), + prefix: z.string().trim().max(50).nullable().optional(), + suffix: z.string().trim().max(50).nullable().optional(), + next_number: z.number().int().min(1).optional(), + padding: z.number().int().min(0).max(20).optional(), + valid_from: z.iso.date().nullable().optional(), + valid_to: z.iso.date().nullable().optional(), + is_default: z.boolean().optional(), + is_active: z.boolean().optional(), + }) + .refine( + (value) => !value.valid_from || !value.valid_to || value.valid_from <= value.valid_to, + { + path: ["valid_to"], + message: "valid_to must be greater than or equal to valid_from", + } + ); + +export type UpdateDocumentSeriesRequestDTO = z.infer; +export type UpdateDocumentSeriesParamsRequestDTO = z.infer< + typeof UpdateDocumentSeriesParamsRequestSchema +>; diff --git a/modules/document-series/src/common/dto/response/assign-next-document-number.response.dto.ts b/modules/document-series/src/common/dto/response/assign-next-document-number.response.dto.ts new file mode 100644 index 00000000..d2c94220 --- /dev/null +++ b/modules/document-series/src/common/dto/response/assign-next-document-number.response.dto.ts @@ -0,0 +1,12 @@ +import { z } from "zod/v4"; + +export const AssignNextDocumentNumberResponseSchema = z.object({ + series_id: z.uuid(), + series_code: z.string(), + number: z.string(), + reference: z.string(), +}); + +export type AssignNextDocumentNumberResponseDTO = z.infer< + typeof AssignNextDocumentNumberResponseSchema +>; diff --git a/modules/document-series/src/common/dto/response/document-series.response.dto.ts b/modules/document-series/src/common/dto/response/document-series.response.dto.ts new file mode 100644 index 00000000..c7944462 --- /dev/null +++ b/modules/document-series/src/common/dto/response/document-series.response.dto.ts @@ -0,0 +1,24 @@ +import { z } from "zod/v4"; + +export const DocumentSeriesResponseSchema = z.object({ + id: z.uuid(), + company_id: z.uuid(), + branch_id: z.uuid().nullable(), + document_type: z.enum(["proforma", "issued_invoice"]), + code: z.string(), + name: z.string(), + description: z.string().nullable(), + prefix: z.string().nullable(), + suffix: z.string().nullable(), + next_number: z.number().int().min(1), + padding: z.number().int().min(0), + valid_from: z.iso.date().nullable(), + valid_to: z.iso.date().nullable(), + is_default: z.boolean(), + is_active: z.boolean(), + created_at: z.iso.datetime(), + updated_at: z.iso.datetime(), + deleted_at: z.iso.datetime().nullable(), +}); + +export type DocumentSeriesResponseDTO = z.infer; diff --git a/modules/document-series/src/common/dto/response/index.ts b/modules/document-series/src/common/dto/response/index.ts new file mode 100644 index 00000000..13096d11 --- /dev/null +++ b/modules/document-series/src/common/dto/response/index.ts @@ -0,0 +1,3 @@ +export * from "./assign-next-document-number.response.dto"; +export * from "./document-series.response.dto"; +export * from "./list-document-series.response.dto"; diff --git a/modules/document-series/src/common/dto/response/list-document-series.response.dto.ts b/modules/document-series/src/common/dto/response/list-document-series.response.dto.ts new file mode 100644 index 00000000..1f8ca64d --- /dev/null +++ b/modules/document-series/src/common/dto/response/list-document-series.response.dto.ts @@ -0,0 +1,10 @@ +import { z } from "zod/v4"; + +import { DocumentSeriesResponseSchema } from "./document-series.response.dto"; + +export const ListDocumentSeriesResponseSchema = z.object({ + items: z.array(DocumentSeriesResponseSchema), + total: z.number().int().min(0), +}); + +export type ListDocumentSeriesResponseDTO = z.infer; diff --git a/modules/document-series/src/common/dto/shared/document-series-summary.dto.ts b/modules/document-series/src/common/dto/shared/document-series-summary.dto.ts new file mode 100644 index 00000000..d9673f9f --- /dev/null +++ b/modules/document-series/src/common/dto/shared/document-series-summary.dto.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +export const DocumentSeriesSummarySchema = z.object({ + id: z.uuid(), + code: z.string(), + name: z.string(), + document_type: z.enum(["proforma", "issued_invoice"]), + next_number: z.number().int().min(1), + padding: z.number().int().min(0), + is_default: z.boolean(), + is_active: z.boolean(), +}); + +export type DocumentSeriesSummaryDTO = z.infer; diff --git a/modules/document-series/src/common/dto/shared/index.ts b/modules/document-series/src/common/dto/shared/index.ts new file mode 100644 index 00000000..7f6211e4 --- /dev/null +++ b/modules/document-series/src/common/dto/shared/index.ts @@ -0,0 +1 @@ +export * from "./document-series-summary.dto"; diff --git a/modules/document-series/src/common/index.ts b/modules/document-series/src/common/index.ts new file mode 100644 index 00000000..0392b1b4 --- /dev/null +++ b/modules/document-series/src/common/index.ts @@ -0,0 +1 @@ +export * from "./dto"; diff --git a/modules/document-series/tsconfig.json b/modules/document-series/tsconfig.json new file mode 100644 index 00000000..4a24fa30 --- /dev/null +++ b/modules/document-series/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "@erp/document-series/*": ["./src/*"] + }, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/typescript-config/root.json b/packages/typescript-config/root.json index 0edbc9d6..fe0c147f 100644 --- a/packages/typescript-config/root.json +++ b/packages/typescript-config/root.json @@ -26,6 +26,9 @@ ], "@erp/factuges/*": [ "modules/factuges/src/*" + ], + "@erp/document-series/*": [ + "modules/document-series/src/*" ] }, "target": "ES2021", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4d1ce58..cdb4e17b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@erp/customers': specifier: workspace:* version: link:../../modules/customers + '@erp/document-series': + specifier: workspace:* + version: link:../../modules/document-series '@erp/factuges': specifier: workspace:* version: link:../../modules/factuges @@ -626,6 +629,9 @@ importers: '@erp/customers': specifier: workspace:* version: link:../customers + '@erp/document-series': + specifier: workspace:* + version: link:../document-series '@erp/identity': specifier: workspace:* version: link:../identity @@ -821,6 +827,43 @@ importers: specifier: ^6.0.2 version: 6.0.2 + modules/document-series: + dependencies: + '@erp/core': + specifier: workspace:* + version: link:../core + '@erp/identity': + specifier: workspace:* + version: link:../identity + '@repo/rdx-criteria': + specifier: workspace:* + version: link:../../packages/rdx-criteria + '@repo/rdx-ddd': + specifier: workspace:* + version: link:../../packages/rdx-ddd + '@repo/rdx-utils': + specifier: workspace:* + version: link:../../packages/rdx-utils + express: + specifier: ^4.22.1 + version: 4.22.2 + sequelize: + specifier: ^6.37.8 + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4) + zod: + specifier: ^4.3.6 + version: 4.4.3 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + modules/factuges: dependencies: '@erp/catalogs':