From f6f57359d5904d97f02c1e9d8a5c9c1a4886ddb0 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 15 Jun 2026 20:27:11 +0200 Subject: [PATCH] . --- .../payment-methods/public/index.ts | 2 +- .../api/application/payment-terms/index.ts | 1 + .../application/payment-terms/public/index.ts | 3 + .../payment-terms/public/mappers/index.ts | 3 + .../payment-term-public-model.mapper.ts | 14 ++ .../payment-terms/public/models/index.ts | 1 + .../models/payment-term-public.model.ts | 12 ++ .../payment-terms/public/services/index.ts | 1 + .../services/payment-term-public-finder.ts | 86 ++++++++ .../services/payment-term-public-services.ts | 10 +- .../payment-term-repository.interface.ts | 7 +- .../payment-terms/services/index.ts | 3 +- modules/catalogs/src/api/index.ts | 1 + .../sequelize-payment-method.repository.ts | 15 +- .../sequelize-payment-term.repository.ts | 36 +++- .../sequelize-tax-definition.repository.ts | 5 +- .../sequelize-tax-regime.repository.ts | 5 +- .../customer-invoice-criteria-whitelist.ts | 167 --------------- .../blocks/proformas-grid/proformas-grid.bak | 73 ------- .../ui/blocks/proforma-update-header.tsx | 2 - modules/customers/package.json | 1 + .../models/customer-full-read-model.ts | 20 ++ .../customer-payment-method-read.model.ts | 12 ++ .../customer-payment-term-read.model.ts | 12 ++ .../customer-tax-regime-full-read.model.ts | 8 + .../src/api/application/models/index.ts | 4 + .../src/api/application/presenters/index.ts | 2 - .../application/presenters/queries/index.ts | 1 - .../queries/list-customers.presenter.ts | 84 -------- .../customer-full-read-model.assembler.ts | 196 ++++++++++++++++++ .../application/services/assemblers/index.ts | 1 + .../full/customer-full-snapshot-builder.ts | 30 ++- .../use-cases/get-customer-by-id.use-case.ts | 29 ++- .../domain/aggregates/customer.aggregate.ts | 123 +++++------ .../sequelize-customer-domain.mapper.ts | 52 +++-- .../models/sequelize-customer.model.ts | 37 +++- .../request/create-customer.request.dto.ts | 6 + .../update-customer-by-id.request.dto.ts | 8 +- .../get-customer-by-id.response.dto.ts | 14 +- .../shared/customer-payment-method-ref.dto.ts | 9 + .../shared/customer-payment-term-ref.dto.ts | 8 + .../dto/shared/customer-tax-regime-ref.dto.ts | 8 + .../customers/src/common/dto/shared/index.ts | 4 +- .../dto/shared/tax-combination-code.dto.ts | 23 -- .../use-customer-grid-columns.tsx | 134 +++++++++--- .../use-customer-update-page.controller.ts | 5 + .../ui/blocks/customer-update-header.tsx | 123 +++++++++++ .../src/web/update/ui/blocks/index.ts | 1 + .../update/ui/pages/customer-update-page.tsx | 78 ++++--- .../supplier-invoice.sequelize-repository.ts | 39 ++-- .../sequelize-supplier.repository.ts | 2 +- pnpm-lock.yaml | 3 + 52 files changed, 964 insertions(+), 560 deletions(-) create mode 100644 modules/catalogs/src/api/application/payment-terms/public/index.ts create mode 100644 modules/catalogs/src/api/application/payment-terms/public/mappers/index.ts create mode 100644 modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts create mode 100644 modules/catalogs/src/api/application/payment-terms/public/models/index.ts create mode 100644 modules/catalogs/src/api/application/payment-terms/public/models/payment-term-public.model.ts create mode 100644 modules/catalogs/src/api/application/payment-terms/public/services/index.ts create mode 100644 modules/catalogs/src/api/application/payment-terms/public/services/payment-term-public-finder.ts rename modules/catalogs/src/api/application/payment-terms/{ => public}/services/payment-term-public-services.ts (58%) delete mode 100644 modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-criteria-whitelist.ts delete mode 100644 modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/proformas-grid.bak create mode 100644 modules/customers/src/api/application/models/customer-full-read-model.ts create mode 100644 modules/customers/src/api/application/models/customer-payment-method-read.model.ts create mode 100644 modules/customers/src/api/application/models/customer-payment-term-read.model.ts create mode 100644 modules/customers/src/api/application/models/customer-tax-regime-full-read.model.ts delete mode 100644 modules/customers/src/api/application/presenters/index.ts delete mode 100644 modules/customers/src/api/application/presenters/queries/index.ts delete mode 100644 modules/customers/src/api/application/presenters/queries/list-customers.presenter.ts create mode 100644 modules/customers/src/api/application/services/assemblers/customer-full-read-model.assembler.ts create mode 100644 modules/customers/src/api/application/services/assemblers/index.ts create mode 100644 modules/customers/src/common/dto/shared/customer-payment-method-ref.dto.ts create mode 100644 modules/customers/src/common/dto/shared/customer-payment-term-ref.dto.ts create mode 100644 modules/customers/src/common/dto/shared/customer-tax-regime-ref.dto.ts delete mode 100644 modules/customers/src/common/dto/shared/tax-combination-code.dto.ts create mode 100644 modules/customers/src/web/update/ui/blocks/customer-update-header.tsx create mode 100644 modules/customers/src/web/update/ui/blocks/index.ts diff --git a/modules/catalogs/src/api/application/payment-methods/public/index.ts b/modules/catalogs/src/api/application/payment-methods/public/index.ts index 0035998c..d169696f 100644 --- a/modules/catalogs/src/api/application/payment-methods/public/index.ts +++ b/modules/catalogs/src/api/application/payment-methods/public/index.ts @@ -1,4 +1,4 @@ export * from "./mappers"; export * from "./models/"; export * from "./services"; -export * from "./services/"; + diff --git a/modules/catalogs/src/api/application/payment-terms/index.ts b/modules/catalogs/src/api/application/payment-terms/index.ts index b68ea0dc..77d7c040 100644 --- a/modules/catalogs/src/api/application/payment-terms/index.ts +++ b/modules/catalogs/src/api/application/payment-terms/index.ts @@ -1,6 +1,7 @@ export * from "./di"; export * from "./mappers"; export * from "./models"; +export * from "./public"; export * from "./repositories"; export * from "./services"; export * from "./snapshot-builders"; diff --git a/modules/catalogs/src/api/application/payment-terms/public/index.ts b/modules/catalogs/src/api/application/payment-terms/public/index.ts new file mode 100644 index 00000000..b5bfd7e1 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/public/index.ts @@ -0,0 +1,3 @@ +export * from "./mappers"; +export * from "./models"; +export * from "./services"; diff --git a/modules/catalogs/src/api/application/payment-terms/public/mappers/index.ts b/modules/catalogs/src/api/application/payment-terms/public/mappers/index.ts new file mode 100644 index 00000000..4970a04f --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/public/mappers/index.ts @@ -0,0 +1,3 @@ + +export * from "./payment-term-public-model.mapper"; + diff --git a/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts b/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts new file mode 100644 index 00000000..20848448 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts @@ -0,0 +1,14 @@ +import type { PaymentTerm } from "../../../domain"; +import type { PaymentTermPublicModel } from "../models"; + +export class PaymentTermPublicModelMapper { + public toPublicModel(paymentTerm: PaymentTerm): PaymentTermPublicModel { + return { + id: paymentTerm.id, + companyId: paymentTerm.companyId, + name: paymentTerm.name.toPrimitive(), + description: paymentTerm.description.map((value) => value.toPrimitive()), + isActive: paymentTerm.isActive, + }; + } +} diff --git a/modules/catalogs/src/api/application/payment-terms/public/models/index.ts b/modules/catalogs/src/api/application/payment-terms/public/models/index.ts new file mode 100644 index 00000000..f780a18c --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/public/models/index.ts @@ -0,0 +1 @@ +export * from "./payment-term-public.model"; diff --git a/modules/catalogs/src/api/application/payment-terms/public/models/payment-term-public.model.ts b/modules/catalogs/src/api/application/payment-terms/public/models/payment-term-public.model.ts new file mode 100644 index 00000000..627fb255 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/public/models/payment-term-public.model.ts @@ -0,0 +1,12 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; + +export interface PaymentTermPublicModel { + id: UniqueID; + companyId: UniqueID; + + name: string; + description: Maybe; + + isActive: boolean; +} diff --git a/modules/catalogs/src/api/application/payment-terms/public/services/index.ts b/modules/catalogs/src/api/application/payment-terms/public/services/index.ts new file mode 100644 index 00000000..75bce232 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/public/services/index.ts @@ -0,0 +1 @@ +export * from "./payment-term-public-finder"; diff --git a/modules/catalogs/src/api/application/payment-terms/public/services/payment-term-public-finder.ts b/modules/catalogs/src/api/application/payment-terms/public/services/payment-term-public-finder.ts new file mode 100644 index 00000000..bec9e9cb --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/public/services/payment-term-public-finder.ts @@ -0,0 +1,86 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { PaymentTermPublicModelMapper } from "../mappers"; +import type { PaymentTermPublicModel } from "../models"; +import type { IPaymentTermRepository } from "../repositories"; + +export interface FindPaymentTermByIdInCompanyParams { + companyId: UniqueID; + id: UniqueID; + transaction?: unknown; +} + +export interface IPaymentTermPublicFinder { + existsByIdInCompany(params: FindPaymentTermByIdInCompanyParams): Promise>; + + getByIdInCompany( + params: FindPaymentTermByIdInCompanyParams + ): Promise>; + + findByIdInCompany( + params: FindPaymentTermByIdInCompanyParams + ): Promise, Error>>; +} + +export class PaymentTermPublicFinder implements IPaymentTermPublicFinder { + public constructor( + private readonly deps: { + repository: IPaymentTermRepository; + mapper: PaymentTermPublicModelMapper; + } + ) {} + + public async existsByIdInCompany( + params: FindPaymentTermByIdInCompanyParams + ): Promise> { + const result = await this.deps.repository.existsByIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(result.data); + } + + public async getByIdInCompany( + params: FindPaymentTermByIdInCompanyParams + ): Promise> { + const result = await this.deps.repository.getByIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(this.deps.mapper.toPublicModel(result.data)); + } + + public async findByIdInCompany( + params: FindPaymentTermByIdInCompanyParams + ): Promise, Error>> { + const result = await this.deps.repository.findByIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok( + result.data.match( + (paymentTerm) => Maybe.some(this.deps.mapper.toPublicModel(paymentTerm)), + () => Maybe.none() + ) + ); + } +} diff --git a/modules/catalogs/src/api/application/payment-terms/services/payment-term-public-services.ts b/modules/catalogs/src/api/application/payment-terms/public/services/payment-term-public-services.ts similarity index 58% rename from modules/catalogs/src/api/application/payment-terms/services/payment-term-public-services.ts rename to modules/catalogs/src/api/application/payment-terms/public/services/payment-term-public-services.ts index b4306fe9..72cdbea3 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/payment-term-public-services.ts +++ b/modules/catalogs/src/api/application/payment-terms/public/services/payment-term-public-services.ts @@ -1,8 +1,8 @@ -import type { IPaymentTermCreator } from "./payment-term-creator.service"; -import type { IPaymentTermDeleter } from "./payment-term-deleter.service"; -import type { IPaymentTermFinder } from "./payment-term-finder.service"; -import type { IPaymentTermStatusChanger } from "./payment-term-status-changer.service"; -import type { IPaymentTermUpdater } from "./payment-term-updater.service"; +import type { IPaymentTermCreator } from "../../services"; +import type { IPaymentTermDeleter } from "../../services"; +import type { IPaymentTermFinder } from "../../services"; +import type { IPaymentTermStatusChanger } from "../../services"; +import type { IPaymentTermUpdater } from "../../services"; export interface IPaymentTermPublicServices { finder: IPaymentTermFinder; diff --git a/modules/catalogs/src/api/application/payment-terms/repositories/payment-term-repository.interface.ts b/modules/catalogs/src/api/application/payment-terms/repositories/payment-term-repository.interface.ts index a2c913dd..039eca47 100644 --- a/modules/catalogs/src/api/application/payment-terms/repositories/payment-term-repository.interface.ts +++ b/modules/catalogs/src/api/application/payment-terms/repositories/payment-term-repository.interface.ts @@ -1,6 +1,6 @@ import type { Criteria } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; -import type { Collection, Result } from "@repo/rdx-utils"; +import type { Collection, Maybe, Result } from "@repo/rdx-utils"; import type { PaymentTerm } from "../../../domain"; import type { PaymentTermSummary } from "../models/payment-term-summary.model"; @@ -23,6 +23,11 @@ export interface IPaymentTermRepository { id: UniqueID, transaction?: unknown ): Promise>; + findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise, Error>>; findByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, diff --git a/modules/catalogs/src/api/application/payment-terms/services/index.ts b/modules/catalogs/src/api/application/payment-terms/services/index.ts index 135f0453..0bd4014c 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/index.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/index.ts @@ -1,6 +1,7 @@ +export * from "../public/services/payment-term-public-services"; + export * from "./payment-term-creator.service"; export * from "./payment-term-deleter.service"; export * from "./payment-term-finder.service"; -export * from "./payment-term-public-services"; export * from "./payment-term-status-changer.service"; export * from "./payment-term-updater.service"; diff --git a/modules/catalogs/src/api/index.ts b/modules/catalogs/src/api/index.ts index ad825e3c..ee067789 100644 --- a/modules/catalogs/src/api/index.ts +++ b/modules/catalogs/src/api/index.ts @@ -16,6 +16,7 @@ import { } from "./infrastructure/di/catalogs.di"; export * from "./application/payment-methods/public"; +export * from "./application/payment-terms/public"; export * from "./application/tax-definitions/public"; export * from "./application/tax-regimes/public"; diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts index 97338523..0375ae30 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts @@ -191,9 +191,12 @@ export class SequelizePaymentMethodRepository ): Promise, Error>> { try { const criteriaConverter = new CriteriaToSequelizeConverter(); - const query = criteriaConverter.convert(criteria, { + const criteriaQuery = criteriaConverter.convert(criteria, { mappings: { - isActive: "is_active", + isActive: { + type: "root", + column: "is_active", + }, }, searchableFields: [], sortableFields: ["name"], @@ -202,19 +205,19 @@ export class SequelizePaymentMethodRepository strictMode: true, // fuerza error si ORDER BY no permitido }); - query.where = { - ...query.where, + criteriaQuery.where = { + ...criteriaQuery.where, company_id: companyId.toString(), deleted_at: null, }; const [rows, count] = await Promise.all([ PaymentMethodModel.findAll({ - ...query, + ...criteriaQuery, transaction, }), PaymentMethodModel.count({ - where: query.where, + where: criteriaQuery.where, distinct: true, // evita duplicados por LEFT JOIN transaction, }), diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts index c5fb907f..7d3a3eda 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts @@ -6,7 +6,7 @@ import { } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; -import { type Collection, Result } from "@repo/rdx-utils"; +import { type Collection, Maybe, Result } from "@repo/rdx-utils"; import type { Sequelize, Transaction } from "sequelize"; import type { PaymentTermSummary } from "../../../../../application/payment-terms/models/payment-term-summary.model"; @@ -118,7 +118,10 @@ export class SequelizePaymentTermRepository const query = criteriaConverter.convert(criteria, { searchableFields: [], mappings: { - isActive: "is_active", + isActive: { + type: "root", + column: "is_active", + }, }, sortableFields: ["name"], enableFullText: true, @@ -151,6 +154,35 @@ export class SequelizePaymentTermRepository } } + async findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: Transaction + ): Promise, Error>> { + try { + const row = await PaymentTermModel.findOne({ + where: { + id: id.toString(), + company_id: companyId.toString(), + }, + transaction, + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + const mappedResult = this.domainMapper.mapToDomain(row); + if (mappedResult.isFailure) { + return Result.fail(mappedResult.error); + } + + return Result.ok(Maybe.some(mappedResult.data)); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + async deleteByIdInCompany( companyId: UniqueID, id: UniqueID, diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts index 7aa65a2e..7d900f29 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/repositories/sequelize-tax-definition.repository.ts @@ -188,7 +188,10 @@ export class SequelizeTaxDefinitionRepository const criteriaConverter = new CriteriaToSequelizeConverter(); const query = criteriaConverter.convert(criteria, { mappings: { - isActive: "is_active", + isActive: { + type: "root", + column: "is_active", + }, }, searchableFields: ["code", "name", "description", "invoice_note"], sortableFields: ["code", "name"], diff --git a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts index 3ac7dfb7..42b52848 100644 --- a/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts +++ b/modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/repositories/sequelize-tax-regime.repository.ts @@ -204,7 +204,10 @@ export class SequelizeTaxRegimeRepository const criteriaConverter = new CriteriaToSequelizeConverter(); const query = criteriaConverter.convert(criteria, { mappings: { - isActive: "is_active", + isActive: { + type: "root", + column: "is_active", + }, }, searchableFields: [], sortableFields: ["code"], diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-criteria-whitelist.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-criteria-whitelist.ts deleted file mode 100644 index 006af1b3..00000000 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/customer-invoice-criteria-whitelist.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { Criteria } from "@repo/rdx-criteria/server"; -import { Op, type OrderItem, type WhereOptions, literal } from "sequelize"; - -// Campos físicos (DB) que permitimos filtrar/ordenar -const ALLOWED_FILTERS = { - id: "id", - customerId: "customer_id", - invoiceSeries: "invoice_series", - invoiceNumber: "invoice_number", - invoiceDate: "invoice_date", - status: "status", - currencyCode: "currency_code", - // Rango por total (en unidades menores) - totalAmountValue: "total_amount_value", -} as const; - -const ALLOWED_SORT: Record = { - // Sort "invoiceDate" realmente ordena por (invoice_date DESC, invoice_series ASC, invoice_number DESC, id DESC) - invoiceDate: ["invoice_date"], - invoiceNumber: ["invoice_number"], - invoiceSeries: ["invoice_series"], - status: ["status"], - createdAt: ["created_at"], - updatedAt: ["updated_at"], -}; - -// Proyección mínima para el listado (evita N+1 y payloads grandes) -export const DEFAULT_LIST_ATTRIBUTES = [ - "id", - "company_id", - "customer_id", - "invoice_series", - "invoice_number", - "invoice_date", - "status", - "total_amount_value", - "total_amount_scale", - "currency_code", - // Agregamos itemsCount por subconsulta (no es columna real) -] as const; - -type Sanitized = { - where: WhereOptions; - order: OrderItem[]; - limit: number; - offset: number; - attributes: (string | any)[]; - // keyset opcional - keyset?: { - after?: { invoiceDate: string; invoiceSeries: string; invoiceNumber: number; id: string }; - }; -}; - -const MAX_LIMIT = 100; -const DEFAULT_LIMIT = 25; - -export function sanitizeListCriteria(criteria: Criteria): Sanitized { - const { filters = {}, sort = [], pagination = {}, search } = (criteria ?? {}) as any; - - // LIMIT/OFFSET - const rawLimit = Number(pagination?.limit ?? DEFAULT_LIMIT); - const rawOffset = Number(pagination?.offset ?? 0); - const limit = Number.isFinite(rawLimit) - ? Math.max(1, Math.min(MAX_LIMIT, rawLimit)) - : DEFAULT_LIMIT; - const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; - - // WHERE base siempre por seguridad a nivel superior (company en repo) - const where: WhereOptions = {}; - - // Búsqueda libre (q) → aplicar sobre campos concretos (series/number/status) - if (typeof search?.q === "string" && search.q.trim() !== "") { - const q = `%${search.q.trim()}%`; - Object.assign(where, { - [Op.or]: [ - { invoice_series: { [Op.like]: q } }, - { status: { [Op.like]: q } }, - // Buscar por número como texto - literal(`CAST(invoice_number AS CHAR) LIKE ${escapeLikeLiteral(q)}`), - ], - }); - } - - // Filtros permitidos - for (const [key, value] of Object.entries(filters)) { - const column = (ALLOWED_FILTERS as any)[key]; - if (!column) { - throw forbiddenFilter(key); - } - if (value == null) continue; - - // Operadores sencillos permitidos - if (typeof value === "object" && !Array.isArray(value)) { - const v = value as any; - const ops: any = {}; - if (v.eq !== undefined) ops[Op.eq] = v.eq; - if (v.ne !== undefined) ops[Op.ne] = v.ne; - if (v.in !== undefined && Array.isArray(v.in)) ops[Op.in] = v.in; - if (v.like !== undefined) ops[Op.like] = `%${String(v.like)}%`; - if (v.gte !== undefined) ops[Op.gte] = v.gte; - if (v.lte !== undefined) ops[Op.lte] = v.lte; - if (Object.keys(ops).length === 0) continue; - (where as any)[column] = ops; - } else { - (where as any)[column] = value; - } - } - - // ORDER (determinista) - const order: OrderItem[] = []; - const sortArray = Array.isArray(sort) - ? sort - : String(sort || "") - .split(",") - .filter(Boolean); - if (sortArray.length === 0) { - // orden por defecto: invoice_date desc, invoice_series asc, invoice_number desc, id desc - order.push( - ["invoice_date", "DESC"], - ["invoice_series", "ASC"], - ["invoice_number", "DESC"], - ["id", "DESC"] - ); - } else { - for (const part of sortArray) { - const desc = String(part).startsWith("-"); - const key = desc ? String(part).slice(1) : String(part); - const allowed = ALLOWED_SORT[key]; - if (!allowed) throw forbiddenSort(key); - const cols = Array.isArray(allowed) ? allowed : [allowed]; - for (const c of cols) { - order.push([c, desc ? "DESC" : "ASC"]); - } - } - // tiebreaker final - order.push(["id", "DESC"]); - } - - // Atributos (proyección) - const attributes: any[] = [...DEFAULT_LIST_ATTRIBUTES]; - - // itemsCount por subconsulta (evitamos include) - attributes.push([ - literal( - "(SELECT COUNT(1) FROM customer_invoice_items it WHERE it.invoice_id = customer_invoices.id AND it.deleted_at IS NULL)" - ), - "itemsCount", - ]); - - return { where, order, limit, offset, attributes }; -} - -// Helpers -function forbiddenFilter(field: string) { - const e = new Error(`Filter "${field}" is not allowed`); - (e as any).code = "FORBIDDEN_FILTER"; - return e; -} -function forbiddenSort(field: string) { - const e = new Error(`Sort "${field}" is not allowed`); - (e as any).code = "FORBIDDEN_SORT"; - return e; -} -function escapeLikeLiteral(v: string) { - // Simple escapado para usar en literal; asume conexión confiable. Para máxima seguridad usa replacements. - return `'${v.replace(/'/g, "''")}'`; -} diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/proformas-grid.bak b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/proformas-grid.bak deleted file mode 100644 index 0ea74b29..00000000 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/proformas-grid.bak +++ /dev/null @@ -1,73 +0,0 @@ -import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components"; -import type { ColumnDef } from "@tanstack/react-table"; - -import { useTranslation } from "../../../../../i18n"; -import type { ProformaList, ProformaListRow } from "../../../../shared"; - -interface ProformasGridProps { - data?: ProformaList; - loading: boolean; - fetching?: boolean; - - columns: ColumnDef[]; - - pageIndex: number; - pageSize: number; - onPageChange: (pageIndex: number) => void; - onPageSizeChange: (size: number) => void; - - onRowClick?: (proformaId: string) => void; -} - -export const ProformasGrid = ({ - data, - loading, - fetching, - columns, - pageIndex, - pageSize, - onPageChange, - onPageSizeChange, - onRowClick, -}: ProformasGridProps) => { - const { t } = useTranslation(); - const { items, totalItems } = data || { items: [], totalItems: 0 }; - - if (loading) { - return ( - - ); - } - - return ( -
- {/* - * ─── CAPA DE FADE ────────────────────────────────────────────────────── - * Div absolutamente posicionado sobre el área scrollable. - * - pointer-events: none → no bloquea interacciones. - * - linear-gradient → de transparente a bg-card (blanco/oscuro). - * - z-10 → queda por DEBAJO de la columna sticky (z-20). - * - Solo visible cuando aún hay contenido a la derecha. - * ──────────────────────────────────────────────────────────────────────── - */} - onRowClick?.(row.id)} - pageIndex={pageIndex} - pageSize={pageSize} - totalItems={totalItems} - /> -
- ); -}; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx index a9a7c06d..4f7b8a33 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx @@ -1,5 +1,3 @@ -// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx - import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; import { CancelActionButton, diff --git a/modules/customers/package.json b/modules/customers/package.json index 3a5beaf2..444569a3 100644 --- a/modules/customers/package.json +++ b/modules/customers/package.json @@ -36,6 +36,7 @@ "dependencies": { "@erp/auth": "workspace:*", "@erp/core": "workspace:*", + "@erp/catalogs": "workspace:*", "@hookform/resolvers": "^5.2.2", "@repo/i18next": "workspace:*", "@repo/rdx-criteria": "workspace:*", diff --git a/modules/customers/src/api/application/models/customer-full-read-model.ts b/modules/customers/src/api/application/models/customer-full-read-model.ts new file mode 100644 index 00000000..434f9472 --- /dev/null +++ b/modules/customers/src/api/application/models/customer-full-read-model.ts @@ -0,0 +1,20 @@ +import type { Maybe } from "@repo/rdx-utils"; + +import type { Customer } from "../../domain"; + +import type { CustomerPaymentMethodReadModel } from "./customer-payment-method-read.model"; +import type { CustomerPaymentTermReadModel } from "./customer-payment-term-read.model"; +import type { CustomerTaxRegimeFullReadModel } from "./customer-tax-regime-full-read.model"; + +/** + * Modelo de un cliente con datos accesorios. + * + * Combina el agregado con datos auxiliares necesarios para + * construir la respuesta API generada por el snapshot builder. + */ +export interface CustomerFullReadModel { + customer: Customer; + paymentMethod: Maybe; + paymentTerm: Maybe; + taxRegime: Maybe; +} diff --git a/modules/customers/src/api/application/models/customer-payment-method-read.model.ts b/modules/customers/src/api/application/models/customer-payment-method-read.model.ts new file mode 100644 index 00000000..741ce5c5 --- /dev/null +++ b/modules/customers/src/api/application/models/customer-payment-method-read.model.ts @@ -0,0 +1,12 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; + +/** + * Datos del método de pago que completan al cliente + */ + +export interface CustomerPaymentMethodReadModel { + id: UniqueID; + name: string; + description: Maybe; +} diff --git a/modules/customers/src/api/application/models/customer-payment-term-read.model.ts b/modules/customers/src/api/application/models/customer-payment-term-read.model.ts new file mode 100644 index 00000000..0cf885f3 --- /dev/null +++ b/modules/customers/src/api/application/models/customer-payment-term-read.model.ts @@ -0,0 +1,12 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; + +/** + * Datos del plazo de pago que completan al cliente + */ + +export interface CustomerPaymentTermReadModel { + id: UniqueID; + name: string; + description: Maybe; +} diff --git a/modules/customers/src/api/application/models/customer-tax-regime-full-read.model.ts b/modules/customers/src/api/application/models/customer-tax-regime-full-read.model.ts new file mode 100644 index 00000000..bd4b715f --- /dev/null +++ b/modules/customers/src/api/application/models/customer-tax-regime-full-read.model.ts @@ -0,0 +1,8 @@ +/** + * Datos del régimen de pago que completan al cliente + */ + +export interface CustomerTaxRegimeFullReadModel { + code: string; + description: string; +} diff --git a/modules/customers/src/api/application/models/index.ts b/modules/customers/src/api/application/models/index.ts index 51c283fb..439bf5e6 100644 --- a/modules/customers/src/api/application/models/index.ts +++ b/modules/customers/src/api/application/models/index.ts @@ -1 +1,5 @@ +export * from "./customer-full-read-model"; +export * from "./customer-payment-method-read.model"; +export * from "./customer-payment-term-read.model"; export * from "./customer-summary"; +export * from "./customer-tax-regime-full-read.model"; diff --git a/modules/customers/src/api/application/presenters/index.ts b/modules/customers/src/api/application/presenters/index.ts deleted file mode 100644 index 9e03d7a9..00000000 --- a/modules/customers/src/api/application/presenters/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./domain"; -export * from "./queries"; diff --git a/modules/customers/src/api/application/presenters/queries/index.ts b/modules/customers/src/api/application/presenters/queries/index.ts deleted file mode 100644 index 10b03959..00000000 --- a/modules/customers/src/api/application/presenters/queries/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./list-customers.presenter"; diff --git a/modules/customers/src/api/application/presenters/queries/list-customers.presenter.ts b/modules/customers/src/api/application/presenters/queries/list-customers.presenter.ts deleted file mode 100644 index e62a0795..00000000 --- a/modules/customers/src/api/application/presenters/queries/list-customers.presenter.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { CriteriaDTO } from "@erp/core"; -import { Presenter } from "@erp/core/api"; -import type { Criteria } from "@repo/rdx-criteria/server"; -import { maybeToEmptyString } from "@repo/rdx-ddd"; -import type { Collection } from "@repo/rdx-utils"; - -import type { ListCustomersResponseDTO } from "../../../../common/dto"; -import type { CustomerListDTO } from "../../../infrastructure/mappers"; - -export class ListCustomersPresenter extends Presenter { - protected _mapCustomer(customer: CustomerListDTO) { - const address = customer.address.toPrimitive(); - - return { - id: customer.id.toPrimitive(), - company_id: customer.companyId.toPrimitive(), - - reference: maybeToEmptyString(customer.reference, (value) => value.toPrimitive()), - - is_company: String(customer.isCompany), - name: customer.name.toPrimitive(), - - trade_name: maybeToEmptyString(customer.tradeName, (value) => value.toPrimitive()), - - tin: maybeToEmptyString(customer.tin, (value) => value.toPrimitive()), - - street: maybeToEmptyString(address.street, (value) => value.toPrimitive()), - street2: maybeToEmptyString(address.street2, (value) => value.toPrimitive()), - city: maybeToEmptyString(address.city, (value) => value.toPrimitive()), - province: maybeToEmptyString(address.province, (value) => value.toPrimitive()), - postal_code: maybeToEmptyString(address.postalCode, (value) => value.toPrimitive()), - country: maybeToEmptyString(address.country, (value) => value.toPrimitive()), - - email_primary: maybeToEmptyString(customer.emailPrimary, (value) => value.toPrimitive()), - email_secondary: maybeToEmptyString(customer.emailSecondary, (value) => value.toPrimitive()), - phone_primary: maybeToEmptyString(customer.phonePrimary, (value) => value.toPrimitive()), - phone_secondary: maybeToEmptyString(customer.phoneSecondary, (value) => value.toPrimitive()), - mobile_primary: maybeToEmptyString(customer.mobilePrimary, (value) => value.toPrimitive()), - mobile_secondary: maybeToEmptyString(customer.mobileSecondary, (value) => - value.toPrimitive() - ), - - fax: maybeToEmptyString(customer.fax, (value) => value.toPrimitive()), - website: maybeToEmptyString(customer.website, (value) => value.toPrimitive()), - - status: customer.status ? "active" : "inactive", - language_code: customer.languageCode.toPrimitive(), - currency_code: customer.currencyCode.toPrimitive(), - - metadata: { - entity: "customer", - //created_at: customer.createdAt.toPrimitive(), - //updated_at: customer.updatedAt.toPrimitive() - }, - }; - } - - toOutput(params: { - customers: Collection; - criteria: Criteria; - }): ListCustomersResponseDTO { - const { customers, criteria } = params; - - const items = customers.map((customer) => this._mapCustomer(customer)); - const totalItems = customers.total(); - - return { - page: criteria.pageNumber, - per_page: criteria.pageSize, - total_pages: Math.ceil(totalItems / criteria.pageSize), - total_items: totalItems, - items: items, - metadata: { - entity: "customers", - criteria: criteria.toJSON() as CriteriaDTO, - //links: { - // self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`, - // first: `/api/customer-invoices?page=1&per_page=${criteria.pageSize}`, - // last: `/api/customer-invoices?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`, - //}, - }, - }; - } -} diff --git a/modules/customers/src/api/application/services/assemblers/customer-full-read-model.assembler.ts b/modules/customers/src/api/application/services/assemblers/customer-full-read-model.assembler.ts new file mode 100644 index 00000000..2e70e60e --- /dev/null +++ b/modules/customers/src/api/application/services/assemblers/customer-full-read-model.assembler.ts @@ -0,0 +1,196 @@ +import type { + IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { Customer } from "../../../domain"; +import type { + CustomerFullReadModel, + CustomerPaymentMethodReadModel, + CustomerTaxRegimeFullReadModel, +} from "../../models"; + +export interface ICustomerFullReadModelAssembler { + assemble(params: { + companyId: UniqueID; + customer: Customer; + transaction?: unknown; + }): Promise>; +} + +/** + * Enriquece un cliente recuperado con + * datos accesorios recuperados del catálogo. + * + * No modifica el agregado. Su responsabilidad es + * preparar el modelo completo que recibirá el + * snapshot builder después. + */ + +export class CustomerFullReadModelAssembler implements ICustomerFullReadModelAssembler { + public constructor( + private readonly deps: { + paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; + } + ) {} + + public async assemble(params: { + companyId: UniqueID; + customer: Customer; + transaction?: unknown; + }): Promise> { + const paymentMethod = await this.resolvePaymentMethod(params); + + if (paymentMethod.isFailure) { + return Result.fail(paymentMethod.error); + } + + const paymentTerm = await this.resolvePaymentTerm(params); + + if (paymentTerm.isFailure) { + return Result.fail(paymentTerm.error); + } + + const taxRegime = await this.resolveTaxRegime(params); + + if (taxRegime.isFailure) { + return Result.fail(taxRegime.error); + } + + return Result.ok({ + customer: params.customer, + paymentMethod: paymentMethod.data, + paymentTerm: paymentTerm.data, + taxRegime: taxRegime.data, + }); + } + + private async resolvePaymentMethod(params: { + companyId: UniqueID; + customer: Customer; + transaction?: unknown; + }): Promise, Error>> { + if (params.customer.paymentMethodId.isNone()) { + return Result.ok(Maybe.none()); + } + + const paymentMethodId = params.customer.paymentMethodId.unwrap(); + + const result = await this.deps.paymentMethodFinder.findByIdInCompany({ + companyId: params.companyId, + id: paymentMethodId, + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.ok( + Maybe.some({ + id: paymentMethodId, + name: "", + description: Maybe.none(), + }) + ); + } + + const paymentMethod = result.data.unwrap(); + + return Result.ok( + Maybe.some({ + id: paymentMethod.id, + name: paymentMethod.name.toString(), + description: paymentMethod.description ?? null, + }) + ); + } + + private async resolvePaymentTerm(params: { + companyId: UniqueID; + customer: Customer; + transaction?: unknown; + }): Promise, Error>> { + if (params.customer.paymentTermId.isNone()) { + return Result.ok(Maybe.none()); + } + + const paymentTermId = params.customer.paymentTermId.unwrap(); + + const result = await this.deps.paymentTermFinder.findByIdInCompany({ + companyId: params.companyId, + id: paymentTermId, + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.ok( + Maybe.some({ + id: paymentTermId, + name: "", + description: Maybe.none(), + }) + ); + } + + const paymentTerm = result.data.unwrap(); + + return Result.ok( + Maybe.some({ + id: paymentTerm.id, + name: paymentTerm.name.toString(), + description: paymentTerm.description ?? null, + }) + ); + } + + private async resolveTaxRegime(params: { + companyId: UniqueID; + customer: Customer; + transaction?: unknown; + }): Promise, Error>> { + if (params.customer.taxRegimeCode.isNone()) { + return Result.ok(Maybe.none()); + } + + const taxRegimeCode = params.customer.taxRegimeCode.unwrap(); + + const result = await this.deps.taxRegimeFinder.findByCodeInCompany({ + companyId: params.companyId, + code: taxRegimeCode, + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.ok( + Maybe.some({ + code: taxRegimeCode, + description: "", + }) + ); + } + + const taxRegime = result.data.unwrap(); + + return Result.ok( + Maybe.some({ + code: taxRegime.code, + description: taxRegime.description, + }) + ); + } +} diff --git a/modules/customers/src/api/application/services/assemblers/index.ts b/modules/customers/src/api/application/services/assemblers/index.ts new file mode 100644 index 00000000..32f6a3bc --- /dev/null +++ b/modules/customers/src/api/application/services/assemblers/index.ts @@ -0,0 +1 @@ +export * from "./customer-full-read-model.assembler"; diff --git a/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts b/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts index 475a5a99..4e804438 100644 --- a/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts +++ b/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts @@ -1,14 +1,17 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { toNullable } from "@repo/rdx-ddd"; +import { maybeToEmptyString, maybeToNullable, toNullable } from "@repo/rdx-ddd"; import type { GetCustomerByIdResponseDTO } from "../../../../common"; -import type { Customer } from "../../../domain"; +import type { CustomerFullReadModel } from "../../models"; + +export type CustomerFullSnapshot = GetCustomerByIdResponseDTO; export interface ICustomerFullSnapshotBuilder - extends ISnapshotBuilder {} + extends ISnapshotBuilder {} export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder { - toOutput(customer: Customer): GetCustomerByIdResponseDTO { + toOutput(source: CustomerFullReadModel): CustomerFullSnapshot { + const { customer, paymentMethod, taxRegime, paymentTerm } = source; const address = customer.address.toPrimitive(); return { @@ -47,7 +50,24 @@ export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder legal_record: toNullable(customer.legalRecord, (value) => value.toPrimitive()), - default_taxes: customer.defaultTaxes.toKey(), + payment_method: maybeToNullable(paymentMethod, (paymentMethod) => ({ + id: paymentMethod.id.toString(), + name: paymentMethod.name, + description: maybeToEmptyString(paymentMethod.description), + })), + + payment_term: maybeToNullable(paymentTerm, (paymentTerm) => ({ + id: paymentTerm.id.toString(), + name: paymentTerm.name, + description: maybeToEmptyString(paymentTerm.description), + })), + + tax_regime: maybeToNullable(taxRegime, (taxRegime) => taxRegime), + + fiscal_defaults: { + uses_equivalence_surcharge: customer.usesEquivalenceSurcharge, + uses_retention: customer.usesRetention, + }, language_code: customer.languageCode.toPrimitive(), currency_code: customer.currencyCode.toPrimitive(), diff --git a/modules/customers/src/api/application/use-cases/get-customer-by-id.use-case.ts b/modules/customers/src/api/application/use-cases/get-customer-by-id.use-case.ts index 84e20fe1..30764028 100644 --- a/modules/customers/src/api/application/use-cases/get-customer-by-id.use-case.ts +++ b/modules/customers/src/api/application/use-cases/get-customer-by-id.use-case.ts @@ -3,6 +3,7 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { ICustomerFinder } from "../services"; +import type { ICustomerFullReadModelAssembler } from "../services/assemblers"; import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders"; type GetCustomerUseCaseInput = { @@ -11,25 +12,29 @@ type GetCustomerUseCaseInput = { }; export class GetCustomerByIdUseCase { - constructor( - private readonly finder: ICustomerFinder, - private readonly fullSnapshotBuilder: ICustomerFullSnapshotBuilder, - private readonly transactionManager: ITransactionManager + public constructor( + private readonly deps: { + finder: ICustomerFinder; + fullReadModelAssembler: ICustomerFullReadModelAssembler; + fullSnapshotBuilder: ICustomerFullSnapshotBuilder; + transactionManager: ITransactionManager; + } ) {} public execute(params: GetCustomerUseCaseInput) { const { customer_id, companyId } = params; const idOrError = UniqueID.create(customer_id); + if (idOrError.isFailure) { return Result.fail(idOrError.error); } const customerId = idOrError.data; - return this.transactionManager.complete(async (transaction) => { + return this.deps.transactionManager.complete(async (transaction) => { try { - const customerResult = await this.finder.findCustomerById( + const customerResult = await this.deps.finder.findCustomerById( companyId, customerId, transaction @@ -39,7 +44,17 @@ export class GetCustomerByIdUseCase { return Result.fail(customerResult.error); } - const fullSnapshot = this.fullSnapshotBuilder.toOutput(customerResult.data); + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ + companyId, + customer: customerResult.data, + transaction, + }); + + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); + } + + const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data); return Result.ok(fullSnapshot); } catch (error: unknown) { diff --git a/modules/customers/src/api/domain/aggregates/customer.aggregate.ts b/modules/customers/src/api/domain/aggregates/customer.aggregate.ts index 41a5864a..5b61ad1a 100644 --- a/modules/customers/src/api/domain/aggregates/customer.aggregate.ts +++ b/modules/customers/src/api/domain/aggregates/customer.aggregate.ts @@ -15,7 +15,7 @@ import { } from "@repo/rdx-ddd"; import { type Maybe, Result } from "@repo/rdx-utils"; -import { type CustomerStatus, CustomerTaxes } from "../value-objects"; +import type { CustomerStatus } from "../value-objects"; export interface ICustomerCreateProps { companyId: UniqueID; @@ -43,7 +43,11 @@ export interface ICustomerCreateProps { legalRecord: Maybe; - defaultTaxes: CustomerTaxes; + paymentMethodId: Maybe; + paymentTermId: Maybe; + taxRegimeCode: Maybe; + usesEquivalenceSurcharge: boolean; + usesRetention: boolean; languageCode: LanguageCode; currencyCode: CurrencyCode; @@ -60,54 +64,50 @@ export interface ICustomer { // comportamiento update(partialCustomer: CustomerPatchProps): Result; - // propiedades (getters) - readonly isIndividual: boolean; - readonly isCompany: boolean; - readonly isActive: boolean; + isIndividual: boolean; + isCompany: boolean; + isActive: boolean; - readonly companyId: UniqueID; + companyId: UniqueID; - readonly reference: Maybe; - readonly name: Name; - readonly tradeName: Maybe; - readonly tin: Maybe; + reference: Maybe; + name: Name; + tradeName: Maybe; + tin: Maybe; - readonly address: PostalAddress; + address: PostalAddress; - readonly emailPrimary: Maybe; - readonly emailSecondary: Maybe; + emailPrimary: Maybe; + emailSecondary: Maybe; - readonly phonePrimary: Maybe; - readonly phoneSecondary: Maybe; - readonly mobilePrimary: Maybe; - readonly mobileSecondary: Maybe; + phonePrimary: Maybe; + phoneSecondary: Maybe; + mobilePrimary: Maybe; + mobileSecondary: Maybe; - readonly fax: Maybe; - readonly website: Maybe; + fax: Maybe; + website: Maybe; - readonly legalRecord: Maybe; + legalRecord: Maybe; - readonly defaultTaxes: CustomerTaxes; + paymentMethodId: Maybe; + paymentTermId: Maybe; + taxRegimeCode: Maybe; + usesEquivalenceSurcharge: boolean; + usesRetention: boolean; - readonly languageCode: LanguageCode; - readonly currencyCode: CurrencyCode; + languageCode: LanguageCode; + currencyCode: CurrencyCode; } -export type CustomerInternalProps = Omit; +export type CustomerInternalProps = Omit; export class Customer extends AggregateRoot implements ICustomer { private _address: PostalAddress; - private _defaultTaxes: CustomerTaxes; - protected constructor( - props: CustomerInternalProps, - address: PostalAddress, - defaultTaxes: CustomerTaxes, - id?: UniqueID - ) { + protected constructor(props: CustomerInternalProps, address: PostalAddress, id?: UniqueID) { super(props, id); this._address = address; - this._defaultTaxes = defaultTaxes; } static create(props: ICustomerCreateProps, id?: UniqueID): Result { @@ -117,7 +117,7 @@ export class Customer extends AggregateRoot implements IC return Result.fail(validationResult.error); } - const { address, defaultTaxes, ...internalProps } = props; + const { address, ...internalProps } = props; // Postal Address const postalAddressResult = PostalAddress.create(address); @@ -126,19 +126,12 @@ export class Customer extends AggregateRoot implements IC } const postalAddress = postalAddressResult.data; - // Customer Taxes - const taxesResult = CustomerTaxes.create(defaultTaxes); - if (taxesResult.isFailure) { - return Result.fail(taxesResult.error); - } - const taxes = taxesResult.data; - // Reglas de negocio / validaciones // ... // ... // Crear instancia de Customer - const contact = new Customer(internalProps, postalAddress, taxes, id); + const contact = new Customer(internalProps, postalAddress, id); // Disparar eventos de dominio // ... @@ -152,17 +145,12 @@ export class Customer extends AggregateRoot implements IC } // Rehidratación desde persistencia - static rehydrate( - props: CustomerInternalProps, - address: PostalAddress, - defaultTaxes: CustomerTaxes, - id: UniqueID - ): Customer { - return new Customer(props, address, defaultTaxes, id); + static rehydrate(props: CustomerInternalProps, address: PostalAddress, id: UniqueID): Customer { + return new Customer(props, address, id); } public update(partialCustomer: CustomerPatchProps): Result { - const { address: partialAddress, defaultTaxes: partialTaxes, ...rest } = partialCustomer; + const { address: partialAddress, ...rest } = partialCustomer; const nextProps: CustomerInternalProps = { ...this.props, @@ -170,7 +158,6 @@ export class Customer extends AggregateRoot implements IC }; let nextAddress = this._address; - let nextDefaultTaxes = this._defaultTaxes; if (partialAddress) { const nextAddressResult = PostalAddress.create({ @@ -185,22 +172,8 @@ export class Customer extends AggregateRoot implements IC nextAddress = nextAddressResult.data; } - if (partialTaxes) { - const nextTaxesResult = CustomerTaxes.create({ - ...this._defaultTaxes.getProps(), - ...partialTaxes, - }); - - if (nextTaxesResult.isFailure) { - return Result.fail(nextTaxesResult.error); - } - - nextDefaultTaxes = nextTaxesResult.data; - } - Object.assign(this.props, nextProps); this._address = nextAddress; - this._defaultTaxes = nextDefaultTaxes; return Result.ok(); } @@ -279,8 +252,24 @@ export class Customer extends AggregateRoot implements IC return this.props.legalRecord; } - public get defaultTaxes(): CustomerTaxes { - return this._defaultTaxes; + public get paymentMethodId(): Maybe { + return this.props.paymentMethodId; + } + + public get paymentTermId(): Maybe { + return this.props.paymentTermId; + } + + public get taxRegimeCode(): Maybe { + return this.props.taxRegimeCode; + } + + public get usesEquivalenceSurcharge(): boolean { + return this.props.usesEquivalenceSurcharge; + } + + public get usesRetention(): boolean { + return this.props.usesRetention; } public get languageCode(): LanguageCode { diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts index 3ea3cb45..2fd546af 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts @@ -24,12 +24,7 @@ import { } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { - Customer, - type CustomerInternalProps, - CustomerStatus, - CustomerTaxes, -} from "../../../../../domain"; +import { Customer, type CustomerInternalProps, CustomerStatus } from "../../../../../domain"; import type { CustomerCreationAttributes, CustomerModel } from "../../models"; export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< @@ -179,12 +174,30 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< errors ); - const defaultTaxes = extractOrPushError( - CustomerTaxes.fromKey(source.default_taxes, this.taxCatalog), - "default_taxes", + const paymentMethodId = extractOrPushError( + maybeFromNullableResult(source.payment_method_id, (value) => + UniqueID.create(String(value)) + ), + "payment_method_id", errors ); + const paymentTermId = extractOrPushError( + maybeFromNullableResult(source.payment_term_id, (value) => UniqueID.create(String(value))), + "payment_term_id", + errors + ); + + // Tax regime code + const taxRegimeCode = extractOrPushError( + maybeFromNullableResult(source.tax_regime_code, (value) => Result.ok(String(value))), + "tax_regime_code", + errors + ); + + const usesEquivalenceSurcharge = source.uses_equivalence_surcharge; + const usesRetention = source.uses_retention; + // Now, create the PostalAddress VO const postalAddressProps = { street: street!, @@ -226,16 +239,18 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< website: website!, legalRecord: legalRecord!, + + paymentMethodId: paymentMethodId!, + paymentTermId: paymentTermId!, + taxRegimeCode: taxRegimeCode!, + usesEquivalenceSurcharge: usesEquivalenceSurcharge, + usesRetention: usesRetention, + languageCode: languageCode!, currencyCode: currencyCode!, }; - const customer = Customer.rehydrate( - customerProps, - postalAddress!, - defaultTaxes!, - customerId! - ); + const customer = Customer.rehydrate(customerProps, postalAddress!, customerId!); return Result.ok(customer); } catch (err: unknown) { return Result.fail(err as Error); @@ -266,7 +281,12 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< website: maybeToNullable(source.website, (website) => website.toPrimitive()), legal_record: maybeToNullable(source.legalRecord, (legalRecord) => legalRecord.toPrimitive()), - default_taxes: source.defaultTaxes.toKey(), + + payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()), + payment_term_id: maybeToNullable(source.paymentTermId, (value) => value.toPrimitive()), + tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value), + uses_equivalence_surcharge: source.usesEquivalenceSurcharge, + uses_retention: source.usesRetention, status: source.isActive ? "active" : "inactive", language_code: source.languageCode.toPrimitive(), diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model.ts index 033187f8..45810ed7 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model.ts @@ -51,8 +51,14 @@ export class CustomerModel extends Model< declare legal_record: CreationOptional; - declare default_taxes: string; + declare payment_method_id: CreationOptional; + declare payment_term_id: CreationOptional; + declare tax_regime_code: CreationOptional; + declare uses_equivalence_surcharge: CreationOptional; + declare uses_retention: CreationOptional; + declare status: string; + declare language_code: CreationOptional; declare currency_code: CreationOptional; @@ -189,10 +195,33 @@ export default (database: Sequelize) => { allowNull: true, }, - default_taxes: { - type: DataTypes.STRING, - defaultValue: null, + payment_method_id: { + type: DataTypes.UUID, allowNull: true, + defaultValue: null, + }, + + payment_term_id: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + + tax_regime_code: { + type: DataTypes.STRING(2), + allowNull: true, + }, + + uses_equivalence_surcharge: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + + uses_retention: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, }, status: { diff --git a/modules/customers/src/common/dto/request/create-customer.request.dto.ts b/modules/customers/src/common/dto/request/create-customer.request.dto.ts index bd3cbdf9..ca865f6d 100644 --- a/modules/customers/src/common/dto/request/create-customer.request.dto.ts +++ b/modules/customers/src/common/dto/request/create-customer.request.dto.ts @@ -29,6 +29,12 @@ export const CreateCustomerRequestSchema = z.object({ legal_record: z.string().optional(), + payment_method_id: z.uuid().nullable().optional(), + payment_term_id: z.uuid().nullable().optional(), + tax_regime_code: z.string().nullable().optional(), + uses_equivalence_surcharge: z.boolean().optional(), + uses_retention: z.boolean().optional(), + language_code: z.string().toLowerCase().default("es"), currency_code: z.string().toUpperCase().default("EUR"), }); diff --git a/modules/customers/src/common/dto/request/update-customer-by-id.request.dto.ts b/modules/customers/src/common/dto/request/update-customer-by-id.request.dto.ts index a60a53a1..5d765714 100644 --- a/modules/customers/src/common/dto/request/update-customer-by-id.request.dto.ts +++ b/modules/customers/src/common/dto/request/update-customer-by-id.request.dto.ts @@ -11,8 +11,6 @@ import { } from "@erp/core"; import { z } from "zod/v4"; -import { TaxCombinationCodeSchema } from "../shared"; - export const UpdateCustomerByIdParamsRequestSchema = z.object({ customer_id: z.uuid(), }); @@ -49,7 +47,11 @@ export const UpdateCustomerByIdRequestSchema = z.object({ trade_name: z.string().nullable().optional(), tin: TinSchema.nullable().optional(), - default_taxes: TaxCombinationCodeSchema.optional(), + payment_method_id: z.uuid().nullable().optional(), + payment_term_id: z.uuid().nullable().optional(), + tax_regime_code: z.string().nullable().optional(), + uses_equivalence_surcharge: z.boolean().optional(), + uses_retention: z.boolean().optional(), address: UpdateCustomerAddressPatchRequestSchema.optional(), contact: UpdateCustomerContactPatchRequestSchema.optional(), diff --git a/modules/customers/src/common/dto/response/get-customer-by-id.response.dto.ts b/modules/customers/src/common/dto/response/get-customer-by-id.response.dto.ts index 81fbf17b..9bbf0dbb 100644 --- a/modules/customers/src/common/dto/response/get-customer-by-id.response.dto.ts +++ b/modules/customers/src/common/dto/response/get-customer-by-id.response.dto.ts @@ -12,7 +12,11 @@ import { } from "@erp/core"; import { z } from "zod/v4"; -import { TaxCombinationCodeSchema } from "../shared"; +import { + CustomerPaymentMethodRefSchema, + CustomerPaymentTermRefSchema, + CustomerTaxRegimeRefSchema, +} from "../shared"; import { CustomerStatusSchema } from "../shared/customer-status.dto"; export const GetCustomerByIdResponseSchema = z.object({ @@ -49,7 +53,13 @@ export const GetCustomerByIdResponseSchema = z.object({ legal_record: z.string().nullable(), - default_taxes: TaxCombinationCodeSchema, + payment_method: CustomerPaymentMethodRefSchema.nullable(), + payment_term: CustomerPaymentTermRefSchema.nullable(), + tax_regime: CustomerTaxRegimeRefSchema.nullable(), + fiscal_defaults: z.object({ + uses_equivalence_surcharge: z.boolean(), + uses_retention: z.boolean(), + }), language_code: LanguageCodeSchema, currency_code: CurrencyCodeSchema, diff --git a/modules/customers/src/common/dto/shared/customer-payment-method-ref.dto.ts b/modules/customers/src/common/dto/shared/customer-payment-method-ref.dto.ts new file mode 100644 index 00000000..f0634721 --- /dev/null +++ b/modules/customers/src/common/dto/shared/customer-payment-method-ref.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const CustomerPaymentMethodRefSchema = z.object({ + id: z.uuid(), + name: z.string(), + description: z.string(), +}); + +export type CustomerPaymentMethodRefDTO = z.infer; diff --git a/modules/customers/src/common/dto/shared/customer-payment-term-ref.dto.ts b/modules/customers/src/common/dto/shared/customer-payment-term-ref.dto.ts new file mode 100644 index 00000000..df141ee8 --- /dev/null +++ b/modules/customers/src/common/dto/shared/customer-payment-term-ref.dto.ts @@ -0,0 +1,8 @@ +import { z } from "zod/v4"; + +export const CustomerPaymentTermRefSchema = z.object({ + id: z.uuid(), + description: z.string(), +}); + +export type CustomerPaymentTermRefDTO = z.infer; diff --git a/modules/customers/src/common/dto/shared/customer-tax-regime-ref.dto.ts b/modules/customers/src/common/dto/shared/customer-tax-regime-ref.dto.ts new file mode 100644 index 00000000..d373a813 --- /dev/null +++ b/modules/customers/src/common/dto/shared/customer-tax-regime-ref.dto.ts @@ -0,0 +1,8 @@ +import { z } from "zod/v4"; + +export const CustomerTaxRegimeRefSchema = z.object({ + code: z.string(), + description: z.string(), +}); + +export type CustomerTaxRegimeRefDTO = z.infer; diff --git a/modules/customers/src/common/dto/shared/index.ts b/modules/customers/src/common/dto/shared/index.ts index ea0d72f9..02156324 100644 --- a/modules/customers/src/common/dto/shared/index.ts +++ b/modules/customers/src/common/dto/shared/index.ts @@ -1,3 +1,5 @@ +export * from "./customer-payment-method-ref.dto"; +export * from "./customer-payment-term-ref.dto"; export * from "./customer-status.dto"; export * from "./customer-summary.dto"; -export * from "./tax-combination-code.dto"; +export * from "./customer-tax-regime-ref.dto"; diff --git a/modules/customers/src/common/dto/shared/tax-combination-code.dto.ts b/modules/customers/src/common/dto/shared/tax-combination-code.dto.ts deleted file mode 100644 index b34b9cb8..00000000 --- a/modules/customers/src/common/dto/shared/tax-combination-code.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from "zod/v4"; - -const TAX_CODE_PATTERN = /^[a-z0-9_]+$/i; -const EMPTY_TAX_SLOT = "#"; - -export const TaxCombinationCodeSchema = z.string().refine( - (value) => { - const parts = value.split(";"); - - if (parts.length !== 3) { - return false; - } - - return parts.every((part) => { - return part === EMPTY_TAX_SLOT || TAX_CODE_PATTERN.test(part); - }); - }, - { - message: "taxes must use format ';;'", - } -); - -export type TaxCombinationCodeDTO = z.infer; diff --git a/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx b/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx index 03e81cbf..f53de539 100644 --- a/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx +++ b/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx @@ -2,16 +2,14 @@ import { DataTableColumnHeader, InitialsAvatar } from "@repo/rdx-ui/components"; import { Badge, Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + Checkbox, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "@repo/shadcn-ui/components"; import type { ColumnDef } from "@tanstack/react-table"; -import { Building2Icon, EyeIcon, MoreHorizontalIcon, UserIcon } from "lucide-react"; +import { Building2Icon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react"; import * as React from "react"; import { useTranslation } from "../../../../i18n"; @@ -23,16 +21,43 @@ type GridActionHandlers = { onViewClick?: (customer: CustomerListRow) => void; onSummaryClick?: (customer: CustomerListRow) => void; onDeleteClick?: (customer: CustomerListRow) => void; + + canEdit?: (proforma: CustomerListRow) => boolean; + canDelete?: (proforma: CustomerListRow) => boolean; }; export function useCustomersGridColumns( actionHandlers: GridActionHandlers = {} ): ColumnDef[] { const { t } = useTranslation(); - const { onEditClick, onViewClick, onDeleteClick, onSummaryClick } = actionHandlers; return React.useMemo[]>( () => [ + { + id: "select", + header: ({ table }) => { + const isAllSelected = table.getIsAllPageRowsSelected(); + const isSomeSelected = table.getIsSomePageRowsSelected(); + + return ( + table.toggleAllPageRowsSelected(!!value)} + /> + ); + }, + cell: ({ row }) => ( + row.toggleSelected(!!value)} + /> + ), + enableHiding: false, + enableSorting: false, + }, { id: "customer", header: ({ column }) => ( @@ -52,7 +77,9 @@ export function useCustomersGridColumns( return ( + } + /> + Editar + + + )} + + {/* Eliminar */} + {!actionHandlers.onDeleteClick && ( + + + { + event.preventDefault(); + event.stopPropagation(); + + actionHandlers.onDeleteClick?.(customer); + }} + size="icon" + variant="ghost" + > + + + } + /> + Eliminar + + + )} + + ); + }, + }, + ], + [t, actionHandlers] + ); +} + +/*
- - ); - }, - }, - ], - [t, onDeleteClick, onEditClick, onViewClick, onSummaryClick] - ); -} + + */ diff --git a/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts b/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts index 8fa1564c..0fad2ec1 100644 --- a/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts @@ -1,4 +1,5 @@ import { useUrlParamId } from "@erp/core/hooks"; +import { useSearchParams } from "react-router-dom"; import { useCustomerUpdateController } from "./use-customer-update.controller"; @@ -10,10 +11,14 @@ import { useCustomerUpdateController } from "./use-customer-update.controller"; export const useCustomerUpdatePageController = () => { const customerId = useUrlParamId(); + const [searchParams] = useSearchParams(); const updateCtrl = useCustomerUpdateController(customerId); + const returnTo = searchParams.get("returnTo") ?? "/customers"; + return { updateCtrl, + returnTo, }; }; diff --git a/modules/customers/src/web/update/ui/blocks/customer-update-header.tsx b/modules/customers/src/web/update/ui/blocks/customer-update-header.tsx new file mode 100644 index 00000000..0cd0fc0b --- /dev/null +++ b/modules/customers/src/web/update/ui/blocks/customer-update-header.tsx @@ -0,0 +1,123 @@ +import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; +import { + CancelActionButton, + FormActionsBar, + type FormSecondaryAction, + FormSecondaryActionsMenu, + RhfSubmitActionButton, +} from "@repo/rdx-ui/components"; +import type { ReactNode } from "react"; + +export interface CustomerUpdateHeaderLabels { + title: string; + back: string; + modified: string; + cancel: string; + save: string; + saving: string; + moreActions: string; + keyboardShortcuts: string; + duplicate: string; + delete: string; +} + +export interface CustomerUpdateHeaderProps { + formId?: string; + + labels: CustomerUpdateHeaderLabels; + + onCancel?: () => void; + onDuplicate?: () => void; + onDelete?: () => void; + + disabled?: boolean; + readOnly?: boolean; + isSaving?: boolean; + hasChanges?: boolean; + + className?: string; + children?: ReactNode; +} + +export const CustomerUpdateHeader = ({ + formId, + labels, + onCancel, + onDuplicate, + onDelete, + disabled = false, + readOnly = false, + isSaving = false, + hasChanges = false, + className, + children, +}: CustomerUpdateHeaderProps) => { + const computedDisabled = disabled || isSaving; + + const secondaryActions: FormSecondaryAction[] = [ + /*{ + id: "duplicate", + label: labels.duplicate, + icon: