feat(document-series): implement document series management API
- Add UpdateDocumentSeriesController for updating document series. - Create documentSeriesApiErrorMapper for error handling. - Define document series routes including CRUD operations. - Implement Sequelize-based persistence layer for document series. - Add DTOs for request and response schemas for document series operations. - Establish common structures for document series data handling. - Configure TypeScript settings for the document series module.
This commit is contained in:
parent
65015c8c4f
commit
5ed6556036
@ -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:*",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
81
docs/customer-invoices/proforma-series-contract.md
Normal file
81
docs/customer-invoices/proforma-series-contract.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Proforma Series Contract
|
||||
|
||||
## Objetivo
|
||||
|
||||
Normalizar el contrato publico de proformas para separar definitivamente la serie propia de la proforma de la serie futura de la factura emitida.
|
||||
|
||||
## Semantica correcta
|
||||
|
||||
En proformas:
|
||||
|
||||
- `document_series_id` identifica la serie documental propia de la proforma
|
||||
- `proforma_number` guarda el numero propio de la proforma
|
||||
- `proforma_reference` guarda la referencia visible de la proforma, por ejemplo `PF-0139`
|
||||
- `proforma_series_code` es el codigo funcional que se usa solo en create para elegir una serie de `document_type = proforma`
|
||||
- `target_invoice_series_code` identifica la serie futura que se usara al emitir una `issued_invoice`, por ejemplo `F26`
|
||||
|
||||
`target_invoice_series_code` nunca debe apuntar implicitamente a la serie documental de proforma. Un valor como `PF` solo seria valido si existiera tambien una serie activa `PF` de `document_type = issued_invoice`, lo cual no debe asumirse.
|
||||
|
||||
## Reglas funcionales
|
||||
|
||||
### Crear proforma
|
||||
|
||||
- se pide fecha
|
||||
- se pide cliente
|
||||
- `proforma_series_code` es opcional; si falta, se usa la serie default activa de `document_type = proforma`
|
||||
- `target_invoice_series_code` es opcional; si falta, se persiste `NULL`
|
||||
- la UI obtiene ambas listas desde `GET /document-series`, filtrando por `document_type`
|
||||
|
||||
### Editar draft
|
||||
|
||||
- se permiten cambios comerciales
|
||||
- se permite cambiar `target_invoice_series_code`
|
||||
- no se permite cambiar `proforma_series_code`, `document_series_id`, `proforma_number` ni `proforma_reference`
|
||||
|
||||
### Editar approved
|
||||
|
||||
- la edicion queda restringida
|
||||
- solo se permite ajustar `target_invoice_series_code` antes de emitir
|
||||
|
||||
### Editar issued
|
||||
|
||||
- no se permite edicion funcional
|
||||
|
||||
## Contrato actual
|
||||
|
||||
### Requests
|
||||
|
||||
- create acepta `proforma_series_code`
|
||||
- create acepta `target_invoice_series_code`
|
||||
- update acepta `target_invoice_series_code`
|
||||
- el flujo nuevo ya no necesita `series`
|
||||
|
||||
### Responses
|
||||
|
||||
- el backend emite `target_invoice_series_code` como nombre preferente
|
||||
- `series` puede mantenerse solo como alias legacy de salida mientras existan consumidores antiguos
|
||||
|
||||
## Regla de emision
|
||||
|
||||
- si `target_invoice_series_code` tiene valor, la emision usa ese `seriesCode` para `document_type = issued_invoice`
|
||||
- si `target_invoice_series_code` es `NULL`, `document-series` resuelve la serie default activa de `issued_invoice`
|
||||
- la UI nunca debe consumir numeracion directa desde `POST /document-series/assign-next`
|
||||
|
||||
## Validacion defensiva
|
||||
|
||||
Si el request informa `target_invoice_series_code`:
|
||||
|
||||
- debe corresponder a una serie activa de `document_type = issued_invoice`
|
||||
- no se permite volver a persistir series de `document_type = proforma` como target de factura
|
||||
|
||||
Si el request informa `proforma_series_code`:
|
||||
|
||||
- debe corresponder a una serie activa de `document_type = proforma`
|
||||
- solo se usa para numerar la proforma en create
|
||||
- despues queda congelado en `document_series_id`, `proforma_number` y `proforma_reference`
|
||||
|
||||
## Retirada futura de `series`
|
||||
|
||||
- `series` era ambiguo porque mezclaba dos conceptos distintos
|
||||
- el contrato correcto usa `proforma_series_code` y `target_invoice_series_code`
|
||||
- `series` debe retirarse por completo cuando ya no queden consumidores legacy de responses
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
15
docs/customer-invoices/sql/fix-proforma-series-semantics.sql
Normal file
15
docs/customer-invoices/sql/fix-proforma-series-semantics.sql
Normal file
@ -0,0 +1,15 @@
|
||||
UPDATE proformas AS p
|
||||
JOIN document_series AS ds
|
||||
ON ds.company_id = p.company_id
|
||||
AND ds.code = p.target_invoice_series_code
|
||||
SET
|
||||
p.target_invoice_series_code = NULL,
|
||||
p.updated_at = NOW()
|
||||
WHERE ds.document_type = 'proforma';
|
||||
|
||||
SELECT p.id, p.proforma_reference, p.target_invoice_series_code
|
||||
FROM proformas AS p
|
||||
JOIN document_series AS ds
|
||||
ON ds.company_id = p.company_id
|
||||
AND ds.code = p.target_invoice_series_code
|
||||
WHERE ds.document_type = 'proforma';
|
||||
@ -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';
|
||||
74
docs/document-series/README.md
Normal file
74
docs/document-series/README.md
Normal file
@ -0,0 +1,74 @@
|
||||
# document-series
|
||||
|
||||
Centraliza la gestion de series documentales y su numeracion transaccional para documentos ERP. A fecha de cierre V1, es la API canonica y la tabla canonica para series de `customer-invoices` en `issued_invoice` y `proforma`.
|
||||
|
||||
## Estructura
|
||||
|
||||
- `modules/document-series/src/api/domain`: agregado `DocumentSeries` y VOs
|
||||
- `modules/document-series/src/api/application`: use cases, servicios y repositorio
|
||||
- `modules/document-series/src/api/infrastructure`: Sequelize, Express, DI y servicios públicos
|
||||
- `modules/document-series/src/common/dto`: contratos HTTP y de transporte con Zod
|
||||
|
||||
## Servicios disponibles
|
||||
|
||||
- `GET /document-series`
|
||||
- `GET /document-series/:id`
|
||||
- `POST /document-series`
|
||||
- `PUT /document-series/:id`
|
||||
- `PATCH /document-series/:id/disable`
|
||||
- `POST /document-series/assign-next`
|
||||
|
||||
## Consumo desde otros módulos
|
||||
|
||||
`document-series:general` expone:
|
||||
|
||||
- `assignNextNumber(params)`
|
||||
- `listActiveSeries(params)`
|
||||
|
||||
## Estado de integración
|
||||
|
||||
- `issued-invoices` ya asigna numeracion nueva mediante `document-series`
|
||||
- `proformas` ya asigna referencia nueva mediante `document-series`
|
||||
- las proformas V2 persisten snapshot explicito de serie con `document_series_id`, `proforma_number` y `proforma_reference`
|
||||
- en create de proformas, `proforma_series_code` permite seleccionar la serie de `document_type = proforma`
|
||||
- en emision de proformas, `target_invoice_series_code` apunta solo a `document_type = issued_invoice`
|
||||
- el endpoint transicional `/catalogs/invoice-series` queda eliminado antes de produccion
|
||||
- la UI debe consultar `GET /document-series` filtrando por `document_type`
|
||||
- la UI no debe consumir `POST /document-series/assign-next` para previsualizar numeros
|
||||
- `customer_invoice_series` queda como legado historico a retirar mediante limpieza operativa controlada, no como runtime activo
|
||||
|
||||
## SQL de soporte V1
|
||||
|
||||
- `docs/document-series/sql/migrate-customer-invoice-series-to-document-series.sql`
|
||||
- `docs/document-series/sql/validate-document-series-migration.sql`
|
||||
- `docs/document-series/sql/drop-customer-invoice-series-legacy.sql`
|
||||
- `docs/customer-invoices/sql/add-proforma-document-series-columns.sql`
|
||||
|
||||
Estos scripts son conservadores e idempotentes. No se han ejecutado automaticamente en este workspace ni sustituyen una migracion productiva formal.
|
||||
|
||||
## Estado Fase 2C
|
||||
|
||||
- el entorno detectado para desarrollo es `apps/server/.env.development`
|
||||
- el acceso apunta a `localhost` con `NODE_ENV=development`
|
||||
- en esta terminal no se pudo ejecutar SQL real porque:
|
||||
- no existe cliente `mysql` disponible
|
||||
- la dependencia `mysql2` del workspace no resuelve una dependencia transitiva (`sql-escaper`), por lo que tampoco fue posible abrir conexion desde Node
|
||||
- por tanto, la Fase 2C queda preparada y documentada, pero no ejecutada desde este workspace
|
||||
|
||||
## Pendientes conocidos
|
||||
|
||||
- propagacion real de `branchId` desde el contexto de negocio hasta `assignNextNumber(...)`
|
||||
- validacion funcional contra una BD de desarrollo migrada
|
||||
|
||||
Ejemplo conceptual:
|
||||
|
||||
```ts
|
||||
const documentSeries = getService<DocumentSeriesPublicServicesType>("document-series:general");
|
||||
|
||||
const result = await documentSeries.assignNextNumber({
|
||||
companyId,
|
||||
documentType: "issued_invoice",
|
||||
seriesCode: "F",
|
||||
transaction,
|
||||
});
|
||||
```
|
||||
91
docs/document-series/document-series-design.md
Normal file
91
docs/document-series/document-series-design.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Document Series Design
|
||||
|
||||
## Objetivo del módulo
|
||||
|
||||
`document-series` es un módulo propio porque la serie documental no es un catálogo estático: mantiene reglas de vigencia, flags de activación, defaults y estado transaccional de numeración.
|
||||
|
||||
## Por qué no vive en catalogs
|
||||
|
||||
Los catálogos del ERP resuelven valores de referencia. Aquí hay concurrencia, bloqueo de fila y mutación transaccional de `next_number`, así que el comportamiento es operativo y no meramente descriptivo.
|
||||
|
||||
## Por qué numeración y serie viven juntas
|
||||
|
||||
La numeración depende del estado de la serie:
|
||||
|
||||
- si está activa
|
||||
- si está vigente
|
||||
- si es la serie por defecto
|
||||
- cuál es su prefijo, sufijo y padding
|
||||
|
||||
Separarlo introduciría doble escritura o lecturas no atómicas.
|
||||
|
||||
## Modelo conceptual
|
||||
|
||||
- `DocumentType`: tipo documental cerrado en V1
|
||||
- `DocumentSeries`: agregado con configuración y contador
|
||||
- `AssignedDocumentNumber`: resultado de asignación atómica
|
||||
|
||||
## Campos principales
|
||||
|
||||
- `companyId`, `branchId`
|
||||
- `documentType`
|
||||
- `code`, `name`, `description`
|
||||
- `prefix`, `suffix`
|
||||
- `nextNumber`, `padding`
|
||||
- `validFrom`, `validTo`
|
||||
- `isDefault`, `isActive`
|
||||
|
||||
## Reglas V1
|
||||
|
||||
- tipos soportados: `proforma`, `issued_invoice`
|
||||
- no asigna si la serie está inactiva
|
||||
- no asigna si la serie está fuera de vigencia
|
||||
- default por empresa + tipo, con prioridad de `branch_id` cuando aplica
|
||||
- formato de referencia: `prefix + padded(next_number) + suffix`
|
||||
|
||||
## Concurrencia
|
||||
|
||||
La asignación usa transacción obligatoria y bloqueo de fila con `LOCK.UPDATE` sobre `document_series`. El incremento de `next_number` se persiste dentro de la misma transacción y con comprobación optimista del valor previo para evitar dobles consumos.
|
||||
|
||||
## Integracion con customer-invoices
|
||||
|
||||
En V1:
|
||||
|
||||
- `issued-invoices` delega la asignacion de numero en `document-series`
|
||||
- `proformas` delega la asignacion de referencia en `document-series`
|
||||
- `proformas` persiste `document_series_id` y `proforma_number` como snapshot tecnico adicional a `proforma_reference`
|
||||
- `proformas.document_series_id` identifica la serie documental usada para numerar la proforma
|
||||
- `proformas.proforma_number` guarda el numero propio de la proforma
|
||||
- `proformas.proforma_reference` guarda la referencia visible de la proforma
|
||||
- `proforma_series_code` es el input funcional de create para elegir la serie de `document_type = proforma`
|
||||
- `proformas.target_invoice_series_code` es opcional y, cuando existe, apunta a una serie de `issued_invoice`, nunca a una serie de `proforma`
|
||||
- si `proformas.target_invoice_series_code` es `NULL`, la emision resuelve la serie default activa de `issued_invoice`
|
||||
- en Fase 2E, `series` deja de ser el contrato nuevo para create/update de proformas
|
||||
- la UI consulta `GET /document-series` con filtro por `document_type` e idealmente `is_active = true`
|
||||
- `/catalogs/invoice-series` deja de existir como endpoint runtime
|
||||
- `customer_invoice_series` no participa ya en flujos activos de lectura o numeracion
|
||||
|
||||
## Migracion conservadora
|
||||
|
||||
- la migracion historica de `issued_invoice` parte de `customer_invoice_series`
|
||||
- la creacion de series default de `proforma` usa `companies` como fuente
|
||||
- las validaciones SQL se mantienen separadas del script de migracion
|
||||
- no hay ejecucion automatica ni cutover productivo en esta fase
|
||||
- la DDL de `proformas` usa `information_schema` + SQL dinamico para mantener compatibilidad MariaDB/MySQL sin depender de `IF NOT EXISTS` en `ALTER TABLE`
|
||||
|
||||
## Branch scope pendiente
|
||||
|
||||
El modelo ya contempla `branchId`, y la resolucion default da prioridad a `branch_id` cuando existe. Aun asi, `customer-invoices` no propaga un contexto real de sucursal hasta `assignNextNumber(...)`, asi que en esta fase toda la integracion sigue operando con alcance de empresa.
|
||||
|
||||
## Límites V1
|
||||
|
||||
- sin `format_pattern`
|
||||
- sin reseteos anuales automáticos
|
||||
- sin reservas de numeración
|
||||
- sin huecos justificados
|
||||
- sin migración productiva automática de legacy
|
||||
|
||||
## Extensiones futuras
|
||||
|
||||
- nuevos `DocumentType`
|
||||
- seeds/migraciones controladas de series heredadas
|
||||
87
docs/document-series/manual-concurrency-validation.md
Normal file
87
docs/document-series/manual-concurrency-validation.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Manual concurrency validation
|
||||
|
||||
Validacion manual de concurrencia para `document-series` sin introducir framework nuevo.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Comprobar que varias asignaciones simultaneas sobre la misma serie:
|
||||
|
||||
- no generan duplicados
|
||||
- consumen numeros consecutivos
|
||||
- avanzan `next_number` exactamente `N` posiciones
|
||||
|
||||
## Precondiciones
|
||||
|
||||
- BD de desarrollo segura
|
||||
- serie de `proforma` activa y conocida
|
||||
- backend arrancado contra esa BD
|
||||
- capacidad de lanzar varias peticiones HTTP casi simultaneas
|
||||
|
||||
## Caso base recomendado
|
||||
|
||||
Serie inicial:
|
||||
|
||||
- `document_type = 'proforma'`
|
||||
- `code = 'PF'`
|
||||
- `next_number = 100`
|
||||
|
||||
Numero de asignaciones concurrentes:
|
||||
|
||||
- `N = 10`
|
||||
|
||||
Esperado:
|
||||
|
||||
- referencias unicas `PF-000100` a `PF-000109`
|
||||
- `document_series.next_number = 110`
|
||||
- cero duplicados
|
||||
|
||||
## Preparacion SQL
|
||||
|
||||
```sql
|
||||
SELECT id, company_id, document_type, code, prefix, suffix, next_number, padding
|
||||
FROM document_series
|
||||
WHERE document_type = 'proforma'
|
||||
AND code = 'PF';
|
||||
```
|
||||
|
||||
Si hace falta preparar la serie manualmente en desarrollo, hacerlo de forma conservadora y reversible.
|
||||
|
||||
## Ejecucion HTTP manual
|
||||
|
||||
1. Preparar 10 peticiones de creacion de proforma para la misma empresa y misma serie destino.
|
||||
2. Lanzarlas en paralelo desde el cliente HTTP disponible.
|
||||
3. Registrar `proforma_reference`, `proforma_number` y `document_series_id` devueltos.
|
||||
|
||||
## Verificaciones SQL
|
||||
|
||||
```sql
|
||||
SELECT proforma_reference, proforma_number, document_series_id
|
||||
FROM proformas
|
||||
WHERE document_series_id = '<document_series_id>'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT proforma_reference, COUNT(*) AS duplicates
|
||||
FROM proformas
|
||||
WHERE document_series_id = '<document_series_id>'
|
||||
GROUP BY proforma_reference
|
||||
HAVING COUNT(*) > 1;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT id, next_number
|
||||
FROM document_series
|
||||
WHERE id = '<document_series_id>';
|
||||
```
|
||||
|
||||
## Criterio de aceptacion
|
||||
|
||||
- no hay filas en la query de duplicados
|
||||
- aparecen `N` nuevas referencias distintas
|
||||
- `next_number_final = next_number_inicial + N`
|
||||
|
||||
## Bloqueo actual en este workspace
|
||||
|
||||
En este workspace no se pudo ejecutar la prueba porque la terminal no pudo abrir conexion operativa a la BD de desarrollo. La configuracion detectada es de desarrollo, pero falta cliente `mysql` y el conector `mysql2` local no resuelve todas sus dependencias transitivas.
|
||||
57
docs/document-series/manual-validation.md
Normal file
57
docs/document-series/manual-validation.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Manual validation
|
||||
|
||||
Guia minima de validacion manual para el cierre V1 de `document-series`. No requiere nueva infraestructura de tests automatizados.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Confirmar que `document-series` es la fuente operativa unica para series y numeracion nueva de `customer-invoices` y que las restricciones funcionales V1 siguen activas.
|
||||
|
||||
## Precondiciones
|
||||
|
||||
- BD de desarrollo con tablas `document_series`, `proformas` e `issued_invoices`
|
||||
- DDL de `docs/customer-invoices/sql/add-proforma-document-series-columns.sql` aplicada si `proformas` aun no tiene las columnas nuevas
|
||||
- seed o migracion conservadora de `docs/document-series/sql/migrate-customer-invoice-series-to-document-series.sql` aplicada de forma manual si procede
|
||||
- backend arrancado con `apps/server/.env.development` o equivalente seguro de desarrollo
|
||||
|
||||
## Casos recomendados
|
||||
|
||||
1. Asignacion de `issued_invoice` desde `document-series`
|
||||
- crear o dejar activa una serie `issued_invoice`
|
||||
- emitir una factura desde el flujo normal
|
||||
- comprobar que la referencia/numero resultante coincide con `prefix + padded(next_number) + suffix`
|
||||
- comprobar que `document_series.next_number` se incrementa exactamente en 1
|
||||
|
||||
2. Asignacion de `proforma` desde `document-series`
|
||||
- crear o dejar activa una serie `proforma`
|
||||
- crear una proforma desde el flujo normal
|
||||
- comprobar que `proformas.document_series_id`, `proformas.proforma_number` y `proformas.proforma_reference` quedan informados
|
||||
- comprobar que `proforma_reference` usa el formato de la serie asignada
|
||||
|
||||
3. Serie inactiva
|
||||
- desactivar una serie
|
||||
- intentar asignar numeracion con esa serie
|
||||
- comprobar que el caso falla y no incrementa `next_number`
|
||||
|
||||
4. Vigencia invalida
|
||||
- configurar una serie con ventana fuera de fecha
|
||||
- intentar asignar numeracion
|
||||
- comprobar que el caso falla y no incrementa `next_number`
|
||||
|
||||
5. API canonica de series documentales
|
||||
- llamar a `/document-series` filtrando `document_type = proforma`
|
||||
- llamar a `/document-series` filtrando `document_type = issued_invoice`
|
||||
- comprobar que la respuesta refleja filas activas de `document_series`
|
||||
|
||||
6. Endpoint legacy retirado
|
||||
- llamar a `/catalogs/invoice-series`
|
||||
- comprobar `404` o endpoint no registrado
|
||||
|
||||
## Pendiente conocido
|
||||
|
||||
`branchId` aun no se propaga desde `customer-invoices` hasta `assignNextNumber(...)`. Toda la validacion de esta fase debe asumirse en alcance de empresa.
|
||||
|
||||
## Estado Fase 2C en este workspace
|
||||
|
||||
- configuracion de desarrollo detectada: si
|
||||
- ejecucion real de SQL desde Codex: no
|
||||
- motivo: falta cliente `mysql` en la terminal y la dependencia `mysql2` del workspace no pudo abrirse por una dependencia transitiva ausente
|
||||
105
docs/document-series/migration-from-customer-invoice-series.md
Normal file
105
docs/document-series/migration-from-customer-invoice-series.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Migration from customer_invoice_series
|
||||
|
||||
## Equivalencia conceptual
|
||||
|
||||
- `customer_invoice_series.code` -> `document_series.code`
|
||||
- `customer_invoice_series.company_id` -> `document_series.company_id`
|
||||
- `customer_invoice_series.next_number` -> `document_series.next_number`
|
||||
- `customer_invoice_series.padding_length` -> `document_series.padding`
|
||||
- `customer_invoice_series.is_default` -> `document_series.is_default`
|
||||
- `customer_invoice_series.is_active` -> `document_series.is_active`
|
||||
- `document_series.document_type` para legacy de facturas emitidas será `issued_invoice`
|
||||
|
||||
## Migración conservadora de issued invoices
|
||||
|
||||
```sql
|
||||
INSERT INTO document_series (
|
||||
id,
|
||||
company_id,
|
||||
branch_id,
|
||||
document_type,
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
prefix,
|
||||
suffix,
|
||||
next_number,
|
||||
padding,
|
||||
valid_from,
|
||||
valid_to,
|
||||
is_default,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
cis.id,
|
||||
cis.company_id,
|
||||
NULL,
|
||||
'issued_invoice',
|
||||
cis.code,
|
||||
cis.code,
|
||||
NULL,
|
||||
CONCAT(cis.code, '-'),
|
||||
NULL,
|
||||
cis.next_number,
|
||||
cis.padding_length,
|
||||
NULL,
|
||||
NULL,
|
||||
cis.is_default,
|
||||
cis.is_active,
|
||||
cis.created_at,
|
||||
cis.updated_at
|
||||
FROM customer_invoice_series cis
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM document_series ds
|
||||
WHERE ds.company_id = cis.company_id
|
||||
AND ds.document_type = 'issued_invoice'
|
||||
AND ds.code = cis.code
|
||||
);
|
||||
```
|
||||
|
||||
## Series de proformas
|
||||
|
||||
Crear una serie por defecto por empresa con:
|
||||
|
||||
- `document_type = 'proforma'`
|
||||
- `code = 'PF'`
|
||||
- `prefix = 'PF-'`
|
||||
- `next_number = 1`
|
||||
- `padding = 6`
|
||||
|
||||
En Fase 2B la fuente preferida para este seed pasa a ser `companies`, no `customer_invoice_series`, para cubrir empresas activas sin series legacy previas.
|
||||
|
||||
## Cambio de assigner
|
||||
|
||||
- antes: `InvoiceSeriesNumberAssigner`
|
||||
- después: `DocumentSeriesNumberAssigner` o `document-series:general.assignNextNumber`
|
||||
|
||||
## Estado actual
|
||||
|
||||
- `document_series` es la tabla canonica
|
||||
- `GET /document-series` es la API canonica
|
||||
- `/catalogs/invoice-series` ha sido retirado del runtime
|
||||
- `CustomerInvoiceSeriesModel` y su repositorio legacy ya no participan en runtime
|
||||
|
||||
## Pendiente
|
||||
|
||||
- migración controlada de otros tipos documentales
|
||||
- ejecucion manual y controlada del SQL conservador de migracion
|
||||
- DDL previa de `proformas.document_series_id` y `proformas.proforma_number`
|
||||
- propagacion real de `branchId` desde `customer-invoices`
|
||||
|
||||
## Validacion recomendada
|
||||
|
||||
- ejecutar `docs/document-series/sql/validate-document-series-migration.sql`
|
||||
- revisar que no existan duplicados logicos por `company_id + document_type + code`
|
||||
- revisar que cada empresa activa tenga una default de `proforma`
|
||||
- revisar que ninguna proforma quede sin `proforma_reference`
|
||||
|
||||
## Estado de ejecucion Fase 2C
|
||||
|
||||
- se detecto una configuracion de desarrollo en `apps/server/.env.development`
|
||||
- no se ejecuto SQL real desde esta terminal por bloqueo operativo del cliente/conector local
|
||||
- produccion no se considero objetivo de ejecucion
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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) = '';
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
@ -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);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import type { IInvoiceSeriesRepository } from "../repositories";
|
||||
import {
|
||||
type IInvoiceSeriesNumberAssigner,
|
||||
InvoiceSeriesNumberAssigner,
|
||||
} from "../services";
|
||||
|
||||
export const buildInvoiceSeriesNumberAssigner = (
|
||||
repository: IInvoiceSeriesRepository
|
||||
): IInvoiceSeriesNumberAssigner => new InvoiceSeriesNumberAssigner(repository);
|
||||
@ -1,7 +0,0 @@
|
||||
import { InvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders";
|
||||
|
||||
export function buildInvoiceSeriesSnapshotBuilders() {
|
||||
return {
|
||||
summary: new InvoiceSeriesSummarySnapshotBuilder(),
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,2 @@
|
||||
export * from "./di";
|
||||
export * from "./errors";
|
||||
export * from "./repositories";
|
||||
export * from "./services";
|
||||
export * from "./snapshot-builders";
|
||||
export * from "./use-cases";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./invoice-series-repository.interface";
|
||||
@ -1,25 +0,0 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { InvoiceSeries, InvoiceSeriesCode } from "../../../domain";
|
||||
|
||||
export interface IInvoiceSeriesRepository {
|
||||
findActiveByCompany(params: {
|
||||
companyId: UniqueID;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Collection<InvoiceSeries>, Error>>;
|
||||
|
||||
findByCodeInCompany(params: {
|
||||
companyId: UniqueID;
|
||||
invoiceSeriesCode: InvoiceSeriesCode;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Maybe<InvoiceSeries>, Error>>;
|
||||
|
||||
findByCodeInCompanyForUpdate(params: {
|
||||
companyId: UniqueID;
|
||||
invoiceSeriesCode: InvoiceSeriesCode;
|
||||
transaction: unknown;
|
||||
}): Promise<Result<Maybe<InvoiceSeries>, Error>>;
|
||||
|
||||
update(invoiceSeries: InvoiceSeries, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
}
|
||||
@ -1,2 +1 @@
|
||||
export * from "./invoice-series-finder";
|
||||
export * from "./invoice-series-number-assigner";
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Collection, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { InvoiceSeries } from "../../../domain";
|
||||
import type { IInvoiceSeriesRepository } from "../repositories";
|
||||
|
||||
export interface IInvoiceSeriesFinder {
|
||||
findActiveByCompany(
|
||||
companyId: UniqueID,
|
||||
transaction?: unknown
|
||||
): Promise<Result<Collection<InvoiceSeries>, Error>>;
|
||||
}
|
||||
|
||||
export class InvoiceSeriesFinder implements IInvoiceSeriesFinder {
|
||||
public constructor(private readonly repository: IInvoiceSeriesRepository) {}
|
||||
|
||||
public findActiveByCompany(companyId: UniqueID, transaction?: unknown) {
|
||||
return this.repository.findActiveByCompany({
|
||||
companyId,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,24 +1,18 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { type InvoiceNumber, type InvoiceSeriesCode } from "../../../domain";
|
||||
import {
|
||||
InvoiceSeriesInactiveError,
|
||||
InvoiceSeriesNotFoundError,
|
||||
InvoiceSeriesTransactionRequiredError,
|
||||
} from "../errors";
|
||||
import type { IInvoiceSeriesRepository } from "../repositories";
|
||||
import type { InvoiceNumber, InvoiceSerie } from "../../../domain";
|
||||
|
||||
export interface InvoiceSeriesAssignment {
|
||||
invoiceNumber: InvoiceNumber;
|
||||
formattedNumber: string;
|
||||
invoiceSeriesId: string;
|
||||
invoiceSeriesCode: string;
|
||||
invoiceSeriesCode: InvoiceSerie;
|
||||
}
|
||||
|
||||
export interface InvoiceSeriesNumberAssignerParams {
|
||||
companyId: UniqueID;
|
||||
invoiceSeriesCode: InvoiceSeriesCode;
|
||||
invoiceSeriesCode: Maybe<InvoiceSerie>;
|
||||
transaction: unknown;
|
||||
}
|
||||
|
||||
@ -27,50 +21,3 @@ export interface IInvoiceSeriesNumberAssigner {
|
||||
params: InvoiceSeriesNumberAssignerParams
|
||||
): Promise<Result<InvoiceSeriesAssignment, Error>>;
|
||||
}
|
||||
|
||||
export class InvoiceSeriesNumberAssigner implements IInvoiceSeriesNumberAssigner {
|
||||
public constructor(private readonly repository: IInvoiceSeriesRepository) {}
|
||||
|
||||
public async assignNextNumber(
|
||||
params: InvoiceSeriesNumberAssignerParams
|
||||
): Promise<Result<InvoiceSeriesAssignment, Error>> {
|
||||
if (!params.transaction) {
|
||||
return Result.fail(new InvoiceSeriesTransactionRequiredError());
|
||||
}
|
||||
|
||||
const invoiceSeriesResult = await this.repository.findByCodeInCompanyForUpdate(params);
|
||||
|
||||
if (invoiceSeriesResult.isFailure) {
|
||||
return Result.fail(invoiceSeriesResult.error);
|
||||
}
|
||||
|
||||
if (invoiceSeriesResult.data.isNone()) {
|
||||
return Result.fail(new InvoiceSeriesNotFoundError());
|
||||
}
|
||||
|
||||
const invoiceSeries = invoiceSeriesResult.data.unwrap();
|
||||
|
||||
if (!invoiceSeries.isActive) {
|
||||
return Result.fail(new InvoiceSeriesInactiveError(invoiceSeries.code.toPrimitive()));
|
||||
}
|
||||
|
||||
const invoiceNumberResult = invoiceSeries.assignNextInvoiceNumber();
|
||||
|
||||
if (invoiceNumberResult.isFailure) {
|
||||
return Result.fail(invoiceNumberResult.error);
|
||||
}
|
||||
|
||||
const updateResult = await this.repository.update(invoiceSeries, params.transaction);
|
||||
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
invoiceNumber: invoiceNumberResult.data,
|
||||
formattedNumber: invoiceSeries.formatInvoiceNumber(invoiceNumberResult.data),
|
||||
invoiceSeriesId: invoiceSeries.id.toString(),
|
||||
invoiceSeriesCode: invoiceSeries.code.toPrimitive(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./summary";
|
||||
@ -1 +0,0 @@
|
||||
export * from "./invoice-series-summary.snapshot-builder";
|
||||
@ -1,21 +0,0 @@
|
||||
import type { ISnapshotBuilder } from "@erp/core/api";
|
||||
|
||||
import type { InvoiceSeriesSummaryDTO } from "../../../../../common";
|
||||
import type { InvoiceSeries } from "../../../../domain";
|
||||
|
||||
export interface IInvoiceSeriesSummarySnapshotBuilder
|
||||
extends ISnapshotBuilder<InvoiceSeries, InvoiceSeriesSummaryDTO> {}
|
||||
|
||||
export class InvoiceSeriesSummarySnapshotBuilder
|
||||
implements IInvoiceSeriesSummarySnapshotBuilder
|
||||
{
|
||||
public toOutput(invoiceSeries: InvoiceSeries): InvoiceSeriesSummaryDTO {
|
||||
return {
|
||||
id: invoiceSeries.id.toString(),
|
||||
code: invoiceSeries.code.toPrimitive(),
|
||||
next_number: invoiceSeries.nextNumber.toPrimitive(),
|
||||
padding_length: invoiceSeries.paddingLength.toPrimitive(),
|
||||
is_default: invoiceSeries.isDefault,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./list-invoice-series.use-case";
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -2,17 +2,16 @@ import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
type IIssuedInvoiceCreateProps,
|
||||
InvoiceSeriesCode,
|
||||
IssuedInvoice,
|
||||
} from "../../../domain";
|
||||
import type { IInvoiceSeriesNumberAssigner } from "../../invoice-series";
|
||||
import type { IIssuedInvoiceRepository } from "../repositories";
|
||||
import type { ProformaToIssuedInvoiceCreateProps } from "./proforma-to-issued-invoice-props-converter";
|
||||
|
||||
export interface IIssuedInvoiceCreatorParams {
|
||||
companyId: UniqueID;
|
||||
id: UniqueID;
|
||||
props: Omit<IIssuedInvoiceCreateProps, "invoiceNumber">;
|
||||
props: ProformaToIssuedInvoiceCreateProps;
|
||||
transaction: unknown;
|
||||
}
|
||||
|
||||
@ -37,15 +36,9 @@ export class IssuedInvoiceCreator implements IIssuedInvoiceCreator {
|
||||
async create(params: IIssuedInvoiceCreatorParams): Promise<Result<IssuedInvoice, Error>> {
|
||||
const { companyId, id, props, transaction } = params;
|
||||
|
||||
const invoiceSeriesCodeResult = InvoiceSeriesCode.create(props.series.toPrimitive());
|
||||
|
||||
if (invoiceSeriesCodeResult.isFailure) {
|
||||
return Result.fail(invoiceSeriesCodeResult.error);
|
||||
}
|
||||
|
||||
const numberResult = await this.invoiceSeriesNumberAssigner.assignNextNumber({
|
||||
companyId,
|
||||
invoiceSeriesCode: invoiceSeriesCodeResult.data,
|
||||
invoiceSeriesCode: props.series,
|
||||
transaction,
|
||||
});
|
||||
|
||||
@ -55,7 +48,15 @@ export class IssuedInvoiceCreator implements IIssuedInvoiceCreator {
|
||||
|
||||
const invoiceNumber = numberResult.data.invoiceNumber;
|
||||
|
||||
const invoiceResult = IssuedInvoice.create({ ...props, invoiceNumber, companyId }, id);
|
||||
const invoiceResult = IssuedInvoice.create(
|
||||
{
|
||||
...props,
|
||||
series: numberResult.data.invoiceSeriesCode,
|
||||
invoiceNumber,
|
||||
companyId,
|
||||
},
|
||||
id
|
||||
);
|
||||
|
||||
if (invoiceResult.isFailure) {
|
||||
return Result.fail(invoiceResult.error);
|
||||
|
||||
@ -6,6 +6,7 @@ import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import {
|
||||
type IIssuedInvoiceCreateProps,
|
||||
InvoicePaymentMethod,
|
||||
type InvoiceSerie,
|
||||
InvoiceStatus,
|
||||
InvoiceTaxRegime,
|
||||
IssuedInvoiceItem,
|
||||
@ -16,7 +17,12 @@ import {
|
||||
} from "../../../domain";
|
||||
import type { ProformaIssueReadModel } from "../models";
|
||||
|
||||
export type ProformaToIssuedInvoiceCreateProps = Omit<IIssuedInvoiceCreateProps, "invoiceNumber">;
|
||||
export type ProformaToIssuedInvoiceCreateProps = Omit<
|
||||
IIssuedInvoiceCreateProps,
|
||||
"invoiceNumber" | "series"
|
||||
> & {
|
||||
series: Maybe<InvoiceSerie>;
|
||||
};
|
||||
|
||||
export interface IProformaToIssuedInvoiceConverter {
|
||||
toCreateProps(source: ProformaIssueReadModel): Result<ProformaToIssuedInvoiceCreateProps, Error>;
|
||||
@ -70,7 +76,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
|
||||
companyId: proforma.companyId,
|
||||
status: InvoiceStatus.issued(),
|
||||
|
||||
series: proforma.series.getOrUndefined()!,
|
||||
series: proforma.series,
|
||||
linkedProformaId: proforma.id,
|
||||
|
||||
// La fecha de factura debe reflejar la emisión, no la fecha original de la proforma.
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import type { IProformaCreateProps, IProformaItemCreateProps } from "../../../domain";
|
||||
import type { Maybe } from "@repo/rdx-utils";
|
||||
import type { InvoiceSerie } from "../../../domain";
|
||||
|
||||
import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.model";
|
||||
|
||||
@ -12,7 +14,12 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.
|
||||
* - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio.
|
||||
*/
|
||||
|
||||
export type ProformaCreateInputProps = Omit<IProformaCreateProps, "items" | "proformaReference"> & {
|
||||
export type ProformaCreateInputProps = Omit<
|
||||
IProformaCreateProps,
|
||||
"items" | "proformaReference" | "proformaNumber" | "documentSeriesId" | "series"
|
||||
> & {
|
||||
proformaSeriesCode: Maybe<InvoiceSerie>;
|
||||
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
|
||||
items: ProformaItemCreateInputProps[];
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import type { ProformaItemPatchProps, ProformaPatchProps } from "../../../domain";
|
||||
import type { Maybe } from "@repo/rdx-utils";
|
||||
import type { InvoiceSerie } from "../../../domain";
|
||||
|
||||
import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.model";
|
||||
|
||||
@ -12,7 +14,8 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.
|
||||
* - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio.
|
||||
*/
|
||||
|
||||
export type ProformaPatchInputProps = Omit<ProformaPatchProps, "items"> & {
|
||||
export type ProformaPatchInputProps = Omit<ProformaPatchProps, "items" | "series"> & {
|
||||
targetInvoiceSeriesCode?: Maybe<InvoiceSerie>;
|
||||
items?: ProformaItemPatchInputProps[];
|
||||
};
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -4,12 +4,15 @@ import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import {
|
||||
type IProformaCreateProps,
|
||||
type IProformaItemCreateProps,
|
||||
InvoiceNumber,
|
||||
Proforma,
|
||||
} from "../../../domain";
|
||||
import type { ProformaCreateInputProps } from "../models";
|
||||
import type { IProformaRepository } from "../repositories";
|
||||
|
||||
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
|
||||
import type { ProformaSeriesValidator } from "./proforma-series-validator.interface";
|
||||
import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface";
|
||||
import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver";
|
||||
import type { IProformaNumberGenerator } from "./proforma-number-generator.interface";
|
||||
|
||||
@ -31,6 +34,8 @@ export class ProformaCreator implements IProformaCreator {
|
||||
numberService: IProformaNumberGenerator;
|
||||
taxResolver: ProformaTaxResolver;
|
||||
paymentResolver: ProformaPaymentResolver;
|
||||
proformaSeriesValidator: ProformaSeriesValidator;
|
||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||
}
|
||||
) {}
|
||||
|
||||
@ -43,11 +48,21 @@ export class ProformaCreator implements IProformaCreator {
|
||||
return Result.fail(resolvedProps.error);
|
||||
}
|
||||
|
||||
const proformaSeriesValidationResult =
|
||||
await this.deps.proformaSeriesValidator.ensureValidProformaSeries({
|
||||
companyId,
|
||||
proformaSeriesCode: resolvedProps.data.proformaSeriesCode,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (proformaSeriesValidationResult.isFailure) {
|
||||
return Result.fail(proformaSeriesValidationResult.error);
|
||||
}
|
||||
|
||||
// 1. Obtener siguiente número
|
||||
const { series } = props;
|
||||
const numberResult = await this.deps.numberService.getNextForCompany(
|
||||
companyId,
|
||||
series,
|
||||
resolvedProps.data.proformaSeriesCode,
|
||||
transaction
|
||||
);
|
||||
|
||||
@ -55,11 +70,41 @@ export class ProformaCreator implements IProformaCreator {
|
||||
return Result.fail(numberResult.error);
|
||||
}
|
||||
|
||||
const proformaReference = numberResult.data;
|
||||
const targetInvoiceSeriesValidationResult =
|
||||
await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({
|
||||
companyId,
|
||||
targetInvoiceSeriesCode: resolvedProps.data.targetInvoiceSeriesCode,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (targetInvoiceSeriesValidationResult.isFailure) {
|
||||
return Result.fail(targetInvoiceSeriesValidationResult.error);
|
||||
}
|
||||
|
||||
const proformaReferenceResult = InvoiceNumber.create(numberResult.data.reference);
|
||||
|
||||
if (proformaReferenceResult.isFailure) {
|
||||
return Result.fail(proformaReferenceResult.error);
|
||||
}
|
||||
|
||||
const proformaNumberResult = InvoiceNumber.create(numberResult.data.number);
|
||||
|
||||
if (proformaNumberResult.isFailure) {
|
||||
return Result.fail(proformaNumberResult.error);
|
||||
}
|
||||
|
||||
const proformaReference = proformaReferenceResult.data;
|
||||
|
||||
// 2. Crear agregado
|
||||
const proformaResult = Proforma.create(
|
||||
{ ...resolvedProps.data, proformaReference, companyId },
|
||||
{
|
||||
...resolvedProps.data,
|
||||
companyId,
|
||||
documentSeriesId: Maybe.some(numberResult.data.documentSeriesId),
|
||||
proformaNumber: Maybe.some(proformaNumberResult.data),
|
||||
proformaReference,
|
||||
series: resolvedProps.data.targetInvoiceSeriesCode,
|
||||
},
|
||||
id
|
||||
);
|
||||
|
||||
@ -88,7 +133,9 @@ export class ProformaCreator implements IProformaCreator {
|
||||
|
||||
private async resolveCreateProps(
|
||||
props: ProformaCreateInputProps
|
||||
): Promise<Result<Omit<IProformaCreateProps, "proformaReference">, Error>> {
|
||||
): Promise<
|
||||
Result<Omit<IProformaCreateProps, "proformaReference" | "proformaNumber" | "documentSeriesId">, Error>
|
||||
> {
|
||||
// TODO: Esto hay que cambiarlo en el futuro para buscar valores por defecto, desde la empresa o desde el cliente
|
||||
const _newProps = {
|
||||
...props,
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import type { InvoiceNumber, InvoiceSerie } from "@erp/customer-invoices/api/domain";
|
||||
import type { InvoiceSerie } from "@erp/customer-invoices/api/domain";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
export type ProformaNumberAssignment = {
|
||||
documentSeriesId: string;
|
||||
documentSeriesCode: string;
|
||||
number: string;
|
||||
reference: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Servicio de dominio que define cómo se genera el siguiente número de factura.
|
||||
*/
|
||||
@ -15,7 +22,7 @@ export interface IProformaNumberGenerator {
|
||||
*/
|
||||
getNextForCompany(
|
||||
companyId: UniqueID,
|
||||
series: Maybe<InvoiceSerie>,
|
||||
proformaSeriesCode: Maybe<InvoiceSerie>,
|
||||
transaction: any
|
||||
): Promise<Result<InvoiceNumber, Error>>;
|
||||
): Promise<Result<ProformaNumberAssignment, Error>>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { InvoiceSerie } from "../../../domain";
|
||||
|
||||
export interface EnsureValidProformaSeriesParams {
|
||||
companyId: UniqueID;
|
||||
proformaSeriesCode: Maybe<InvoiceSerie>;
|
||||
transaction?: unknown;
|
||||
}
|
||||
|
||||
export interface ProformaSeriesValidator {
|
||||
ensureValidProformaSeries(params: EnsureValidProformaSeriesParams): Promise<Result<void, Error>>;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { InvoiceSerie } from "../../../domain";
|
||||
|
||||
export interface EnsureValidIssuedInvoiceSeriesParams {
|
||||
companyId: UniqueID;
|
||||
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
|
||||
transaction?: unknown;
|
||||
}
|
||||
|
||||
export interface ProformaTargetInvoiceSeriesValidator {
|
||||
ensureValidIssuedInvoiceSeries(
|
||||
params: EnsureValidIssuedInvoiceSeriesParams
|
||||
): Promise<Result<void, Error>>;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||
import { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Proforma, ProformaItemPatchProps, ProformaPatchProps } from "../../../domain";
|
||||
@ -7,6 +7,7 @@ import type { IProformaRepository } from "../repositories";
|
||||
|
||||
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
|
||||
import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver";
|
||||
import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface";
|
||||
|
||||
export interface IProformaUpdater {
|
||||
update(params: {
|
||||
@ -23,6 +24,7 @@ export class ProformaUpdater implements IProformaUpdater {
|
||||
repository: IProformaRepository;
|
||||
taxResolver: ProformaTaxResolver;
|
||||
paymentResolver: ProformaPaymentResolver;
|
||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||
}
|
||||
) {}
|
||||
|
||||
@ -43,10 +45,35 @@ export class ProformaUpdater implements IProformaUpdater {
|
||||
|
||||
const proforma = existingResult.data;
|
||||
|
||||
if (proforma.status.isIssued()) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"PROFORMA_ALREADY_ISSUED",
|
||||
"status",
|
||||
"Issued proformas cannot be edited"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (proforma.status.isApproved()) {
|
||||
const allowedApprovedPatch = this.isApprovedPatchAllowed(patchProps);
|
||||
|
||||
if (!allowedApprovedPatch) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"APPROVED_PROFORMA_RESTRICTED_UPDATE",
|
||||
"status",
|
||||
"Approved proformas only allow target invoice series changes before issuing"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedPatch = await this.resolvePatchProps({
|
||||
companyId,
|
||||
currentInvoiceDate: proforma.invoiceDate,
|
||||
patch: patchProps,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (resolvedPatch.isFailure) {
|
||||
@ -81,8 +108,9 @@ export class ProformaUpdater implements IProformaUpdater {
|
||||
companyId: UniqueID;
|
||||
currentInvoiceDate: UtcDate;
|
||||
patch: ProformaPatchInputProps;
|
||||
transaction: unknown;
|
||||
}): Promise<Result<ProformaPatchProps, Error>> {
|
||||
const { patch, companyId, currentInvoiceDate } = params;
|
||||
const { patch, companyId, currentInvoiceDate, transaction } = params;
|
||||
|
||||
if (patch.taxRegimeCode !== undefined) {
|
||||
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
|
||||
@ -106,8 +134,24 @@ export class ProformaUpdater implements IProformaUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.targetInvoiceSeriesCode !== undefined) {
|
||||
const targetInvoiceSeriesValidationResult =
|
||||
await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({
|
||||
companyId,
|
||||
targetInvoiceSeriesCode: patch.targetInvoiceSeriesCode,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (targetInvoiceSeriesValidationResult.isFailure) {
|
||||
return Result.fail(targetInvoiceSeriesValidationResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.items === undefined) {
|
||||
return Result.ok(patch as ProformaPatchProps);
|
||||
return Result.ok({
|
||||
...patch,
|
||||
series: patch.targetInvoiceSeriesCode,
|
||||
} as ProformaPatchProps);
|
||||
}
|
||||
|
||||
const effectiveInvoiceDate = patch.proformaDate ?? currentInvoiceDate;
|
||||
@ -132,7 +176,14 @@ export class ProformaUpdater implements IProformaUpdater {
|
||||
|
||||
return Result.ok({
|
||||
...patch,
|
||||
series: patch.targetInvoiceSeriesCode,
|
||||
items: resolvedItems,
|
||||
});
|
||||
}
|
||||
|
||||
private isApprovedPatchAllowed(patch: ProformaPatchInputProps): boolean {
|
||||
const allowedKeys = new Set(["targetInvoiceSeriesCode"]);
|
||||
|
||||
return Object.keys(patch).every((key) => allowedKeys.has(key));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -19,7 +19,6 @@ import {
|
||||
InvoiceStatus,
|
||||
type ItemAmount,
|
||||
} from "../../common/value-objects";
|
||||
import { InvalidInvoiceSeriesCodeError } from "../../invoice-series";
|
||||
import {
|
||||
type IProformaItemCreateProps,
|
||||
type IProformaItems,
|
||||
@ -36,6 +35,8 @@ export interface IProformaCreateProps {
|
||||
companyId: UniqueID;
|
||||
status: InvoiceStatus;
|
||||
|
||||
documentSeriesId: Maybe<string>;
|
||||
proformaNumber: Maybe<InvoiceNumber>;
|
||||
proformaReference: InvoiceNumber;
|
||||
series: Maybe<InvoiceSerie>;
|
||||
|
||||
@ -86,6 +87,8 @@ export interface IProforma {
|
||||
companyId: UniqueID;
|
||||
status: InvoiceStatus;
|
||||
|
||||
documentSeriesId: Maybe<string>;
|
||||
proformaNumber: Maybe<InvoiceNumber>;
|
||||
series: Maybe<InvoiceSerie>;
|
||||
proformaReference: InvoiceNumber;
|
||||
|
||||
@ -234,6 +237,14 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
|
||||
return this.props.series;
|
||||
}
|
||||
|
||||
public get documentSeriesId(): Maybe<string> {
|
||||
return this.props.documentSeriesId;
|
||||
}
|
||||
|
||||
public get proformaNumber(): Maybe<InvoiceNumber> {
|
||||
return this.props.proformaNumber;
|
||||
}
|
||||
|
||||
public get proformaReference() {
|
||||
return this.props.proformaReference;
|
||||
}
|
||||
@ -363,10 +374,6 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
|
||||
}
|
||||
|
||||
private validateCanBeIssued(): Result<void, Error> {
|
||||
if (this.series.isNone()) {
|
||||
return Result.fail(InvalidInvoiceSeriesCodeError.required());
|
||||
}
|
||||
|
||||
if (this.description.isNone()) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import {
|
||||
type CreationOptional,
|
||||
DataTypes,
|
||||
type InferAttributes,
|
||||
type InferCreationAttributes,
|
||||
Model,
|
||||
type Sequelize,
|
||||
} from "sequelize";
|
||||
|
||||
export class CustomerInvoiceSeriesModel extends Model<
|
||||
InferAttributes<CustomerInvoiceSeriesModel>,
|
||||
InferCreationAttributes<CustomerInvoiceSeriesModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare company_id: string;
|
||||
declare code: string;
|
||||
declare next_number: number;
|
||||
declare padding_length: number;
|
||||
declare is_default: CreationOptional<boolean>;
|
||||
declare is_active: CreationOptional<boolean>;
|
||||
declare created_at: CreationOptional<Date>;
|
||||
declare updated_at: CreationOptional<Date>;
|
||||
}
|
||||
|
||||
export default (database: Sequelize) => {
|
||||
CustomerInvoiceSeriesModel.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_bin",
|
||||
} as any,
|
||||
|
||||
company_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_bin",
|
||||
} as any,
|
||||
|
||||
code: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false,
|
||||
},
|
||||
next_number: {
|
||||
type: DataTypes.INTEGER.UNSIGNED,
|
||||
allowNull: false,
|
||||
},
|
||||
padding_length: {
|
||||
type: DataTypes.SMALLINT.UNSIGNED,
|
||||
allowNull: false,
|
||||
},
|
||||
is_default: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize: database,
|
||||
modelName: "CustomerInvoiceSeriesModel",
|
||||
tableName: "customer_invoice_series",
|
||||
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_unicode_ci",
|
||||
underscored: true,
|
||||
paranoid: false,
|
||||
timestamps: true,
|
||||
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_customer_invoice_series_company_id",
|
||||
fields: ["company_id"],
|
||||
},
|
||||
{
|
||||
name: "idx_customer_invoice_series_company_active",
|
||||
fields: ["company_id", "is_active"],
|
||||
},
|
||||
{
|
||||
name: "uq_customer_invoice_series_company_code",
|
||||
fields: ["company_id", "code"],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
|
||||
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
|
||||
|
||||
defaultScope: {},
|
||||
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
return CustomerInvoiceSeriesModel;
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -35,6 +35,8 @@ export class ProformaModel extends Model<
|
||||
declare company_id: string;
|
||||
declare status: string;
|
||||
|
||||
declare document_series_id: CreationOptional<string | null>;
|
||||
declare proforma_number: CreationOptional<string | null>;
|
||||
declare proforma_reference: string;
|
||||
declare proforma_date: string;
|
||||
declare operation_date: CreationOptional<string | null>;
|
||||
@ -171,6 +173,18 @@ export default (database: Sequelize) => {
|
||||
allowNull: false,
|
||||
defaultValue: "draft",
|
||||
},
|
||||
document_series_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_bin",
|
||||
} as any,
|
||||
proforma_number: {
|
||||
type: new DataTypes.STRING(32),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
proforma_reference: {
|
||||
type: new DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
@ -414,6 +428,10 @@ export default (database: Sequelize) => {
|
||||
name: "idx_proformas_company",
|
||||
fields: ["company_id"],
|
||||
},
|
||||
{
|
||||
name: "idx_proformas_company_document_series",
|
||||
fields: ["company_id", "document_series_id"],
|
||||
},
|
||||
{
|
||||
name: "uq_proformas_company_reference",
|
||||
fields: ["company_id", "proforma_reference"],
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export * from "./common";
|
||||
export * from "./invoice-series";
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./invoice-series.di";
|
||||
export * from "./invoice-series-repository.di";
|
||||
@ -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);
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./list-invoice-series.controller";
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from "./controllers";
|
||||
export * from "./invoice-series-api-error-mapper";
|
||||
export * from "./invoice-series.routes";
|
||||
@ -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);
|
||||
@ -1,25 +0,0 @@
|
||||
import type { StartParams } from "@erp/core/api";
|
||||
import { requireIdentityTenant } from "@erp/identity/api";
|
||||
import { type NextFunction, type Request, type Response, Router } from "express";
|
||||
|
||||
import type { InvoiceSeriesInternalDeps } from "../di/invoice-series.di";
|
||||
|
||||
import { ListInvoiceSeriesController } from "./controllers";
|
||||
|
||||
export const invoiceSeriesRouter = (params: StartParams) => {
|
||||
const { app, config, getInternal } = params;
|
||||
|
||||
const deps = getInternal<InvoiceSeriesInternalDeps>("customer-invoices", "invoiceSeries");
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(...requireIdentityTenant(params));
|
||||
|
||||
router.get("/", (req: Request, res: Response, next: NextFunction) => {
|
||||
const useCase = deps.useCases.listInvoiceSeries();
|
||||
const controller = new ListInvoiceSeriesController(useCase);
|
||||
return controller.execute(req, res, next);
|
||||
});
|
||||
|
||||
app.use(`${config.server.apiBasePath}/catalogs/invoice-series`, router);
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from "./invoice-series-domain.mapper";
|
||||
@ -1,82 +0,0 @@
|
||||
import {
|
||||
UniqueID,
|
||||
ValidationErrorCollection,
|
||||
type ValidationErrorDetail,
|
||||
extractOrPushError,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
type InvalidInvoiceSeriesCodeError,
|
||||
type InvalidInvoiceSeriesNextNumberError,
|
||||
type InvalidInvoiceSeriesPaddingLengthError,
|
||||
InvoiceSeries,
|
||||
InvoiceSeriesCode,
|
||||
type InvoiceSeriesInternalProps,
|
||||
InvoiceSeriesNextNumber,
|
||||
InvoiceSeriesPaddingLength,
|
||||
} from "../../../../../domain";
|
||||
import type { CustomerInvoiceSeriesModel } from "../../../../common";
|
||||
|
||||
export class InvoiceSeriesDomainMapper {
|
||||
public mapToDomain(raw: CustomerInvoiceSeriesModel): Result<InvoiceSeries, Error> {
|
||||
const errors: ValidationErrorDetail[] = [];
|
||||
|
||||
const id = extractOrPushError(UniqueID.create(raw.id), "id", errors);
|
||||
const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors);
|
||||
|
||||
const codeResult = InvoiceSeriesCode.create(raw.code);
|
||||
if (codeResult.isFailure) {
|
||||
return Result.fail(codeResult.error as InvalidInvoiceSeriesCodeError);
|
||||
}
|
||||
|
||||
const nextNumberResult = InvoiceSeriesNextNumber.create(raw.next_number);
|
||||
if (nextNumberResult.isFailure) {
|
||||
return Result.fail(nextNumberResult.error as InvalidInvoiceSeriesNextNumberError);
|
||||
}
|
||||
|
||||
const paddingLengthResult = InvoiceSeriesPaddingLength.create(raw.padding_length);
|
||||
if (paddingLengthResult.isFailure) {
|
||||
return Result.fail(paddingLengthResult.error as InvalidInvoiceSeriesPaddingLengthError);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Result.fail(
|
||||
new ValidationErrorCollection("Invoice series mapping failed [mapToDomain]", errors)
|
||||
);
|
||||
}
|
||||
|
||||
const props: InvoiceSeriesInternalProps = {
|
||||
companyId: companyId!,
|
||||
code: codeResult.data,
|
||||
nextNumber: nextNumberResult.data,
|
||||
paddingLength: paddingLengthResult.data,
|
||||
isDefault: raw.is_default,
|
||||
isActive: raw.is_active,
|
||||
};
|
||||
|
||||
return Result.ok(InvoiceSeries.rehydrate(props, id!));
|
||||
}
|
||||
|
||||
public mapToPersistence(source: InvoiceSeries) {
|
||||
return Result.ok({
|
||||
id: source.id.toString(),
|
||||
company_id: source.companyId.toString(),
|
||||
code: source.code.toPrimitive(),
|
||||
next_number: source.nextNumber.toPrimitive(),
|
||||
padding_length: source.paddingLength.toPrimitive(),
|
||||
is_default: source.isDefault,
|
||||
is_active: source.isActive,
|
||||
});
|
||||
}
|
||||
|
||||
public mapOptionalToDomain(
|
||||
raw: CustomerInvoiceSeriesModel | null
|
||||
): Result<Maybe<InvoiceSeries>, Error> {
|
||||
if (!raw) {
|
||||
return Result.ok(Maybe.none<InvoiceSeries>());
|
||||
}
|
||||
|
||||
return this.mapToDomain(raw).map((invoiceSeries) => Maybe.some(invoiceSeries));
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./invoice-series.repository";
|
||||
@ -1,136 +0,0 @@
|
||||
import { SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Collection, Result } from "@repo/rdx-utils";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
|
||||
import type { IInvoiceSeriesRepository } from "../../../../../application";
|
||||
import {
|
||||
InvoiceSeriesConcurrencyError,
|
||||
InvoiceSeriesLockRequiredError,
|
||||
InvoiceSeriesTransactionRequiredError,
|
||||
} from "../../../../../application";
|
||||
import type { InvoiceSeries, InvoiceSeriesCode } from "../../../../../domain";
|
||||
import { CustomerInvoiceSeriesModel } from "../../../../common";
|
||||
import type { InvoiceSeriesDomainMapper } from "../mappers";
|
||||
|
||||
export class SequelizeInvoiceSeriesRepository
|
||||
extends SequelizeRepository<InvoiceSeries>
|
||||
implements IInvoiceSeriesRepository
|
||||
{
|
||||
public constructor(
|
||||
private readonly domainMapper: InvoiceSeriesDomainMapper,
|
||||
database: Sequelize
|
||||
) {
|
||||
super({ database });
|
||||
}
|
||||
|
||||
public async findActiveByCompany(params: { companyId: UniqueID; transaction?: unknown }) {
|
||||
try {
|
||||
const rows = await CustomerInvoiceSeriesModel.findAll({
|
||||
where: {
|
||||
company_id: params.companyId.toString(),
|
||||
is_active: true,
|
||||
},
|
||||
order: [
|
||||
["is_default", "DESC"],
|
||||
["code", "ASC"],
|
||||
],
|
||||
transaction: params.transaction as Transaction | undefined,
|
||||
});
|
||||
|
||||
const invoiceSeriesRows: InvoiceSeries[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const invoiceSeriesResult = this.domainMapper.mapToDomain(row);
|
||||
|
||||
if (invoiceSeriesResult.isFailure) {
|
||||
return Result.fail(invoiceSeriesResult.error);
|
||||
}
|
||||
|
||||
invoiceSeriesRows.push(invoiceSeriesResult.data);
|
||||
}
|
||||
|
||||
return Result.ok(new Collection(invoiceSeriesRows));
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(translateSequelizeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
public async findByCodeInCompany(params: {
|
||||
companyId: UniqueID;
|
||||
invoiceSeriesCode: InvoiceSeriesCode;
|
||||
transaction?: unknown;
|
||||
}) {
|
||||
try {
|
||||
const row = await CustomerInvoiceSeriesModel.findOne({
|
||||
where: {
|
||||
company_id: params.companyId.toString(),
|
||||
code: params.invoiceSeriesCode.toPrimitive(),
|
||||
},
|
||||
transaction: params.transaction as Transaction | undefined,
|
||||
});
|
||||
|
||||
return this.domainMapper.mapOptionalToDomain(row);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(translateSequelizeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
public async findByCodeInCompanyForUpdate(params: {
|
||||
companyId: UniqueID;
|
||||
invoiceSeriesCode: InvoiceSeriesCode;
|
||||
transaction: unknown;
|
||||
}) {
|
||||
try {
|
||||
const transaction = params.transaction as Transaction;
|
||||
|
||||
if (!transaction) {
|
||||
return Result.fail(new InvoiceSeriesLockRequiredError());
|
||||
}
|
||||
|
||||
const row = await CustomerInvoiceSeriesModel.findOne({
|
||||
where: {
|
||||
company_id: params.companyId.toString(),
|
||||
code: params.invoiceSeriesCode.toPrimitive(),
|
||||
},
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
return this.domainMapper.mapOptionalToDomain(row);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(translateSequelizeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
public async update(
|
||||
invoiceSeries: InvoiceSeries,
|
||||
transaction?: unknown
|
||||
): Promise<Result<void, Error>> {
|
||||
try {
|
||||
if (!transaction) {
|
||||
return Result.fail(new InvoiceSeriesTransactionRequiredError());
|
||||
}
|
||||
|
||||
const dtoResult = this.domainMapper.mapToPersistence(invoiceSeries);
|
||||
|
||||
if (dtoResult.isFailure) {
|
||||
return Result.fail(dtoResult.error);
|
||||
}
|
||||
|
||||
const { id, ...payload } = dtoResult.data;
|
||||
const [affectedRows] = await CustomerInvoiceSeriesModel.update(payload, {
|
||||
where: { id },
|
||||
transaction: transaction as Transaction | undefined,
|
||||
});
|
||||
|
||||
if (affectedRows !== 1) {
|
||||
return Result.fail(new InvoiceSeriesConcurrencyError());
|
||||
}
|
||||
|
||||
return Result.ok();
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(translateSequelizeError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { SetupParams } from "@erp/core/api";
|
||||
import { buildCatalogs } from "@erp/core/api";
|
||||
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
@ -9,9 +10,9 @@ import {
|
||||
type IIssuedInvoiceServicesContext,
|
||||
buildIssuedInvoiceCreator,
|
||||
} from "../../../application/issued-invoices";
|
||||
import { buildInvoiceSeriesNumberAssigner } from "../../../application/invoice-series";
|
||||
import type { IInvoiceSeriesNumberAssigner } from "../../../application/invoice-series";
|
||||
import { InvoiceNumber, InvoiceSerie } from "../../../domain";
|
||||
|
||||
import { buildInvoiceSeriesRepository } from "../../invoice-series";
|
||||
import { buildV2IssuedInvoicePersistence } from "./issued-invoice-persistence.di";
|
||||
import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di";
|
||||
|
||||
@ -23,11 +24,46 @@ export function buildIssuedInvoicePublicServices(
|
||||
|
||||
// Infrastructure
|
||||
const catalogs = buildCatalogs();
|
||||
const documentSeriesServices =
|
||||
params.getService<DocumentSeriesPublicServicesType>("document-series:general");
|
||||
// Fase 1C: la emision ya persiste en `issued_invoices` y relaciona Verifactu por `issued_invoice_id`.
|
||||
const persistence = buildV2IssuedInvoicePersistence({ catalogs, database });
|
||||
const { repository } = persistence;
|
||||
const invoiceSeriesRepository = buildInvoiceSeriesRepository(database);
|
||||
const invoiceSeriesNumberAssigner = buildInvoiceSeriesNumberAssigner(invoiceSeriesRepository);
|
||||
const invoiceSeriesNumberAssigner: IInvoiceSeriesNumberAssigner = {
|
||||
assignNextNumber: async ({ companyId, invoiceSeriesCode, transaction }) => {
|
||||
const requestedSeriesCode = invoiceSeriesCode
|
||||
.map((series) => series.toPrimitive())
|
||||
.unwrapOr(null);
|
||||
|
||||
const assignResult = await documentSeriesServices.assignNextNumber({
|
||||
companyId: companyId.toString(),
|
||||
documentType: "issued_invoice",
|
||||
seriesCode: requestedSeriesCode,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (assignResult.isFailure) {
|
||||
return Result.fail(assignResult.error);
|
||||
}
|
||||
|
||||
const invoiceNumberResult = InvoiceNumber.create(assignResult.data.number);
|
||||
if (invoiceNumberResult.isFailure) {
|
||||
return Result.fail(invoiceNumberResult.error);
|
||||
}
|
||||
|
||||
const assignedSeriesResult = InvoiceSerie.create(assignResult.data.series_code);
|
||||
if (assignedSeriesResult.isFailure) {
|
||||
return Result.fail(assignedSeriesResult.error);
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
invoiceNumber: invoiceNumberResult.data,
|
||||
formattedNumber: assignResult.data.reference,
|
||||
invoiceSeriesId: assignResult.data.series_id,
|
||||
invoiceSeriesCode: assignedSeriesResult.data,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Application helpers
|
||||
const creator = buildIssuedInvoiceCreator({ invoiceSeriesNumberAssigner, repository });
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { SetupParams } from "@erp/core/api";
|
||||
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { Transaction } from "sequelize";
|
||||
@ -17,6 +18,8 @@ import type { Proforma } from "../../../domain";
|
||||
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
|
||||
import type { ProformasInternalDeps } from "./proformas.di";
|
||||
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
|
||||
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
|
||||
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
|
||||
|
||||
type ProformaServicesContext = {
|
||||
transaction: Transaction;
|
||||
@ -48,6 +51,8 @@ export function buildProformaPublicServices(
|
||||
const { database } = params;
|
||||
|
||||
const catalogs = resolveProformaCatalogsDeps(params);
|
||||
const documentSeriesServices =
|
||||
params.getService<DocumentSeriesPublicServicesType>("document-series:general");
|
||||
|
||||
/**
|
||||
* Fase 1C: los servicios publicos ya consumen Proforma V2.
|
||||
@ -55,6 +60,7 @@ export function buildProformaPublicServices(
|
||||
*/
|
||||
const persistence = buildV2ProformaPersistence({
|
||||
database,
|
||||
documentSeriesServices,
|
||||
});
|
||||
const { repository, numberGenerator: numberService } = persistence;
|
||||
const finder = buildProformaFinder(repository);
|
||||
@ -72,12 +78,20 @@ export function buildProformaPublicServices(
|
||||
});
|
||||
|
||||
const snapshotBuilders = buildProformaSnapshotBuilders();
|
||||
const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(
|
||||
documentSeriesServices
|
||||
);
|
||||
const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator(
|
||||
documentSeriesServices
|
||||
);
|
||||
|
||||
const creator = buildProformaCreator({
|
||||
repository,
|
||||
numberService,
|
||||
taxResolver: catalogResolvers.taxResolver,
|
||||
paymentResolver: catalogResolvers.paymentResolver,
|
||||
proformaSeriesValidator,
|
||||
targetInvoiceSeriesValidator,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
||||
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
|
||||
|
||||
import type { ICompanyPublicServices } from "../../../../../../companies/src/api";
|
||||
import {
|
||||
@ -32,6 +33,8 @@ import {
|
||||
buildUpdateProformaUseCase,
|
||||
} from "../../../application";
|
||||
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
|
||||
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
|
||||
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
|
||||
|
||||
import { buildProformaDocumentServices } from "./proforma-documents.di";
|
||||
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
|
||||
@ -62,6 +65,8 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
||||
|
||||
const catalogs = resolveProformaCatalogsDeps(params);
|
||||
const companiesServices = params.getService<ICompanyPublicServices>("companies:general");
|
||||
const documentSeriesServices =
|
||||
params.getService<DocumentSeriesPublicServicesType>("document-series:general");
|
||||
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
/**
|
||||
@ -70,6 +75,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
||||
*/
|
||||
const persistence = buildV2ProformaPersistence({
|
||||
database,
|
||||
documentSeriesServices,
|
||||
});
|
||||
const { repository, numberGenerator: proformaNumberService } = persistence;
|
||||
|
||||
@ -89,18 +95,27 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
||||
});
|
||||
|
||||
const finder = buildProformaFinder(repository);
|
||||
const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(
|
||||
documentSeriesServices
|
||||
);
|
||||
const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator(
|
||||
documentSeriesServices
|
||||
);
|
||||
|
||||
const creator = buildProformaCreator({
|
||||
taxResolver: catalogResolvers.taxResolver,
|
||||
paymentResolver: catalogResolvers.paymentResolver,
|
||||
numberService: proformaNumberService,
|
||||
repository,
|
||||
proformaSeriesValidator,
|
||||
targetInvoiceSeriesValidator,
|
||||
});
|
||||
|
||||
const updater = buildProformaUpdater({
|
||||
repository,
|
||||
taxResolver: catalogResolvers.taxResolver,
|
||||
paymentResolver: catalogResolvers.paymentResolver,
|
||||
targetInvoiceSeriesValidator,
|
||||
});
|
||||
|
||||
const statusChanger = buildProformaStatusChanger({
|
||||
|
||||
@ -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!,
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -1,74 +1,40 @@
|
||||
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
import type { Transaction, WhereOptions } from "sequelize";
|
||||
import type { Transaction } from "sequelize";
|
||||
|
||||
import type { IProformaNumberGenerator } from "../../../../../application/proformas";
|
||||
import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain";
|
||||
import { ProformaModel } from "../../../../common/persistence";
|
||||
import type { InvoiceSerie } from "../../../../../domain";
|
||||
|
||||
/**
|
||||
* Numeracion V2 basada en la tabla fisica `proformas`.
|
||||
* Queda preparada para el cutover, pero no debe activarse mientras legacy siga en produccion.
|
||||
* Adapter V2 para delegar la numeracion de proformas en document-series.
|
||||
*/
|
||||
export class SequelizeProformaV2NumberGenerator implements IProformaNumberGenerator {
|
||||
private static readonly PREFIX = "PF-";
|
||||
private static readonly DIGITS = 4;
|
||||
public constructor(
|
||||
private readonly documentSeriesServices: DocumentSeriesPublicServicesType
|
||||
) {}
|
||||
|
||||
public async getNextForCompany(
|
||||
companyId: UniqueID,
|
||||
_series: Maybe<InvoiceSerie>,
|
||||
proformaSeriesCode: Maybe<InvoiceSerie>,
|
||||
transaction: Transaction
|
||||
): Promise<Result<InvoiceNumber, Error>> {
|
||||
const where: WhereOptions = {
|
||||
company_id: companyId.toString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const existingInvoices = await ProformaModel.findAll({
|
||||
attributes: ["proforma_reference"],
|
||||
where,
|
||||
transaction,
|
||||
raw: true,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
const maxSequence = existingInvoices.reduce((max, currentInvoice) => {
|
||||
const currentSequence = this.extractSequence(currentInvoice.proforma_reference);
|
||||
return Math.max(max, currentSequence);
|
||||
}, 0);
|
||||
|
||||
const nextValue = this.formatSequence(maxSequence + 1);
|
||||
|
||||
const numberResult = InvoiceNumber.create(nextValue);
|
||||
if (numberResult.isFailure) {
|
||||
return Result.fail(numberResult.error);
|
||||
) {
|
||||
return this.documentSeriesServices.assignNextNumber({
|
||||
companyId: companyId.toString(),
|
||||
documentType: "proforma",
|
||||
seriesCode: proformaSeriesCode.getOrUndefined()?.toPrimitive() ?? null,
|
||||
transaction,
|
||||
}).then((assignmentResult) => {
|
||||
if (assignmentResult.isFailure) {
|
||||
return Result.fail(assignmentResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(numberResult.data);
|
||||
} catch (error) {
|
||||
return Result.fail(
|
||||
new Error(
|
||||
`Error generating proforma number for company ${companyId}: ${(error as Error).message}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private extractSequence(invoiceNumber: string): number {
|
||||
const normalized = invoiceNumber.trim().toUpperCase();
|
||||
|
||||
if (normalized.startsWith(SequelizeProformaV2NumberGenerator.PREFIX)) {
|
||||
const suffix = normalized.slice(SequelizeProformaV2NumberGenerator.PREFIX.length);
|
||||
const parsed = Number(suffix);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
private formatSequence(sequence: number): string {
|
||||
const padded = String(sequence).padStart(SequelizeProformaV2NumberGenerator.DIGITS, "0");
|
||||
return `${SequelizeProformaV2NumberGenerator.PREFIX}${padded}`;
|
||||
return Result.ok({
|
||||
documentSeriesId: assignmentResult.data.series_id,
|
||||
documentSeriesCode: assignmentResult.data.series_code,
|
||||
number: assignmentResult.data.number,
|
||||
reference: assignmentResult.data.reference,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
|
||||
import { DomainValidationError } from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type {
|
||||
EnsureValidProformaSeriesParams,
|
||||
ProformaSeriesValidator,
|
||||
} from "../../../application";
|
||||
import type { InvoiceSerie } from "../../../domain";
|
||||
|
||||
export class DocumentSeriesProformaSeriesValidator implements ProformaSeriesValidator {
|
||||
public constructor(private readonly documentSeriesServices: DocumentSeriesPublicServicesType) {}
|
||||
|
||||
public async ensureValidProformaSeries(
|
||||
params: EnsureValidProformaSeriesParams
|
||||
): Promise<Result<void, Error>> {
|
||||
const proformaSeriesCode = this.normalize(params.proformaSeriesCode);
|
||||
|
||||
if (proformaSeriesCode === null) {
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
const listResult = await this.documentSeriesServices.listActiveSeries({
|
||||
companyId: params.companyId.toString(),
|
||||
documentType: "proforma",
|
||||
transaction: params.transaction,
|
||||
});
|
||||
|
||||
if (listResult.isFailure) {
|
||||
return Result.fail(listResult.error);
|
||||
}
|
||||
|
||||
const exists = listResult.data.some((series) => series.code === proformaSeriesCode);
|
||||
|
||||
if (!exists) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"INVALID_PROFORMA_SERIES_CODE",
|
||||
"proforma_series_code",
|
||||
"Proforma series code must reference an active proforma series"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private normalize(proformaSeriesCode: Maybe<InvoiceSerie>): string | null {
|
||||
return proformaSeriesCode.isSome() ? proformaSeriesCode.unwrap().toPrimitive() : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
|
||||
import { DomainValidationError } from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type {
|
||||
EnsureValidIssuedInvoiceSeriesParams,
|
||||
ProformaTargetInvoiceSeriesValidator,
|
||||
} from "../../../application";
|
||||
import type { InvoiceSerie } from "../../../domain";
|
||||
|
||||
export class DocumentSeriesProformaTargetInvoiceSeriesValidator
|
||||
implements ProformaTargetInvoiceSeriesValidator
|
||||
{
|
||||
public constructor(private readonly documentSeriesServices: DocumentSeriesPublicServicesType) {}
|
||||
|
||||
public async ensureValidIssuedInvoiceSeries(
|
||||
params: EnsureValidIssuedInvoiceSeriesParams
|
||||
): Promise<Result<void, Error>> {
|
||||
const targetInvoiceSeriesCode = this.normalize(params.targetInvoiceSeriesCode);
|
||||
|
||||
if (targetInvoiceSeriesCode === null) {
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
const listResult = await this.documentSeriesServices.listActiveSeries({
|
||||
companyId: params.companyId.toString(),
|
||||
documentType: "issued_invoice",
|
||||
transaction: params.transaction,
|
||||
});
|
||||
|
||||
if (listResult.isFailure) {
|
||||
return Result.fail(listResult.error);
|
||||
}
|
||||
|
||||
const exists = listResult.data.some((series) => series.code === targetInvoiceSeriesCode);
|
||||
|
||||
if (!exists) {
|
||||
return Result.fail(
|
||||
new DomainValidationError(
|
||||
"INVALID_TARGET_INVOICE_SERIES_CODE",
|
||||
"target_invoice_series_code",
|
||||
"Target invoice series code must reference an active issued_invoice series"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private normalize(targetInvoiceSeriesCode: Maybe<InvoiceSerie>): string | null {
|
||||
return targetInvoiceSeriesCode.isSome() ? targetInvoiceSeriesCode.unwrap().toPrimitive() : null;
|
||||
}
|
||||
}
|
||||
@ -29,7 +29,8 @@ export const CreateProformaRequestSchema = z.object({
|
||||
id: z.uuid(),
|
||||
|
||||
proforma_reference: z.string().nullable().optional(),
|
||||
series: z.string().nullable(),
|
||||
proforma_series_code: z.string().nullable().optional(),
|
||||
target_invoice_series_code: z.string().nullable().optional(),
|
||||
|
||||
proforma_date: IsoDateSchema,
|
||||
operation_date: IsoDateSchema.nullable().optional(),
|
||||
@ -49,7 +50,7 @@ export const CreateProformaRequestSchema = z.object({
|
||||
payment_term_id: z.uuid().nullable().optional(),
|
||||
tax_regime_code: z.string().nullable().optional(),
|
||||
|
||||
items: z.array(CreateProformaItemRequestSchema),
|
||||
items: z.array(CreateProformaItemRequestSchema).optional(),
|
||||
});
|
||||
|
||||
export type CreateProformaRequestDTO = z.infer<typeof CreateProformaRequestSchema>;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export * from "./invoice-series";
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./list-invoice-series.response.dto";
|
||||
@ -1,9 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InvoiceSeriesSummarySchema } from "../../shared/invoice-series";
|
||||
|
||||
export const ListInvoiceSeriesResponseSchema = z.object({
|
||||
items: z.array(InvoiceSeriesSummarySchema),
|
||||
});
|
||||
|
||||
export type ListInvoiceSeriesResponseDTO = z.infer<typeof ListInvoiceSeriesResponseSchema>;
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from "./invoice-series-summary.dto";
|
||||
@ -1,11 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const InvoiceSeriesSummarySchema = z.object({
|
||||
id: z.uuid(),
|
||||
code: z.string(),
|
||||
next_number: z.number().int().nonnegative(),
|
||||
padding_length: z.number().int().nonnegative(),
|
||||
is_default: z.boolean(),
|
||||
});
|
||||
|
||||
export type InvoiceSeriesSummaryDTO = z.infer<typeof InvoiceSeriesSummarySchema>;
|
||||
@ -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,
|
||||
|
||||
@ -267,7 +267,7 @@
|
||||
"placeholder": "",
|
||||
"description": ""
|
||||
},
|
||||
"invoice_date": {
|
||||
"proforma_date": {
|
||||
"label": "Proforma date",
|
||||
"placeholder": "Select a date",
|
||||
"description": "Proforma date"
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -19,8 +19,8 @@ import type { FieldErrors } from "react-hook-form";
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import type { Proforma } from "../../shared";
|
||||
import {
|
||||
buildInvoiceSeriesSelectItems,
|
||||
useInvoiceSeriesQuery,
|
||||
buildDocumentSeriesSelectItems,
|
||||
useDocumentSeriesQuery,
|
||||
useProformaCreateMutation,
|
||||
} from "../../shared";
|
||||
import { buildCreateProformaParams } from "../adapters";
|
||||
@ -55,7 +55,8 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
|
||||
error: createError,
|
||||
} = useProformaCreateMutation();
|
||||
|
||||
const seriesQuery = useInvoiceSeriesQuery();
|
||||
const proformaSeriesQuery = useDocumentSeriesQuery("proforma");
|
||||
const targetInvoiceSeriesQuery = useDocumentSeriesQuery("issued_invoice");
|
||||
|
||||
const initialValues = useMemo<ProformaCreateForm>(() => buildProformaCreateDefault(), []);
|
||||
|
||||
@ -65,27 +66,35 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
|
||||
disabled: isCreating,
|
||||
});
|
||||
|
||||
const seriesOptions = useMemo(
|
||||
() => buildInvoiceSeriesSelectItems(seriesQuery.data ?? []),
|
||||
[seriesQuery.data]
|
||||
const proformaSeriesOptions = useMemo(
|
||||
() => buildDocumentSeriesSelectItems(proformaSeriesQuery.data ?? []),
|
||||
[proformaSeriesQuery.data]
|
||||
);
|
||||
|
||||
const defaultSeries = useMemo(() => {
|
||||
const invoiceSeries = seriesQuery.data ?? [];
|
||||
const targetInvoiceSeriesOptions = useMemo(
|
||||
() => [
|
||||
{ value: "", label: "Automática" },
|
||||
...buildDocumentSeriesSelectItems(targetInvoiceSeriesQuery.data ?? []),
|
||||
],
|
||||
[targetInvoiceSeriesQuery.data]
|
||||
);
|
||||
|
||||
const defaultProformaSeries = useMemo(() => {
|
||||
const invoiceSeries = proformaSeriesQuery.data ?? [];
|
||||
|
||||
return invoiceSeries.find((series) => series.isDefault)?.code ?? invoiceSeries[0]?.code ?? "";
|
||||
}, [seriesQuery.data]);
|
||||
}, [proformaSeriesQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.getValues("series")) return;
|
||||
if (!defaultSeries) return;
|
||||
if (form.getValues("proformaSeriesCode")) return;
|
||||
if (!defaultProformaSeries) return;
|
||||
|
||||
form.setValue("series", defaultSeries, {
|
||||
form.setValue("proformaSeriesCode", defaultProformaSeries, {
|
||||
shouldDirty: false,
|
||||
shouldTouch: false,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}, [defaultSeries, form]);
|
||||
}, [defaultProformaSeries, form]);
|
||||
|
||||
const setCustomer = (customer: CustomerSelectionOption) => {
|
||||
setSelectedCustomer(customer);
|
||||
@ -128,7 +137,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
|
||||
form.reset(
|
||||
{
|
||||
...buildProformaCreateDefault(),
|
||||
series: formData.series,
|
||||
proformaSeriesCode: formData.proformaSeriesCode,
|
||||
},
|
||||
{ keepDirty: false }
|
||||
);
|
||||
@ -179,9 +188,11 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
|
||||
selectedCustomer,
|
||||
setCustomer,
|
||||
clearCustomer,
|
||||
seriesOptions,
|
||||
isSeriesLoading: seriesQuery.isLoading,
|
||||
isSeriesLoadError: seriesQuery.isError,
|
||||
seriesLoadError: seriesQuery.error,
|
||||
proformaSeriesOptions,
|
||||
targetInvoiceSeriesOptions,
|
||||
isProformaSeriesLoading: proformaSeriesQuery.isLoading,
|
||||
isTargetInvoiceSeriesLoading: targetInvoiceSeriesQuery.isLoading,
|
||||
isSeriesLoadError: proformaSeriesQuery.isError || targetInvoiceSeriesQuery.isError,
|
||||
seriesLoadError: proformaSeriesQuery.error ?? targetInvoiceSeriesQuery.error,
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
export interface ProformaCreateForm {
|
||||
customerId: string;
|
||||
proformaDate: string;
|
||||
series: string;
|
||||
proformaSeriesCode: string;
|
||||
targetInvoiceSeriesCode: string;
|
||||
languageCode: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -7,10 +7,10 @@ export const ProformaCreateAutoAppliedInfo = () => {
|
||||
<AlertCircleIcon className="mt-0.5 size-5 text-blue-600" />
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Se aplicará automáticamente</h2>
|
||||
<h2 className="text-lg font-semibold">Se preparará automáticamente</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
Los siguientes datos se tomarán del cliente o de la configuración de empresa y
|
||||
podrás modificarlos después en la edición.
|
||||
Estos valores iniciales se tomarán del cliente o de la configuración de empresa.
|
||||
Podrás revisarlos y modificarlos después en la edición.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -45,8 +45,8 @@ export const ProformaCreateAutoAppliedInfo = () => {
|
||||
<div>
|
||||
<p className="font-medium">Fiscalidad inicial</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
La fiscalidad se tomará del cliente o de la configuración de empresa y podrás
|
||||
modificarla después en la edición.
|
||||
Se tomará del cliente o de la configuración de empresa. Los impuestos no se
|
||||
calcularán hasta que añadas líneas a la proforma.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,7 @@ import type { ProformaCreateForm } from "../../entities";
|
||||
|
||||
interface ProformaCreateCustomerFieldProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
selectedCustomer?: CustomerSelectionOption | null;
|
||||
onSelectCustomer: () => void;
|
||||
onClearCustomer: () => void;
|
||||
@ -15,6 +16,7 @@ interface ProformaCreateCustomerFieldProps {
|
||||
|
||||
export const ProformaCreateCustomerField = ({
|
||||
disabled = false,
|
||||
className,
|
||||
selectedCustomer,
|
||||
onSelectCustomer,
|
||||
onClearCustomer,
|
||||
@ -28,7 +30,7 @@ export const ProformaCreateCustomerField = ({
|
||||
control={control}
|
||||
name="customerId"
|
||||
render={({ fieldState }) => (
|
||||
<Field className="gap-1" data-invalid={fieldState.invalid}>
|
||||
<Field className={cn("gap-1", className)} data-invalid={fieldState.invalid}>
|
||||
<label className="text-sm font-medium leading-none" htmlFor={triggerId}>
|
||||
Cliente <span className="text-destructive">*</span>
|
||||
</label>
|
||||
@ -46,7 +48,9 @@ export const ProformaCreateCustomerField = ({
|
||||
variant="outline"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedCustomer?.name || selectedCustomer?.tradeName || "Buscar o seleccionar cliente..."}
|
||||
{selectedCustomer?.name ||
|
||||
selectedCustomer?.tradeName ||
|
||||
"Buscar o seleccionar cliente..."}
|
||||
</span>
|
||||
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-60" />
|
||||
</Button>
|
||||
|
||||
@ -9,24 +9,28 @@ import { ProformaCreateInitialInfoCard } from "./proforma-create-initial-info-ca
|
||||
interface ProformaCreateFormProps {
|
||||
formId: string;
|
||||
isSubmitting: boolean;
|
||||
isSeriesLoading?: boolean;
|
||||
isProformaSeriesLoading?: boolean;
|
||||
isTargetInvoiceSeriesLoading?: boolean;
|
||||
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
|
||||
selectedCustomer?: CustomerSelectionOption | null;
|
||||
onSelectCustomer: () => void;
|
||||
onClearCustomer: () => void;
|
||||
seriesOptions: { value: string; label: string }[];
|
||||
proformaSeriesOptions: { value: string; label: string }[];
|
||||
targetInvoiceSeriesOptions: { value: string; label: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ProformaCreateForm = ({
|
||||
formId,
|
||||
isSubmitting,
|
||||
isSeriesLoading = false,
|
||||
isProformaSeriesLoading = false,
|
||||
isTargetInvoiceSeriesLoading = false,
|
||||
onSubmit,
|
||||
selectedCustomer,
|
||||
onSelectCustomer,
|
||||
onClearCustomer,
|
||||
seriesOptions,
|
||||
proformaSeriesOptions,
|
||||
targetInvoiceSeriesOptions,
|
||||
className,
|
||||
}: ProformaCreateFormProps) => {
|
||||
return (
|
||||
@ -35,11 +39,13 @@ export const ProformaCreateForm = ({
|
||||
<div className="space-y-6">
|
||||
<ProformaCreateInitialInfoCard
|
||||
disabled={isSubmitting}
|
||||
isSeriesLoading={isSeriesLoading}
|
||||
isProformaSeriesLoading={isProformaSeriesLoading}
|
||||
isTargetInvoiceSeriesLoading={isTargetInvoiceSeriesLoading}
|
||||
onClearCustomer={onClearCustomer}
|
||||
onSelectCustomer={onSelectCustomer}
|
||||
proformaSeriesOptions={proformaSeriesOptions}
|
||||
selectedCustomer={selectedCustomer}
|
||||
seriesOptions={seriesOptions}
|
||||
targetInvoiceSeriesOptions={targetInvoiceSeriesOptions}
|
||||
/>
|
||||
|
||||
<ProformaCreateAutoAppliedInfo />
|
||||
|
||||
@ -14,35 +14,38 @@ import { ProformaCreateCustomerField } from "./proforma-create-customer-field";
|
||||
|
||||
interface ProformaCreateInitialInfoCardProps {
|
||||
disabled?: boolean;
|
||||
isSeriesLoading?: boolean;
|
||||
isProformaSeriesLoading?: boolean;
|
||||
isTargetInvoiceSeriesLoading?: boolean;
|
||||
selectedCustomer?: CustomerSelectionOption | null;
|
||||
onSelectCustomer: () => void;
|
||||
onClearCustomer: () => void;
|
||||
seriesOptions: SelectFieldItem[];
|
||||
proformaSeriesOptions: SelectFieldItem[];
|
||||
targetInvoiceSeriesOptions: SelectFieldItem[];
|
||||
}
|
||||
|
||||
export const ProformaCreateInitialInfoCard = ({
|
||||
disabled = false,
|
||||
isSeriesLoading = false,
|
||||
isProformaSeriesLoading = false,
|
||||
isTargetInvoiceSeriesLoading = false,
|
||||
selectedCustomer,
|
||||
onSelectCustomer,
|
||||
onClearCustomer,
|
||||
seriesOptions,
|
||||
proformaSeriesOptions,
|
||||
targetInvoiceSeriesOptions,
|
||||
}: ProformaCreateInitialInfoCardProps) => {
|
||||
return (
|
||||
<FormSectionCard icon={<FileTextIcon className="size-5" />} title="Información inicial">
|
||||
<FormSectionGrid>
|
||||
<div className="md:col-span-5">
|
||||
<ProformaCreateCustomerField
|
||||
disabled={disabled}
|
||||
onClearCustomer={onClearCustomer}
|
||||
onSelectCustomer={onSelectCustomer}
|
||||
selectedCustomer={selectedCustomer}
|
||||
/>
|
||||
</div>
|
||||
<FormSectionGrid className="md:grid-cols-6 2xl:grid-cols-12">
|
||||
<ProformaCreateCustomerField
|
||||
className="md:col-span-4"
|
||||
disabled={disabled}
|
||||
onClearCustomer={onClearCustomer}
|
||||
onSelectCustomer={onSelectCustomer}
|
||||
selectedCustomer={selectedCustomer}
|
||||
/>
|
||||
|
||||
<DatePickerField<ProformaCreateForm>
|
||||
className="md:col-span-3"
|
||||
className="md:col-span-2"
|
||||
disabled={disabled}
|
||||
label="Fecha"
|
||||
name="proformaDate"
|
||||
@ -51,19 +54,28 @@ export const ProformaCreateInitialInfoCard = ({
|
||||
/>
|
||||
|
||||
<SelectField<ProformaCreateForm>
|
||||
className="md:col-span-4"
|
||||
disabled={disabled || isSeriesLoading}
|
||||
items={seriesOptions}
|
||||
label="Serie"
|
||||
name="series"
|
||||
className="md:col-span-2"
|
||||
disabled={disabled || isProformaSeriesLoading}
|
||||
items={proformaSeriesOptions}
|
||||
label="Serie de esta proforma"
|
||||
name="proformaSeriesCode"
|
||||
placeholder={
|
||||
isSeriesLoading
|
||||
isProformaSeriesLoading
|
||||
? "Cargando series..."
|
||||
: seriesOptions.length > 0
|
||||
: proformaSeriesOptions.length > 0
|
||||
? "Selecciona una serie"
|
||||
: "Sin serie"
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectField<ProformaCreateForm>
|
||||
className="md:col-span-2"
|
||||
disabled={disabled || isTargetInvoiceSeriesLoading}
|
||||
items={targetInvoiceSeriesOptions}
|
||||
label="Serie de la factura"
|
||||
name="targetInvoiceSeriesCode"
|
||||
placeholder={isTargetInvoiceSeriesLoading ? "Cargando series..." : "Automática"}
|
||||
/>
|
||||
</FormSectionGrid>
|
||||
</FormSectionCard>
|
||||
);
|
||||
|
||||
@ -54,13 +54,15 @@ export const ProformaCreatePage = () => {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ProformaCreateForm
|
||||
formId={createCtrl.formId}
|
||||
isSeriesLoading={createCtrl.isSeriesLoading}
|
||||
isProformaSeriesLoading={createCtrl.isProformaSeriesLoading}
|
||||
isSubmitting={createCtrl.isCreating}
|
||||
isTargetInvoiceSeriesLoading={createCtrl.isTargetInvoiceSeriesLoading}
|
||||
onClearCustomer={createCtrl.clearCustomer}
|
||||
onSelectCustomer={selectCustomerCtrl.selectCtrl.openDialog}
|
||||
onSubmit={createCtrl.onSubmit}
|
||||
proformaSeriesOptions={createCtrl.proformaSeriesOptions}
|
||||
selectedCustomer={createCtrl.selectedCustomer}
|
||||
seriesOptions={createCtrl.seriesOptions}
|
||||
targetInvoiceSeriesOptions={createCtrl.targetInvoiceSeriesOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user