From 9a45e7ee9af9a55d08deefb37f7fd622c300cdfd Mon Sep 17 00:00:00 2001 From: david Date: Mon, 16 Mar 2026 18:45:45 +0100 Subject: [PATCH] =?UTF-8?q?Importaci=C3=B3n=20desde=20FactuGES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/package.json | 3 +- apps/server/src/lib/modules/module-loader.ts | 23 +- apps/server/src/register-modules.ts | 6 +- biome.json | 2 +- input.json | 1 - .../express/api-error-mapper.ts | 26 +- modules/core/src/api/modules/types.ts | 4 +- modules/core/src/web/components/index.ts | 1 + .../src/web/components/not-found-card.tsx | 0 modules/core/src/web/hooks/index.ts | 1 + .../core/src/web/hooks/use-rhf-error-focus.ts | 11 + modules/customer-invoices/package.json | 1 + .../map-dto-to-customer-invoice-props.ts | 11 +- .../factories/proforma-factory.interface.ts | 4 +- .../proformas/factories/proforma-factory.ts | 4 +- .../mappers/create-proforma-input.mapper.ts | 26 +- .../proformas/services/proforma-creator.ts | 18 +- .../common/value-objects/invoice-status.vo.ts | 20 +- .../aggregates/proforma.aggregate.ts | 13 +- .../issue-customer-invoice-domain-service.ts | 2 +- modules/customer-invoices/src/api/index.ts | 26 +- .../domain/customer-invoice-item.mapper.ts | 6 +- .../mappers/domain/customer-invoice.mapper.ts | 4 +- .../domain/invoice-recipient.mapper.ts | 8 +- .../domain/invoice-verifactu.mapper.ts | 4 +- .../sequelize-invoice-number-generator.ts | 66 -- .../src/api/infrastructure/index.ts | 1 - .../di/issued-invoice-public-services.ts | 52 +- .../proformas/di/proforma-public-services.ts | 77 ++- .../mappers/create-proforma-request-mapper.ts | 9 +- .../sequelize-proforma-domain.mapper.ts | 4 +- .../sequelize-proforma-item-domain.mapper.ts | 6 +- ...uelize-proforma-recipient-domain.mapper.ts | 4 +- .../list/ui/pages/proforma-list-page.tsx | 3 +- modules/customer-invoices/tsconfig.json | 7 +- modules/customers/package.json | 1 + .../application/di/customer-use-cases.di.ts | 32 +- .../mappers/create-customer-input.mapper.ts | 4 +- .../src/api/application/mappers/index.ts | 1 + .../mappers/update-customer-input.mapper.ts | 289 +++++++++ .../customer-repository.interface.ts | 12 +- .../application/services/customer-finder.ts | 18 +- .../application/services/customer-updater.ts | 6 +- .../src/api/application/services/index.ts | 1 + .../map-dto-to-update-customer-props.ts | 279 --------- .../update/update-customer.use-case.ts | 47 +- .../domain/aggregates/customer.aggregate.ts | 15 +- .../domain/value-objects/customer-taxes.vo.ts | 65 ++ .../src/api/domain/value-objects/index.ts | 1 + modules/customers/src/api/index.ts | 22 +- .../di/customer-public-services.ts | 83 ++- .../express/customers.routes.ts | 9 +- .../customers/src/api/infrastructure/index.ts | 1 + .../sequelize/models/customer.model.ts | 9 +- .../repositories/customer.repository.ts | 55 +- .../response/create-customer.result.dto.ts | 2 +- .../customers/src/web/common/api/api-types.ts | 12 + .../api/get-customer-by-ip.api.ts | 0 .../api/get-customer-list.api.ts | 0 .../src/web/{view => common}/api/index.ts | 1 + .../customers/src/web/common/hooks/index.ts | 3 + .../web/common/hooks/toValidationErrors.ts | 10 + .../hooks/use-customer-get-query.ts} | 0 .../hooks/use-customer-list-query.tsx | 2 +- .../hooks/use-customer-update-mutation.ts | 60 ++ modules/customers/src/web/common/index.ts | 2 + .../components/editor/customer-edit-form.tsx | 6 +- .../src/web/components/editor/index.ts | 3 +- modules/customers/src/web/components/index.ts | 4 +- .../src/web/context/customers-context.tsx | 2 +- modules/customers/src/web/customer-routes.tsx | 8 +- .../web/hooks/use-create-customer-mutation.ts | 10 +- .../web/hooks/use-update-customer-mutation.ts | 2 +- .../adapters/customer-summary-dto.adapter.ts | 2 +- .../customers/src/web/list/api/api-types.ts | 6 - modules/customers/src/web/list/api/index.ts | 2 - .../use-customer-list.controller.ts | 2 +- modules/customers/src/web/list/hooks/index.ts | 1 - modules/customers/src/web/list/types/types.ts | 3 +- .../src/web/list/ui/components/index.ts | 4 +- modules/customers/src/web/list/ui/index.ts | 2 - modules/customers/src/web/pages/index.ts | 2 - .../web/pages/update/customer-update-page.tsx | 3 +- .../src/web/pages/view/customer-view-page.tsx | 333 ---------- modules/customers/src/web/pages/view/index.ts | 1 - .../src/web/update/controllers/index.ts | 1 + .../use-customer-update-page.controller.ts} | 6 +- .../customers/src/web/update/hooks/index.ts | 2 + .../src/web/update/hooks/use-customer-form.ts | 22 + .../update/hooks/use-customer-get-query.ts | 35 ++ .../hooks/use-customer-update-mutation.ts | 46 ++ modules/customers/src/web/update/index.ts | 1 + .../customers/src/web/update/types/index.ts | 1 + .../customers/src/web/update/types/types.ts | 3 + .../components/customer-editor-skeleton.tsx | 0 .../{view => update}/ui/components/index.ts | 0 modules/customers/src/web/update/ui/index.ts | 1 + .../update/ui/pages/customer-update-page.tsx | 117 ++++ .../src/web/update/ui/pages/index.ts | 1 + .../web/view/adapters/customer-dto.adapter.ts | 2 +- .../customers/src/web/view/api/api-types.ts | 3 - .../use-customer-view.controller.ts | 2 +- modules/customers/src/web/view/hooks/index.ts | 1 - modules/customers/src/web/view/types/types.ts | 2 +- .../web/view/ui/pages/customer-view-page.tsx | 108 ++-- modules/customers/tsconfig.json | 2 +- .../src/api/domain/aggregates/doc-number.ts | 150 ----- .../src/api/domain/aggregates/index.ts | 1 - .../src/api/domain/entities/index.ts | 0 modules/doc-numbering/src/api/domain/index.ts | 5 - .../doc-number-repository.interface.ts | 12 - .../src/api/domain/repositories/index.ts | 1 - .../src/api/domain/value-objects/doc-type.ts | 136 ----- .../src/api/domain/value-objects/index.ts | 1 - modules/doc-numbering/src/api/index.ts | 29 - .../src/api/infrastructure/indes.ts | 0 .../src/api/infrastructure/index.ts | 1 - .../src/api/infrastructure/sequelize/index.ts | 2 - .../sequelize/models/doc-number.ts | 84 --- .../infrastructure/sequelize/models/index.ts | 1 - .../repositories/doc-number.repository.ts | 41 -- .../sequelize/repositories/index.ts | 0 .../{doc-numbering => factuges}/package.json | 20 +- .../di/factuges-input-mappers.di.ts | 17 + .../application/di/factuges-use-cases.di.ts | 31 + .../factuges/src/api/application/di/index.ts | 2 + modules/factuges/src/api/application/index.ts | 2 + ...ate-proforma-from-factuges-input.mapper.ts | 575 ++++++++++++++++++ .../src/api/application/mappers/index.ts | 1 + .../create-proforma-from-factuges.use-case.ts | 219 +++++++ .../src/api/application/use-cases/index.ts | 1 + modules/factuges/src/api/index.ts | 75 +++ .../src/api/infraestructure/di/factuges.di.ts | 46 ++ .../src/api/infraestructure/di/index.ts | 1 + ...reate-proforma-from-factuges.controller.ts | 39 ++ .../express/controllers/index.ts | 1 + .../express/factuges-api-error-mapper.ts | 3 + .../express/factuges.routes.ts | 56 ++ .../src/api/infraestructure/express/index.ts | 1 + .../factuges/src/api/infraestructure/index.ts | 1 + modules/factuges/src/common/dto/index.ts | 2 + ...eate-proforma-from-factuges.request.dto.ts | 78 +++ .../factuges/src/common/dto/request/index.ts | 1 + ...ate-proforma-from-factuges.response.dto.ts | 6 + modules/factuges/src/common/index.ts | 1 + .../{doc-numbering => factuges}/tsconfig.json | 2 +- .../src/value-objects/currency-code.ts | 10 + .../src/value-objects/postal-address.ts | 8 +- pnpm-lock.yaml | 52 +- uecko-erp.code-workspace | 5 +- 150 files changed, 2508 insertions(+), 1460 deletions(-) delete mode 100644 input.json rename modules/{customers => core}/src/web/components/not-found-card.tsx (100%) create mode 100644 modules/core/src/web/hooks/use-rhf-error-focus.ts delete mode 100644 modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/sequelize-invoice-number-generator.ts create mode 100644 modules/customers/src/api/application/mappers/update-customer-input.mapper.ts delete mode 100644 modules/customers/src/api/application/use-cases/update/map-dto-to-update-customer-props.ts create mode 100644 modules/customers/src/api/domain/value-objects/customer-taxes.vo.ts create mode 100644 modules/customers/src/web/common/api/api-types.ts rename modules/customers/src/web/{view => common}/api/get-customer-by-ip.api.ts (100%) rename modules/customers/src/web/{list => common}/api/get-customer-list.api.ts (100%) rename modules/customers/src/web/{view => common}/api/index.ts (63%) create mode 100644 modules/customers/src/web/common/hooks/index.ts create mode 100644 modules/customers/src/web/common/hooks/toValidationErrors.ts rename modules/customers/src/web/{view/hooks/use-customer-query.ts => common/hooks/use-customer-get-query.ts} (100%) rename modules/customers/src/web/{list => common}/hooks/use-customer-list-query.tsx (99%) create mode 100644 modules/customers/src/web/common/hooks/use-customer-update-mutation.ts create mode 100644 modules/customers/src/web/common/index.ts delete mode 100644 modules/customers/src/web/list/api/api-types.ts delete mode 100644 modules/customers/src/web/list/api/index.ts delete mode 100644 modules/customers/src/web/list/hooks/index.ts delete mode 100644 modules/customers/src/web/pages/view/customer-view-page.tsx delete mode 100644 modules/customers/src/web/pages/view/index.ts create mode 100644 modules/customers/src/web/update/controllers/index.ts rename modules/customers/src/web/{pages/update/use-customer-update-controller.ts => update/controllers/use-customer-update-page.controller.ts} (95%) create mode 100644 modules/customers/src/web/update/hooks/index.ts create mode 100644 modules/customers/src/web/update/hooks/use-customer-form.ts create mode 100644 modules/customers/src/web/update/hooks/use-customer-get-query.ts create mode 100644 modules/customers/src/web/update/hooks/use-customer-update-mutation.ts create mode 100644 modules/customers/src/web/update/index.ts create mode 100644 modules/customers/src/web/update/types/index.ts create mode 100644 modules/customers/src/web/update/types/types.ts rename modules/customers/src/web/{view => update}/ui/components/customer-editor-skeleton.tsx (100%) rename modules/customers/src/web/{view => update}/ui/components/index.ts (100%) create mode 100644 modules/customers/src/web/update/ui/index.ts create mode 100644 modules/customers/src/web/update/ui/pages/customer-update-page.tsx create mode 100644 modules/customers/src/web/update/ui/pages/index.ts delete mode 100644 modules/customers/src/web/view/api/api-types.ts delete mode 100644 modules/customers/src/web/view/hooks/index.ts delete mode 100644 modules/doc-numbering/src/api/domain/aggregates/doc-number.ts delete mode 100644 modules/doc-numbering/src/api/domain/aggregates/index.ts delete mode 100644 modules/doc-numbering/src/api/domain/entities/index.ts delete mode 100644 modules/doc-numbering/src/api/domain/index.ts delete mode 100644 modules/doc-numbering/src/api/domain/repositories/doc-number-repository.interface.ts delete mode 100644 modules/doc-numbering/src/api/domain/repositories/index.ts delete mode 100644 modules/doc-numbering/src/api/domain/value-objects/doc-type.ts delete mode 100644 modules/doc-numbering/src/api/domain/value-objects/index.ts delete mode 100644 modules/doc-numbering/src/api/index.ts delete mode 100644 modules/doc-numbering/src/api/infrastructure/indes.ts delete mode 100644 modules/doc-numbering/src/api/infrastructure/index.ts delete mode 100644 modules/doc-numbering/src/api/infrastructure/sequelize/index.ts delete mode 100644 modules/doc-numbering/src/api/infrastructure/sequelize/models/doc-number.ts delete mode 100644 modules/doc-numbering/src/api/infrastructure/sequelize/models/index.ts delete mode 100644 modules/doc-numbering/src/api/infrastructure/sequelize/repositories/doc-number.repository.ts delete mode 100644 modules/doc-numbering/src/api/infrastructure/sequelize/repositories/index.ts rename modules/{doc-numbering => factuges}/package.json (62%) create mode 100644 modules/factuges/src/api/application/di/factuges-input-mappers.di.ts create mode 100644 modules/factuges/src/api/application/di/factuges-use-cases.di.ts create mode 100644 modules/factuges/src/api/application/di/index.ts create mode 100644 modules/factuges/src/api/application/index.ts create mode 100644 modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts create mode 100644 modules/factuges/src/api/application/mappers/index.ts create mode 100644 modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts create mode 100644 modules/factuges/src/api/application/use-cases/index.ts create mode 100644 modules/factuges/src/api/index.ts create mode 100644 modules/factuges/src/api/infraestructure/di/factuges.di.ts create mode 100644 modules/factuges/src/api/infraestructure/di/index.ts create mode 100644 modules/factuges/src/api/infraestructure/express/controllers/create-proforma-from-factuges.controller.ts create mode 100644 modules/factuges/src/api/infraestructure/express/controllers/index.ts create mode 100644 modules/factuges/src/api/infraestructure/express/factuges-api-error-mapper.ts create mode 100644 modules/factuges/src/api/infraestructure/express/factuges.routes.ts create mode 100644 modules/factuges/src/api/infraestructure/express/index.ts create mode 100644 modules/factuges/src/api/infraestructure/index.ts create mode 100644 modules/factuges/src/common/dto/index.ts create mode 100644 modules/factuges/src/common/dto/request/create-proforma-from-factuges.request.dto.ts create mode 100644 modules/factuges/src/common/dto/request/index.ts create mode 100644 modules/factuges/src/common/dto/response/create-proforma-from-factuges.response.dto.ts create mode 100644 modules/factuges/src/common/index.ts rename modules/{doc-numbering => factuges}/tsconfig.json (94%) diff --git a/apps/server/package.json b/apps/server/package.json index d47bdf51..bbba5b10 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,8 +35,9 @@ "dependencies": { "@erp/auth": "workspace:*", "@erp/core": "workspace:*", - "@erp/customer-invoices": "workspace:*", "@erp/customers": "workspace:*", + "@erp/customer-invoices": "workspace:*", + "@erp/factuges": "workspace:*", "@repo/rdx-logger": "workspace:*", "@repo/rdx-utils": "workspace:*", "bcrypt": "^5.1.1", diff --git a/apps/server/src/lib/modules/module-loader.ts b/apps/server/src/lib/modules/module-loader.ts index 996a0583..7433da5b 100644 --- a/apps/server/src/lib/modules/module-loader.ts +++ b/apps/server/src/lib/modules/module-loader.ts @@ -132,8 +132,11 @@ async function setupModule(name: string, params: ModuleParams, stack: string[]) // 5) services (namespaced) if (pkgApi?.services) { await withPhase(name, "registerServices", async () => { + validateModuleServices(name, pkgApi.services); + for (const [serviceKey, serviceApi] of Object.entries(pkgApi.services!)) { - registerService(`${name}:${serviceKey}`, serviceApi); + const fullName = buildServiceName(name, serviceKey); + registerService(fullName, serviceApi); } }); } @@ -187,6 +190,24 @@ function trackDependencyUse(requester: string, dep: string) { set.add(dep); } +function buildServiceName(moduleName: string, serviceKey: string): string { + return `${moduleName}:${serviceKey}`; +} + +function validateModuleServices(moduleName: string, services: Record) { + for (const [serviceKey, serviceApi] of Object.entries(services)) { + if (!serviceKey || typeof serviceKey !== "string") { + throw new Error(`Invalid service key from module "${moduleName}"`); + } + + const fullName = `${moduleName}:${serviceKey}`; + + if (serviceApi === undefined) { + throw new Error(`Service "${fullName}" is undefined`); + } + } +} + function validateModuleDependencies() { for (const [moduleName, pkg] of registeredModules.entries()) { const declared = new Set(pkg.dependencies ?? []); diff --git a/apps/server/src/register-modules.ts b/apps/server/src/register-modules.ts index 153abd1b..d09c85e7 100644 --- a/apps/server/src/register-modules.ts +++ b/apps/server/src/register-modules.ts @@ -1,8 +1,6 @@ import customerInvoicesAPIModule from "@erp/customer-invoices/api"; - -//import verifactuAPIModule from "@erp/verifactu/api"; - import customersAPIModule from "@erp/customers/api"; +import factuGESAPIModule from "@erp/factuges/api"; import { registerModule } from "./lib"; @@ -10,5 +8,5 @@ export const registerModules = () => { //registerModule(authAPIModule); registerModule(customersAPIModule); registerModule(customerInvoicesAPIModule); - //registerModule(verifactuAPIModule); + registerModule(factuGESAPIModule); }; diff --git a/biome.json b/biome.json index 72d2d7fd..da4a4040 100644 --- a/biome.json +++ b/biome.json @@ -140,7 +140,7 @@ "noControlCharactersInRegex": "error", "noDoubleEquals": "error", "noDuplicateCase": "error", - "noEmptyBlockStatements": "error", + "noEmptyBlockStatements": "off", "noFallthroughSwitchClause": "error", "noFunctionAssign": "error", "noGlobalAssign": "error", diff --git a/input.json b/input.json deleted file mode 100644 index 1e632b2d..00000000 --- a/input.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"019c1ef1-7c3d-79ed-a12f-995cdc40252f","company_id":"5e4dc5b3-96b9-4968-9490-14bd032fec5f","invoice_number":"021","series":"F26","status":"issued","language_code":"es","currency_code":"EUR","invoice_date":"02/02/2026","payment_method":"DOMICILIACION BANCARIA","recipient":{"name":"AHUPA (Asociación Amigos Hospital Princesa)","tin":"G85096766","format_address":"C/ Diego de León, 45 3ºA\n28006 Madrid"},"items":[{"description":"Servicio mensual de mantenimiento de la web ahupa.es","quantity":"1","unit_amount":"64,00 €","subtotal_amount":"64,00 €","discount_percentage":"","discount_amount":"","taxable_amount":"64,00 €","taxes_amount":"13,44 €","total_amount":"77,44 €"},{"description":"Periodo: Febrero","quantity":"0","unit_amount":"","subtotal_amount":"","discount_percentage":"","discount_amount":"","taxable_amount":"","taxes_amount":"","total_amount":""},{"description":"","quantity":"","unit_amount":"","subtotal_amount":"","discount_percentage":"","discount_amount":"","taxable_amount":"","taxes_amount":"","total_amount":""}],"taxes":[{"taxable_amount":"64,00 €","iva_code":"iva_21","iva_percentage":"21,00 %","iva_amount":"13,44 €","rec_code":"","rec_percentage":"","rec_amount":"","retention_code":"","retention_percentage":"","retention_amount":"","taxes_amount":"13,44 €"}],"subtotal_amount":"64,00 €","discount_percentage":"","discount_amount":"","taxable_amount":"64,00 €","taxes_amount":"13,44 €","total_amount":"77,44 €","verifactu":{"id":"019c1ef1-7c49-7d1c-bf4d-56008cb14614","status":"Correcto","url":"https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR?nif=B83999441&numserie=F26021&fecha=02-02-2026&importe=77.44","qr_code":"iVBORw0KGgoAAAANSUhEUgAAASIAAAEiCAYAAABdvt+2AAAAAklEQVR4AewaftIAAAyJSURBVO3BUW5rS3AEwcqB9r/ltL4bMjA+5FVTzxWB31JVteikqmrZSVXVspOqqmUnVVXLTqqqlp1UVS07qapadlJVteykqmrZSVXVspOqqmUnVVXLTqqqlp1UVS07qapadlJVteykqmrZSVXVspOqqmUnVVXLTqqqlp1UVS07qapadlJVteykqmrZSVXVspOqqmUnVVXLTqqqlp1UVS37yiIgn0TNU0CeUnMDyKTmFpAbam4AuaFmAjKpmYD8BjUTkEnNDSCTmgnIJ1Gz4aSqatlJVdWyk6qqZSdVVcu+8mHU/AYg76TmBpAJyA01E5CfqLmhZgIyqZnUTEAmIJOaCcgNNZ8EyKTmKTW/AcinOKmqWnZSVbXspKpq2UlV1bKv/AFAnlLzCiCfAsik5idAbgC5AeSGmhtA3g3IpGYC8hcBeUrNJzupqlp2UlW17KSqatlJVdWyr9T/mZobQG6oeQrIT9RMQCY1E5Abam4AeScgP1EzAZnU3ADyFJBJTf3spKpq2UlV1bKTqqplJ1VVy75SbwHkhpoJyKRmAjKpuaVmAvIUkHdSMwG5BeQpIJOaCUi930lV1bKTqqplJ1VVy06qqpZ95Q9Q8+nUTEAmIDeAvALIDTUTkEnNU0BuALmh5t3U3FCzRc1/1UlV1bKTqqplJ1VVy06qqpadVFUt+8qHAfIXAZnUTEAmNROQSc0E5CdqJiDvBGRSc0PNBGRSMwH5iZoJyKRmAjKpmYBMaiYgk5obQP6/OamqWnZSVbXspKpq2UlV1bKvLFLzFwGZ1GxQs0XNO6mZgNwCMql5JzXvpKaSk6qqZSdVVctOqqqWnVRVLcNvWQJkUjMB+Q1qbgC5oeadgNxSMwH5ZGp+A5BJzQ0gN9TcAPIb1Hyyk6qqZSdVVctOqqqWnVRVLfvKH6BmAvIKNTeATGomIBOQSc0E5IaaCcgr1NwAMqmZgExqJiDvBuSGmgnIpGZSMwF5Ss0tIE8BuaFmw0lV1bKTqqplJ1VVy06qqpbht3wQIJOaCcgWNRuATGpeAeSd1ExAtqiZgExqbgCZ1ExA3k3NBGRSMwGZ1HyKk6qqZSdVVctOqqqWnVRVLfvKIiCTmgnIDTXvBmQCMql5Csik5hVAbqi5AWRSMwH5JEAmNROQSc1TaiYgk5pbQG4AmdR8spOqqmUnVVXLTqqqlp1UVS37yiI1E5BJzbsBeUrNDSBPAZnUbFEzAbmhZgJyQ80WIJOafw3IT9S8E5BJzYaTqqplJ1VVy06qqpadVFUt+8oiIJOa36DmKSCTmk+i5gaQSc0NNROQp9RMQCY1t4A8peYGkHdScwvIpOavOamqWnZSVbXspKpq2UlV1TL8lg8HZFJzA8gr1LwTkEnNBOQVam4AuaFmAjKpmYDcUDMBmdS8Asik5gaQG2omIDfU/ATIO6n5FCdVVctOqqqWnVRVLTupqlr2lT9AzQ0gt9RMQG4AuaHmKTUTkHdTcwPIpOaGmhtAJjUTkFeomYBMap4CMqmZgExAXqFmAvLJTqqqlp1UVS07qapadlJVtQy/5cMBmdTcAPJuam4AmdRMQG6omYD8RM0EZFIzAZnUTEDeSc0rgExqJiCTmhtAbqh5CsgtNROQG2o+xUlV1bKTqqplJ1VVy06qqpadVFUt+8oiIDfUTEBeoeZTqJmATEBeoeaGmhtqngLyG4BMaiYgN9RMQCYgN9RMan4C5Ck1E5BJzYaTqqplJ1VVy06qqpadVFUtw2/5cEB+g5obQCY1E5Cn1NwA8go1E5BJzVNAJjUTkEnNuwG5oeYGkEnNbwAyqZmA3FCz4aSqatlJVdWyk6qqZSdVVcu+8kep+Q1AJjXvpGYCMqmZ1Lybmg1qJiCTmp8AeScgk5p/DcgrgPw1J1VVy06qqpadVFUtO6mqWobfsgTIDTU3gHw6Ne8E5BVqJiD/mpoJyKTmFpBJzVNAPp2aG0AmNZ/ipKpq2UlV1bKTqqplJ1VVy76ySM0NIJOaG2peAeSdgNxQMwG5pead1DwFZAIyqZmAfBI1E5Abal4BZAJyQ80EZFKz4aSqatlJVdWyk6qqZSdVVcu+sgjIFiCTmhtqJiA31NwAMqmZgPwEyKRmAvIUkEnNU0BeoWYCMql5CsikZgJyA8ik5paaCcgEZFLzKU6qqpadVFUtO6mqWnZSVbXsK3+AmgnIK9Q8BWRSMwG5AWRSMwGZ1GxR805qJiC3gDwFZFLzr6l5BZAbaiYgk5oNJ1VVy06qqpadVFUtO6mqWvaVD6NmAjKpmYBMQN5NzQTkkwCZ1DwF5J3U3FBzC8hTam4AeQrIb1AzAflkJ1VVy06qqpadVFUtO6mqWvaVDwNkUjMBmdS8G5AJyFNAfoOaCchTaiYgN9RMQCY1N4C8AsgNNZOaTwLkhpoJyKc4qapadlJVteykqmrZSVXVsq/8AUBuAHmFmqfU3AAyqbmh5haQSc0NIE+peQrIDTWvAPIUkKfU3ADybkAmNZ/ipKpq2UlV1bKTqqplJ1VVy/BblgC5oeYGkEnNfxmQV6h5JyDvpGYC8go1E5AbaiYgk5oJyA01PwHylJoJyKRmw0lV1bKTqqplJ1VVy06qqpZ95Q8A8hSQV6iZgExqJiCTmhtAbqh5NyA31Dyl5ik1rwAyqZmA3FAzAZnUTEBeoea/4KSqatlJVdWyk6qqZSdVVctOqqqWfeU/RM0E5Cdq3gnIpOYGkBtqJiC31ExAJjUTkAnIpOYpIJOaW0BuqLmh5ik176ZmAjKp+WtOqqqWnVRVLTupqlp2UlW1DL/lgwC5oeYGkHdT8xSQSc1TQH6iZgOQSc07AfmJmncCckPNOwH5iZoJyFNqPsVJVdWyk6qqZSdVVctOqqqWfeUPUHMDyC01N4BMQCY1TwG5oeYWkEnNBOSd1ExAbqiZgNwCckPNBOSGmgnIpOYGkFeomYBMaiYgk5oNJ1VVy06qqpadVFUtO6mqWobf8kGAvJOanwC5oeYGkEnNDSA31NwC8q+pmYA8peYVQG6ouQHkhprfAGRScwPIpOZTnFRVLTupqlp2UlW17KSqatlX/gA1N4BMQH6iZgLylJobQN4JyLupuQHkKTVPAXk3IE8BmdRMQF6h5ik1n+ykqmrZSVXVspOqqmUnVVXL8Fv+I4BMan4CZFLzFJAbam4AmdRMQH6i5lMAeSc1nwTIDTW/AcgNNZ/ipKpq2UlV1bKTqqplJ1VVy77yYYD8BjUTkHdSMwGZ1Dyl5haQSc1TQN5JzW8AckPNBOSdgNxSMwG5oWYCMqnZcFJVteykqmrZSVXVspOqqmX4LUuATGpuALmh5idAnlLzTkAmNROQW2qeAvKvqbkBZFLz6YD8BjVPAZnUbDipqlp2UlW17KSqatlJVdUy/Jb6XwGZ1NwAMql5Csik5idAJjU3gExqngJyQ80rgNxQMwG5oWYCckPNK4A8pWYCMqnZcFJVteykqmrZSVXVspOqqmVfWQTkk6iZ1ExA/jUg7wZkUvMUkEnNDTUTkEnNBOSWmhtqbgB5JyCTmltqJiA31HyKk6qqZSdVVctOqqqWnVRVLfvKh1HzG4DcAHJDzQ0gN9RMQD6JmndSMwH5DUAmNZOaCchTal4B5AaQSc2nOKmqWnZSVbXspKpq2UlV1bKTqqplX/kDgDyl5t3U3AByQ80NNROQn6iZgExAbgD514BMam4BmYBMaiY1N4A8BeQ3qPlrTqqqlp1UVS07qapadlJVtewr9X8GZFJzQ80EZFJzQ80tNU8BmdRMQG4AmdRMQLYAmdT8a0B+ouYGkEnNBGRSs+GkqmrZSVXVspOqqmUnVVXLvlJvAWRSMwG5AWRSMwHZAuSGmhtAJjUTkFtqJiCTmknNBGRSMwGZ1ExAJjU/ATKpmdTcUPMpTqqqlp1UVS07qapadlJVtewrf4CaLWomIDeATGomIJOaCcgtNU8BmdQ8BWRSM6mZgNxSc0PNBGRS85SaG2omID9RcwPIpOaTnVRVLTupqlp2UlW17KSqahl+yxIgn0TNBGRS8xSQSc0EZFLzCiA31ExAJjUTkEnNBGRS8wogN9TcADKpuQFkUvMKIJOaG0AmNZ/ipKpq2UlV1bKTqqplJ1VVy/BbqqoWnVRVLTupqlp2UlW17KSqatlJVdWyk6qqZSdVVctOqqqWnVRVLTupqlp2UlW17KSqatlJVdWyk6qqZSdVVctOqqqWnVRVLTupqlp2UlW17KSqatlJVdWyk6qqZSdVVctOqqqWnVRVLTupqlp2UlW17KSqatlJVdWy/wFU0qWIqZ1EkAAAAABJRU5ErkJggg==","uuid":"bd21a72f-5c5b-4f7e-9b1f-bd9b92b96885","operacion":""}} \ No newline at end of file diff --git a/modules/core/src/api/infrastructure/express/api-error-mapper.ts b/modules/core/src/api/infrastructure/express/api-error-mapper.ts index 11bc96e2..20e82df8 100644 --- a/modules/core/src/api/infrastructure/express/api-error-mapper.ts +++ b/modules/core/src/api/infrastructure/express/api-error-mapper.ts @@ -114,7 +114,7 @@ export class ApiErrorMapper { } // ──────────────────────────────────────────────────────────────────────────────── -// Reglas por defecto (prioridad alta a más específicas) +// Reglas por defecto: a prioridad más alta en valor, error más específico. // ──────────────────────────────────────────────────────────────────────────────── const defaultRules: ReadonlyArray = [ // 1) Validación múltiple (colección) @@ -165,7 +165,7 @@ const defaultRules: ReadonlyArray = [ // 5.5) Errores de FastReport inesperados { - priority: 55, + priority: 56, matches: (e) => isDocumentGenerationError(e), build: (e) => { const error = e as DocumentGenerationError; @@ -178,6 +178,7 @@ const defaultRules: ReadonlyArray = [ return new InternalApiError(cause.message, title); }, }, + { priority: 55, matches: (e) => isFastReportError(e), @@ -198,10 +199,11 @@ const defaultRules: ReadonlyArray = [ // 7) Autenticación/autorización por nombre (si no tienes clases dedicadas) { - priority: 40, + priority: 45, matches: (e): e is Error => e instanceof Error && e.name === "UnauthorizedError", build: (e) => new UnauthorizedApiError((e as Error).message || "Unauthorized"), }, + { priority: 40, matches: (e): e is Error => e instanceof Error && e.name === "ForbiddenError", @@ -223,10 +225,22 @@ function defaultFallback(e: unknown): ApiError { return e; // ya es un ApiError } - const message = typeof (e as any)?.message === "string" ? (e as any).message : ""; - const detail = typeof (e as any)?.detail === "string" ? (e as any).detail : ""; + const message = + typeof (e as Partial<{ message: string }>).message === "string" + ? String((e as Partial<{ message: string }>).message) + : ""; - return new InternalApiError(`${message} ${detail}`); + const detail = + typeof (e as Partial<{ detail: string }>).detail === "string" + ? String((e as Partial<{ detail: string }>).detail) + : ""; + + const cause = + typeof (e as Partial<{ cause: string }>).cause === "string" + ? String((e as Partial<{ cause: string }>).cause) + : ""; + + return new InternalApiError(`${message} ${detail} ${cause}`); } // ──────────────────────────────────────────────────────────────────────────────── diff --git a/modules/core/src/api/modules/types.ts b/modules/core/src/api/modules/types.ts index 0055d68f..8e3a8022 100644 --- a/modules/core/src/api/modules/types.ts +++ b/modules/core/src/api/modules/types.ts @@ -22,9 +22,7 @@ export type ModuleSetupResult = { internal?: Record; }; -export type SetupParams = ModuleParams & { - registerService: (name: string, api: unknown) => void; -}; +export type SetupParams = ModuleParams; export type StartParams = ModuleParams & { /** diff --git a/modules/core/src/web/components/index.ts b/modules/core/src/web/components/index.ts index 84a45556..77056ba5 100644 --- a/modules/core/src/web/components/index.ts +++ b/modules/core/src/web/components/index.ts @@ -1,3 +1,4 @@ export * from "./error-alert"; export * from "./form"; +export * from "./not-found-card"; export * from "./page-header"; diff --git a/modules/customers/src/web/components/not-found-card.tsx b/modules/core/src/web/components/not-found-card.tsx similarity index 100% rename from modules/customers/src/web/components/not-found-card.tsx rename to modules/core/src/web/components/not-found-card.tsx diff --git a/modules/core/src/web/hooks/index.ts b/modules/core/src/web/hooks/index.ts index 4b181058..406831fa 100644 --- a/modules/core/src/web/hooks/index.ts +++ b/modules/core/src/web/hooks/index.ts @@ -5,6 +5,7 @@ export * from "./use-pagination"; export * from "./use-percentage"; export * from "./use-quantity"; export * from "./use-query-key"; +export * from "./use-rhf-error-focus"; export * from "./use-toggle"; export * from "./use-unsaved-changes-notifier"; export * from "./use-url-param-id"; diff --git a/modules/core/src/web/hooks/use-rhf-error-focus.ts b/modules/core/src/web/hooks/use-rhf-error-focus.ts new file mode 100644 index 00000000..3e7be87d --- /dev/null +++ b/modules/core/src/web/hooks/use-rhf-error-focus.ts @@ -0,0 +1,11 @@ +import type { FieldErrors, FieldValues } from "react-hook-form"; + +export function useRHFErrorFocus() { + return (errors: FieldErrors) => { + const firstKey = Object.keys(errors)[0] as keyof T | undefined; + + if (firstKey) { + document.querySelector(`[name="${String(firstKey)}"]`)?.focus(); + } + }; +} diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index 745ac74d..39a302e4 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -12,6 +12,7 @@ ".": "./src/common/index.ts", "./common": "./src/common/index.ts", "./api": "./src/api/index.ts", + "./api/domain": "./src/api/domain/index.ts", "./client": "./src/web/manifest.ts", "./globals.css": "./src/web/globals.css" }, diff --git a/modules/customer-invoices/src/api/application/helpers.bak/map-dto-to-customer-invoice-props.ts b/modules/customer-invoices/src/api/application/helpers.bak/map-dto-to-customer-invoice-props.ts index ea309dea..065e264c 100644 --- a/modules/customer-invoices/src/api/application/helpers.bak/map-dto-to-customer-invoice-props.ts +++ b/modules/customer-invoices/src/api/application/helpers.bak/map-dto-to-customer-invoice-props.ts @@ -10,7 +10,12 @@ import { import { Result } from "@repo/rdx-utils"; import type { CreateCustomerInvoiceRequestDTO } from "../../../common"; -import { type IProformaProps, InvoiceNumber, InvoiceSerie, InvoiceStatus } from "../../domain"; +import { + type IProformaCreateProps, + InvoiceNumber, + InvoiceSerie, + InvoiceStatus, +} from "../../domain"; import { mapDTOToCustomerInvoiceItemsProps } from "./map-dto-to-customer-invoice-items-props"; @@ -66,12 +71,12 @@ export function mapDTOToCustomerInvoiceProps(dto: CreateCustomerInvoiceRequestDT return Result.fail(new ValidationErrorCollection("Invoice dto mapping failed", errors)); } - const invoiceProps: IProformaProps = { + const invoiceProps: IProformaCreateProps = { invoiceNumber: invoiceNumber!, series: invoiceSeries!, invoiceDate: invoiceDate!, operationDate: operationDate!, - status: InvoiceStatus.createDraft(), + status: InvoiceStatus.fromDraft(), currencyCode: currencyCode!, }; diff --git a/modules/customer-invoices/src/api/application/proformas/factories/proforma-factory.interface.ts b/modules/customer-invoices/src/api/application/proformas/factories/proforma-factory.interface.ts index 49887c79..1eae2c5f 100644 --- a/modules/customer-invoices/src/api/application/proformas/factories/proforma-factory.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/factories/proforma-factory.interface.ts @@ -1,7 +1,7 @@ import type { UniqueID } from "@repo/rdx-ddd"; import type { Result } from "@repo/rdx-utils"; -import type { IProformaProps, Proforma } from "../../../domain"; +import type { IProformaCreateProps, Proforma } from "../../../domain"; export interface IProformaFactory { /** @@ -11,7 +11,7 @@ export interface IProformaFactory { */ createProforma( companyId: UniqueID, - props: Omit, + props: Omit, proformaId?: UniqueID ): Result; } diff --git a/modules/customer-invoices/src/api/application/proformas/factories/proforma-factory.ts b/modules/customer-invoices/src/api/application/proformas/factories/proforma-factory.ts index b404a811..93f57405 100644 --- a/modules/customer-invoices/src/api/application/proformas/factories/proforma-factory.ts +++ b/modules/customer-invoices/src/api/application/proformas/factories/proforma-factory.ts @@ -1,14 +1,14 @@ import type { UniqueID } from "@repo/rdx-ddd"; import type { Result } from "@repo/rdx-utils"; -import { type IProformaProps, Proforma } from "../../../domain"; +import { type IProformaCreateProps, Proforma } from "../../../domain"; import type { IProformaFactory } from "./proforma-factory.interface"; export class ProformaFactory implements IProformaFactory { createProforma( companyId: UniqueID, - props: Omit, + props: Omit, proformaId?: UniqueID ): Result { return Proforma.create({ ...props, companyId }, proformaId); diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts index 70ae0f4e..4d95eafe 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts @@ -17,8 +17,8 @@ import { Maybe, Result } from "@repo/rdx-utils"; import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../common"; import { + type IProformaCreateProps, type IProformaItemProps, - type IProformaProps, InvoiceNumber, InvoicePaymentMethod, type InvoiceRecipient, @@ -51,7 +51,7 @@ export interface ICreateProformaInputMapper { map( dto: CreateProformaRequestDTO, params: { companyId: UniqueID } - ): Result<{ id: UniqueID; props: IProformaProps }>; + ): Result<{ id: UniqueID; props: IProformaCreateProps }>; } export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ { @@ -64,12 +64,12 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ public map( dto: CreateProformaRequestDTO, params: { companyId: UniqueID } - ): Result<{ id: UniqueID; props: IProformaProps }> { + ): Result<{ id: UniqueID; props: IProformaCreateProps }> { const errors: ValidationErrorDetail[] = []; const { companyId } = params; try { - const defaultStatus = InvoiceStatus.createDraft(); + const defaultStatus = InvoiceStatus.fromDraft(); const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", errors); @@ -159,13 +159,9 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ errors, }); - if (errors.length > 0) { - return Result.fail( - new ValidationErrorCollection("Customer invoice props mapping failed", errors) - ); - } + this.throwIfValidationErrors(errors); - const props: IProformaProps = { + const props: IProformaCreateProps = { companyId, status: defaultStatus, @@ -200,6 +196,12 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ } } + private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { + if (errors.length > 0) { + throw new ValidationErrorCollection("Customer proforma props mapping failed", errors); + } + } + private mapItemsProps( dto: CreateProformaRequestDTO, params: { @@ -241,6 +243,8 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ errors: params.errors, }); + this.throwIfValidationErrors(params.errors); + itemsProps.push({ globalDiscountPercentage: params.globalDiscountPercentage, languageCode: params.languageCode, @@ -321,6 +325,8 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ } }); + this.throwIfValidationErrors(errors); + return taxesProps; } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts index 07d2e1de..a7f1a86b 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts @@ -2,19 +2,21 @@ import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { Transaction } from "sequelize"; -import type { IProformaProps, Proforma } from "../../../domain"; +import type { IProformaCreateProps, Proforma } from "../../../domain"; import type { IProformaFactory } from "../factories"; import type { IProformaRepository } from "../repositories"; import type { IProformaNumberGenerator } from "./proforma-number-generator.interface"; +export interface IProformaCreatorParams { + companyId: UniqueID; + id: UniqueID; + props: Omit; + transaction: Transaction; +} + export interface IProformaCreator { - create(params: { - companyId: UniqueID; - id: UniqueID; - props: IProformaProps; - transaction: Transaction; - }): Promise>; + create(params: IProformaCreatorParams): Promise>; } type ProformaCreatorDeps = { @@ -37,7 +39,7 @@ export class ProformaCreator implements IProformaCreator { async create(params: { companyId: UniqueID; id: UniqueID; - props: IProformaProps; + props: IProformaCreateProps; transaction: Transaction; }): Promise> { const { companyId, id, props, transaction } = params; diff --git a/modules/customer-invoices/src/api/domain/common/value-objects/invoice-status.vo.ts b/modules/customer-invoices/src/api/domain/common/value-objects/invoice-status.vo.ts index a7ae5319..0f460e93 100644 --- a/modules/customer-invoices/src/api/domain/common/value-objects/invoice-status.vo.ts +++ b/modules/customer-invoices/src/api/domain/common/value-objects/invoice-status.vo.ts @@ -39,34 +39,34 @@ export class InvoiceStatus extends ValueObject { return Result.ok( value === "rejected" - ? InvoiceStatus.createRejected() + ? InvoiceStatus.fromRejected() : value === "sent" - ? InvoiceStatus.createSent() + ? InvoiceStatus.fromSent() : value === "issued" - ? InvoiceStatus.createIssued() + ? InvoiceStatus.fromIssued() : value === "approved" - ? InvoiceStatus.createApproved() - : InvoiceStatus.createDraft() + ? InvoiceStatus.fromApproved() + : InvoiceStatus.fromDraft() ); } - public static createDraft(): InvoiceStatus { + public static fromDraft(): InvoiceStatus { return new InvoiceStatus({ value: INVOICE_STATUS.DRAFT }); } - public static createIssued(): InvoiceStatus { + public static fromIssued(): InvoiceStatus { return new InvoiceStatus({ value: INVOICE_STATUS.ISSUED }); } - public static createSent(): InvoiceStatus { + public static fromSent(): InvoiceStatus { return new InvoiceStatus({ value: INVOICE_STATUS.SENT }); } - public static createApproved(): InvoiceStatus { + public static fromApproved(): InvoiceStatus { return new InvoiceStatus({ value: INVOICE_STATUS.APPROVED }); } - public static createRejected(): InvoiceStatus { + public static fromRejected(): InvoiceStatus { return new InvoiceStatus({ value: INVOICE_STATUS.REJECTED }); } diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index d637850c..dc4c8817 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -30,7 +30,7 @@ import { ProformaItemMismatch } from "../errors"; import { type IProformaTaxTotals, ProformaTaxesCalculator } from "../services"; import { ProformaItemTaxes } from "../value-objects"; -export interface IProformaProps { +export interface IProformaCreateProps { companyId: UniqueID; status: InvoiceStatus; @@ -100,16 +100,15 @@ export interface IProforma { totals(): IProformaTotals; } -export type ProformaPatchProps = Partial> & { +export type ProformaPatchProps = Partial> & { //items?: ProformaItems; }; -type CreateProformaProps = IProformaProps; -type InternalProformaProps = Omit; +type InternalProformaProps = Omit; export class Proforma extends AggregateRoot implements IProforma { // Creación funcional - static create(props: CreateProformaProps, id?: UniqueID): Result { + static create(props: IProformaCreateProps, id?: UniqueID): Result { const { items, ...internalProps } = props; const proforma = new Proforma(internalProps, id); @@ -221,12 +220,12 @@ export class Proforma extends AggregateRoot implements IP // Mutabilidad public update( - partialProforma: Partial> + partialProforma: Partial> ): Result { const updatedProps = { ...this.props, ...partialProforma, - } as IProformaProps; + } as IProformaCreateProps; return Proforma.create(updatedProps, this.id); } diff --git a/modules/customer-invoices/src/api/domain/services/issue-customer-invoice-domain-service.ts b/modules/customer-invoices/src/api/domain/services/issue-customer-invoice-domain-service.ts index 909729b9..248cde11 100644 --- a/modules/customer-invoices/src/api/domain/services/issue-customer-invoice-domain-service.ts +++ b/modules/customer-invoices/src/api/domain/services/issue-customer-invoice-domain-service.ts @@ -67,7 +67,7 @@ export class IssueCustomerInvoiceDomainService { ...proformaProps, isProforma: false, proformaId: Maybe.some(proforma.id), - status: InvoiceStatus.createIssued(), + status: InvoiceStatus.fromIssued(), invoiceNumber: issueNumber, invoiceDate: issueDate, description: proformaProps.description.isNone() ? Maybe.some(".") : proformaProps.description, diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index 53d7501c..38952a1c 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -1,7 +1,9 @@ import type { IModuleServer } from "@erp/core/api"; import { + type IssuedInvoicePublicServices, type IssuedInvoicesInternalDeps, + type ProformaPublicServices, type ProformasInternalDeps, buildIssuedInvoiceServices, buildIssuedInvoicesDependencies, @@ -12,6 +14,8 @@ import { proformasRouter, } from "./infrastructure"; +export type { IssuedInvoicePublicServices, ProformaPublicServices }; + export const customerInvoicesAPIModule: IModuleServer = { name: "customer-invoices", version: "1.0.0", @@ -28,12 +32,18 @@ export const customerInvoicesAPIModule: IModuleServer = { const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; // 1) Dominio interno - const issuedInvoicesInternalDeps = buildIssuedInvoicesDependencies(params); - const proformasInternalDeps = buildProformasDependencies(params); + const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params); + const proformasInternal = buildProformasDependencies(params); // 2) Servicios públicos (Application Services) - const issuedInvoicesServices = buildIssuedInvoiceServices(issuedInvoicesInternalDeps); - const proformasServices = buildProformaServices(proformasInternalDeps); + const issuedInvoicesServices: IssuedInvoicePublicServices = buildIssuedInvoiceServices( + params, + issuedInvoicesInternal + ); + const proformasServices: ProformaPublicServices = buildProformaServices( + params, + proformasInternal + ); logger.info("🚀 CustomerInvoices module dependencies registered", { label: this.name }); @@ -43,14 +53,14 @@ export const customerInvoicesAPIModule: IModuleServer = { // Servicios expuestos a otros módulos services: { - issuedInvoices: issuedInvoicesServices, - proformas: proformasServices, + issuedInvoices: issuedInvoicesServices, // 'customer-invoices:issuedInvoices' + proformas: proformasServices, // 'customer-invoices:proformas' }, // Implementación privada del módulo internal: { - issuedInvoices: issuedInvoicesInternalDeps, - proformas: proformasInternalDeps, + issuedInvoices: issuedInvoicesInternal, + proformas: proformasInternal, }, }; }, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/customer-invoice-item.mapper.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/customer-invoice-item.mapper.ts index 604916ad..7b6358ab 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/customer-invoice-item.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/customer-invoice-item.mapper.ts @@ -16,7 +16,7 @@ import { import { Result } from "@repo/rdx-utils"; import { - type IProformaProps, + type IProformaCreateProps, IssuedInvoiceItem, type IssuedInvoiceItemProps, ItemAmount, @@ -68,7 +68,7 @@ export class CustomerInvoiceItemDomainMapper const { errors, index, attributes } = params as { index: number; errors: ValidationErrorDetail[]; - attributes: Partial; + attributes: Partial; }; const itemId = extractOrPushError( @@ -163,7 +163,7 @@ export class CustomerInvoiceItemDomainMapper const { errors, index } = params as { index: number; errors: ValidationErrorDetail[]; - attributes: Partial; + attributes: Partial; }; // 1) Valores escalares (atributos generales) diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/customer-invoice.mapper.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/customer-invoice.mapper.ts index 627960be..216679da 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/customer-invoice.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/customer-invoice.mapper.ts @@ -20,7 +20,7 @@ import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils"; import { CustomerInvoiceItems, - type IProformaProps, + type IProformaCreateProps, InvoiceNumber, InvoicePaymentMethod, InvoiceSerie, @@ -249,7 +249,7 @@ export class CustomerInvoiceDomainMapper items: itemsResults.data.getAll(), }); - const invoiceProps: IProformaProps = { + const invoiceProps: IProformaCreateProps = { companyId: attributes.companyId!, isProforma: attributes.isProforma, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/invoice-recipient.mapper.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/invoice-recipient.mapper.ts index fa003a79..1bb03df2 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/invoice-recipient.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/invoice-recipient.mapper.ts @@ -15,7 +15,11 @@ import { } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import { type IProformaProps, InvoiceRecipient, type Proforma } from "../../../../../../domain"; +import { + type IProformaCreateProps, + InvoiceRecipient, + type Proforma, +} from "../../../../../../domain"; import type { CustomerInvoiceModel } from "../../../../sequelize"; export class InvoiceRecipientDomainMapper { @@ -30,7 +34,7 @@ export class InvoiceRecipientDomainMapper { const { errors, attributes } = params as { errors: ValidationErrorDetail[]; - attributes: Partial; + attributes: Partial; }; const { isProforma } = attributes; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/invoice-verifactu.mapper.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/invoice-verifactu.mapper.ts index 0682e39f..a08d33e2 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/invoice-verifactu.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/mappers/domain/invoice-verifactu.mapper.ts @@ -12,7 +12,7 @@ import { import { Maybe, Result } from "@repo/rdx-utils"; import { - type IProformaProps, + type IProformaCreateProps, type Proforma, VerifactuRecord, VerifactuRecordEstado, @@ -43,7 +43,7 @@ export class CustomerInvoiceVerifactuDomainMapper ): Result, Error> { const { errors, attributes } = params as { errors: ValidationErrorDetail[]; - attributes: Partial; + attributes: Partial; }; if (!source) { diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/sequelize-invoice-number-generator.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/sequelize-invoice-number-generator.ts deleted file mode 100644 index 21806c12..00000000 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/sequelize-invoice-number-generator.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { UniqueID } from "@repo/rdx-ddd"; -import { type Maybe, Result } from "@repo/rdx-utils"; -import { type Transaction, type WhereOptions, literal } from "sequelize"; - -import { InvoiceNumber, type InvoiceSerie } from "../../../../domain"; - -import { CustomerInvoiceModel } from "./models"; - -/** - * Generador de números de factura - */ -export class SequelizeInvoiceNumberGenerator implements ICustomerInvoiceNumberGenerator { - public async nextForCompany( - companyId: UniqueID, - series: Maybe, - transaction: Transaction - ): Promise> { - const where: WhereOptions = { - company_id: companyId.toString(), - is_proforma: false, - }; - - series.match( - (serieVO) => { - where.series = serieVO.toString(); - }, - () => { - where.series = null; - } - ); - - try { - const lastInvoice = await CustomerInvoiceModel.findOne({ - attributes: ["invoice_number"], - where, - // Orden numérico real: CAST(... AS UNSIGNED) - order: [literal("CAST(invoice_number AS UNSIGNED) DESC")], - transaction, - raw: true, - // Bloqueo opcional para evitar carreras si estás dentro de una TX - lock: transaction.LOCK.UPDATE, // requiere InnoDB y TX abierta - }); - - let nextValue = "001"; // valor inicial por defecto - - if (lastInvoice) { - const current = Number(lastInvoice.invoice_number); - const next = Number.isFinite(current) && current > 0 ? current + 1 : 1; - nextValue = String(next).padStart(3, "0"); - } - - const numberResult = InvoiceNumber.create(nextValue); - if (numberResult.isFailure) { - return Result.fail(numberResult.error); - } - - return Result.ok(numberResult.data); - } catch (error) { - return Result.fail( - new Error( - `Error generating invoice number for company ${companyId}: ${(error as Error).message}` - ) - ); - } - } -} diff --git a/modules/customer-invoices/src/api/infrastructure/index.ts b/modules/customer-invoices/src/api/infrastructure/index.ts index 29526a1d..9db9d935 100644 --- a/modules/customer-invoices/src/api/infrastructure/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/index.ts @@ -1,4 +1,3 @@ export * from "./common/persistence"; export * from "./issued-invoices"; export * from "./proformas"; -export * from "./renderers"; diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts index 8a78cd22..46fd31f0 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts @@ -1,26 +1,48 @@ +import type { SetupParams } from "@erp/core/api"; +import { buildCatalogs, buildTransactionManager } from "@erp/core/api"; + +import { + buildIssuedInvoiceFinder, + buildIssuedInvoiceSnapshotBuilders, +} from "../../../application/issued-invoices"; + +import { buildIssuedInvoiceDocumentService } from "./issued-invoice-documents.di"; +import { buildIssuedInvoicePersistenceMappers } from "./issued-invoice-persistence-mappers.di"; +import { buildIssuedInvoiceRepository } from "./issued-invoice-repositories.di"; import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di"; -export type IssuedInvoicesServiceslDeps = { - services: { - listIssuedInvoices: (filters: unknown, context: unknown) => null; - getIssuedInvoiceById: (id: unknown, context: unknown) => null; - generateIssuedInvoiceReport: (id: unknown, options: unknown, context: unknown) => null; - }; +export type IssuedInvoicePublicServices = { + listIssuedInvoices: (filters: unknown, context: unknown) => null; + getIssuedInvoiceById: (id: unknown, context: unknown) => null; + generateIssuedInvoiceReport: (id: unknown, options: unknown, context: unknown) => null; }; export function buildIssuedInvoiceServices( + params: SetupParams, deps: IssuedInvoicesInternalDeps -): IssuedInvoicesServiceslDeps { +): IssuedInvoicePublicServices { + const { database } = params; + + // Infrastructure + const transactionManager = buildTransactionManager(database); + const catalogs = buildCatalogs(); + const persistenceMappers = buildIssuedInvoicePersistenceMappers(catalogs); + + const repository = buildIssuedInvoiceRepository({ database, mappers: persistenceMappers }); + + // Application helpers + const finder = buildIssuedInvoiceFinder(repository); + const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders(); + const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(params); + return { - services: { - listIssuedInvoices: (filters, context) => null, - //internal.useCases.listIssuedInvoices().execute(filters, context), + listIssuedInvoices: (filters, context) => null, + //internal.useCases.listIssuedInvoices().execute(filters, context), - getIssuedInvoiceById: (id, context) => null, - //internal.useCases.getIssuedInvoiceById().execute(id, context), + getIssuedInvoiceById: (id, context) => null, + //internal.useCases.getIssuedInvoiceById().execute(id, context), - generateIssuedInvoiceReport: (id, options, context) => null, - //internal.useCases.reportIssuedInvoice().execute(id, options, context), - }, + generateIssuedInvoiceReport: (id, options, context) => null, + //internal.useCases.reportIssuedInvoice().execute(id, options, context), }; } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts index 97c0a20e..bb920930 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts @@ -1,24 +1,73 @@ +import { type SetupParams, buildCatalogs } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Transaction } from "sequelize"; + +import { type IProformaCreatorParams, buildProformaCreator } from "../../../application"; +import type { Proforma } from "../../../domain"; + +import { buildProformaNumberGenerator } from "./proforma-number-generator.di"; +import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di"; +import { buildProformaRepository } from "./proforma-repositories.di"; import type { ProformasInternalDeps } from "./proformas.di"; -export type ProformasServicesDeps = { - services: { - listProformas: (filters: unknown, context: unknown) => null; - getProformaById: (id: unknown, context: unknown) => null; - generateProformaReport: (id: unknown, options: unknown, context: unknown) => null; - }; +type ProformaServicesContext = { + transaction: Transaction; + companyId: UniqueID; }; -export function buildProformaServices(deps: ProformasInternalDeps): ProformasServicesDeps { +export type ProformaPublicServices = { + createProforma: ( + id: UniqueID, + props: IProformaCreatorParams["props"], + context: ProformaServicesContext + ) => Promise>; + + listProformas: (filters: unknown, context: unknown) => null; + getProformaById: (id: unknown, context: unknown) => null; + generateProformaReport: (id: unknown, options: unknown, context: unknown) => null; +}; + +export function buildProformaServices( + params: SetupParams, + deps: ProformasInternalDeps +): ProformaPublicServices { + const { database } = params; + + // Infrastructure + const catalogs = buildCatalogs(); + const persistenceMappers = buildProformaPersistenceMappers(catalogs); + + const repository = buildProformaRepository({ database, mappers: persistenceMappers }); + const numberService = buildProformaNumberGenerator(); + + // Application helpers + const creator = buildProformaCreator({ numberService, repository }); + return { - services: { - listProformas: (filters, context) => null, - //internal.useCases.listProformas().execute(filters, context), + createProforma: async ( + id: UniqueID, + props: IProformaCreatorParams["props"], + context: ProformaServicesContext + ) => { + const { transaction, companyId } = context; - getProformaById: (id, context) => null, - //internal.useCases.getProformaById().execute(id, context), + const createResult = await creator.create({ companyId, id, props, transaction }); - generateProformaReport: (id, options, context) => null, - //internal.useCases.reportProforma().execute(id, options, context), + if (createResult.isFailure) { + return Result.fail(createResult.error); + } + + return Result.ok(createResult.data); }, + + listProformas: (filters, context) => null, + //internal.useCases.listProformas().execute(filters, context), + + getProformaById: (id, context) => null, + //internal.useCases.getProformaById().execute(id, context), + + generateProformaReport: (id, options, context) => null, + //internal.useCases.reportProforma().execute(id, options, context), }; } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/create-proforma-request-mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/create-proforma-request-mapper.ts index 7b33d845..7d16b0ff 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/create-proforma-request-mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/mappers/create-proforma-request-mapper.ts @@ -16,8 +16,8 @@ import { Maybe, Result } from "@repo/rdx-utils"; import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../../common"; import { + type IProformaCreateProps, type IProformaItemProps, - type IProformaProps, InvoiceNumber, InvoicePaymentMethod, type InvoiceRecipient, @@ -41,7 +41,6 @@ import { * */ - export class CreateProformaRequestMapper { private readonly taxCatalog: JsonTaxCatalogProvider; private errors: ValidationErrorDetail[] = []; @@ -58,7 +57,7 @@ export class CreateProformaRequestMapper { try { this.errors = []; - const defaultStatus = InvoiceStatus.createDraft(); + const defaultStatus = InvoiceStatus.fromDraft(); const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", this.errors); @@ -149,7 +148,7 @@ export class CreateProformaRequestMapper { ); } - const proformaProps: Omit & { items: IProformaItemProps[] } = { + const proformaProps: Omit & { items: IProformaItemProps[] } = { companyId, status: defaultStatus!, @@ -182,7 +181,7 @@ export class CreateProformaRequestMapper { } } - private mapItems(items: CreateProformaItemRequestDTO[]): IProformaItemProps[] { + private mapItems(items: CreateProformaItemRequestDTO[]): IProformaItemProps[] { const proformaItems = CustomerInvoiceItems.create({ currencyCode: this.currencyCode!, languageCode: this.languageCode!, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts index 1d0024e4..32f8ee03 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts @@ -14,7 +14,7 @@ import { import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils"; import { - type IProformaProps, + type IProformaCreateProps, InvoiceNumber, InvoicePaymentMethod, InvoiceSerie, @@ -217,7 +217,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< items: itemsResults.data.getAll(), }); - const invoiceProps: IProformaProps = { + const invoiceProps: IProformaCreateProps = { companyId: attributes.companyId!, status: attributes.status!, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts index 2e7925fb..df5c8989 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts @@ -16,8 +16,8 @@ import { import { Result } from "@repo/rdx-utils"; import { + type IProformaCreateProps, type IProformaItemProps, - type IProformaProps, ItemAmount, ItemDescription, ItemQuantity, @@ -58,7 +58,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper< const { errors, index, parent } = params as { index: number; errors: ValidationErrorDetail[]; - parent: Partial; + parent: Partial; }; const itemId = extractOrPushError( @@ -139,7 +139,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper< const { errors, index } = params as { index: number; errors: ValidationErrorDetail[]; - parent: Partial; + parent: Partial; }; // 1) Valores escalares (atributos generales) diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-recipient-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-recipient-domain.mapper.ts index fefab8f7..15a51e0d 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-recipient-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-recipient-domain.mapper.ts @@ -14,7 +14,7 @@ import { } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import { type IProformaProps, InvoiceRecipient } from "../../../../../../domain"; +import { type IProformaCreateProps, InvoiceRecipient } from "../../../../../../domain"; import type { CustomerInvoiceModel } from "../../../../../common"; export class SequelizeProformaRecipientDomainMapper { @@ -28,7 +28,7 @@ export class SequelizeProformaRecipientDomainMapper { const { errors, parent } = params as { errors: ValidationErrorDetail[]; - parent: Partial; + parent: Partial; }; /* if (!source.current_customer) { diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/proforma-list-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/proforma-list-page.tsx index 24826cfb..e1a371c2 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/proforma-list-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/proforma-list-page.tsx @@ -1,5 +1,4 @@ -import { PageHeader, SimpleSearchInput } from "@erp/core/components"; -import { ErrorAlert } from "@erp/customers/components"; +import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components"; import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; import { Button, diff --git a/modules/customer-invoices/tsconfig.json b/modules/customer-invoices/tsconfig.json index 98ee25b1..b4a95fde 100644 --- a/modules/customer-invoices/tsconfig.json +++ b/modules/customer-invoices/tsconfig.json @@ -28,11 +28,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": [ - "src", - "../core/src/api/domain/value-objects/tax-percentage.vo.ts", - "../core/src/api/domain/value-objects/discount-percentage.vo.ts", - "../core/src/api/infrastructure/di/catalogs.di.ts" - ], + "include": ["src"], "exclude": ["node_modules"] } diff --git a/modules/customers/package.json b/modules/customers/package.json index 8bf4d1e0..1c0f402d 100644 --- a/modules/customers/package.json +++ b/modules/customers/package.json @@ -12,6 +12,7 @@ ".": "./src/common/index.ts", "./common": "./src/common/index.ts", "./api": "./src/api/index.ts", + "./api/domain": "./src/api/domain/index.ts", "./client": "./src/web/manifest.ts", "./globals.css": "./src/web/globals.css", "./components": "./src/web/components/index.ts" diff --git a/modules/customers/src/api/application/di/customer-use-cases.di.ts b/modules/customers/src/api/application/di/customer-use-cases.di.ts index 94067a4f..a85073b9 100644 --- a/modules/customers/src/api/application/di/customer-use-cases.di.ts +++ b/modules/customers/src/api/application/di/customer-use-cases.di.ts @@ -1,12 +1,17 @@ import type { ITransactionManager } from "@erp/core/api"; -import type { ICreateCustomerInputMapper } from "../mappers"; -import type { ICustomerCreator, ICustomerFinder } from "../services"; +import type { ICreateCustomerInputMapper, IUpdateCustomerInputMapper } from "../mappers"; +import type { ICustomerCreator, ICustomerFinder, ICustomerUpdater } from "../services"; import type { ICustomerFullSnapshotBuilder, ICustomerSummarySnapshotBuilder, } from "../snapshot-builders"; -import { CreateCustomerUseCase, GetCustomerByIdUseCase, ListCustomersUseCase } from "../use-cases"; +import { + CreateCustomerUseCase, + GetCustomerByIdUseCase, + ListCustomersUseCase, + UpdateCustomerUseCase, +} from "../use-cases"; export function buildGetCustomerByIdUseCase(deps: { finder: ICustomerFinder; @@ -42,6 +47,20 @@ export function buildCreateCustomerUseCase(deps: { }); } +export function buildUpdateCustomerUseCase(deps: { + updater: ICustomerUpdater; + dtoMapper: IUpdateCustomerInputMapper; + fullSnapshotBuilder: ICustomerFullSnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new UpdateCustomerUseCase({ + dtoMapper: deps.dtoMapper, + updater: deps.updater, + fullSnapshotBuilder: deps.fullSnapshotBuilder, + transactionManager: deps.transactionManager, + }); +} + /*export function buildReportCustomerUseCase(deps: { finder: ICustomerFinder; fullSnapshotBuilder: ICustomerFullSnapshotBuilder; @@ -58,12 +77,7 @@ export function buildCreateCustomerUseCase(deps: { ); }*/ -/*export function buildUpdateCustomerUseCase(deps: { - finder: ICustomerFinder; - fullSnapshotBuilder: ICustomerFullSnapshotBuilder; -}) { - return new UpdateCustomerUseCase(deps.finder, deps.fullSnapshotBuilder); -} +/* export function buildDeleteCustomerUseCase(deps: { finder: ICustomerFinder }) { return new DeleteCustomerUseCase(deps.finder); diff --git a/modules/customers/src/api/application/mappers/create-customer-input.mapper.ts b/modules/customers/src/api/application/mappers/create-customer-input.mapper.ts index c0fd4cdc..aa2ed532 100644 --- a/modules/customers/src/api/application/mappers/create-customer-input.mapper.ts +++ b/modules/customers/src/api/application/mappers/create-customer-input.mapper.ts @@ -22,7 +22,7 @@ import { extractOrPushError, maybeFromNullableResult, } from "@repo/rdx-ddd"; -import { Collection, Result } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; import type { CreateCustomerRequestDTO } from "../../../common"; import { CustomerStatus, type ICustomerCreateProps } from "../../domain"; @@ -176,7 +176,7 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { errors ); - const defaultTaxes = new Collection(); + const defaultTaxes: TaxCode[] = []; /*if (!isNullishOrEmpty(dto.default_taxes)) { dto.default_taxes!.map((taxCode, index) => { diff --git a/modules/customers/src/api/application/mappers/index.ts b/modules/customers/src/api/application/mappers/index.ts index 80f95a4d..46c310e9 100644 --- a/modules/customers/src/api/application/mappers/index.ts +++ b/modules/customers/src/api/application/mappers/index.ts @@ -1 +1,2 @@ export * from "./create-customer-input.mapper"; +export * from "./update-customer-input.mapper"; diff --git a/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts b/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts new file mode 100644 index 00000000..dbc80708 --- /dev/null +++ b/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts @@ -0,0 +1,289 @@ +import { + City, + Country, + CurrencyCode, + DomainError, + EmailAddress, + LanguageCode, + Name, + PhoneNumber, + type PostalAddressPatchProps, + PostalCode, + Province, + Street, + TINNumber, + type TaxCode, + TextValue, + URLAddress, + type UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, +} from "@repo/rdx-ddd"; +import { Collection, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; + +import type { UpdateCustomerByIdRequestDTO } from "../../../common"; +import type { CustomerPatchProps } from "../../domain"; + +/** + * UpdateCustomerInputMapper + * Convierte el DTO a las props validadas (CustomerProps). + * No construye directamente el agregado. + * Tri-estado: + * - campo omitido → no se cambia + * - campo con valor null/"" → se quita el valor -> set(None()), + * - campo con valor no-vacío → se pone el nuevo valor -> set(Some(VO)). + * + * @param dto - DTO con los datos a cambiar en el cliente + * @returns Cambios en las propiedades del cliente + * + */ + +export interface IUpdateCustomerInputMapper { + map( + dto: UpdateCustomerByIdRequestDTO, + params: { companyId: UniqueID } + ): Result; +} + +export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper { + public map(dto: UpdateCustomerByIdRequestDTO, params: { companyId: UniqueID }) { + try { + const errors: ValidationErrorDetail[] = []; + const customerPatchProps: CustomerPatchProps = {}; + + toPatchField(dto.reference).ifSet((reference) => { + customerPatchProps.reference = extractOrPushError( + maybeFromNullableResult(reference, (value) => Name.create(value)), + "reference", + errors + ); + }); + + toPatchField(dto.is_company).ifSet((is_company) => { + if (isNullishOrEmpty(is_company)) { + errors.push({ path: "is_company", message: "is_company cannot be empty" }); + return; + } + customerPatchProps.isCompany = extractOrPushError( + Result.ok(Boolean(is_company!)), + "is_company", + errors + ); + }); + + toPatchField(dto.name).ifSet((name) => { + if (isNullishOrEmpty(name)) { + errors.push({ path: "name", message: "Name cannot be empty" }); + return; + } + customerPatchProps.name = extractOrPushError(Name.create(name!), "name", errors); + }); + + toPatchField(dto.trade_name).ifSet((trade_name) => { + customerPatchProps.tradeName = extractOrPushError( + maybeFromNullableResult(trade_name, (value) => Name.create(value)), + "trade_name", + errors + ); + }); + + toPatchField(dto.tin).ifSet((tin) => { + customerPatchProps.tin = extractOrPushError( + maybeFromNullableResult(tin, (value) => TINNumber.create(value)), + "tin", + errors + ); + }); + + toPatchField(dto.email_primary).ifSet((email_primary) => { + customerPatchProps.emailPrimary = extractOrPushError( + maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)), + "email_primary", + errors + ); + }); + + toPatchField(dto.email_secondary).ifSet((email_secondary) => { + customerPatchProps.emailSecondary = extractOrPushError( + maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)), + "email_secondary", + errors + ); + }); + + toPatchField(dto.mobile_primary).ifSet((mobile_primary) => { + customerPatchProps.mobilePrimary = extractOrPushError( + maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)), + "mobile_primary", + errors + ); + }); + + toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => { + customerPatchProps.mobilePrimary = extractOrPushError( + maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)), + "mobile_secondary", + errors + ); + }); + + toPatchField(dto.phone_primary).ifSet((phone_primary) => { + customerPatchProps.phonePrimary = extractOrPushError( + maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)), + "phone_primary", + errors + ); + }); + + toPatchField(dto.phone_secondary).ifSet((phone_secondary) => { + customerPatchProps.phoneSecondary = extractOrPushError( + maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)), + "phone_secondary", + errors + ); + }); + + toPatchField(dto.fax).ifSet((fax) => { + customerPatchProps.fax = extractOrPushError( + maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)), + "fax", + errors + ); + }); + + toPatchField(dto.website).ifSet((website) => { + customerPatchProps.website = extractOrPushError( + maybeFromNullableResult(website, (value) => URLAddress.create(value)), + "website", + errors + ); + }); + + toPatchField(dto.legal_record).ifSet((legalRecord) => { + customerPatchProps.legalRecord = extractOrPushError( + maybeFromNullableResult(legalRecord, (value) => TextValue.create(value)), + "legal_record", + errors + ); + }); + + toPatchField(dto.language_code).ifSet((languageCode) => { + if (isNullishOrEmpty(languageCode)) { + errors.push({ path: "language_code", message: "Language code cannot be empty" }); + return; + } + + customerPatchProps.languageCode = extractOrPushError( + LanguageCode.create(languageCode!), + "language_code", + errors + ); + }); + + toPatchField(dto.currency_code).ifSet((currencyCode) => { + if (isNullishOrEmpty(currencyCode)) { + errors.push({ path: "currency_code", message: "Currency code cannot be empty" }); + return; + } + + customerPatchProps.currencyCode = extractOrPushError( + CurrencyCode.create(currencyCode!), + "currency_code", + errors + ); + }); + + // Default taxes + const defaultTaxesCollection = new Collection(); + /*toPatchField(dto.default_taxes).ifSet((defaultTaxes) => { + customerPatchProps.defaultTaxes = defaultTaxesCollection; + + if (isNullishOrEmpty(defaultTaxes)) { + return; + } + + defaultTaxes!.forEach((taxCode, index) => { + const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors); + if (tax && customerPatchProps.defaultTaxes) { + customerPatchProps.defaultTaxes.add(tax); + } + }); + });*/ + + // PostalAddress + const addressPatchProps = this.mapPostalAddress(dto, errors); + if (addressPatchProps) { + customerPatchProps.address = addressPatchProps; + } + + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Customer props mapping failed (update)", errors) + ); + } + + return Result.ok(customerPatchProps); + } catch (err: unknown) { + return Result.fail(new DomainError("Customer props mapping failed", { cause: err })); + } + } + + public mapPostalAddress( + dto: UpdateCustomerByIdRequestDTO, + errors: ValidationErrorDetail[] + ): PostalAddressPatchProps | undefined { + const postalAddressPatchProps: PostalAddressPatchProps = {}; + + toPatchField(dto.street).ifSet((street) => { + postalAddressPatchProps.street = extractOrPushError( + maybeFromNullableResult(street, (value) => Street.create(value)), + "street", + errors + ); + }); + + toPatchField(dto.street2).ifSet((street2) => { + postalAddressPatchProps.street2 = extractOrPushError( + maybeFromNullableResult(street2, (value) => Street.create(value)), + "street2", + errors + ); + }); + + toPatchField(dto.city).ifSet((city) => { + postalAddressPatchProps.city = extractOrPushError( + maybeFromNullableResult(city, (value) => City.create(value)), + "city", + errors + ); + }); + + toPatchField(dto.province).ifSet((province) => { + postalAddressPatchProps.province = extractOrPushError( + maybeFromNullableResult(province, (value) => Province.create(value)), + "province", + errors + ); + }); + + toPatchField(dto.postal_code).ifSet((postalCode) => { + postalAddressPatchProps.postalCode = extractOrPushError( + maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)), + "postal_code", + errors + ); + }); + + toPatchField(dto.country).ifSet((country) => { + postalAddressPatchProps.country = extractOrPushError( + maybeFromNullableResult(country, (value) => Country.create(value)), + "country", + errors + ); + }); + + return Object.keys(postalAddressPatchProps).length > 0 ? postalAddressPatchProps : undefined; + } +} diff --git a/modules/customers/src/api/application/repositories/customer-repository.interface.ts b/modules/customers/src/api/application/repositories/customer-repository.interface.ts index 0bb36c36..6dc565a0 100644 --- a/modules/customers/src/api/application/repositories/customer-repository.interface.ts +++ b/modules/customers/src/api/application/repositories/customer-repository.interface.ts @@ -1,5 +1,5 @@ import type { Criteria } from "@repo/rdx-criteria/server"; -import type { UniqueID } from "@repo/rdx-ddd"; +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; import type { Collection, Result } from "@repo/rdx-utils"; import type { Customer } from "../../domain/aggregates"; @@ -48,6 +48,16 @@ export interface ICustomerRepository { transaction: unknown ): Promise>; + /** + * Recupera un Customer por su TIN y companyId. + * Devuelve un `NotFoundError` si no se encuentra. + */ + getByTINInCompany( + companyId: UniqueID, + tin: TINNumber, + transaction?: unknown + ): Promise>; + /** * Recupera múltiples customers dentro de una empresa * según un criterio dinámico (búsqueda, paginación, etc.). diff --git a/modules/customers/src/api/application/services/customer-finder.ts b/modules/customers/src/api/application/services/customer-finder.ts index 6492df5b..42e48056 100644 --- a/modules/customers/src/api/application/services/customer-finder.ts +++ b/modules/customers/src/api/application/services/customer-finder.ts @@ -1,5 +1,5 @@ import type { Criteria } from "@repo/rdx-criteria/server"; -import type { UniqueID } from "@repo/rdx-ddd"; +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; import type { Collection, Result } from "@repo/rdx-utils"; import type { Transaction } from "sequelize"; @@ -10,7 +10,13 @@ import type { ICustomerRepository } from "../repositories"; export interface ICustomerFinder { findCustomerById( companyId: UniqueID, - invoiceId: UniqueID, + customerId: UniqueID, + transaction?: Transaction + ): Promise>; + + findCustomerByTIN( + companyId: UniqueID, + tin: TINNumber, transaction?: Transaction ): Promise>; @@ -38,6 +44,14 @@ export class CustomerFinder implements ICustomerFinder { return this.repository.getByIdInCompany(companyId, customerId, transaction); } + findCustomerByTIN( + companyId: UniqueID, + tin: TINNumber, + transaction?: Transaction + ): Promise> { + return this.repository.getByTINInCompany(companyId, tin, transaction); + } + async customerExists( companyId: UniqueID, customerId: UniqueID, diff --git a/modules/customers/src/api/application/services/customer-updater.ts b/modules/customers/src/api/application/services/customer-updater.ts index fed2ed41..522e1da9 100644 --- a/modules/customers/src/api/application/services/customer-updater.ts +++ b/modules/customers/src/api/application/services/customer-updater.ts @@ -2,14 +2,14 @@ import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { Transaction } from "sequelize"; -import type { Customer, ICustomerCreateProps } from "../../domain"; +import type { Customer, CustomerPatchProps } from "../../domain"; import type { ICustomerRepository } from "../repositories"; export interface ICustomerUpdater { update(params: { companyId: UniqueID; id: UniqueID; - props: Partial; + props: CustomerPatchProps; transaction: Transaction; }): Promise>; } @@ -28,7 +28,7 @@ export class CustomerUpdater implements ICustomerUpdater { async update(params: { companyId: UniqueID; id: UniqueID; - props: Partial; + props: CustomerPatchProps; transaction: Transaction; }): Promise> { const { companyId, id, props, transaction } = params; diff --git a/modules/customers/src/api/application/services/index.ts b/modules/customers/src/api/application/services/index.ts index dbcc05eb..f70f97e0 100644 --- a/modules/customers/src/api/application/services/index.ts +++ b/modules/customers/src/api/application/services/index.ts @@ -1,2 +1,3 @@ export * from "./customer-creator"; export * from "./customer-finder"; +export * from "./customer-updater"; diff --git a/modules/customers/src/api/application/use-cases/update/map-dto-to-update-customer-props.ts b/modules/customers/src/api/application/use-cases/update/map-dto-to-update-customer-props.ts deleted file mode 100644 index 664c3d43..00000000 --- a/modules/customers/src/api/application/use-cases/update/map-dto-to-update-customer-props.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { - City, - Country, - CurrencyCode, - DomainError, - EmailAddress, - LanguageCode, - Name, - PhoneNumber, - type PostalAddressPatchProps, - PostalCode, - Province, - Street, - TINNumber, - TaxCode, - TextValue, - URLAddress, - ValidationErrorCollection, - type ValidationErrorDetail, - extractOrPushError, - maybeFromNullableResult, -} from "@repo/rdx-ddd"; -import { Collection, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; - -import type { UpdateCustomerByIdRequestDTO } from "../../../../common"; -import type { CustomerPatchProps } from "../../../domain"; - -/** - * mapDTOToUpdateCustomerPatchProps - * Convierte el DTO a las props validadas (CustomerProps). - * No construye directamente el agregado. - * Tri-estado: - * - campo omitido → no se cambia - * - campo con valor null/"" → se quita el valor -> set(None()), - * - campo con valor no-vacío → se pone el nuevo valor -> set(Some(VO)). - * - * @param dto - DTO con los datos a cambiar en el cliente - * @returns Cambios en las propiedades del cliente - * - */ - -export function mapDTOToUpdateCustomerPatchProps(dto: UpdateCustomerByIdRequestDTO) { - try { - const errors: ValidationErrorDetail[] = []; - const customerPatchProps: CustomerPatchProps = {}; - - toPatchField(dto.reference).ifSet((reference) => { - customerPatchProps.reference = extractOrPushError( - maybeFromNullableResult(reference, (value) => Name.create(value)), - "reference", - errors - ); - }); - - toPatchField(dto.is_company).ifSet((is_company) => { - if (isNullishOrEmpty(is_company)) { - errors.push({ path: "is_company", message: "is_company cannot be empty" }); - return; - } - customerPatchProps.isCompany = extractOrPushError( - Result.ok(Boolean(is_company!)), - "is_company", - errors - ); - }); - - toPatchField(dto.name).ifSet((name) => { - if (isNullishOrEmpty(name)) { - errors.push({ path: "name", message: "Name cannot be empty" }); - return; - } - customerPatchProps.name = extractOrPushError(Name.create(name!), "name", errors); - }); - - toPatchField(dto.trade_name).ifSet((trade_name) => { - customerPatchProps.tradeName = extractOrPushError( - maybeFromNullableResult(trade_name, (value) => Name.create(value)), - "trade_name", - errors - ); - }); - - toPatchField(dto.tin).ifSet((tin) => { - customerPatchProps.tin = extractOrPushError( - maybeFromNullableResult(tin, (value) => TINNumber.create(value)), - "tin", - errors - ); - }); - - toPatchField(dto.email_primary).ifSet((email_primary) => { - customerPatchProps.emailPrimary = extractOrPushError( - maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)), - "email_primary", - errors - ); - }); - - toPatchField(dto.email_secondary).ifSet((email_secondary) => { - customerPatchProps.emailSecondary = extractOrPushError( - maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)), - "email_secondary", - errors - ); - }); - - toPatchField(dto.mobile_primary).ifSet((mobile_primary) => { - customerPatchProps.mobilePrimary = extractOrPushError( - maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)), - "mobile_primary", - errors - ); - }); - - toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => { - customerPatchProps.mobilePrimary = extractOrPushError( - maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)), - "mobile_secondary", - errors - ); - }); - - toPatchField(dto.phone_primary).ifSet((phone_primary) => { - customerPatchProps.phonePrimary = extractOrPushError( - maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)), - "phone_primary", - errors - ); - }); - - toPatchField(dto.phone_secondary).ifSet((phone_secondary) => { - customerPatchProps.phoneSecondary = extractOrPushError( - maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)), - "phone_secondary", - errors - ); - }); - - toPatchField(dto.fax).ifSet((fax) => { - customerPatchProps.fax = extractOrPushError( - maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)), - "fax", - errors - ); - }); - - toPatchField(dto.website).ifSet((website) => { - customerPatchProps.website = extractOrPushError( - maybeFromNullableResult(website, (value) => URLAddress.create(value)), - "website", - errors - ); - }); - - toPatchField(dto.legal_record).ifSet((legalRecord) => { - customerPatchProps.legalRecord = extractOrPushError( - maybeFromNullableResult(legalRecord, (value) => TextValue.create(value)), - "legal_record", - errors - ); - }); - - toPatchField(dto.language_code).ifSet((languageCode) => { - if (isNullishOrEmpty(languageCode)) { - errors.push({ path: "language_code", message: "Language code cannot be empty" }); - return; - } - - customerPatchProps.languageCode = extractOrPushError( - LanguageCode.create(languageCode!), - "language_code", - errors - ); - }); - - toPatchField(dto.currency_code).ifSet((currencyCode) => { - if (isNullishOrEmpty(currencyCode)) { - errors.push({ path: "currency_code", message: "Currency code cannot be empty" }); - return; - } - - customerPatchProps.currencyCode = extractOrPushError( - CurrencyCode.create(currencyCode!), - "currency_code", - errors - ); - }); - - // Default taxes - const defaultTaxesCollection = new Collection(); - toPatchField(dto.default_taxes).ifSet((defaultTaxes) => { - customerPatchProps.defaultTaxes = defaultTaxesCollection; - - if (isNullishOrEmpty(defaultTaxes)) { - return; - } - - defaultTaxes!.forEach((taxCode, index) => { - const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors); - if (tax && customerPatchProps.defaultTaxes) { - customerPatchProps.defaultTaxes.add(tax); - } - }); - }); - - // PostalAddress - const addressPatchProps = mapDTOToUpdatePostalAddressPatchProps(dto, errors); - if (addressPatchProps) { - customerPatchProps.address = addressPatchProps; - } - - if (errors.length > 0) { - return Result.fail( - new ValidationErrorCollection("Customer props mapping failed (update)", errors) - ); - } - - return Result.ok(customerPatchProps); - } catch (err: unknown) { - return Result.fail(new DomainError("Customer props mapping failed", { cause: err })); - } -} - -function mapDTOToUpdatePostalAddressPatchProps( - dto: UpdateCustomerByIdRequestDTO, - errors: ValidationErrorDetail[] -): PostalAddressPatchProps | undefined { - const postalAddressPatchProps: PostalAddressPatchProps = {}; - - toPatchField(dto.street).ifSet((street) => { - postalAddressPatchProps.street = extractOrPushError( - maybeFromNullableResult(street, (value) => Street.create(value)), - "street", - errors - ); - }); - - toPatchField(dto.street2).ifSet((street2) => { - postalAddressPatchProps.street2 = extractOrPushError( - maybeFromNullableResult(street2, (value) => Street.create(value)), - "street2", - errors - ); - }); - - toPatchField(dto.city).ifSet((city) => { - postalAddressPatchProps.city = extractOrPushError( - maybeFromNullableResult(city, (value) => City.create(value)), - "city", - errors - ); - }); - - toPatchField(dto.province).ifSet((province) => { - postalAddressPatchProps.province = extractOrPushError( - maybeFromNullableResult(province, (value) => Province.create(value)), - "province", - errors - ); - }); - - toPatchField(dto.postal_code).ifSet((postalCode) => { - postalAddressPatchProps.postalCode = extractOrPushError( - maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)), - "postal_code", - errors - ); - }); - - toPatchField(dto.country).ifSet((country) => { - postalAddressPatchProps.country = extractOrPushError( - maybeFromNullableResult(country, (value) => Country.create(value)), - "country", - errors - ); - }); - - return Object.keys(postalAddressPatchProps).length > 0 ? postalAddressPatchProps : undefined; -} diff --git a/modules/customers/src/api/application/use-cases/update/update-customer.use-case.ts b/modules/customers/src/api/application/use-cases/update/update-customer.use-case.ts index 54ae78ce..073464c0 100644 --- a/modules/customers/src/api/application/use-cases/update/update-customer.use-case.ts +++ b/modules/customers/src/api/application/use-cases/update/update-customer.use-case.ts @@ -1,11 +1,13 @@ -import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api"; +import type { ITransactionManager } from "@erp/core/api"; import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { Transaction } from "sequelize"; import type { UpdateCustomerByIdRequestDTO } from "../../../../common/dto"; import type { CustomerPatchProps } from "../../../domain"; -import type { CustomerApplicationService } from "../../customer-application.service"; +import type { IUpdateCustomerInputMapper } from "../../mappers"; +import type { ICustomerUpdater } from "../../services"; +import type { ICustomerFullSnapshotBuilder } from "../../snapshot-builders"; type UpdateCustomerUseCaseInput = { companyId: UniqueID; @@ -13,12 +15,25 @@ type UpdateCustomerUseCaseInput = { dto: UpdateCustomerByIdRequestDTO; }; +type UpdateCustomerUseCaseDeps = { + dtoMapper: IUpdateCustomerInputMapper; + updater: ICustomerUpdater; + fullSnapshotBuilder: ICustomerFullSnapshotBuilder; + transactionManager: ITransactionManager; +}; + export class UpdateCustomerUseCase { - constructor( - private readonly service: CustomerApplicationService, - private readonly transactionManager: ITransactionManager, - private readonly presenterRegistry: IPresenterRegistry - ) {} + private readonly dtoMapper: IUpdateCustomerInputMapper; + private readonly updater: ICustomerUpdater; + private readonly fullSnapshotBuilder: ICustomerFullSnapshotBuilder; + private readonly transactionManager: ITransactionManager; + + constructor(deps: UpdateCustomerUseCaseDeps) { + this.dtoMapper = deps.dtoMapper; + this.updater = deps.updater; + this.fullSnapshotBuilder = deps.fullSnapshotBuilder; + this.transactionManager = deps.transactionManager; + } public execute(params: UpdateCustomerUseCaseInput) { const { companyId, customer_id, dto } = params; @@ -27,6 +42,8 @@ export class UpdateCustomerUseCase { if (idOrError.isFailure) { return Result.fail(idOrError.error); } + const id = idOrError.data; + // Mapear DTO → props de dominio const patchPropsResult = this.dtoMapper.map(dto, { companyId }); if (patchPropsResult.isFailure) { @@ -37,20 +54,20 @@ export class UpdateCustomerUseCase { return this.transactionManager.complete(async (transaction: Transaction) => { try { - const updatedCustomer = await this.service.patchCustomerByIdInCompany( + const updateResult = await this.updater.update({ companyId, - customerId, - patchProps, - transaction - ); + id, + props: patchProps, + transaction, + }); - if (updatedCustomer.isFailure) { - return Result.fail(updatedCustomer.error); + if (updateResult.isFailure) { + return Result.fail(updateResult.error); } const customerOrError = await this.service.updateCustomerInCompany( companyId, - updatedCustomer.data, + updateResult.data, transaction ); const customer = customerOrError.data; diff --git a/modules/customers/src/api/domain/aggregates/customer.aggregate.ts b/modules/customers/src/api/domain/aggregates/customer.aggregate.ts index ab352065..b51c5c2a 100644 --- a/modules/customers/src/api/domain/aggregates/customer.aggregate.ts +++ b/modules/customers/src/api/domain/aggregates/customer.aggregate.ts @@ -9,14 +9,13 @@ import { type PostalAddressPatchProps, type PostalAddressProps, type TINNumber, - type TaxCode, type TextValue, type URLAddress, type UniqueID, } from "@repo/rdx-ddd"; -import { type Collection, type Maybe, Result } from "@repo/rdx-utils"; +import { type Maybe, Result } from "@repo/rdx-utils"; -import type { CustomerStatus } from "../value-objects"; +import type { CustomerStatus, CustomerTaxesProps } from "../value-objects"; export interface ICustomerCreateProps { companyId: UniqueID; @@ -44,14 +43,14 @@ export interface ICustomerCreateProps { legalRecord: Maybe; - defaultTaxes: TaxCode[]; + defaultTaxes: CustomerTaxesProps; languageCode: LanguageCode; currencyCode: CurrencyCode; } export type CustomerPatchProps = Partial< - Omit + Omit > & { address?: PostalAddressPatchProps; }; @@ -87,7 +86,7 @@ export interface ICustomer { readonly website: Maybe; readonly legalRecord: Maybe; - readonly defaultTaxes: Collection; + readonly defaultTaxes: CustomerTaxesProps; readonly languageCode: LanguageCode; readonly currencyCode: CurrencyCode; @@ -142,8 +141,6 @@ export class Customer extends AggregateRoot implements IC if (addressResult.isFailure) { return Result.fail(addressResult.error); } - - this.props.address = addressResult.data; } return Result.ok(); @@ -223,7 +220,7 @@ export class Customer extends AggregateRoot implements IC return this.props.legalRecord; } - public get defaultTaxes(): Collection { + public get defaultTaxes(): CustomerTaxesProps { return this.props.defaultTaxes; } diff --git a/modules/customers/src/api/domain/value-objects/customer-taxes.vo.ts b/modules/customers/src/api/domain/value-objects/customer-taxes.vo.ts new file mode 100644 index 00000000..e3d6b380 --- /dev/null +++ b/modules/customers/src/api/domain/value-objects/customer-taxes.vo.ts @@ -0,0 +1,65 @@ +import type { Tax } from "@erp/core/api"; +import { ValueObject } from "@repo/rdx-ddd"; +import { type Maybe, Result } from "@repo/rdx-utils"; + +export type CustomerTaxesProps = { + iva: Maybe; // si existe + rec: Maybe; // si existe + retention: Maybe; // si existe +}; + +export interface ICustomerItemTaxes { + iva: Maybe; // si existe + rec: Maybe; // si existe + retention: Maybe; // si existe + + toKey(): string; // Clave para representar un trío. +} + +export class CustomerTaxes + extends ValueObject + implements ICustomerItemTaxes +{ + static create(props: CustomerTaxesProps) { + return Result.ok(new CustomerTaxes(props)); + } + + toKey(): string { + const ivaCode = this.props.iva.match( + (iva) => iva.code, + () => "#" + ); + + const recCode = this.props.rec.match( + (rec) => rec.code, + () => "#" + ); + + const retentionCode = this.props.retention.match( + (retention) => retention.code, + () => "#" + ); + + return `${ivaCode};${recCode};${retentionCode}`; + } + + get iva(): Maybe { + return this.props.iva; + } + + get rec(): Maybe { + return this.props.rec; + } + + get retention(): Maybe { + return this.props.retention; + } + + getProps() { + return this.props; + } + + toPrimitive() { + return this.getProps(); + } +} diff --git a/modules/customers/src/api/domain/value-objects/index.ts b/modules/customers/src/api/domain/value-objects/index.ts index faadcba4..968969b6 100644 --- a/modules/customers/src/api/domain/value-objects/index.ts +++ b/modules/customers/src/api/domain/value-objects/index.ts @@ -2,3 +2,4 @@ export * from "./customer-address-type.vo"; export * from "./customer-number.vo"; export * from "./customer-serie.vo"; export * from "./customer-status.vo"; +export * from "./customer-taxes.vo"; \ No newline at end of file diff --git a/modules/customers/src/api/index.ts b/modules/customers/src/api/index.ts index e3087dad..387a05a4 100644 --- a/modules/customers/src/api/index.ts +++ b/modules/customers/src/api/index.ts @@ -1,10 +1,16 @@ import type { IModuleServer } from "@erp/core/api"; -import { customersRouter, models } from "./infrastructure"; -import { buildCustomersDependencies, buildCustomerServices, CustomersInternalDeps } from "./infrastructure/di"; +import { type CustomerPublicServices, customersRouter, models } from "./infrastructure"; +import { + type CustomersInternalDeps, + buildCustomerServices, + buildCustomersDependencies, +} from "./infrastructure/di"; export * from "./infrastructure/sequelize"; +export type { CustomerPublicServices }; + export const customersAPIModule: IModuleServer = { name: "customers", version: "1.0.0", @@ -21,10 +27,10 @@ export const customersAPIModule: IModuleServer = { const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; // 1) Dominio interno - const customerInternalDeps = buildCustomersDependencies(params); + const internal = buildCustomersDependencies(params); // 2) Servicios públicos (Application Services) - const customerServices = buildCustomerServices(customerInternalDeps); + const customersServices: CustomerPublicServices = buildCustomerServices(params, internal); logger.info("🚀 Customers module dependencies registered", { label: this.name, @@ -35,10 +41,12 @@ export const customersAPIModule: IModuleServer = { models, // Servicios expuestos a otros módulos - services: customerServices, - + services: { + general: customersServices, // 'customers:general' + }, + // Implementación privada del módulo - internal:customerInternalDeps, + internal, }; }, diff --git a/modules/customers/src/api/infrastructure/di/customer-public-services.ts b/modules/customers/src/api/infrastructure/di/customer-public-services.ts index e0b3ac14..999c2900 100644 --- a/modules/customers/src/api/infrastructure/di/customer-public-services.ts +++ b/modules/customers/src/api/infrastructure/di/customer-public-services.ts @@ -1,24 +1,79 @@ +import type { SetupParams } from "@erp/core/api"; +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Transaction } from "sequelize"; + +import { buildCustomerCreator, buildCustomerFinder } from "../../application"; +import type { Customer, ICustomerCreateProps } from "../../domain"; + +import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di"; +import { buildCustomerRepository } from "./customer-repositories.di"; import type { CustomersInternalDeps } from "./customers.di"; -export type CustomersServicesDeps = { - services: { - listCustomers: (filters: unknown, context: unknown) => null; - getCustomerById: (id: unknown, context: unknown) => null; - //generateCustomerReport: (id: unknown, options: unknown, context: unknown) => null; - }; +type CustomerServicesContext = { + transaction: Transaction; + companyId: UniqueID; }; -export function buildCustomerServices(deps: CustomersInternalDeps): CustomersServicesDeps { +export type CustomerPublicServices = { + //listCustomers: (filters: unknown, context: unknown) => null; + findCustomerByTIN: ( + tin: TINNumber, + context: CustomerServicesContext + ) => Promise>; + createCustomer: ( + id: UniqueID, + props: ICustomerCreateProps, + context: CustomerServicesContext + ) => Promise>; + //generateCustomerReport: (id: unknown, options: unknown, context: unknown) => null; +}; + +export function buildCustomerServices( + params: SetupParams, + deps: CustomersInternalDeps +): CustomerPublicServices { + const { database } = params; + + // Infrastructure + const persistenceMappers = buildCustomerPersistenceMappers(); + const repository = buildCustomerRepository({ database, mappers: persistenceMappers }); + + const finder = buildCustomerFinder({ repository }); + const creator = buildCustomerCreator({ repository }); + return { - services: { - listCustomers: (filters, context) => null, - //internal.useCases.listCustomers().execute(filters, context), + findCustomerByTIN: async (tin: TINNumber, context: CustomerServicesContext) => { + const { companyId, transaction } = context; - getCustomerById: (id, context) => null, - //internal.useCases.getCustomerById().execute(id, context), + const customerResult = await finder.findCustomerByTIN(companyId, tin, transaction); - //generateCustomerReport: (id, options, context) => null, - //internal.useCases.reportCustomer().execute(id, options, context), + if (customerResult.isFailure) { + return Result.fail(customerResult.error); + } + + return Result.ok(customerResult.data); + }, + + createCustomer: async ( + id: UniqueID, + props: ICustomerCreateProps, + context: CustomerServicesContext + ) => { + const { companyId, transaction } = context; + + const customerResult = await creator.create({ + companyId, + id, + props, + transaction, + }); + + if (customerResult.isFailure) { + return Result.fail(customerResult.error); + } + + return Result.ok(customerResult.data); }, }; } diff --git a/modules/customers/src/api/infrastructure/express/customers.routes.ts b/modules/customers/src/api/infrastructure/express/customers.routes.ts index 9d26bd70..b581bba0 100644 --- a/modules/customers/src/api/infrastructure/express/customers.routes.ts +++ b/modules/customers/src/api/infrastructure/express/customers.routes.ts @@ -6,6 +6,8 @@ import { CreateCustomerRequestSchema, CustomerListRequestSchema, GetCustomerByIdRequestSchema, + UpdateCustomerByIdParamsRequestSchema, + UpdateCustomerByIdRequestSchema, } from "../../../common/dto"; import type { CustomersInternalDeps } from "../di"; @@ -13,6 +15,7 @@ import { CreateCustomerController, GetCustomerController, ListCustomersController, + UpdateCustomerController, } from "./controllers"; export const customersRouter = (params: ModuleParams, deps: CustomersInternalDeps) => { @@ -75,19 +78,19 @@ export const customersRouter = (params: ModuleParams, deps: CustomersInternalDep } ); - /* router.put( + router.put( "/:customer_id", //checkTabContext, validateRequest(UpdateCustomerByIdParamsRequestSchema, "params"), validateRequest(UpdateCustomerByIdRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.useCases.update(); + const useCase = deps.useCases.updateCustomer(); const controller = new UpdateCustomerController(useCase); return controller.execute(req, res, next); } ); - */ + /*router.delete( "/:customer_id", //checkTabContext, diff --git a/modules/customers/src/api/infrastructure/index.ts b/modules/customers/src/api/infrastructure/index.ts index f35c8878..610844a7 100644 --- a/modules/customers/src/api/infrastructure/index.ts +++ b/modules/customers/src/api/infrastructure/index.ts @@ -1,3 +1,4 @@ +export * from "./di"; export * from "./express"; export * from "./mappers"; export * from "./sequelize"; diff --git a/modules/customers/src/api/infrastructure/sequelize/models/customer.model.ts b/modules/customers/src/api/infrastructure/sequelize/models/customer.model.ts index af7a0954..6fb796f4 100644 --- a/modules/customers/src/api/infrastructure/sequelize/models/customer.model.ts +++ b/modules/customers/src/api/infrastructure/sequelize/models/customer.model.ts @@ -1,10 +1,10 @@ import { - CreationOptional, + type CreationOptional, DataTypes, - InferAttributes, - InferCreationAttributes, + type InferAttributes, + type InferCreationAttributes, Model, - Sequelize, + type Sequelize, } from "sequelize"; export type CustomerCreationAttributes = InferCreationAttributes & {}; @@ -238,6 +238,7 @@ export default (database: Sequelize) => { fields: ["company_id", "deleted_at", "name"], }, { name: "idx_name", fields: ["name"] }, // <- para ordenación + { name: "idx_tin", fields: ["tin"] }, // <- para servicios externos { name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get { name: "idx_factuges", fields: ["factuges_id"], unique: true }, // <- para el proceso python diff --git a/modules/customers/src/api/infrastructure/sequelize/repositories/customer.repository.ts b/modules/customers/src/api/infrastructure/sequelize/repositories/customer.repository.ts index f0355ae2..218a7be6 100644 --- a/modules/customers/src/api/infrastructure/sequelize/repositories/customer.repository.ts +++ b/modules/customers/src/api/infrastructure/sequelize/repositories/customer.repository.ts @@ -5,7 +5,7 @@ import { translateSequelizeError, } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; -import type { UniqueID } from "@repo/rdx-ddd"; +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; import { type Collection, Result } from "@repo/rdx-utils"; import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize"; @@ -162,6 +162,59 @@ export class CustomerRepository } } + /** + * Recupera un cliente por su ID y companyId. + * + * @param companyId - Identificador UUID de la empresa a la que pertenece el cliente. + * @param tin - TIN del cliente. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async getByTINInCompany( + companyId: UniqueID, + tin: TINNumber, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + // Normalización defensiva de order/include + const normalizedOrder = Array.isArray(options.order) + ? options.order + : options.order + ? [options.order] + : []; + + const normalizedInclude = Array.isArray(options.include) + ? options.include + : options.include + ? [options.include] + : []; + + const mergedOptions: FindOptions> = { + ...options, + where: { + ...(options.where ?? {}), + tin: tin.toString(), + company_id: companyId.toString(), + }, + order: normalizedOrder, + include: normalizedInclude, + transaction, + }; + + const row = await CustomerModel.findOne(mergedOptions); + + if (!row) { + return Result.fail(new EntityNotFoundError("Customer", "tin", tin.toString())); + } + + const customer = this.domainMapper.mapToDomain(row); + return customer; + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + /** * Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.). * diff --git a/modules/customers/src/common/dto/response/create-customer.result.dto.ts b/modules/customers/src/common/dto/response/create-customer.result.dto.ts index 4d035b70..32540d38 100644 --- a/modules/customers/src/common/dto/response/create-customer.result.dto.ts +++ b/modules/customers/src/common/dto/response/create-customer.result.dto.ts @@ -1,5 +1,5 @@ import { - GetCustomerByIdResponseDTO, + type GetCustomerByIdResponseDTO, GetCustomerByIdResponseSchema, } from "./get-customer-by-id.response.dto"; diff --git a/modules/customers/src/web/common/api/api-types.ts b/modules/customers/src/web/common/api/api-types.ts new file mode 100644 index 00000000..19361a48 --- /dev/null +++ b/modules/customers/src/web/common/api/api-types.ts @@ -0,0 +1,12 @@ +import type { ArrayElement } from "@repo/rdx-utils"; + +import type { GetCustomerByIdResponseDTO, ListCustomersResponseDTO } from "../../../common"; + +// Elemento de consulta paginada +export type CustomerSummary = Omit, "metadata">; + +// Consulta paginada con criteria +export type CustomerSummaryPage = Omit; + +// Cliente +export type Customer = Omit; diff --git a/modules/customers/src/web/view/api/get-customer-by-ip.api.ts b/modules/customers/src/web/common/api/get-customer-by-ip.api.ts similarity index 100% rename from modules/customers/src/web/view/api/get-customer-by-ip.api.ts rename to modules/customers/src/web/common/api/get-customer-by-ip.api.ts diff --git a/modules/customers/src/web/list/api/get-customer-list.api.ts b/modules/customers/src/web/common/api/get-customer-list.api.ts similarity index 100% rename from modules/customers/src/web/list/api/get-customer-list.api.ts rename to modules/customers/src/web/common/api/get-customer-list.api.ts diff --git a/modules/customers/src/web/view/api/index.ts b/modules/customers/src/web/common/api/index.ts similarity index 63% rename from modules/customers/src/web/view/api/index.ts rename to modules/customers/src/web/common/api/index.ts index 83fe3b24..57dbdbd7 100644 --- a/modules/customers/src/web/view/api/index.ts +++ b/modules/customers/src/web/common/api/index.ts @@ -1,2 +1,3 @@ export * from "./api-types"; export * from "./get-customer-by-ip.api"; +export * from "./get-customer-list.api"; diff --git a/modules/customers/src/web/common/hooks/index.ts b/modules/customers/src/web/common/hooks/index.ts new file mode 100644 index 00000000..fc6f787e --- /dev/null +++ b/modules/customers/src/web/common/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./use-customer-get-query"; +export * from "./use-customer-list-query"; +export * from "./use-customer-update-mutation"; diff --git a/modules/customers/src/web/common/hooks/toValidationErrors.ts b/modules/customers/src/web/common/hooks/toValidationErrors.ts new file mode 100644 index 00000000..4f04945e --- /dev/null +++ b/modules/customers/src/web/common/hooks/toValidationErrors.ts @@ -0,0 +1,10 @@ +import type { ZodError } from "zod"; + +// Helpers de validación a errores de dominio + +export function toValidationErrors(error: ZodError) { + return error.issues.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); +} diff --git a/modules/customers/src/web/view/hooks/use-customer-query.ts b/modules/customers/src/web/common/hooks/use-customer-get-query.ts similarity index 100% rename from modules/customers/src/web/view/hooks/use-customer-query.ts rename to modules/customers/src/web/common/hooks/use-customer-get-query.ts diff --git a/modules/customers/src/web/list/hooks/use-customer-list-query.tsx b/modules/customers/src/web/common/hooks/use-customer-list-query.tsx similarity index 99% rename from modules/customers/src/web/list/hooks/use-customer-list-query.tsx rename to modules/customers/src/web/common/hooks/use-customer-list-query.tsx index f3d4c390..0e116a6f 100644 --- a/modules/customers/src/web/list/hooks/use-customer-list-query.tsx +++ b/modules/customers/src/web/common/hooks/use-customer-list-query.tsx @@ -8,7 +8,7 @@ import { useQuery, } from "@tanstack/react-query"; -import { type CustomerSummaryPage, getCustomerListApi } from "../api"; +import { type CustomerSummaryPage, getCustomerListApi } from ".."; export const CUSTOMERS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [ "customers", diff --git a/modules/customers/src/web/common/hooks/use-customer-update-mutation.ts b/modules/customers/src/web/common/hooks/use-customer-update-mutation.ts new file mode 100644 index 00000000..8513c0eb --- /dev/null +++ b/modules/customers/src/web/common/hooks/use-customer-update-mutation.ts @@ -0,0 +1,60 @@ +import { useDataSource } from "@erp/core/hooks"; +import { ValidationErrorCollection } from "@repo/rdx-ddd"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { UpdateCustomerByIdRequestSchema } from "../../../common"; +import type { Customer } from "../api"; + +import { toValidationErrors } from "./toValidationErrors"; + +export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const; + +type UpdateCustomerContext = {}; + +type UpdateCustomerPayload = { + id: string; + data: Partial; +}; + +export const useCustomerUpdateMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + const schema = UpdateCustomerByIdRequestSchema; + + return useMutation({ + mutationKey: CUSTOMER_UPDATE_KEY, + + mutationFn: async (payload) => { + const { id: customerId, data } = payload; + if (!customerId) { + throw new Error("customerId is required"); + } + + const result = schema.safeParse(data); + if (!result.success) { + throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); + } + + const updated = await dataSource.updateOne("customers", customerId, data); + return updated as Customer; + }, + + onSuccess: (updated: Customer, variables) => { + const { id: customerId } = updated; + + // Invalida el listado para refrescar desde servidor + //invalidateCustomerListCache(queryClient); + + // Actualiza detalle + //setCustomerDetailCache(queryClient, customerId, updated); + + // Actualiza todas las páginas donde aparezca + //upsertCustomerIntoListCaches(queryClient, { ...updated }); + }, + + onSettled: () => { + // Refresca todos los listados + //invalidateCustomerListCache(queryClient); + }, + }); +}; diff --git a/modules/customers/src/web/common/index.ts b/modules/customers/src/web/common/index.ts new file mode 100644 index 00000000..35312304 --- /dev/null +++ b/modules/customers/src/web/common/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./hooks"; diff --git a/modules/customers/src/web/components/editor/customer-edit-form.tsx b/modules/customers/src/web/components/editor/customer-edit-form.tsx index 4b842a59..a2f293aa 100644 --- a/modules/customers/src/web/components/editor/customer-edit-form.tsx +++ b/modules/customers/src/web/components/editor/customer-edit-form.tsx @@ -1,10 +1,10 @@ import { FormDebug } from "@erp/core/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; -import { cn } from '@repo/shadcn-ui/lib/utils'; import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields"; import { CustomerAddressFields } from "./customer-address-fields"; import { CustomerBasicInfoFields } from "./customer-basic-info-fields"; -import { CustomerContactFields } from './customer-contact-fields'; +import { CustomerContactFields } from "./customer-contact-fields"; type CustomerFormProps = { formId: string; @@ -15,7 +15,7 @@ type CustomerFormProps = { export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => { return ( -
+
diff --git a/modules/customers/src/web/components/editor/index.ts b/modules/customers/src/web/components/editor/index.ts index a09d2728..b8428a3a 100644 --- a/modules/customers/src/web/components/editor/index.ts +++ b/modules/customers/src/web/components/editor/index.ts @@ -1,2 +1 @@ -//export * from "./customer-edit-form"; -export * from "../../view/ui/components/customer-editor-skeleton"; +export * from "./customer-edit-form"; diff --git a/modules/customers/src/web/components/index.ts b/modules/customers/src/web/components/index.ts index bf45a03e..e866dbd4 100644 --- a/modules/customers/src/web/components/index.ts +++ b/modules/customers/src/web/components/index.ts @@ -1,5 +1,3 @@ //export * from "./client-selector-modal"; //export * from "./customer-modal-selector"; -//export * from "./editor"; -export * from "../../../../core/src/web/components/error-alert"; -//export * from "./not-found-card"; +export * from "./editor"; diff --git a/modules/customers/src/web/context/customers-context.tsx b/modules/customers/src/web/context/customers-context.tsx index aea978bc..0cd3fd02 100644 --- a/modules/customers/src/web/context/customers-context.tsx +++ b/modules/customers/src/web/context/customers-context.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, createContext } from "react"; +import { type PropsWithChildren, createContext } from "react"; /** * ──────────────────────────────────────────────────────────────────────────────── diff --git a/modules/customers/src/web/customer-routes.tsx b/modules/customers/src/web/customer-routes.tsx index 840d0f56..cc7355c9 100644 --- a/modules/customers/src/web/customer-routes.tsx +++ b/modules/customers/src/web/customer-routes.tsx @@ -8,9 +8,9 @@ const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.Cust const CustomerView = lazy(() => import("./view").then((m) => ({ default: m.CustomerViewPage }))); //const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage }))); -/*const CustomerUpdate = lazy(() => - import("./pages").then((m) => ({ default: m.CustomerUpdatePage })) -);*/ +const CustomerUpdate = lazy(() => + import("./update").then((m) => ({ default: m.CustomerUpdatePage })) +); export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => { return [ @@ -26,7 +26,7 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => { { path: "list", element: }, //{ path: "create", element: }, { path: ":id", element: }, - //{ path: ":id/edit", element: }, + { path: ":id/edit", element: }, // /*{ path: "create", element: }, diff --git a/modules/customers/src/web/hooks/use-create-customer-mutation.ts b/modules/customers/src/web/hooks/use-create-customer-mutation.ts index 6768e5fe..4d92ad9b 100644 --- a/modules/customers/src/web/hooks/use-create-customer-mutation.ts +++ b/modules/customers/src/web/hooks/use-create-customer-mutation.ts @@ -1,9 +1,9 @@ import { useDataSource } from "@erp/core/hooks"; import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; -import type { ZodError } from "zod/v4"; import { CreateCustomerRequestSchema } from "../../common"; +import { toValidationErrors } from "../common/hooks/toValidationErrors"; import type { Customer, CustomerFormData } from "../schemas"; import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query"; @@ -15,14 +15,6 @@ type CreateCustomerPayload = { data: CustomerFormData; }; -// Helpers de validación a errores de dominio -export function toValidationErrors(error: ZodError) { - return error.issues.map((err) => ({ - field: err.path.join("."), - message: err.message, - })); -} - export function useCreateCustomer() { const queryClient = useQueryClient(); const dataSource = useDataSource(); diff --git a/modules/customers/src/web/hooks/use-update-customer-mutation.ts b/modules/customers/src/web/hooks/use-update-customer-mutation.ts index 7325206c..5668b8d2 100644 --- a/modules/customers/src/web/hooks/use-update-customer-mutation.ts +++ b/modules/customers/src/web/hooks/use-update-customer-mutation.ts @@ -3,9 +3,9 @@ import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { UpdateCustomerByIdRequestSchema } from "../../common"; +import { toValidationErrors } from "../common/hooks/toValidationErrors"; import type { Customer, CustomerFormData } from "../schemas"; -import { toValidationErrors } from "./use-create-customer-mutation"; import { invalidateCustomerListCache, upsertCustomerIntoListCaches, diff --git a/modules/customers/src/web/list/adapters/customer-summary-dto.adapter.ts b/modules/customers/src/web/list/adapters/customer-summary-dto.adapter.ts index e98ba75a..939bc1aa 100644 --- a/modules/customers/src/web/list/adapters/customer-summary-dto.adapter.ts +++ b/modules/customers/src/web/list/adapters/customer-summary-dto.adapter.ts @@ -1,4 +1,4 @@ -import type { CustomerSummaryPage } from "../api"; +import type { CustomerSummaryPage } from "../../common"; import type { CustomerSummaryPageData } from "../types"; /** diff --git a/modules/customers/src/web/list/api/api-types.ts b/modules/customers/src/web/list/api/api-types.ts deleted file mode 100644 index 984beff5..00000000 --- a/modules/customers/src/web/list/api/api-types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ListCustomersResponseDTO } from "@erp/customers/common"; -import type { ArrayElement } from "@repo/rdx-utils"; - -// Resultado de consulta con criteria (paginado, etc.) -export type CustomerSummaryPage = Omit; -export type CustomerSummary = Omit, "metadata">; diff --git a/modules/customers/src/web/list/api/index.ts b/modules/customers/src/web/list/api/index.ts deleted file mode 100644 index 5b8ede7c..00000000 --- a/modules/customers/src/web/list/api/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./api-types"; -export * from "./get-customer-list.api"; diff --git a/modules/customers/src/web/list/controllers/use-customer-list.controller.ts b/modules/customers/src/web/list/controllers/use-customer-list.controller.ts index 8b396cac..33ef3d83 100644 --- a/modules/customers/src/web/list/controllers/use-customer-list.controller.ts +++ b/modules/customers/src/web/list/controllers/use-customer-list.controller.ts @@ -2,8 +2,8 @@ import type { CriteriaDTO } from "@erp/core"; import { useDebounce } from "@repo/rdx-ui/components"; import { useMemo, useState } from "react"; +import { useCustomerListQuery } from "../../common"; import { CustomerSummaryDtoAdapter } from "../adapters"; -import { useCustomerListQuery } from "../hooks"; export const useCustomerListController = () => { const [pageIndex, setPageIndex] = useState(0); diff --git a/modules/customers/src/web/list/hooks/index.ts b/modules/customers/src/web/list/hooks/index.ts deleted file mode 100644 index f6a23025..00000000 --- a/modules/customers/src/web/list/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-customer-list-query"; diff --git a/modules/customers/src/web/list/types/types.ts b/modules/customers/src/web/list/types/types.ts index 2044f0b3..78590c95 100644 --- a/modules/customers/src/web/list/types/types.ts +++ b/modules/customers/src/web/list/types/types.ts @@ -1,5 +1,4 @@ -import type { CustomerSummary } from "../../schemas"; -import type { CustomerSummaryPage } from "../api"; +import type { CustomerSummary, CustomerSummaryPage } from "../../common"; export type CustomerSummaryData = CustomerSummary; diff --git a/modules/customers/src/web/list/ui/components/index.ts b/modules/customers/src/web/list/ui/components/index.ts index 6c4ba4bb..46dd269b 100644 --- a/modules/customers/src/web/list/ui/components/index.ts +++ b/modules/customers/src/web/list/ui/components/index.ts @@ -1,5 +1,5 @@ +export * from "./address-cell"; +export * from "./contact-cell"; export * from "./initials"; export * from "./kind-badge"; export * from "./soft"; -export * from "./contact-cell"; -export * from "./address-cell"; \ No newline at end of file diff --git a/modules/customers/src/web/list/ui/index.ts b/modules/customers/src/web/list/ui/index.ts index fc823583..c4e34b27 100644 --- a/modules/customers/src/web/list/ui/index.ts +++ b/modules/customers/src/web/list/ui/index.ts @@ -1,3 +1 @@ -//export * from "./blocks"; -//export * from "./components"; export * from "./pages"; diff --git a/modules/customers/src/web/pages/index.ts b/modules/customers/src/web/pages/index.ts index 764e8887..b034c901 100644 --- a/modules/customers/src/web/pages/index.ts +++ b/modules/customers/src/web/pages/index.ts @@ -1,4 +1,2 @@ export * from "./create"; -export * from "./list"; export * from "./update"; -export * from "./view"; diff --git a/modules/customers/src/web/pages/update/customer-update-page.tsx b/modules/customers/src/web/pages/update/customer-update-page.tsx index a0499b89..8658123b 100644 --- a/modules/customers/src/web/pages/update/customer-update-page.tsx +++ b/modules/customers/src/web/pages/update/customer-update-page.tsx @@ -9,8 +9,7 @@ import { NotFoundCard, } from "../../components"; import { useTranslation } from "../../i18n"; - -import { useCustomerUpdateController } from "./use-customer-update-controller"; +import { useCustomerUpdateController } from "../../update/controllers/use-customer-update-page.controller"; export const CustomerUpdatePage = () => { const customerId = useUrlParamId(); diff --git a/modules/customers/src/web/pages/view/customer-view-page.tsx b/modules/customers/src/web/pages/view/customer-view-page.tsx deleted file mode 100644 index 0b953bd1..00000000 --- a/modules/customers/src/web/pages/view/customer-view-page.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { PageHeader } from "@erp/core/components"; -import { useUrlParamId } from "@erp/core/hooks"; -import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, -} from "@repo/shadcn-ui/components"; -import { - Banknote, - EditIcon, - FileText, - Globe, - Languages, - Mail, - MapPin, - MoreVertical, - Phone, - Smartphone, -} from "lucide-react"; -import { useNavigate } from "react-router-dom"; - -import { CustomerEditorSkeleton, ErrorAlert } from "../../components"; -import { useCustomerQuery } from "../../hooks"; -import { useTranslation } from "../../i18n"; - -export const CustomerViewPage = () => { - const customerId = useUrlParamId(); - const { t } = useTranslation(); - const navigate = useNavigate(); - - // 1) Estado de carga del cliente (query) - const { - data: customer, - isLoading: isLoadingCustomer, - isError: isLoadError, - error: loadError, - } = useCustomerQuery(customerId, { enabled: !!customerId }); - - if (isLoadingCustomer) { - return ; - } - - if (isLoadError) { - return ( - <> - - - -
- -
-
- - ); - } - - return ( - <> - - - - {customer?.tin} - - {customer?.is_company ? "Empresa" : "Persona"} - - } - rightSlot={ -
- - -
- } - title={ -
- {customer?.name}{" "} - {customer?.trade_name && ( - ({customer.trade_name}) - )} -
- } - /> -
- - {/* Main Content Grid */} -
- {/* Información Básica */} - - - - - Información Básica - - - -
-
Nombre
-
{customer?.name}
-
-
-
Referencia
-
{customer?.reference}
-
-
-
Registro Legal
-
{customer?.legal_record}
-
-
-
Impuestos por Defecto
-
- {customer?.default_taxes.map((tax) => ( - - {tax} - - ))} -
-
-
-
- - {/* Dirección */} - - - - - Dirección - - - -
-
Calle
-
- {customer?.street} - {customer?.street2 && ( - <> -
- {customer?.street2} - - )} -
-
-
-
-
Ciudad
-
{customer?.city}
-
-
-
Código Postal
-
{customer?.postal_code}
-
-
-
-
-
Provincia
-
{customer?.province}
-
-
-
País
-
{customer?.country}
-
-
-
-
- - {/* Información de Contacto */} - - - - - Información de Contacto - - - -
- {/* Contacto Principal */} -
-

Contacto Principal

- {customer?.email_primary && ( -
- -
-
Email
-
- {customer?.email_primary} -
-
-
- )} - {customer?.mobile_primary && ( -
- -
-
Móvil
-
- {customer?.mobile_primary} -
-
-
- )} - {customer?.phone_primary && ( -
- -
-
Teléfono
-
- {customer?.phone_primary} -
-
-
- )} -
- - {/* Contacto Secundario */} -
-

Contacto Secundario

- {customer?.email_secondary && ( -
- -
-
Email
-
- {customer?.email_secondary} -
-
-
- )} - {customer?.mobile_secondary && ( -
- -
-
Móvil
-
- {customer?.mobile_secondary} -
-
-
- )} - {customer?.phone_secondary && ( -
- -
-
Teléfono
-
- {customer?.phone_secondary} -
-
-
- )} -
- - {/* Otros Contactos */} - {(customer?.website || customer?.fax) && ( -
-

Otros

-
- {customer?.website && ( -
- -
-
Sitio Web
-
- - {customer?.website} - -
-
-
- )} - {customer?.fax && ( -
- -
-
Fax
-
{customer?.fax}
-
-
- )} -
-
- )} -
-
-
- - {/* Preferencias */} - - - - - Preferencias - - - -
-
- -
-
Idioma Preferido
-
{customer?.language_code}
-
-
-
- -
-
Moneda Preferida
-
{customer?.currency_code}
-
-
-
-
-
-
-
- - ); -}; diff --git a/modules/customers/src/web/pages/view/index.ts b/modules/customers/src/web/pages/view/index.ts deleted file mode 100644 index 44ef4e26..00000000 --- a/modules/customers/src/web/pages/view/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./customer-view-page"; diff --git a/modules/customers/src/web/update/controllers/index.ts b/modules/customers/src/web/update/controllers/index.ts new file mode 100644 index 00000000..debbf28e --- /dev/null +++ b/modules/customers/src/web/update/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-customer-update-page.controller"; diff --git a/modules/customers/src/web/pages/update/use-customer-update-controller.ts b/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts similarity index 95% rename from modules/customers/src/web/pages/update/use-customer-update-controller.ts rename to modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts index 58a5311e..e74bb275 100644 --- a/modules/customers/src/web/pages/update/use-customer-update-controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts @@ -4,7 +4,7 @@ import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui import { useEffect, useId, useMemo } from "react"; import { type FieldErrors, FormProvider } from "react-hook-form"; -import { useCustomerQuery, useUpdateCustomer } from "../../hooks"; +import { useCustomerGetQuery, useCustomerUpdateMutation } from "../../common"; import { useTranslation } from "../../i18n"; import { type Customer, @@ -34,7 +34,7 @@ export const useCustomerUpdateController = ( isLoading, isError: isLoadError, error: loadError, - } = useCustomerQuery(customerId, { enabled: Boolean(customerId) }); + } = useCustomerGetQuery(customerId, { enabled: Boolean(customerId) }); // 2) Estado de creación (mutación) const { @@ -42,7 +42,7 @@ export const useCustomerUpdateController = ( isPending: isUpdating, isError: isUpdateError, error: updateError, - } = useUpdateCustomer(); + } = useCustomerUpdateMutation(); const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]); diff --git a/modules/customers/src/web/update/hooks/index.ts b/modules/customers/src/web/update/hooks/index.ts new file mode 100644 index 00000000..b4938e3b --- /dev/null +++ b/modules/customers/src/web/update/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./use-customer-form"; +export * from "./use-customer-update-mutation"; diff --git a/modules/customers/src/web/update/hooks/use-customer-form.ts b/modules/customers/src/web/update/hooks/use-customer-form.ts new file mode 100644 index 00000000..293f2430 --- /dev/null +++ b/modules/customers/src/web/update/hooks/use-customer-form.ts @@ -0,0 +1,22 @@ +import { useHookForm } from "@erp/core/hooks"; +import { useEffect, useMemo } from "react"; + +import type { Customer } from "../api"; + +function useCustomerForm(customerData: Customer | undefined, isDisabled: boolean) { + const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]); + + const form = useHookForm()({ + resolverSchema: CustomerFormSchema, + initialValues, + disabled: isDisabled, + }); + + useEffect(() => { + if (customerData) form.reset(customerData); + }, [customerData, form]); + + const resetForm = () => form.reset(customerData ?? defaultCustomerFormData); + + return { form, resetForm }; +} diff --git a/modules/customers/src/web/update/hooks/use-customer-get-query.ts b/modules/customers/src/web/update/hooks/use-customer-get-query.ts new file mode 100644 index 00000000..bf6f8147 --- /dev/null +++ b/modules/customers/src/web/update/hooks/use-customer-get-query.ts @@ -0,0 +1,35 @@ +import { useDataSource } from "@erp/core/hooks"; +import { + type DefaultError, + type QueryKey, + type UseQueryResult, + useQuery, +} from "@tanstack/react-query"; + +import { type Customer, getCustomerById } from "../api"; + +export const CUSTOMER_QUERY_KEY = (customerId?: string): QueryKey => [ + "customers:detail", + { + customerId, + }, +]; + +type CustomerQueryOptions = { + enabled?: boolean; +}; + +export const useCustomerGetQuery = ( + customerId?: string, + options?: CustomerQueryOptions +): UseQueryResult => { + const dataSource = useDataSource(); + const enabled = options?.enabled ?? Boolean(customerId); + + return useQuery({ + queryKey: CUSTOMER_QUERY_KEY(customerId), + queryFn: async ({ signal }) => getCustomerById(dataSource, signal, customerId), + enabled, + placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`) + }); +}; diff --git a/modules/customers/src/web/update/hooks/use-customer-update-mutation.ts b/modules/customers/src/web/update/hooks/use-customer-update-mutation.ts new file mode 100644 index 00000000..efb64e7c --- /dev/null +++ b/modules/customers/src/web/update/hooks/use-customer-update-mutation.ts @@ -0,0 +1,46 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { UseFormReturn } from "react-hook-form"; + +import type { Customer } from "../api"; + +function useCustomerUpdateMutation( + customerId?: string, + options?: UseCustomerUpdateControllerOptions +) { + const queryClient = useQueryClient(); + const { mutateAsync, isPending } = useUpdateCustomer(); + + const updateCustomer = async ( + patchData: ReturnType, + previousData: Customer | undefined, + form: UseFormReturn + ) => { + if (!customerId) return; + + if (options?.undoAllowed) { + queryClient.setQueryData(["customers", customerId], (old: Customer) => ({ + ...old, + ...patchData, + })); + } + + try { + const updated = await mutateAsync({ id: customerId, data: patchData }); + + queryClient.setQueryData(["customers", customerId], updated); + form.reset(updated); + + options?.onUpdated?.(updated); + } catch (error: any) { + queryClient.setQueryData(["customers", customerId], previousData); + form.reset(previousData); + + options?.onError?.(error, patchData); + } + }; + + return { + updateCustomer, + isUpdating: isPending, + }; +} diff --git a/modules/customers/src/web/update/index.ts b/modules/customers/src/web/update/index.ts new file mode 100644 index 00000000..4aedf593 --- /dev/null +++ b/modules/customers/src/web/update/index.ts @@ -0,0 +1 @@ +export * from "./ui"; diff --git a/modules/customers/src/web/update/types/index.ts b/modules/customers/src/web/update/types/index.ts new file mode 100644 index 00000000..eea524d6 --- /dev/null +++ b/modules/customers/src/web/update/types/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/modules/customers/src/web/update/types/types.ts b/modules/customers/src/web/update/types/types.ts new file mode 100644 index 00000000..bb510873 --- /dev/null +++ b/modules/customers/src/web/update/types/types.ts @@ -0,0 +1,3 @@ +import type { Customer } from "../api"; + +export type CustomerData = Customer; diff --git a/modules/customers/src/web/view/ui/components/customer-editor-skeleton.tsx b/modules/customers/src/web/update/ui/components/customer-editor-skeleton.tsx similarity index 100% rename from modules/customers/src/web/view/ui/components/customer-editor-skeleton.tsx rename to modules/customers/src/web/update/ui/components/customer-editor-skeleton.tsx diff --git a/modules/customers/src/web/view/ui/components/index.ts b/modules/customers/src/web/update/ui/components/index.ts similarity index 100% rename from modules/customers/src/web/view/ui/components/index.ts rename to modules/customers/src/web/update/ui/components/index.ts diff --git a/modules/customers/src/web/update/ui/index.ts b/modules/customers/src/web/update/ui/index.ts new file mode 100644 index 00000000..c4e34b27 --- /dev/null +++ b/modules/customers/src/web/update/ui/index.ts @@ -0,0 +1 @@ +export * from "./pages"; diff --git a/modules/customers/src/web/update/ui/pages/customer-update-page.tsx b/modules/customers/src/web/update/ui/pages/customer-update-page.tsx new file mode 100644 index 00000000..d3f99f4a --- /dev/null +++ b/modules/customers/src/web/update/ui/pages/customer-update-page.tsx @@ -0,0 +1,117 @@ +import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components"; +import { UnsavedChangesProvider, UpdateCommitButtonGroup, useUrlParamId } from "@erp/core/hooks"; +import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; +import { useNavigate } from "react-router-dom"; + +import { CustomerEditForm } from "../../../components"; +import { useTranslation } from "../../../i18n"; +import { useCustomerUpdateController } from "../../controllers"; +import { CustomerEditorSkeleton } from "../components"; + +export const CustomerUpdatePage = () => { + const initialCustomerId = useUrlParamId(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { + form, + formId, + onSubmit, + resetForm, + + customerData, + isLoading, + isLoadError, + loadError, + + isUpdating, + isUpdateError, + updateError, + + FormProvider, + } = useCustomerUpdateController(initialCustomerId, {}); + + if (isLoading) { + return ; + } + + if (isLoadError) { + return ( + <> + + + +
+ +
+
+ + ); + } + + if (!customerData) + return ( + <> + + + + + ); + + return ( + + + + } + title={t("pages.update.title")} + /> + + + {/* Alerta de error de actualización (si ha fallado el último intento) */} + {isUpdateError && ( + + )} + + + + + + + ); +}; diff --git a/modules/customers/src/web/update/ui/pages/index.ts b/modules/customers/src/web/update/ui/pages/index.ts new file mode 100644 index 00000000..73e11d78 --- /dev/null +++ b/modules/customers/src/web/update/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./customer-update-page"; diff --git a/modules/customers/src/web/view/adapters/customer-dto.adapter.ts b/modules/customers/src/web/view/adapters/customer-dto.adapter.ts index 13a938ee..93ad56af 100644 --- a/modules/customers/src/web/view/adapters/customer-dto.adapter.ts +++ b/modules/customers/src/web/view/adapters/customer-dto.adapter.ts @@ -1,4 +1,4 @@ -import type { Customer } from "../api"; +import type { Customer } from "../../common"; import type { CustomerData } from "../types"; /** diff --git a/modules/customers/src/web/view/api/api-types.ts b/modules/customers/src/web/view/api/api-types.ts deleted file mode 100644 index ae2c0977..00000000 --- a/modules/customers/src/web/view/api/api-types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { GetCustomerByIdResponseDTO } from "@erp/customers/common"; - -export type Customer = Omit; diff --git a/modules/customers/src/web/view/controllers/use-customer-view.controller.ts b/modules/customers/src/web/view/controllers/use-customer-view.controller.ts index ae9d1ad8..48881c10 100644 --- a/modules/customers/src/web/view/controllers/use-customer-view.controller.ts +++ b/modules/customers/src/web/view/controllers/use-customer-view.controller.ts @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; +import { useCustomerGetQuery } from "../../common/hooks"; import { CustomerDtoAdapter } from "../adapters"; -import { useCustomerGetQuery } from "../hooks"; export const useCustomerViewController = () => { const [customerId, setCustomerId] = useState(""); diff --git a/modules/customers/src/web/view/hooks/index.ts b/modules/customers/src/web/view/hooks/index.ts deleted file mode 100644 index e75bf5cf..00000000 --- a/modules/customers/src/web/view/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-customer-query"; diff --git a/modules/customers/src/web/view/types/types.ts b/modules/customers/src/web/view/types/types.ts index bb510873..f0fec968 100644 --- a/modules/customers/src/web/view/types/types.ts +++ b/modules/customers/src/web/view/types/types.ts @@ -1,3 +1,3 @@ -import type { Customer } from "../api"; +import type { Customer } from "../../common/api"; export type CustomerData = Customer; diff --git a/modules/customers/src/web/view/ui/pages/customer-view-page.tsx b/modules/customers/src/web/view/ui/pages/customer-view-page.tsx index 031f824d..31854ee2 100644 --- a/modules/customers/src/web/view/ui/pages/customer-view-page.tsx +++ b/modules/customers/src/web/view/ui/pages/customer-view-page.tsx @@ -25,8 +25,8 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "../../../i18n"; -import { useCustomerViewPageController } from "../../controllers/use-customer-view-page.controller"; -import { CustomerEditorSkeleton } from "../components"; +import { CustomerEditorSkeleton } from "../../../update/ui/components"; +import { useCustomerViewPageController } from "../../controllers"; export const CustomerViewPage = () => { const initialCustomerId = useUrlParamId(); @@ -34,7 +34,7 @@ export const CustomerViewPage = () => { const navigate = useNavigate(); const { - viewCtrl: { setCustomerId, customerId, data, isError, error, isLoading }, + viewCtrl: { setCustomerId, customerId, data: customerData, isError, error, isLoading }, } = useCustomerViewPageController(); useEffect(() => { @@ -43,6 +43,10 @@ export const CustomerViewPage = () => { } }, [initialCustomerId, customerId, setCustomerId]); + if (isLoading) { + return ; + } + if (isError) { return ( <> @@ -63,10 +67,6 @@ export const CustomerViewPage = () => { ); } - if (isLoading) { - return ; - } - return ( <> @@ -75,9 +75,9 @@ export const CustomerViewPage = () => { description={
- {data?.tin} + {customerData?.tin} - {data?.is_company ? "Empresa" : "Persona"} + {customerData?.is_company ? "Empresa" : "Persona"}
} rightSlot={ @@ -93,9 +93,9 @@ export const CustomerViewPage = () => { } title={
- {data?.name}{" "} - {data?.trade_name && ( - ({data.trade_name}) + {customerData?.name}{" "} + {customerData?.trade_name && ( + ({customerData.trade_name}) )}
} @@ -115,20 +115,22 @@ export const CustomerViewPage = () => {
Nombre
-
{data?.name}
+
{customerData?.name}
Referencia
-
{data?.reference}
+
+ {customerData?.reference} +
Registro Legal
-
{data?.legal_record}
+
{customerData?.legal_record}
Impuestos por Defecto
- {data?.default_taxes.map((tax) => ( + {customerData?.default_taxes.map((tax) => ( {tax} @@ -150,11 +152,11 @@ export const CustomerViewPage = () => {
Calle
- {data?.street} - {data?.street2 && ( + {customerData?.street} + {customerData?.street2 && ( <>
- {data?.street2} + {customerData?.street2} )}
@@ -162,21 +164,21 @@ export const CustomerViewPage = () => {
Ciudad
-
{data?.city}
+
{customerData?.city}
Código Postal
-
{data?.postal_code}
+
{customerData?.postal_code}
Provincia
-
{data?.province}
+
{customerData?.province}
País
-
{data?.country}
+
{customerData?.country}
@@ -195,30 +197,36 @@ export const CustomerViewPage = () => { {/* Contacto Principal */}

Contacto Principal

- {data?.email_primary && ( + {customerData?.email_primary && (
Email
-
{data?.email_primary}
+
+ {customerData?.email_primary} +
)} - {data?.mobile_primary && ( + {customerData?.mobile_primary && (
Móvil
-
{data?.mobile_primary}
+
+ {customerData?.mobile_primary} +
)} - {data?.phone_primary && ( + {customerData?.phone_primary && (
Teléfono
-
{data?.phone_primary}
+
+ {customerData?.phone_primary} +
)} @@ -227,59 +235,69 @@ export const CustomerViewPage = () => { {/* Contacto Secundario */}

Contacto Secundario

- {data?.email_secondary && ( + {customerData?.email_secondary && (
Email
-
{data?.email_secondary}
+
+ {customerData?.email_secondary} +
)} - {data?.mobile_secondary && ( + {customerData?.mobile_secondary && (
Móvil
-
{data?.mobile_secondary}
+
+ {customerData?.mobile_secondary} +
)} - {data?.phone_secondary && ( + {customerData?.phone_secondary && (
Teléfono
-
{data?.phone_secondary}
+
+ {customerData?.phone_secondary} +
)}
{/* Otros Contactos */} - {(data?.website || data?.fax) && ( + {(customerData?.website || customerData?.fax) && (

Otros

- {data?.website && ( + {customerData?.website && ( )} - {data?.fax && ( + {customerData?.fax && (
Fax
-
{data?.fax}
+
{customerData?.fax}
)} @@ -304,14 +322,18 @@ export const CustomerViewPage = () => {
Idioma Preferido
-
{data?.language_code}
+
+ {customerData?.language_code} +
Moneda Preferida
-
{data?.currency_code}
+
+ {customerData?.currency_code} +
diff --git a/modules/customers/tsconfig.json b/modules/customers/tsconfig.json index 60f73fec..4fea4749 100644 --- a/modules/customers/tsconfig.json +++ b/modules/customers/tsconfig.json @@ -28,6 +28,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src", "../core/src/web/components/error-alert.tsx"], + "include": ["src"], "exclude": ["node_modules"] } diff --git a/modules/doc-numbering/src/api/domain/aggregates/doc-number.ts b/modules/doc-numbering/src/api/domain/aggregates/doc-number.ts deleted file mode 100644 index b398448b..00000000 --- a/modules/doc-numbering/src/api/domain/aggregates/doc-number.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { AggregateRoot, UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; -import { DocType } from "../value-objects"; - -/** - * - * - * const num1 = DocumentNumber.create(7, "2025", "{series}/{number:0003}"); - * console.log(num1.isSuccess, num1.data.getFormatted()); // true, "2025/007" - * - * const num2 = DocumentNumber.create(7, undefined, "{series}/{number:0003}"); - * console.log(num2.isFailure, num2.error.message); // true, error porque falta {series} - * - *. Número simple sin padding - * DocumentNumber.create(123, undefined, "{number}") - * // → "123" - * - * 2. Número con padding - * DocumentNumber.create(7, undefined, "{number:0005}") - * // → "00007" - * - * 3. Serie + número con padding - * DocumentNumber.create(45, "2025", "{series}/{number:0003}") - * // → "2025/045" - * - * 4. Serie + número sin padding - * DocumentNumber.create(987, "Sucursal-01", "{series}-{number}") - * // → "Sucursal-01-987" - * - * 5. Año + mes + día + número - * - * (Suponiendo fecha 2025-09-26 y rawValue=12) - * - * DocumentNumber.create(12, "2025", "{year}/{month}/{day}-{number:0002}") - * // → "2025/09/26-12" - * - * 6. Escapes de llaves - * DocumentNumber.create(33, "2025", "Factura N° {{{number}}}") - * // → "Factura N° {33}" - * - * 7. Patrón sin número (no muy común, pero válido) - * DocumentNumber.create(77, "2025", "SERIE-{series}") - * // → "SERIE-2025" - * - */ - -export interface DocNumberProps { - companyId: UniqueID; - year: number; - docType: DocType; // INVOICE, QUOTATION, DELIVERY_NOTE, PAYMENT... - series: string; // opcional: "2025", "Sucursal-01" - currentValue: number; - formatPattern: string; // ej: "{year}/{number:000000}" -} - -export class DocNumber extends AggregateRoot { - protected _formatted!: string; - - static create(props: DocNumberProps, id?: UniqueID): Result { - const docNumber = new DocNumber(props, id); - - // Reglas de negocio / validaciones - // ... - // ... - - // 🔹 Disparar evento de dominio "CustomerAuthenticatedEvent" - // ... - // ... - - return Result.ok(docNumber); - } - - protected constructor(props: DocNumberProps, id?: UniqueID) { - super(props, id); - - this._formatted = this.applyFormat(); - } - - private applyFormat(): string { - // 🔹 Preprocesar escapes de llaves - const pattern = this.props.formatPattern - .replace(/{{/g, "__LBRACE__") - .replace(/}}/g, "__RBRACE__"); - - const date = new Date(); - - // 🔹 Expresión regular para tokens {token} o {token:0000} - const tokenRegex = /{([a-zA-Z]+)(?::([0]+))?}/g; - - const formatted = pattern.replace(tokenRegex, (_match, token, pad) => { - const name = token.toLowerCase(); - - switch (name) { - case "number": { - const raw = String(this.props.currentValue); - if (pad) { - // validar que el especificador son sólo ceros - if (!/^0+$/.test(pad)) { - throw new Error( - `DocumentNumber: especificador de padding inválido en patrón '${pad}'` - ); - } - return raw.padStart(pad.length, "0"); - } - return raw; - } - - case "series": { - if (!this.props.series) { - throw new Error( - "DocumentNumber: el patrón requiere {series} pero no se proporcionó serie" - ); - } - return this.props.series; - } - - case "year": - return String(date.getFullYear()); - - case "month": - return String(date.getMonth() + 1).padStart(2, "0"); - - case "day": - return String(date.getDate()).padStart(2, "0"); - - default: - throw new Error(`DocumentNumber: token desconocido {${token}}`); - } - }); - - // 🔹 Restaurar escapes - return formatted.replace(/LBRACE/g, "{").replace(/RBRACE/g, "}"); - } - - getFormatted(): string { - return this._formatted; - } - - public get docType(): DocType { - return this.props.docType; - } - - public get series(): string { - return this.props.series; - } - - public get currentValue(): number { - return this.props.currentValue; - } -} diff --git a/modules/doc-numbering/src/api/domain/aggregates/index.ts b/modules/doc-numbering/src/api/domain/aggregates/index.ts deleted file mode 100644 index d32cbec2..00000000 --- a/modules/doc-numbering/src/api/domain/aggregates/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./doc-number"; diff --git a/modules/doc-numbering/src/api/domain/entities/index.ts b/modules/doc-numbering/src/api/domain/entities/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/doc-numbering/src/api/domain/index.ts b/modules/doc-numbering/src/api/domain/index.ts deleted file mode 100644 index 2c5c423d..00000000 --- a/modules/doc-numbering/src/api/domain/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./aggregates"; -export * from "./entities"; -export * from "./repositories"; -export * from "./services"; -export * from "./value-objects"; diff --git a/modules/doc-numbering/src/api/domain/repositories/doc-number-repository.interface.ts b/modules/doc-numbering/src/api/domain/repositories/doc-number-repository.interface.ts deleted file mode 100644 index cf2408df..00000000 --- a/modules/doc-numbering/src/api/domain/repositories/doc-number-repository.interface.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; -import { DocNumber } from "../aggregates/doc-number"; - -export interface IDocNumberingRepository { - getByReferenceInCompany( - companyId: UniqueID, - reference: string, - transaction?: unknown - ): Promise>; - save(reference: string, transaction?: unknown): Promise>; -} diff --git a/modules/doc-numbering/src/api/domain/repositories/index.ts b/modules/doc-numbering/src/api/domain/repositories/index.ts deleted file mode 100644 index b9705864..00000000 --- a/modules/doc-numbering/src/api/domain/repositories/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./doc-number-repository.interface"; diff --git a/modules/doc-numbering/src/api/domain/value-objects/doc-type.ts b/modules/doc-numbering/src/api/domain/value-objects/doc-type.ts deleted file mode 100644 index 2e17b65b..00000000 --- a/modules/doc-numbering/src/api/domain/value-objects/doc-type.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { ValueObject, translateZodValidationError } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; -import { z } from "zod/v4"; - -// 🔹 Conjunto canónico de tipos admitidos en todo el ERP -export const DOCUMENT_TYPE_CODES = [ - "INVOICE", - "QUOTATION", - "DELIVERY_NOTE", - "PAYMENT", - "CREDIT_NOTE", - "RECEIPT", - "PURCHASE_ORDER", -] as const; - -export type DocTypeCode = (typeof DOCUMENT_TYPE_CODES)[number]; - -// 🔹 Alias comunes (entrada libre) → código canónico -const ALIASES: Record = { - // facturas - INVOICE: "INVOICE", - FACTURA: "INVOICE", - "FACTURA-RECTIFICATIVA": "CREDIT_NOTE", - FACTURA_RECTIFICATIVA: "CREDIT_NOTE", - "CREDIT-NOTE": "CREDIT_NOTE", - CREDIT_NOTE: "CREDIT_NOTE", - ABONO: "CREDIT_NOTE", - - // presupuestos - QUOTATION: "QUOTATION", - QUOTE: "QUOTATION", - PRESUPUESTO: "QUOTATION", - - // albaranes - "DELIVERY-NOTE": "DELIVERY_NOTE", - DELIVERY_NOTE: "DELIVERY_NOTE", - ALBARAN: "DELIVERY_NOTE", - ALBARÁN: "DELIVERY_NOTE", - - // pagos / cobros - PAYMENT: "PAYMENT", - PAGO: "PAYMENT", - RECEIPT: "RECEIPT", - RECIBO: "RECEIPT", - - // pedidos - "PURCHASE-ORDER": "PURCHASE_ORDER", - PURCHASE_ORDER: "PURCHASE_ORDER", - "ORDEN-DE-COMPRA": "PURCHASE_ORDER", - ORDEN_DE_COMPRA: "PURCHASE_ORDER", -}; - -// 🔹 Normaliza texto a forma comparable: mayúsculas, sin acentos, separadores como "" -function normalizeToken(input: string): string { - return ( - input - .trim() - .toUpperCase() - // eliminar diacríticos (tildes) - .normalize("NFD") - .replace(/\p{Diacritic}/gu, "") - // espacios/guiones → guion bajo - .replace(/[\s-]+/g, "") - ); -} - -interface DocTypeProps { - value: DocTypeCode; -} - -export class DocType extends ValueObject { - // Validación de código canónico con zod - protected static validateCanonical(value: string) { - const schema = z.enum(DOCUMENT_TYPE_CODES); - return schema.safeParse(value); - } - - // Intenta mapear la entrada libre a un código canónico - protected static canonicalize(input: string): Result { - const token = normalizeToken(input); - const fromAlias = ALIASES[token]; - const candidate = (fromAlias ?? token) as string; - - const parsed = DocType.validateCanonical(candidate); - if (!parsed.success) { - return Result.fail(translateZodValidationError("DocType creation failed", parsed.error)); - } - return Result.ok(parsed.data); - } - - /** - * Factoría desde entrada libre (admite alias). - * Devuelve Result con el VO o el error de validación. - */ - static create(input: string) { - const canon = DocType.canonicalize(input); - if (canon.isFailure) { - return Result.fail(canon.error); - } - return Result.ok(new DocType({ value: canon.data })); - } - - /** - * Factoría directa para código canónico ya validado. - * Útil en mappers desde persistencia. - */ - static from(code: DocTypeCode): DocType { - return new DocType({ value: code }); - } - - /** - * Lista de tipos canónicos soportados (para UI/validadores externos). - */ - static list(): readonly DocTypeCode[] { - return DOCUMENT_TYPE_CODES; - } - - /** - * ¿Pertenece a alguno de los tipos indicados? - */ - isOneOf(...types: DocTypeCode[]): boolean { - return types.includes(this.props.value); - } - - getProps(): DocTypeCode { - return this.props.value; - } - - toPrimitive(): DocTypeCode { - return this.getProps(); - } - - toString(): string { - return this.getProps(); - } -} diff --git a/modules/doc-numbering/src/api/domain/value-objects/index.ts b/modules/doc-numbering/src/api/domain/value-objects/index.ts deleted file mode 100644 index b82a7101..00000000 --- a/modules/doc-numbering/src/api/domain/value-objects/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./doc-type"; diff --git a/modules/doc-numbering/src/api/index.ts b/modules/doc-numbering/src/api/index.ts deleted file mode 100644 index 7ef6d69b..00000000 --- a/modules/doc-numbering/src/api/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IModuleServer, ModuleParams } from "@erp/core/api"; -import { models } from "./infrastructure"; - -export * from "./infrastructure/sequelize"; - -export const docNumberingAPIModule: IModuleServer = { - name: "customers", - version: "1.0.0", - dependencies: [], - - async init(params: ModuleParams) { - const { logger } = params; - logger.info("🚀 Document Numbering module initialized", { label: this.name }); - }, - async registerDependencies(params) { - const { logger } = params; - logger.info("🚀 Document Numbering module dependencies registered", { - label: this.name, - }); - return { - models, - services: { - /*...*/ - }, - }; - }, -}; - -export default docNumberingAPIModule; diff --git a/modules/doc-numbering/src/api/infrastructure/indes.ts b/modules/doc-numbering/src/api/infrastructure/indes.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/doc-numbering/src/api/infrastructure/index.ts b/modules/doc-numbering/src/api/infrastructure/index.ts deleted file mode 100644 index 2af2dbff..00000000 --- a/modules/doc-numbering/src/api/infrastructure/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from ""; diff --git a/modules/doc-numbering/src/api/infrastructure/sequelize/index.ts b/modules/doc-numbering/src/api/infrastructure/sequelize/index.ts deleted file mode 100644 index 1b51346d..00000000 --- a/modules/doc-numbering/src/api/infrastructure/sequelize/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./models"; -export * from "./repositories"; diff --git a/modules/doc-numbering/src/api/infrastructure/sequelize/models/doc-number.ts b/modules/doc-numbering/src/api/infrastructure/sequelize/models/doc-number.ts deleted file mode 100644 index 0ff1baad..00000000 --- a/modules/doc-numbering/src/api/infrastructure/sequelize/models/doc-number.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize"; - -export type DocNumberCreationAttributes = InferCreationAttributes & {}; - -export class DocNumberModel extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: string; // UUID - declare company_id: string; - declare year: number; // 2025, 2026 - declare docType: string; // ej. "INVOICE" - declare series: string | null; // ej. "2025", "Sucursal-01" - declare currentValue: number; // último número asignado - declare formatPattern: string; // ej. "{series}/{number:000000}" - - static associate(database: Sequelize) {} - - static hooks(database: Sequelize) {} -} - -export default (database: Sequelize) => { - DocNumberModel.init( - { - id: { - type: DataTypes.UUID, - primaryKey: true, - }, - company_id: { - type: DataTypes.UUID, - allowNull: false, - }, - year: { - type: DataTypes.SMALLINT(), - allowNull: false, - }, - docType: { - type: DataTypes.STRING(), - allowNull: false, - }, - series: { - type: DataTypes.STRING(), - allowNull: true, - defaultValue: null, - }, - currentValue: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - defaultValue: 0, - }, - formatPattern: { - type: DataTypes.STRING(), - allowNull: false, - defaultValue: "{series}/{number:000000}", - }, - }, - { - sequelize: database, - tableName: "doc-numbers", - - underscored: true, - paranoid: false, // no softs deletes - timestamps: true, - - createdAt: "created_at", - updatedAt: "updated_at", - deletedAt: "deleted_at", - - indexes: [ - { - unique: true, - fields: ["company_id", "year", "docType", "series"], - }, - ], - - whereMergeStrategy: "and", // <- cómo tratar el merge de un scope - - defaultScope: {}, - - scopes: {}, - } - ); - return DocNumberModel; -}; diff --git a/modules/doc-numbering/src/api/infrastructure/sequelize/models/index.ts b/modules/doc-numbering/src/api/infrastructure/sequelize/models/index.ts deleted file mode 100644 index d32cbec2..00000000 --- a/modules/doc-numbering/src/api/infrastructure/sequelize/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./doc-number"; diff --git a/modules/doc-numbering/src/api/infrastructure/sequelize/repositories/doc-number.repository.ts b/modules/doc-numbering/src/api/infrastructure/sequelize/repositories/doc-number.repository.ts deleted file mode 100644 index f33665cc..00000000 --- a/modules/doc-numbering/src/api/infrastructure/sequelize/repositories/doc-number.repository.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api"; - -import { UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; -import { Transaction } from "sequelize"; -import { DocNumber, IDocNumberingRepository } from "../../../domain"; -import { DocNumberModel } from "../models"; - -export class DocNumberRepository - extends SequelizeRepository - implements IDocNumberingRepository -{ - async getByReferenceInCompany( - companyId: UniqueID, - reference: string, - transaction?: unknown - ): Promise> { - try { - const mapper: IDocNumberDomainMapper = this._registry.getDomainMapper({ - resource: "doc-number", - }); - - const row = await DocNumberModel.findOne({ - where: { reference, company_id: companyId.toString() }, - transaction, - }); - - if (!row) { - return Result.fail(new EntityNotFoundError("Customer", "id", id.toString())); - } - - const customer = mapper.mapToDomain(row); - return customer; - } catch (error: any) { - return Result.fail(translateSequelizeError(error)); - } - } - save(reference: string, transaction?: Transaction): Promise> { - throw new Error("Method not implemented."); - } -} diff --git a/modules/doc-numbering/src/api/infrastructure/sequelize/repositories/index.ts b/modules/doc-numbering/src/api/infrastructure/sequelize/repositories/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/doc-numbering/package.json b/modules/factuges/package.json similarity index 62% rename from modules/doc-numbering/package.json rename to modules/factuges/package.json index 0f88a058..9ce01968 100644 --- a/modules/doc-numbering/package.json +++ b/modules/factuges/package.json @@ -1,5 +1,5 @@ { - "name": "@erp/doc-numbering", + "name": "@erp/factuges", "version": "0.5.0", "private": true, "type": "module", @@ -9,24 +9,26 @@ "clean": "rimraf .turbo node_modules dist" }, "exports": { + ".": "./src/common/index.ts", + "./common": "./src/common/index.ts", "./api": "./src/api/index.ts" }, - "peerDependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, "devDependencies": { "@types/express": "^4.17.21", - "react": "^19.1.0", - "react-dom": "^19.1.0", "typescript": "^5.9.3" }, "dependencies": { "@erp/auth": "workspace:*", "@erp/core": "workspace:*", - "@repo/rdx-ddd": "workspace:*", + "@erp/customers": "workspace:*", + "@erp/customer-invoices": "workspace:*", + "@repo/i18next": "workspace:*", "@repo/rdx-criteria": "workspace:*", + "@repo/rdx-ddd": "workspace:*", + "@repo/rdx-logger": "workspace:*", "@repo/rdx-utils": "workspace:*", - "@repo/rdx-logger": "workspace:*" + "express": "^4.18.2", + "sequelize": "^6.37.5", + "zod": "^4.1.11" } } \ No newline at end of file diff --git a/modules/factuges/src/api/application/di/factuges-input-mappers.di.ts b/modules/factuges/src/api/application/di/factuges-input-mappers.di.ts new file mode 100644 index 00000000..9fc6b00b --- /dev/null +++ b/modules/factuges/src/api/application/di/factuges-input-mappers.di.ts @@ -0,0 +1,17 @@ +import type { ICatalogs } from "@erp/core/api"; +import { CreateProformaFromFactugesInputMapper, ICreateProformaFromFactugesInputMapper } from '../mappers'; + +export interface IFactugesInputMappers { + createInputMapper: ICreateProformaFromFactugesInputMapper; +} + +export const buildFactugesInputMappers = (catalogs: ICatalogs): IFactugesInputMappers => { + const { taxCatalog } = catalogs; + + // Mappers el DTO a las props validadas (FactugesProps) y luego construir agregado + const createInputMapper = new CreateProformaFromFactugesInputMapper({ taxCatalog }); + + return { + createInputMapper, + }; +}; diff --git a/modules/factuges/src/api/application/di/factuges-use-cases.di.ts b/modules/factuges/src/api/application/di/factuges-use-cases.di.ts new file mode 100644 index 00000000..ec0abe6a --- /dev/null +++ b/modules/factuges/src/api/application/di/factuges-use-cases.di.ts @@ -0,0 +1,31 @@ +import type { ICatalogs, ITransactionManager } from "@erp/core/api"; +import type { ProformaPublicServices } from "@erp/customer-invoices/api"; +import type { CustomerPublicServices } from "@erp/customers/api"; + +import type { ICreateProformaFromFactugesInputMapper } from "../mappers"; +import { CreateProformaFromFactugesUseCase } from "../use-cases"; + +export function buildCreateProformaFromFactugesUseCase(deps: { + publicServices: { + customerServices: CustomerPublicServices; + proformaServices: ProformaPublicServices; + }; + dtoMapper: ICreateProformaFromFactugesInputMapper; + catalogs: ICatalogs; + transactionManager: ITransactionManager; +}) { + const { + dtoMapper, + transactionManager, + publicServices: { customerServices, proformaServices }, + } = deps; + const { taxCatalog } = deps.catalogs; + + return new CreateProformaFromFactugesUseCase({ + customerServices, + proformaServices, + dtoMapper, + taxCatalog, + transactionManager, + }); +} diff --git a/modules/factuges/src/api/application/di/index.ts b/modules/factuges/src/api/application/di/index.ts new file mode 100644 index 00000000..b20e0e8d --- /dev/null +++ b/modules/factuges/src/api/application/di/index.ts @@ -0,0 +1,2 @@ +export * from "./factuges-use-cases.di"; +export * from "./factuges-input-mappers.di" \ No newline at end of file diff --git a/modules/factuges/src/api/application/index.ts b/modules/factuges/src/api/application/index.ts new file mode 100644 index 00000000..005229d8 --- /dev/null +++ b/modules/factuges/src/api/application/index.ts @@ -0,0 +1,2 @@ +export * from "./mappers"; +export * from "./use-cases"; diff --git a/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts b/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts new file mode 100644 index 00000000..1aa264f7 --- /dev/null +++ b/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts @@ -0,0 +1,575 @@ +import type { JsonTaxCatalogProvider } from "@erp/core"; +import { DiscountPercentage, Tax } from "@erp/core/api"; +import { + type IProformaItemProps, + InvoicePaymentMethod, + InvoiceSerie, + ItemAmount, + ItemDescription, + ItemQuantity, + type ProformaItemTaxesProps, +} from "@erp/customer-invoices/api/domain"; +import type { CustomerTaxesProps } from "@erp/customers/api/domain"; +import { + City, + Country, + CurrencyCode, + DomainError, + EmailAddress, + LanguageCode, + Name, + Percentage, + PhoneNumber, + type PostalAddressProps, + PostalCode, + Province, + Street, + TINNumber, + TextValue, + URLAddress, + type UniqueID, + UtcDate, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + isValidationErrorCollection, + maybeFromNullableResult, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { + CreateProformaFromFactugesRequestDTO, + CreateProformaItemFromFactugesRequestDTO, +} from "../../../common"; + +export interface IProformaFromFactuGESProps { + customerLookup: { + tin: TINNumber; + }; + + customerDraft: { + //reference: Maybe; + + isCompany: boolean; + name: Name; + //tradeName: Maybe; + tin: TINNumber; + + address: PostalAddressProps; + + emailPrimary: Maybe; + emailSecondary: Maybe; + + phonePrimary: Maybe; + phoneSecondary: Maybe; + + mobilePrimary: Maybe; + mobileSecondary: Maybe; + + //fax: Maybe; + website: Maybe; + + //legalRecord: Maybe; + + //defaultTaxes: CustomerTaxesProps; + + languageCode: LanguageCode; + currencyCode: CurrencyCode; + }; + + proformaDraft: { + series: Maybe; + + invoiceDate: UtcDate; + operationDate: Maybe; + + reference: Maybe; + description: Maybe; + notes: Maybe; + + languageCode: LanguageCode; + currencyCode: CurrencyCode; + + paymentMethod: Maybe; + + items: IProformaItemProps[]; + globalDiscountPercentage: DiscountPercentage; + }; +} + +export interface ICreateProformaFromFactugesInputMapper { + map( + dto: CreateProformaFromFactugesRequestDTO, + params: { companyId: UniqueID } + ): Result; +} + +export class CreateProformaFromFactugesInputMapper + implements ICreateProformaFromFactugesInputMapper +{ + private readonly taxCatalog: JsonTaxCatalogProvider; + + constructor(params: { taxCatalog: JsonTaxCatalogProvider }) { + this.taxCatalog = params.taxCatalog; + } + + public map( + dto: CreateProformaFromFactugesRequestDTO, + params: { companyId: UniqueID } + ): Result { + try { + const errors: ValidationErrorDetail[] = []; + const { companyId } = params; + const currencyCode = CurrencyCode.fromEUR(); + + const proformaProps = this.mapProformaProps(dto, { + companyId, + currencyCode, + errors, + }); + + const customerProps = this.mapCustomerProps(dto.customer, { + companyId, + currencyCode, + errors, + }); + + this.throwIfValidationErrors(errors); + + return Result.ok({ + customerLookup: { + tin: customerProps.tin, + }, + customerDraft: customerProps, + proformaDraft: proformaProps, + }); + } catch (err: unknown) { + const error = isValidationErrorCollection(err) + ? (err as ValidationErrorCollection) + : new DomainError("Customer props mapping failed", { cause: (err as Error).message }); + return Result.fail(error); + } + } + + public mapProformaProps( + dto: CreateProformaFromFactugesRequestDTO, + params: { + companyId: UniqueID; + currencyCode: CurrencyCode; + errors: ValidationErrorDetail[]; + } + ): IProformaFromFactuGESProps["proformaDraft"] { + const errors: ValidationErrorDetail[] = []; + const { companyId } = params; + + //const defaultStatus = InvoiceStatus.fromApproved(); + + //const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", errors); + + const series = extractOrPushError( + maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)), + "series", + errors + ); + + /*const proformaNumber = extractOrPushError( + InvoiceNumber.create(dto.), + "invoice_number", + errors + );*/ + + //const factugesID = String(dto.factuges_id); + + const reference = extractOrPushError( + maybeFromNullableResult(dto.reference, (value) => Result.ok(String(value))), + "reference", + errors + ); + + const invoiceDate = extractOrPushError( + UtcDate.createFromISO(dto.invoice_date), + "invoice_date", + errors + ); + + const operationDate = extractOrPushError( + maybeFromNullableResult(dto.operation_date, (value) => UtcDate.createFromISO(value)), + "operation_date", + errors + ); + + const description = extractOrPushError( + maybeFromNullableResult(dto.reference, (value) => Result.ok(String(value))), + "description", + errors + ); + + const notes = extractOrPushError( + maybeFromNullableResult(dto.notes, (value) => TextValue.create(value)), + "notes", + errors + ); + + const paymentMethod = extractOrPushError( + maybeFromNullableResult(dto.payment_method, (value) => + InvoicePaymentMethod.create({ paymentDescription: value }) + ), + "payment_method", + errors + ); + + const globalDiscountPercentage = extractOrPushError( + Percentage.create({ + value: Number(dto.global_discount_percentage.value), + scale: Number(dto.global_discount_percentage.scale), + }), + "discount_percentage", + errors + ); + + const languageCode = extractOrPushError( + LanguageCode.create(dto.customer.language_code), + "language_code", + errors + ); + + const currencyCode = CurrencyCode.fromEUR(); + + const itemsProps = this.mapItemsProps(dto, { + languageCode: languageCode!, + currencyCode: currencyCode, + globalDiscountPercentage: globalDiscountPercentage!, + errors, + }); + + const props: IProformaFromFactuGESProps["proformaDraft"] = { + //companyId, + //status: defaultStatus, + + //invoiceNumber: proformaNumber!, + series: series!, + + invoiceDate: invoiceDate!, + operationDate: operationDate!, + + //customerId: customerId!, + //recipient, + + reference: reference!, + description: description!, + notes: notes!, + + languageCode: languageCode!, + currencyCode: currencyCode!, + + paymentMethod: paymentMethod!, + globalDiscountPercentage: globalDiscountPercentage!, + + items: itemsProps, // ← IProformaItemProps[] + }; + + return props; + } + + private mapCustomerProps( + dto: CreateProformaFromFactugesRequestDTO["customer"], + params: { + companyId: UniqueID; + currencyCode: CurrencyCode; + errors: ValidationErrorDetail[]; + } + ): IProformaFromFactuGESProps["customerDraft"] { + const { errors, currencyCode } = params; + + const isCompany = dto.is_company === "1"; + const name = extractOrPushError(Name.create(dto.name), "name", errors); + const tinNumber = extractOrPushError(TINNumber.create(dto.tin), "tin", errors); + + const street = extractOrPushError( + maybeFromNullableResult(dto.street, (value) => Street.create(value)), + "street", + errors + ); + + const city = extractOrPushError( + maybeFromNullableResult(dto.city, (value) => City.create(value)), + "city", + errors + ); + + const province = extractOrPushError( + maybeFromNullableResult(dto.province, (value) => Province.create(value)), + "province", + errors + ); + + const postalCode = extractOrPushError( + maybeFromNullableResult(dto.postal_code, (value) => PostalCode.create(value)), + "postal_code", + errors + ); + + const country = extractOrPushError( + maybeFromNullableResult(dto.country, (value) => Country.create(value)), + "country", + errors + ); + + const primaryEmailAddress = extractOrPushError( + maybeFromNullableResult(dto.email_primary, (value) => EmailAddress.create(value)), + "email_primary", + errors + ); + + const secondaryEmailAddress = extractOrPushError( + maybeFromNullableResult(dto.email_secondary, (value) => EmailAddress.create(value)), + "email_secondary", + errors + ); + + const primaryPhoneNumber = extractOrPushError( + maybeFromNullableResult(dto.phone_primary, (value) => PhoneNumber.create(value)), + "phone_primary", + errors + ); + + const secondaryPhoneNumber = extractOrPushError( + maybeFromNullableResult(dto.phone_secondary, (value) => PhoneNumber.create(value)), + "phone_secondary", + errors + ); + + const primaryMobileNumber = extractOrPushError( + maybeFromNullableResult(dto.mobile_primary, (value) => PhoneNumber.create(value)), + "mobile_primary", + errors + ); + + const secondaryMobileNumber = extractOrPushError( + maybeFromNullableResult(dto.mobile_secondary, (value) => PhoneNumber.create(value)), + "mobile_secondary", + errors + ); + + const website = extractOrPushError( + maybeFromNullableResult(dto.website, (value) => URLAddress.create(value)), + "website", + errors + ); + + const languageCode = extractOrPushError( + LanguageCode.create(dto.language_code), + "language_code", + errors + ); + + this.throwIfValidationErrors(errors); + + const postalAddressProps: PostalAddressProps = { + street: street!, + street2: Maybe.none(), + city: city!, + postalCode: postalCode!, + province: province!, + country: country!, + }; + + /*const customerTaxes = this.mapCustomerTaxesProps(dto, { + errors, + });*/ + + const customerProps: IProformaFromFactuGESProps["customerDraft"] = { + //companyId, + //status: status!, + + isCompany: isCompany, + name: name!, + tin: tinNumber!, + //tradeName: Maybe.none(), + //reference: Maybe.none(), + + address: postalAddressProps!, + + emailPrimary: primaryEmailAddress!, + emailSecondary: secondaryEmailAddress!, + + phonePrimary: primaryPhoneNumber!, + phoneSecondary: secondaryPhoneNumber!, + + mobilePrimary: primaryMobileNumber!, + mobileSecondary: secondaryMobileNumber!, + + //fax: Maybe.none(), + website: website!, + + //legalRecord: Maybe.none(), + + //defaultTaxes: customerTaxes, + + languageCode: languageCode!, + currencyCode: currencyCode!, + }; + + return customerProps; + } + + private mapCustomerTaxesProps( + dto: CreateProformaFromFactugesRequestDTO["customer"], + params: { + errors: ValidationErrorDetail[]; + } + ): CustomerTaxesProps { + const { errors } = params; + + const iva = extractOrPushError( + maybeFromNullableResult("iva_21", (value) => Tax.createFromCode(value, this.taxCatalog)), + "iva_21", + errors + ); + + this.throwIfValidationErrors(errors); + + return { + iva: iva!, + rec: Maybe.none(), + retention: Maybe.none(), + }; + } + + private mapItemsProps( + dto: CreateProformaFromFactugesRequestDTO, + params: { + languageCode: LanguageCode; + currencyCode: CurrencyCode; + globalDiscountPercentage: DiscountPercentage; + errors: ValidationErrorDetail[]; + } + ): IProformaItemProps[] { + const itemsProps: IProformaItemProps[] = []; + + dto.items.forEach((item, index) => { + const description = extractOrPushError( + maybeFromNullableResult(item.description, (v) => ItemDescription.create(v)), + `items[${index}].description`, + params.errors + ); + + const quantity = extractOrPushError( + maybeFromNullableResult(item.quantity_value, (v) => ItemQuantity.create(v)), + `items[${index}].quantity`, + params.errors + ); + + const unitAmount = extractOrPushError( + maybeFromNullableResult(item.unit_value, (v) => ItemAmount.create(v)), + `items[${index}].unit_amount`, + params.errors + ); + + const discountPercentage = extractOrPushError( + maybeFromNullableResult(item.discount_percentage_value, (v) => + DiscountPercentage.create(v) + ), + `items[${index}].discount_percentage`, + params.errors + ); + + const taxes = this.mapItempTaxesProps(item, { + itemIndex: index, + errors: params.errors, + }); + + this.throwIfValidationErrors(params.errors); + + itemsProps.push({ + globalDiscountPercentage: params.globalDiscountPercentage, + languageCode: params.languageCode, + currencyCode: params.currencyCode, + + description: description!, + quantity: quantity!, + unitAmount: unitAmount!, + itemDiscountPercentage: discountPercentage!, + taxes, + }); + }); + + return itemsProps; + } + + /* Devuelve las propiedades de los impustos de una línea de detalle */ + + private mapItempTaxesProps( + itemDTO: CreateProformaItemFromFactugesRequestDTO, + params: { itemIndex: number; errors: ValidationErrorDetail[] } + ): ProformaItemTaxesProps { + const { itemIndex, errors } = params; + + const taxesProps: ProformaItemTaxesProps = { + iva: Maybe.none(), + retention: Maybe.none(), + rec: Maybe.none(), + }; + + // Normaliza: "" -> [] + // IVA por defecto => iva_21 + const taxStrCodes = [itemDTO.iva_code === "" ? "iva_21" : itemDTO.iva_code]; + + if (itemDTO.rec_code) taxStrCodes.push(itemDTO.rec_code); + if (itemDTO.retention_code) taxStrCodes.push(itemDTO.retention_code); + + taxStrCodes.forEach((strCode, taxIndex) => { + const taxResult = Tax.createFromCode(strCode, this.taxCatalog); + + if (!taxResult.isSuccess) { + errors.push({ + path: `items[${itemIndex}].taxes[${taxIndex}]`, + message: taxResult.error.message, + }); + return; + } + + const tax = taxResult.data; + + if (tax.isVATLike()) { + if (taxesProps.iva.isSome()) { + errors.push({ + path: `items[${itemIndex}].taxes`, + message: "Multiple taxes for group VAT are not allowed", + }); + } + taxesProps.iva = Maybe.some(tax); + } + + if (tax.isRetention()) { + if (taxesProps.retention.isSome()) { + errors.push({ + path: `items[${itemIndex}].taxes`, + message: "Multiple taxes for group retention are not allowed", + }); + } + taxesProps.retention = Maybe.some(tax); + } + + if (tax.isRec()) { + if (taxesProps.rec.isSome()) { + errors.push({ + path: `items[${itemIndex}].taxes`, + message: "Multiple taxes for group rec are not allowed", + }); + } + taxesProps.rec = Maybe.some(tax); + } + }); + + this.throwIfValidationErrors(errors); + + return taxesProps; + } + + private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { + if (errors.length > 0) { + throw new ValidationErrorCollection("Customer proforma props mapping failed", errors); + } + } +} diff --git a/modules/factuges/src/api/application/mappers/index.ts b/modules/factuges/src/api/application/mappers/index.ts new file mode 100644 index 00000000..4864f63b --- /dev/null +++ b/modules/factuges/src/api/application/mappers/index.ts @@ -0,0 +1 @@ +export * from "./create-proforma-from-factuges-input.mapper"; diff --git a/modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts b/modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts new file mode 100644 index 00000000..720100c9 --- /dev/null +++ b/modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts @@ -0,0 +1,219 @@ +import type { JsonTaxCatalogProvider } from "@erp/core"; +import { type ITransactionManager, Tax, isEntityNotFoundError } from "@erp/core/api"; +import type { ProformaPublicServices } from "@erp/customer-invoices/api"; +import { type InvoiceRecipient, InvoiceStatus } from "@erp/customer-invoices/api/domain"; +import type { CustomerPublicServices } from "@erp/customers/api"; +import { + type Customer, + CustomerStatus, + type CustomerTaxesProps, + type ICustomerCreateProps, +} from "@erp/customers/api/domain"; +import { type Name, type PhoneNumber, type TextValue, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import type { IProformaCreatorParams } from "node_modules/@erp/customer-invoices/src/api/application"; +import type { Transaction } from "sequelize"; + +import type { CreateProformaFromFactugesRequestDTO } from "../../../common"; +import type { + ICreateProformaFromFactugesInputMapper, + IProformaFromFactuGESProps, +} from "../mappers"; + +type CreateProformaFromFactugesUseCaseInput = { + companyId: UniqueID; + dto: CreateProformaFromFactugesRequestDTO; +}; + +type CreateProformaFromFactugesUseCaseDeps = { + customerServices: CustomerPublicServices; + proformaServices: ProformaPublicServices; + dtoMapper: ICreateProformaFromFactugesInputMapper; + taxCatalog: JsonTaxCatalogProvider; + transactionManager: ITransactionManager; +}; + +export class CreateProformaFromFactugesUseCase { + private readonly dtoMapper: ICreateProformaFromFactugesInputMapper; + private readonly customerServices: CustomerPublicServices; + private readonly proformaServices: ProformaPublicServices; + private readonly taxCatalog: JsonTaxCatalogProvider; + private readonly transactionManager: ITransactionManager; + + constructor(deps: CreateProformaFromFactugesUseCaseDeps) { + this.customerServices = deps.customerServices; + this.proformaServices = deps.proformaServices; + this.dtoMapper = deps.dtoMapper; + this.taxCatalog = deps.taxCatalog; + this.transactionManager = deps.transactionManager; + } + + public async execute(params: CreateProformaFromFactugesUseCaseInput) { + const { dto, companyId } = params; + + // 1) Mapear DTO → props + const mappedPropsResult = this.dtoMapper.map(dto, { companyId }); + if (mappedPropsResult.isFailure) { + return Result.fail(mappedPropsResult.error); + } + + const { customerLookup, customerDraft, proformaDraft } = mappedPropsResult.data; + + return this.transactionManager.complete(async (transaction: Transaction) => { + try { + const customerResult = await this.resolveCustomer(customerLookup, customerDraft, { + companyId, + transaction, + }); + if (customerResult.isFailure) { + return Result.fail(customerResult.error); + } + + const customer = customerResult.data; + + // Crear la proforma para ese cliente + const createPropsResult = this.buildProformaCreateProps(proformaDraft, customer.id, { + companyId, + transaction, + }); + if (createPropsResult.isFailure) { + return Result.fail(createPropsResult.error); + } + + const createResult = await this.proformaServices.createProforma( + UniqueID.generateNewID(), + createPropsResult.data, + { + companyId, + transaction, + } + ); + + if (createResult.isFailure) { + return Result.fail(createResult.error); + } + + const proforma = createResult.data; + + const snapshot = { + customer_id: customer.id.toString(), + proforma_id: proforma.id.toString(), + }; + + return Result.ok(snapshot); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } + + private buildProformaCreateProps( + proformaDraft: IProformaFromFactuGESProps["proformaDraft"], + customerId: UniqueID, + context: { + companyId: UniqueID; + transaction: Transaction; + } + ): Result { + const { companyId } = context; + + const defaultStatus = InvoiceStatus.fromApproved(); + const recipient = Maybe.none(); + + return Result.ok({ + ...proformaDraft, + companyId, + customerId, + status: defaultStatus, + recipient, + }); + } + + /** + * Resuelve un cliente existente o lo crea si todavía no existe. + * + * Motivo: + * - Centraliza la política "find or create" del caso de uso. + * - Evita duplicar lógica de control y branching en `execute`. + * - Separa los datos de búsqueda de los datos necesarios para alta. + * + * @param customerLookup - Datos mínimos para localizar un cliente existente. + * @param customerDraft - Datos necesarios para crear el cliente si no existe. + * @param context - Contexto transaccional y de compañía. + * @returns `Result` con el cliente resuelto o el error producido. + */ + private async resolveCustomer( + customerLookup: IProformaFromFactuGESProps["customerLookup"], + customerDraft: IProformaFromFactuGESProps["customerDraft"], + context: { + companyId: UniqueID; + transaction: Transaction; + } + ): Promise> { + const { companyId, transaction } = context; + + const existingCustomerResult = await this.customerServices.findCustomerByTIN( + customerLookup.tin, + { companyId, transaction } + ); + + if (existingCustomerResult.isSuccess) { + return Result.ok(existingCustomerResult.data); + } + + if (!isEntityNotFoundError(existingCustomerResult.error)) { + return Result.fail(existingCustomerResult.error); + } + + const createPropsResult = this.buildCustomerCreateProps(customerDraft, context); + if (createPropsResult.isFailure) { + return Result.fail(createPropsResult.error); + } + + return this.customerServices.createCustomer(UniqueID.generateNewID(), createPropsResult.data, { + companyId, + transaction, + }); + } + + private buildCustomerCreateProps( + customerDraft: IProformaFromFactuGESProps["customerDraft"], + context: { + companyId: UniqueID; + transaction: Transaction; + } + ): Result { + const { companyId } = context; + + const status = CustomerStatus.createActive(); + + const ivaResult = Tax.createFromCode("iva_21", this.taxCatalog); + if (ivaResult.isFailure) { + return Result.fail(ivaResult.error); + } + + const defaultTaxes: CustomerTaxesProps = { + iva: Maybe.some(ivaResult.data), + rec: Maybe.none(), + retention: Maybe.none(), + }; + + const tin = Maybe.some(customerDraft.tin); + const tradeName = Maybe.none(); + const reference = Maybe.none(); + const fax = Maybe.none(); + const legalRecord = Maybe.none(); + + return Result.ok({ + ...customerDraft, + companyId, + status, + tin, + tradeName, + reference, + fax, + legalRecord, + defaultTaxes, + }); + } +} diff --git a/modules/factuges/src/api/application/use-cases/index.ts b/modules/factuges/src/api/application/use-cases/index.ts new file mode 100644 index 00000000..ae38d36d --- /dev/null +++ b/modules/factuges/src/api/application/use-cases/index.ts @@ -0,0 +1 @@ +export * from "./create-proforma-from-factuges.use-case"; diff --git a/modules/factuges/src/api/index.ts b/modules/factuges/src/api/index.ts new file mode 100644 index 00000000..88b28c5a --- /dev/null +++ b/modules/factuges/src/api/index.ts @@ -0,0 +1,75 @@ +import type { IModuleServer } from "@erp/core/api"; +import type { ProformaPublicServices } from "@erp/customer-invoices/api"; +import type { CustomerPublicServices } from "@erp/customers/api"; + +import { factugesRouter } from "./infraestructure"; +import { type FactugesInternalDeps, buildFactugesDependencies } from "./infraestructure/di"; + +export const factugesAPIModule: IModuleServer = { + name: "factuges", + version: "1.0.0", + dependencies: ["customers", "customer-invoices"], + + /** + * Fase de SETUP + * ---------------- + * - Construye el dominio (una sola vez) + * - Define qué expone el módulo + * - NO conecta infraestructura + */ + async setup(params) { + const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; + + // 1) Dominio interno + const factugesInternapDeps = buildFactugesDependencies(params); + + // 2) Servicios públicos (Application Services) + // nada + + logger.info("🚀 Factuges module dependencies registered", { label: this.name }); + + return { + // Modelos Sequelize del módulo + models: [], + + // Servicios expuestos a otros módulos + services: {}, + + // Implementación privada del módulo + internal: factugesInternapDeps, + }; + }, + + /** + * Fase de START + * ------------- + * - Conecta el módulo al runtime + * - Puede usar servicios e internals ya construidos + * - NO construye dominio + */ + async start(params) { + const { app, baseRoutePath, logger, getInternal, getService, listServices } = params; + + // Recuperamos el dominio interno del módulo + const factugesInternapDeps = getInternal("factuges"); + + // Recuperamos servicios externos que necesitemos + const customerServices = getService("customers:general"); + const proformaServices = getService("customer-invoices:proformas"); + + // Registro de rutas HTTP + factugesRouter(params, factugesInternapDeps, { customerServices, proformaServices }); + + logger.info("🚀 Factuges module started", { label: this.name }); + }, + + /** + * Warmup opcional (si lo necesitas en el futuro) + * ---------------------------------------------- + * warmup(params) { + * ... + * } + */ +}; + +export default factugesAPIModule; diff --git a/modules/factuges/src/api/infraestructure/di/factuges.di.ts b/modules/factuges/src/api/infraestructure/di/factuges.di.ts new file mode 100644 index 00000000..dc1e1c01 --- /dev/null +++ b/modules/factuges/src/api/infraestructure/di/factuges.di.ts @@ -0,0 +1,46 @@ +import { type SetupParams, buildCatalogs, buildTransactionManager } from "@erp/core/api"; +import type { ProformaPublicServices } from "@erp/customer-invoices/api"; +import type { CustomerPublicServices } from "@erp/customers/api"; + +import { + buildCreateProformaFromFactugesUseCase, + buildFactugesInputMappers, +} from "../../application/di"; +import type { CreateProformaFromFactugesUseCase } from "../../application/use-cases"; + +export type FactugesInternalDeps = { + useCases: { + createProforma: (publicServices: { + customerServices: CustomerPublicServices; + proformaServices: ProformaPublicServices; + }) => CreateProformaFromFactugesUseCase; + }; +}; + +export function buildFactugesDependencies(params: SetupParams): FactugesInternalDeps { + const { database } = params; + + // Infrastructure + const transactionManager = buildTransactionManager(database); + const catalogs = buildCatalogs(); + + // Application helpers + const inputMappers = buildFactugesInputMappers(catalogs); + + // Internal use cases (factories) + return { + useCases: { + createProforma: (publicServices: { + customerServices: CustomerPublicServices; + proformaServices: ProformaPublicServices; + }) => { + return buildCreateProformaFromFactugesUseCase({ + dtoMapper: inputMappers.createInputMapper, + publicServices, + catalogs, + transactionManager, + }); + }, + }, + }; +} diff --git a/modules/factuges/src/api/infraestructure/di/index.ts b/modules/factuges/src/api/infraestructure/di/index.ts new file mode 100644 index 00000000..4371bb79 --- /dev/null +++ b/modules/factuges/src/api/infraestructure/di/index.ts @@ -0,0 +1 @@ +export * from "./factuges.di"; diff --git a/modules/factuges/src/api/infraestructure/express/controllers/create-proforma-from-factuges.controller.ts b/modules/factuges/src/api/infraestructure/express/controllers/create-proforma-from-factuges.controller.ts new file mode 100644 index 00000000..d8d63c1e --- /dev/null +++ b/modules/factuges/src/api/infraestructure/express/controllers/create-proforma-from-factuges.controller.ts @@ -0,0 +1,39 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { CreateProformaFromFactugesRequestDTO } from "../../../../common/dto/request/create-proforma-from-factuges.request.dto.js"; +import type { CreateProformaFromFactugesUseCase } from "../../../application/use-cases/create-proforma-from-factuges.use-case.js"; +import { factugesApiErrorMapper } from "../factuges-api-error-mapper.js"; + +export class CreateProformaFromFactugesController extends ExpressController { + public constructor(private readonly useCase: CreateProformaFromFactugesUseCase) { + super(); + this.errorMapper = factugesApiErrorMapper; + + // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + const dto = this.req.body as CreateProformaFromFactugesRequestDTO; + + const result = await this.useCase.execute({ dto, companyId }); + + return result.match( + (data) => this.created(data), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/factuges/src/api/infraestructure/express/controllers/index.ts b/modules/factuges/src/api/infraestructure/express/controllers/index.ts new file mode 100644 index 00000000..c02d4553 --- /dev/null +++ b/modules/factuges/src/api/infraestructure/express/controllers/index.ts @@ -0,0 +1 @@ +export * from "./create-proforma-from-factuges.controller"; diff --git a/modules/factuges/src/api/infraestructure/express/factuges-api-error-mapper.ts b/modules/factuges/src/api/infraestructure/express/factuges-api-error-mapper.ts new file mode 100644 index 00000000..d8f83d67 --- /dev/null +++ b/modules/factuges/src/api/infraestructure/express/factuges-api-error-mapper.ts @@ -0,0 +1,3 @@ +import { ApiErrorMapper } from "@erp/core/api"; + +export const factugesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default(); diff --git a/modules/factuges/src/api/infraestructure/express/factuges.routes.ts b/modules/factuges/src/api/infraestructure/express/factuges.routes.ts new file mode 100644 index 00000000..53c38637 --- /dev/null +++ b/modules/factuges/src/api/infraestructure/express/factuges.routes.ts @@ -0,0 +1,56 @@ +import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api"; +import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api"; +import type { ProformaPublicServices } from "@erp/customer-invoices/api"; +import type { CustomerPublicServices } from "@erp/customers/api"; +import { type NextFunction, type Request, type Response, Router } from "express"; + +import { CreateProformaFromFactugesRequestSchema } from "../../../common/dto/request/create-proforma-from-factuges.request.dto"; +import type { FactugesInternalDeps } from "../di/factuges.di"; + +import { CreateProformaFromFactugesController } from "./controllers"; + +export const factugesRouter = ( + params: ModuleParams, + deps: FactugesInternalDeps, + publicServices: { + customerServices: CustomerPublicServices; + proformaServices: ProformaPublicServices; + } +) => { + const { app, config } = params; + + const router: Router = Router({ mergeParams: true }); + + // 🔐 Autenticación + Tenancy para TODO el router + if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") { + router.use( + (req: Request, res: Response, next: NextFunction) => + mockUser(req as RequestWithAuth, res, next) // Debe ir antes de las rutas protegidas + ); + } + + //router.use(/*authenticateJWT(),*/ enforceTenant() /*checkTabContext*/); + router.use([ + (req: Request, res: Response, next: NextFunction) => + requireAuthenticated()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas + + (req: Request, res: Response, next: NextFunction) => + requireCompanyContext()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas + ]); + + // ---------------------------------------------- + + router.post( + "/", + //checkTabContext, + + validateRequest(CreateProformaFromFactugesRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.createProforma(publicServices); + const controller = new CreateProformaFromFactugesController(useCase); + return controller.execute(req, res, next); + } + ); + + app.use(`${config.server.apiBasePath}/factuges`, router); +}; diff --git a/modules/factuges/src/api/infraestructure/express/index.ts b/modules/factuges/src/api/infraestructure/express/index.ts new file mode 100644 index 00000000..f518cdfc --- /dev/null +++ b/modules/factuges/src/api/infraestructure/express/index.ts @@ -0,0 +1 @@ +export * from "./factuges.routes"; diff --git a/modules/factuges/src/api/infraestructure/index.ts b/modules/factuges/src/api/infraestructure/index.ts new file mode 100644 index 00000000..6b5f6511 --- /dev/null +++ b/modules/factuges/src/api/infraestructure/index.ts @@ -0,0 +1 @@ +export * from "./express"; diff --git a/modules/factuges/src/common/dto/index.ts b/modules/factuges/src/common/dto/index.ts new file mode 100644 index 00000000..204ff2a0 --- /dev/null +++ b/modules/factuges/src/common/dto/index.ts @@ -0,0 +1,2 @@ +export * from "./request"; +//export * from "./response"; diff --git a/modules/factuges/src/common/dto/request/create-proforma-from-factuges.request.dto.ts b/modules/factuges/src/common/dto/request/create-proforma-from-factuges.request.dto.ts new file mode 100644 index 00000000..5be833fc --- /dev/null +++ b/modules/factuges/src/common/dto/request/create-proforma-from-factuges.request.dto.ts @@ -0,0 +1,78 @@ +import { NumericStringSchema, PercentageSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const CreateProformaItemFromFactugesRequestSchema = z.object({ + position: z.string(), + description: z.string().default(""), + quantity_value: NumericStringSchema.default(""), // Ya viene escalado + unit_value: NumericStringSchema.default(""), + + discount_percentage_value: NumericStringSchema.default(""), + global_discount_percentage_value: NumericStringSchema.default(""), + + iva_code: z.string().default(""), + iva_percentage_value: NumericStringSchema.default(""), + + rec_code: z.string().default(""), + rec_percentage_value: NumericStringSchema.default(""), + + retention_code: z.string().default(""), + retention_percentage_value: NumericStringSchema.default(""), +}); + +export type CreateProformaItemFromFactugesRequestDTO = z.infer< + typeof CreateProformaItemFromFactugesRequestSchema +>; + +export const CreateProformaFromFactugesRequestSchema = z.object({ + //factuges_id: z.string(), + + //id: z.uuid(), + + series: z.string().default(""), + //invoice_number: z.string(), + + reference: z.string().default(""), + + invoice_date: z.string(), + operation_date: z.string().default(""), + + description: z.string().default(""), + notes: z.string().default(""), + + customer: z.object({ + //factuges_id: z.string(), + is_company: z.string(), + name: z.string(), + tin: z.string(), + + street: z.string(), + city: z.string(), + province: z.string(), + postal_code: z.string(), + country: z.string(), + + language_code: z.string(), + + phone_primary: z.string(), + phone_secondary: z.string(), + mobile_primary: z.string(), + mobile_secondary: z.string(), + email_primary: z.string(), + email_secondary: z.string(), + + website: z.string(), + }), + + global_discount_percentage: PercentageSchema.default({ + value: "0", + scale: "2", + }), + + payment_method: z.string().default(""), + + items: z.array(CreateProformaItemFromFactugesRequestSchema).default([]), +}); +export type CreateProformaFromFactugesRequestDTO = z.infer< + typeof CreateProformaFromFactugesRequestSchema +>; diff --git a/modules/factuges/src/common/dto/request/index.ts b/modules/factuges/src/common/dto/request/index.ts new file mode 100644 index 00000000..04e0c508 --- /dev/null +++ b/modules/factuges/src/common/dto/request/index.ts @@ -0,0 +1 @@ +export * from "./create-proforma-from-factuges.request.dto"; diff --git a/modules/factuges/src/common/dto/response/create-proforma-from-factuges.response.dto.ts b/modules/factuges/src/common/dto/response/create-proforma-from-factuges.response.dto.ts new file mode 100644 index 00000000..df2cf084 --- /dev/null +++ b/modules/factuges/src/common/dto/response/create-proforma-from-factuges.response.dto.ts @@ -0,0 +1,6 @@ +import { z } from "zod/v4"; + +export const CreateProformaFromFactugesResponseSchema = z.object({ + id: z.uuid(), + customer_id: z.uuid(), +}); diff --git a/modules/factuges/src/common/index.ts b/modules/factuges/src/common/index.ts new file mode 100644 index 00000000..0392b1b4 --- /dev/null +++ b/modules/factuges/src/common/index.ts @@ -0,0 +1 @@ +export * from "./dto"; diff --git a/modules/doc-numbering/tsconfig.json b/modules/factuges/tsconfig.json similarity index 94% rename from modules/doc-numbering/tsconfig.json rename to modules/factuges/tsconfig.json index 67847e4e..b209f7ca 100644 --- a/modules/doc-numbering/tsconfig.json +++ b/modules/factuges/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@erp/doc-numbering/*": ["./src/*"] + "@erp/factuges/*": ["./src/*"] }, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", diff --git a/packages/rdx-ddd/src/value-objects/currency-code.ts b/packages/rdx-ddd/src/value-objects/currency-code.ts index 67b02237..be5b415d 100644 --- a/packages/rdx-ddd/src/value-objects/currency-code.ts +++ b/packages/rdx-ddd/src/value-objects/currency-code.ts @@ -1,6 +1,8 @@ import { Result } from "@repo/rdx-utils"; import { z } from "zod/v4"; + import { translateZodValidationError } from "../helpers"; + import { ValueObject } from "./value-object"; interface CurrencyCodeProps { @@ -14,12 +16,16 @@ interface CurrencyCodeProps { export class CurrencyCode extends ValueObject { private static readonly MIN_LENGTH = 3; private static readonly MAX_LENGTH = 3; + private static readonly DEFAULT_CODE = "EUR"; protected static validate(value: string) { const schema = z .string() .trim() .uppercase() + .regex(/^[A-Z]{3}$/, { + message: "CurrencyCode must contain only uppercase letters", + }) .min(CurrencyCode.MIN_LENGTH, { message: `CurrencyCode must be at least ${CurrencyCode.MIN_LENGTH} characters long`, }) @@ -41,6 +47,10 @@ export class CurrencyCode extends ValueObject { return Result.ok(new CurrencyCode({ value: valueIsValid.data })); } + static fromEUR() { + return new CurrencyCode({ value: CurrencyCode.DEFAULT_CODE }); + } + get code(): string { return this.props.value; } diff --git a/packages/rdx-ddd/src/value-objects/postal-address.ts b/packages/rdx-ddd/src/value-objects/postal-address.ts index 47eb177c..f2ed3109 100644 --- a/packages/rdx-ddd/src/value-objects/postal-address.ts +++ b/packages/rdx-ddd/src/value-objects/postal-address.ts @@ -35,12 +35,8 @@ export class PostalAddress extends ValueObject { } public update(partial: PostalAddressPatchProps): Result { - const updatedProps = { - ...this.props, - ...partial, - } as PostalAddressProps; - - return PostalAddress.create(updatedProps); + Object.assign(this.props, partial); + return Result.ok(); } get street(): Maybe { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 687677f0..33565799 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: change-case: specifier: ^5.4.4 version: 5.4.4 - eslint: - specifier: ^10.0.2 - version: 10.0.2(jiti@2.6.1) inquirer: specifier: ^12.10.0 version: 12.10.0(@types/node@22.19.0) @@ -59,6 +56,9 @@ importers: '@erp/customers': specifier: workspace:* version: link:../../modules/customers + '@erp/factuges': + specifier: workspace:* + version: link:../../modules/factuges '@repo/rdx-logger': specifier: workspace:* version: link:../../packages/rdx-logger @@ -682,6 +682,52 @@ importers: specifier: ^5.9.3 version: 5.9.3 + modules/factuges: + dependencies: + '@erp/auth': + specifier: workspace:* + version: link:../auth + '@erp/core': + specifier: workspace:* + version: link:../core + '@erp/customer-invoices': + specifier: workspace:* + version: link:../customer-invoices + '@erp/customers': + specifier: workspace:* + version: link:../customers + '@repo/i18next': + specifier: workspace:* + version: link:../../packages/i18n + '@repo/rdx-criteria': + specifier: workspace:* + version: link:../../packages/rdx-criteria + '@repo/rdx-ddd': + specifier: workspace:* + version: link:../../packages/rdx-ddd + '@repo/rdx-logger': + specifier: workspace:* + version: link:../../packages/rdx-logger + '@repo/rdx-utils': + specifier: workspace:* + version: link:../../packages/rdx-utils + express: + specifier: ^4.18.2 + version: 4.21.2 + sequelize: + specifier: ^6.37.5 + version: 6.37.7(mysql2@3.15.3)(pg-hstore@2.3.4) + zod: + specifier: ^4.1.11 + version: 4.1.12 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/i18n: dependencies: i18next: diff --git a/uecko-erp.code-workspace b/uecko-erp.code-workspace index 362d7c25..a6a83db3 100644 --- a/uecko-erp.code-workspace +++ b/uecko-erp.code-workspace @@ -3,5 +3,8 @@ { "path": "." } - ] + ], + "settings": { + "chatgpt.openOnStartup": true + } } \ No newline at end of file