diff --git a/apps/server/package.json b/apps/server/package.json index 45a26d0d..d003ca38 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@erp/factuges-server", - "version": "0.6.9", + "version": "0.7.3", "private": true, "scripts": { "build": "tsup src/index.ts --config tsup.config.ts", diff --git a/apps/server/src/lib/modules/service-registry.ts b/apps/server/src/lib/modules/service-registry.ts index 6a37c63c..d766c4f3 100644 --- a/apps/server/src/lib/modules/service-registry.ts +++ b/apps/server/src/lib/modules/service-registry.ts @@ -36,7 +36,7 @@ export function getServiceScoped( if (!allowedDeps.includes(serviceModule)) { throw new Error( `❌ Module "${requesterModule}" tried to access service "${name}" ` + - `without declaring dependency on "${serviceModule}"` + `without declaring dependency on "${serviceModule}" (web/manifest.ts api/index.ts file)` ); } @@ -49,7 +49,7 @@ export function getServiceScoped( function getService(name: string): T { const service = services[name]; if (!service) { - throw new Error(`❌ Servicio "${name}" no encontrado.`); + throw new Error(`❌ Service "${name}" not found.`); } return service as T; } diff --git a/apps/web/package.json b/apps/web/package.json index 9ee752ab..f0849c2a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@erp/factuges-web", "private": true, - "version": "0.6.9", + "version": "0.7.3", "type": "module", "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/modules/auth/package.json b/modules/auth/package.json index dd040fc4..c0624db6 100644 --- a/modules/auth/package.json +++ b/modules/auth/package.json @@ -1,6 +1,6 @@ { "name": "@erp/auth", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/catalogs/package.json b/modules/catalogs/package.json index e5153e75..ca4f1237 100644 --- a/modules/catalogs/package.json +++ b/modules/catalogs/package.json @@ -1,7 +1,7 @@ { "name": "@erp/catalogs", "description": "Catalogs module", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, 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 index bec9e9cb..fb61231a 100644 --- 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 @@ -3,7 +3,8 @@ import { Maybe, Result } from "@repo/rdx-utils"; import type { PaymentTermPublicModelMapper } from "../mappers"; import type { PaymentTermPublicModel } from "../models"; -import type { IPaymentTermRepository } from "../repositories"; +import { IPaymentTermRepository } from '../../repositories'; + export interface FindPaymentTermByIdInCompanyParams { companyId: UniqueID; diff --git a/modules/catalogs/src/api/application/tax-definitions/public/mappers/tax-definition-public-model.mapper.ts b/modules/catalogs/src/api/application/tax-definitions/public/mappers/tax-definition-public-model.mapper.ts index 4da9fae6..4a7a0ffc 100644 --- a/modules/catalogs/src/api/application/tax-definitions/public/mappers/tax-definition-public-model.mapper.ts +++ b/modules/catalogs/src/api/application/tax-definitions/public/mappers/tax-definition-public-model.mapper.ts @@ -10,6 +10,7 @@ import type { export class TaxDefinitionPublicModelMapper { public toPublicModel(taxDefinition: TaxDefinition): TaxDefinitionPublicModel { + console.log(taxDefinition.description) return { id: taxDefinition.id, companyId: Maybe.some(taxDefinition.companyId), diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts b/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts index 954e5f6c..b8018256 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts @@ -9,6 +9,8 @@ import { EnablePaymentTermByIdUseCase, GetPaymentTermByIdUseCase, ListPaymentTermsUseCase, + PaymentTermPublicFinder, + PaymentTermPublicModelMapper, UpdatePaymentTermByIdUseCase, buildPaymentTermCreator, buildPaymentTermDeleter, @@ -19,7 +21,6 @@ import { buildPaymentTermUpdater, } from "../../../application/payment-terms"; import type { IPaymentTermRepository } from "../../../application/payment-terms/repositories"; -import { PaymentTermFinder } from "../../../application/payment-terms/services"; import { buildPaymentTermPersistenceMappers } from "./payment-term-persistence-mappers.di"; import { buildPaymentTermRepository } from "./payment-term-repositories.di"; @@ -110,8 +111,10 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm export const buildPaymentTermsPublicServices = ( _params: SetupParams, deps: PaymentTermsInternalDeps -): { finder: PaymentTermFinder } => { +): { finder: PaymentTermPublicFinder } => { + const mapper = new PaymentTermPublicModelMapper(); + return { - finder: new PaymentTermFinder(deps.repository), + finder: new PaymentTermPublicFinder({ repository: deps.repository, mapper }), }; }; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-domain.mapper.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-domain.mapper.ts index 93910d08..7455b4cc 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-domain.mapper.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/mappers/sequelize-tax-definition-domain.mapper.ts @@ -38,7 +38,11 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper< const code = extractOrPushError(TaxDefinitionCodeVO.create(raw.code), "code", errors); const name = extractOrPushError(TaxDefinitionNameVO.create(raw.name), "name", errors); - const description = maybeFromNullableResult(raw.description, (v) => TextValue.create(v)); + const description = extractOrPushError( + maybeFromNullableResult(raw.description, (v) => TextValue.create(v)), + "description", + errors + ); const rate = extractOrPushError( TaxRateVO.create({ value: raw.rate_value, scale: raw.rate_scale }), @@ -60,33 +64,57 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper< errors ); - const jurisdictionRegionCode = maybeFromNullableResult(raw.jurisdiction_region_code, (v) => - CountryRegionCode.create(v) + const jurisdictionRegionCode = extractOrPushError( + maybeFromNullableResult(raw.jurisdiction_region_code, (v) => CountryRegionCode.create(v)), + "jurisdiction_region_code", + errors ); const taxScope = extractOrPushError(TaxScope.create(raw.tax_scope), "tax_scope", errors); - const invoiceNote = maybeFromNullableResult(raw.invoice_note, (v) => TextValue.create(v)); + const invoiceNote = extractOrPushError( + maybeFromNullableResult(raw.invoice_note, (v) => TextValue.create(v)), + "invoice_note", + errors + ); - const allowedSurchargeCodes = maybeFromNullableResult(raw.allowed_surcharge_codes, (v) => { - if (!Array.isArray(v)) return Result.fail(new Error("Invalid allowed_surcharge_codes")); + const allowedSurchargeCodes = extractOrPushError( + maybeFromNullableResult(raw.allowed_surcharge_codes, (v) => { + console.log(v); - const arr: any[] = []; - for (const el of v) { - const _result = TaxDefinitionCodeVO.create(String(el)); - if (_result.isFailure) - return Result.fail(new Error("Invalid allowed_surcharge_codes element")); - arr.push(_result.data); - } + const values = v.split(";"); + if (!Array.isArray(values)) + return Result.fail(new Error("Invalid allowed_surcharge_codes")); - return Result.ok(arr); - }); + const arr: any[] = []; + for (const el of values) { + const _result = TaxDefinitionCodeVO.create(String(el)); + if (_result.isFailure) + return Result.fail(new Error("Invalid allowed_surcharge_codes element")); + arr.push(_result.data); + } + + return Result.ok(arr); + }), + "allowed_surcharge_codes", + errors + ); // valid from / to - const validFrom = maybeFromNullableResult(raw.valid_from, (v) => Result.ok(String(v))); - const validTo = maybeFromNullableResult(raw.valid_to, (v) => Result.ok(String(v))); + const validFrom = extractOrPushError( + maybeFromNullableResult(raw.valid_from, (v) => Result.ok(String(v))), + "valid_from", + errors + ); + + const validTo = extractOrPushError( + maybeFromNullableResult(raw.valid_to, (v) => Result.ok(String(v))), + "valid_to", + errors + ); if (errors.length > 0) { + console.error(errors); return Result.fail( new ValidationErrorCollection("TaxDefinition mapping failed [mapToDomain]", errors) ); @@ -130,8 +158,8 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper< (v) => v.toPrimitive(), () => null ), - rate_value: source.rate.toPrimitive(), - rate_scale: (source.rate as any).scale ?? 2, + rate_value: source.rate.toPrimitive().value, + rate_scale: source.rate.toPrimitive().scale, tax_family: source.taxFamily.toPrimitive(), calculation_behavior: source.calculationBehavior.toPrimitive(), jurisdiction_country_code: source.jurisdictionCountryCode.toPrimitive(), @@ -145,7 +173,7 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper< () => null ), allowed_surcharge_codes: source.allowedSurchargeCodes.match( - (arr) => arr.map((c) => c.toPrimitive()), + (arr) => arr.map((c) => c.toPrimitive()).join(";"), () => null ), is_system: source.isSystem, diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/models/sequelize-tax-definition.model.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/models/sequelize-tax-definition.model.ts index 9f8286a5..45a96fb4 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/models/sequelize-tax-definition.model.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/persistence/sequelize/models/sequelize-tax-definition.model.ts @@ -29,7 +29,7 @@ export class TaxDefinitionModel extends Model< declare tax_scope: string; declare invoice_note: CreationOptional; - declare allowed_surcharge_codes: CreationOptional; + declare allowed_surcharge_codes: CreationOptional; declare is_system: boolean; declare is_active: boolean; @@ -111,7 +111,7 @@ export default (database: Sequelize) => { }, allowed_surcharge_codes: { - type: DataTypes.JSON, + type: DataTypes.STRING, allowNull: true, defaultValue: null, }, diff --git a/modules/core/package.json b/modules/core/package.json index e1d0bb61..450707a7 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -1,6 +1,6 @@ { "name": "@erp/core", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts b/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts index e28e27a9..19148f2b 100644 --- a/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts +++ b/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts @@ -12,7 +12,7 @@ export const globalErrorHandler = async ( res: Response, next: NextFunction ) => { - //console.error(`❌ Global unhandled error: ${error.message}`); + console.error(`❌ Global unhandled error: ${error.message}`); // Si ya se envió una respuesta, delegamos al siguiente error handler if (res.headersSent) { diff --git a/modules/core/src/common/dto/index.ts b/modules/core/src/common/dto/index.ts index 453326af..8d2a5ee8 100644 --- a/modules/core/src/common/dto/index.ts +++ b/modules/core/src/common/dto/index.ts @@ -16,3 +16,4 @@ export * from "./postal-code.dto"; export * from "./quantity.dto"; export * from "./tin.dto"; export * from "./url.dto"; +export * from "./website.dto"; diff --git a/modules/core/src/common/dto/postal-code.dto.ts b/modules/core/src/common/dto/postal-code.dto.ts index df0780c4..a202f12d 100644 --- a/modules/core/src/common/dto/postal-code.dto.ts +++ b/modules/core/src/common/dto/postal-code.dto.ts @@ -8,14 +8,13 @@ import { z } from "zod/v4"; * * Ejemplos válidos: * - "28013" (ES) - * - "SW1A 1AA" (UK) * - "75008" (FR) * - "10115" (DE) */ export const PostalCodeSchema = z .string() .min(1, "Postal code cannot be empty") - .max(16, "Postal code too long") - .regex(/^[A-Za-z0-9\- ]+$/, "Invalid postal code format"); + .max(5, "Postal code too long") + .regex(/^[0-9\- ]+$/, "Invalid postal code format"); export type PostalCodeDTO = z.infer; diff --git a/modules/core/src/common/dto/website.dto.ts b/modules/core/src/common/dto/website.dto.ts new file mode 100644 index 00000000..14060839 --- /dev/null +++ b/modules/core/src/common/dto/website.dto.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; + +export const WebsiteSchema = z.preprocess( + (value) => { + if (typeof value !== "string") return value; + + const trimmed = value.trim(); + + return trimmed === "" ? "" : trimmed; + }, + z + .string() + .refine( + (value) => { + if (!value) return true; + + const candidate = /^https?:\/\//i.test(value) ? value : `https://${value}`; + + try { + const url = new URL(candidate); + + return Boolean(url.hostname.includes(".")); + } catch { + return false; + } + }, + { + message: "Introduce una URL válida", + } + ) + .transform((value) => { + if (!value) return ""; + + return /^https?:\/\//i.test(value) ? value : `https://${value}`; + }) +); + +export type WebsiteDTO = z.infer; diff --git a/modules/core/src/common/index.ts b/modules/core/src/common/index.ts index 2dab51a5..83cccf8d 100644 --- a/modules/core/src/common/index.ts +++ b/modules/core/src/common/index.ts @@ -1,4 +1,5 @@ export * from "./catalogs"; export * from "./dto"; export * from "./helpers"; +export * from "./json-data"; export * from "./types"; diff --git a/modules/core/src/common/json-data/index.ts b/modules/core/src/common/json-data/index.ts new file mode 100644 index 00000000..abb19a64 --- /dev/null +++ b/modules/core/src/common/json-data/index.ts @@ -0,0 +1,2 @@ +export * from "./json-collection.provider"; +export * from "./json-data.provider"; diff --git a/modules/core/src/common/json-data/json-collection.provider.ts b/modules/core/src/common/json-data/json-collection.provider.ts new file mode 100644 index 00000000..d12e1aed --- /dev/null +++ b/modules/core/src/common/json-data/json-collection.provider.ts @@ -0,0 +1,45 @@ +import { JsonDataProvider } from "./json-data.provider"; + +export abstract class JsonCollectionProvider extends JsonDataProvider { + protected constructor(items: readonly TItem[]) { + super(Object.freeze([...items])); + } + + /** + * Devuelve la colección original en modo de solo lectura. + * + * El provider no debe modificar datos de referencia cargados desde JSON. + */ + public getAll(): readonly TItem[] { + return this.data; + } + + /** + * Construye un índice único y falla al cargar datos inconsistentes. + * + * No se permite que el último elemento sobrescriba silenciosamente al anterior, + * porque ocultaría errores en ficheros JSON de referencia. + */ + protected buildUniqueIndex( + indexName: string, + getKey: (item: TItem) => string + ): ReadonlyMap { + const index = new Map(); + + for (const item of this.data) { + const key = getKey(item); + + if (key.length === 0) { + throw new Error(`Invalid empty key for JSON index "${indexName}".`); + } + + if (index.has(key)) { + throw new Error(`Duplicated key "${key}" for JSON index "${indexName}".`); + } + + index.set(key, item); + } + + return index; + } +} diff --git a/modules/core/src/common/json-data/json-data.provider.ts b/modules/core/src/common/json-data/json-data.provider.ts new file mode 100644 index 00000000..a2f79353 --- /dev/null +++ b/modules/core/src/common/json-data/json-data.provider.ts @@ -0,0 +1,7 @@ +export abstract class JsonDataProvider { + protected readonly data: TData; + + protected constructor(data: TData) { + this.data = data; + } +} diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index b9f3fae9..dd893cf4 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -1,6 +1,6 @@ { "name": "@erp/customer-invoices", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-catalog-resolvers.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-catalog-resolvers.di.ts index 85424326..712cdde1 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-catalog-resolvers.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-catalog-resolvers.di.ts @@ -1,5 +1,6 @@ import type { IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, ITaxDefinitionPublicFinder, ITaxRegimePublicFinder, } from "@erp/catalogs/api"; @@ -19,6 +20,7 @@ export function buildProformaCatalogResolvers(params: { taxDefinitionFinder: ITaxDefinitionPublicFinder; taxRegimeFinder: ITaxRegimePublicFinder; paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; }): IProformaCatalogResolvers { return { taxResolver: buildProformaTaxResolver({ @@ -28,6 +30,7 @@ export function buildProformaCatalogResolvers(params: { paymentResolver: new ProformaPaymentResolver({ paymentMethodFinder: params.paymentMethodFinder, + paymentTermFinder: params.paymentTermFinder, }), }; } 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 54efaccc..336ea3da 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 @@ -146,7 +146,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { companyId: params.companyId, status: InvoiceStatus.draft(), - invoiceNumber: invoiceNumber!, + //invoiceNumber: invoiceNumber!, series: series!, invoiceDate: invoiceDate!, diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts index dfb7d6aa..e81a2a14 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts @@ -12,7 +12,7 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input. * - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio. */ -export type ProformaCreateInputProps = Omit & { +export type ProformaCreateInputProps = Omit & { items: ProformaItemCreateInputProps[]; }; diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts index 53d8b32b..90b9427a 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts @@ -1,4 +1,4 @@ -import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api"; +import type { IPaymentMethodPublicFinder, IPaymentTermPublicFinder } from "@erp/catalogs/api"; import type { UniqueID } from "@repo/rdx-ddd"; import { type Maybe, Result } from "@repo/rdx-utils"; @@ -26,13 +26,10 @@ export class ProformaPaymentResolver { public constructor( private readonly deps: { paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; } ) {} - // Comprobar si existe un método de pago (boolean) - - // Recuperar un método de pago si existe (Maybe) - // Solo asegurarnos que existe ese método de pago public async ensurePaymentMethodById(params: { companyId: UniqueID; @@ -59,4 +56,31 @@ export class ProformaPaymentResolver { return Result.ok(undefined); } + + // Solo asegurarnos que existe ese método de pago + public async ensurePaymentTermById(params: { + companyId: UniqueID; + id: Maybe; + }): Promise> { + if (params.id.isNone()) { + return Result.ok(undefined); + } + + const id = params.id.unwrap(); + + const result = await this.deps.paymentTermFinder.findByIdInCompany({ + companyId: params.companyId, + id, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.fail(new Error(`Payment term not found: ${id.toString()}`)); + } + + return Result.ok(undefined); + } } 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 aa423742..f10620a0 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 @@ -78,7 +78,7 @@ export class ProformaCreator implements IProformaCreator { private async resolveCreateProps( props: ProformaCreateInputProps - ): Promise> { + ): Promise, Error>> { // Tax Regime => comprobar que existe const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({ companyId: props.companyId, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index d4aac4d1..024b8973 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -74,6 +74,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter taxDefinitionFinder: catalogs.taxDefinition.finder, taxRegimeFinder: catalogs.taxRegime.finder, paymentMethodFinder: catalogs.paymentMethod.finder, + paymentTermFinder: catalogs.paymentTerm.finder, }); const readModelAssemblers = buildProformaReadModelAssemblers({ @@ -82,6 +83,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter }); const proformaNumberService = buildProformaNumberGenerator(); + const finder = buildProformaFinder(repository); const creator = buildProformaCreator({ diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforrma-catalog-deps.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforrma-catalog-deps.di.ts index 00c3a289..955e89b7 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforrma-catalog-deps.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforrma-catalog-deps.di.ts @@ -11,6 +11,9 @@ type ProformaCatalogsDeps = { paymentMethod: { finder: CatalogsPublicServicesType["paymentMethods"]["finder"]; }; + paymentTerm: { + finder: CatalogsPublicServicesType["paymentTerms"]["finder"]; + }; }; export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatalogsDeps { @@ -35,6 +38,13 @@ export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatal throw new Error("Missing public service: catalogs:paymentMethods.finder"); } + const paymentTerm = + params.getService("catalogs:paymentTerms"); + + if (!paymentTerm?.finder) { + throw new Error("Missing public service: catalogs:paymentTerms.finder"); + } + return { taxDefinition: { finder: taxDefinition.finder, @@ -45,5 +55,8 @@ export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatal paymentMethod: { finder: paymentMethod.finder, }, + paymentTerm: { + finder: paymentTerm.finder, + }, }; } 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 faf4bee8..85614c58 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 @@ -39,7 +39,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< private _recipientMapper: SequelizeProformaRecipientDomainMapper; private _taxesMapper: SequelizeProformaTaxesDomainMapper; - constructor(params: MapperParamsType) { + constructor(params?: MapperParamsType) { super(); this._itemsMapper = new SequelizeProformaItemDomainMapper(); diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 214006ff..e74eee7e 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -57,7 +57,11 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] layout: "app-fullscreen", protected: true, }, - element: , + element: ( + + + + ), }, { diff --git a/modules/customer-invoices/src/web/manifest.ts b/modules/customer-invoices/src/web/manifest.ts index 3b89e0f3..3fff6dde 100644 --- a/modules/customer-invoices/src/web/manifest.ts +++ b/modules/customer-invoices/src/web/manifest.ts @@ -1,4 +1,4 @@ -import type { IModuleClient } from "@erp/core/client"; +import type { IModuleClient, ModuleClientParams } from "@erp/core/client"; import { CustomerInvoiceRoutes } from "./customer-invoice-routes"; @@ -13,7 +13,7 @@ export const CustomerInvoicesModuleManifest: IModuleClient = { protected: true, // protegido por defecto layout: "app-sidebar", // layout por defecto - routes: (params) => CustomerInvoiceRoutes(params), + routes: (params: ModuleClientParams) => CustomerInvoiceRoutes(params), }; export default CustomerInvoicesModuleManifest; diff --git a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-layout.tsx b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-layout.tsx index 0d870da0..bc7a8aab 100644 --- a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-layout.tsx +++ b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-layout.tsx @@ -1,5 +1,9 @@ import type { PropsWithChildren } from "react"; export function ProformaLayout({ children }: PropsWithChildren) { - return
{children}
; + return ( +
+ {children} +
+ ); } diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts index 2e59b4e5..8a5ff263 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts @@ -180,6 +180,7 @@ export const useUpdateProformaController = ( options?.onUpdated?.(updated); } catch (error: unknown) { + console.log(error); const normalizedError = normalizeSubmitError(error); // No revierto el form para que no se pierdan los cambios que @@ -221,7 +222,6 @@ export const useUpdateProformaController = ( } }, (errors: FieldErrors) => { - console.error(errors); focusFirstInputFormError(form); showWarningToast( diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx index 88df8b96..f224fe4c 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx @@ -33,9 +33,6 @@ type ProformaUpdateEditorProps = { totalsCtrl: UseUpdateProformaTotalsControllerResult; paymentCtrl: UseUpdateProformaPaymentControllerResult; - currencyCode?: string; - languageCode?: string; - className?: string; }; @@ -51,57 +48,51 @@ export const ProformaUpdateEditorForm = ({ taxCtrl, totalsCtrl, paymentCtrl, - currencyCode, - languageCode, className, }: ProformaUpdateEditorProps) => { const { t } = useTranslation(); return ( -
-
-
-
- +
+ +
+
+
+ +
+ +
+
+ - + + + +
-
- - - - - -
-
- {/* Footer fijo con totales */} - + {/* Footer fijo con totales */} + +
); }; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx index 724227d1..13028c32 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx @@ -47,7 +47,7 @@ export const ProformaUpdateHeaderEditor = ({ disabled={disabled} label={t("form_fields.proformas.invoice_number.label")} maxLength={16} - name="reference" + name="invoiceNumber" placeholder={t("form_fields.proformas.invoice_number.placeholder")} readOnly={true} rightIcon={ diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx index eea4f979..19198a2b 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx @@ -101,12 +101,9 @@ export const ProformaUpdatePage = () => { )}
null} onReset={updateCtrl.resetForm} diff --git a/modules/customers/package.json b/modules/customers/package.json index 444569a3..25f3bc49 100644 --- a/modules/customers/package.json +++ b/modules/customers/package.json @@ -1,7 +1,7 @@ { "name": "@erp/customers", "description": "Customers", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/customers/src/api/application/di/customer-catalog-resolvers.di.ts b/modules/customers/src/api/application/di/customer-catalog-resolvers.di.ts new file mode 100644 index 00000000..cd990885 --- /dev/null +++ b/modules/customers/src/api/application/di/customer-catalog-resolvers.di.ts @@ -0,0 +1,36 @@ +import type { + IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, + ITaxDefinitionPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; + +import { + CustomerPaymentResolver, + type CustomerTaxResolver, + buildCustomerTaxResolver, +} from "../services/catalog-resolver"; + +export interface ICustomerCatalogResolvers { + taxResolver: CustomerTaxResolver; + paymentResolver: CustomerPaymentResolver; +} + +export function buildCustomerCatalogResolvers(params: { + taxDefinitionFinder: ITaxDefinitionPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; + paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; +}): ICustomerCatalogResolvers { + return { + taxResolver: buildCustomerTaxResolver({ + taxDefinitionFinder: params.taxDefinitionFinder, + taxRegimeFinder: params.taxRegimeFinder, + }), + + paymentResolver: new CustomerPaymentResolver({ + paymentMethodFinder: params.paymentMethodFinder, + paymentTermFinder: params.paymentTermFinder, + }), + }; +} diff --git a/modules/customers/src/api/application/di/customer-input-mappers.di.ts b/modules/customers/src/api/application/di/customer-input-mappers.di.ts index 07631877..a25e9b91 100644 --- a/modules/customers/src/api/application/di/customer-input-mappers.di.ts +++ b/modules/customers/src/api/application/di/customer-input-mappers.di.ts @@ -1,5 +1,3 @@ -import type { ICatalogs } from "@erp/core/api"; - import { CreateCustomerInputMapper, type ICreateCustomerInputMapper, @@ -12,11 +10,9 @@ export interface ICustomerInputMappers { updateInputMapper: IUpdateCustomerInputMapper; } -export const buildCustomerInputMappers = (catalogs: ICatalogs): ICustomerInputMappers => { - const { taxCatalog } = catalogs; - +export const buildCustomerInputMappers = (): ICustomerInputMappers => { // Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado - const createInputMapper = new CreateCustomerInputMapper({ taxCatalog }); + const createInputMapper = new CreateCustomerInputMapper(); const updateInputMapper = new UpdateCustomerInputMapper(); return { diff --git a/modules/customers/src/api/application/di/customer-read-model-assemblers.di.ts b/modules/customers/src/api/application/di/customer-read-model-assemblers.di.ts new file mode 100644 index 00000000..9bf8175d --- /dev/null +++ b/modules/customers/src/api/application/di/customer-read-model-assemblers.di.ts @@ -0,0 +1,28 @@ +import type { + IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; + +import { + CustomerFullReadModelAssembler, + type ICustomerFullReadModelAssembler, +} from "../services/assemblers"; + +export interface ICustomerReadModelAssemblers { + full: ICustomerFullReadModelAssembler; +} + +export function buildCustomerReadModelAssemblers(params: { + paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; +}): ICustomerReadModelAssemblers { + return { + full: new CustomerFullReadModelAssembler({ + paymentMethodFinder: params.paymentMethodFinder, + paymentTermFinder: params.paymentTermFinder, + taxRegimeFinder: params.taxRegimeFinder, + }), + }; +} 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 a85073b9..7d73df01 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 @@ -2,6 +2,7 @@ import type { ITransactionManager } from "@erp/core/api"; import type { ICreateCustomerInputMapper, IUpdateCustomerInputMapper } from "../mappers"; import type { ICustomerCreator, ICustomerFinder, ICustomerUpdater } from "../services"; +import type { ICustomerFullReadModelAssembler } from "../services/assemblers"; import type { ICustomerFullSnapshotBuilder, ICustomerSummarySnapshotBuilder, @@ -15,10 +16,11 @@ import { export function buildGetCustomerByIdUseCase(deps: { finder: ICustomerFinder; + fullReadModelAssembler: ICustomerFullReadModelAssembler; fullSnapshotBuilder: ICustomerFullSnapshotBuilder; transactionManager: ITransactionManager; }) { - return new GetCustomerByIdUseCase(deps.finder, deps.fullSnapshotBuilder, deps.transactionManager); + return new GetCustomerByIdUseCase(deps); } export function buildListCustomersUseCase(deps: { @@ -36,29 +38,21 @@ export function buildListCustomersUseCase(deps: { export function buildCreateCustomerUseCase(deps: { creator: ICustomerCreator; dtoMapper: ICreateCustomerInputMapper; + fullReadModelAssembler: ICustomerFullReadModelAssembler; fullSnapshotBuilder: ICustomerFullSnapshotBuilder; transactionManager: ITransactionManager; }) { - return new CreateCustomerUseCase({ - dtoMapper: deps.dtoMapper, - creator: deps.creator, - fullSnapshotBuilder: deps.fullSnapshotBuilder, - transactionManager: deps.transactionManager, - }); + return new CreateCustomerUseCase(deps); } export function buildUpdateCustomerUseCase(deps: { updater: ICustomerUpdater; dtoMapper: IUpdateCustomerInputMapper; + fullReadModelAssembler: ICustomerFullReadModelAssembler; fullSnapshotBuilder: ICustomerFullSnapshotBuilder; transactionManager: ITransactionManager; }) { - return new UpdateCustomerUseCase({ - dtoMapper: deps.dtoMapper, - updater: deps.updater, - fullSnapshotBuilder: deps.fullSnapshotBuilder, - transactionManager: deps.transactionManager, - }); + return new UpdateCustomerUseCase(deps); } /*export function buildReportCustomerUseCase(deps: { diff --git a/modules/customers/src/api/application/di/index.ts b/modules/customers/src/api/application/di/index.ts index 08ab611a..71b08a98 100644 --- a/modules/customers/src/api/application/di/index.ts +++ b/modules/customers/src/api/application/di/index.ts @@ -1,6 +1,8 @@ +export * from "./customer-catalog-resolvers.di"; export * from "./customer-creator.di"; export * from "./customer-finder.di"; export * from "./customer-input-mappers.di"; +export * from "./customer-read-model-assemblers.di"; export * from "./customer-snapshot-builders.di"; export * from "./customer-updater.di"; export * from "./customer-use-cases.di"; 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 aa2ed532..a208e0d0 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 @@ -1,4 +1,3 @@ -import type { JsonTaxCatalogProvider } from "@erp/core"; import { City, Country, @@ -35,12 +34,6 @@ export interface ICreateCustomerInputMapper { } export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { - private readonly taxCatalog: JsonTaxCatalogProvider; - - constructor(params: { taxCatalog: JsonTaxCatalogProvider }) { - this.taxCatalog = params.taxCatalog; - } - public map( dto: CreateCustomerRequestDTO, params: { companyId: UniqueID } @@ -52,7 +45,7 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { const customerId = extractOrPushError(UniqueID.create(dto.id), "id", errors); const status = CustomerStatus.createActive(); - const isCompany = dto.is_company === "true"; + const isCompany = Boolean(dto.is_company); const reference = extractOrPushError( maybeFromNullableResult(dto.reference, (value) => Name.create(value)), @@ -164,6 +157,27 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { errors ); + const paymentMethodId = extractOrPushError( + maybeFromNullableResult(dto.payment_method_id, (value) => UniqueID.create(value)), + "payment_method_id", + errors + ); + + const paymentTermId = extractOrPushError( + maybeFromNullableResult(dto.payment_term_id, (value) => UniqueID.create(value)), + "payment_term_id", + errors + ); + + const taxRegimeCode = extractOrPushError( + maybeFromNullableResult(dto.tax_regime_code, (value) => Result.ok(String(value))), + "tax_regime_code", + errors + ); + + const usesEquivalenceSurcharge = Boolean(dto.uses_equivalence_surcharge); + const usesRetention = Boolean(dto.uses_retention); + const languageCode = extractOrPushError( LanguageCode.create(dto.language_code), "language_code", @@ -178,15 +192,6 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { const defaultTaxes: TaxCode[] = []; - /*if (!isNullishOrEmpty(dto.default_taxes)) { - dto.default_taxes!.map((taxCode, index) => { - const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors); - if (tax) { - defaultTaxes.add(tax!); - } - }); - }*/ - if (errors.length > 0) { return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors)); } @@ -225,7 +230,14 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { website: website!, legalRecord: legalRecord!, - defaultTaxes: defaultTaxes!, + + paymentMethodId: paymentMethodId!, + paymentTermId: paymentTermId!, + taxRegimeCode: taxRegimeCode!, + + usesEquivalenceSurcharge: usesEquivalenceSurcharge, + usesRetention: usesRetention, + languageCode: languageCode!, currencyCode: currencyCode!, }; diff --git a/modules/customers/src/api/application/mappers/index.ts b/modules/customers/src/api/application/mappers/index.ts index 46c310e9..dc3b5e1f 100644 --- a/modules/customers/src/api/application/mappers/index.ts +++ b/modules/customers/src/api/application/mappers/index.ts @@ -1,2 +1,3 @@ export * from "./create-customer-input.mapper"; +export * from "./tax-definition-to-tax.mapper"; export * from "./update-customer-input.mapper"; diff --git a/modules/customers/src/api/application/mappers/tax-definition-to-tax.mapper.ts b/modules/customers/src/api/application/mappers/tax-definition-to-tax.mapper.ts new file mode 100644 index 00000000..bac6589f --- /dev/null +++ b/modules/customers/src/api/application/mappers/tax-definition-to-tax.mapper.ts @@ -0,0 +1,44 @@ +import type { TaxDefinitionPublicFamily, TaxDefinitionPublicModel } from "@erp/catalogs/api"; +import { Tax, type TaxGroup, TaxPercentage } from "@erp/core/api"; +import { Result } from "@repo/rdx-utils"; + +/** + * Sirve para mapear TaxDefinitionPublicModel a Tax, + * que es el modelo de dominio que se para proforma e issued-invoices. + */ + +export class TaxDefinitionToTaxMapper { + public toTax(taxDefinition: TaxDefinitionPublicModel): Result { + const group = this.toTaxGroup(taxDefinition.taxFamily); + + if (group.isFailure) { + return Result.fail(group.error); + } + + return Tax.create({ + code: taxDefinition.code, + name: taxDefinition.name, + rate: TaxPercentage.create({ + value: taxDefinition.rate.value, + }).data, + group: group.data, + calculationBehavior: taxDefinition.calculationBehavior, + }); + } + + private toTaxGroup(taxFamily: TaxDefinitionPublicFamily): Result { + switch (taxFamily) { + case "iva": + case "igic": + case "ipsi": + case "retention": + return Result.ok(taxFamily); + + case "surcharge": + return Result.ok("surcharge"); + + default: + return Result.fail(new Error(`Unsupported tax family: ${String(taxFamily)}`)); + } + } +} 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 index de637d6c..836b8015 100644 --- a/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts +++ b/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts @@ -14,7 +14,7 @@ import { TINNumber, TextValue, URLAddress, - type UniqueID, + UniqueID, ValidationErrorCollection, type ValidationErrorDetail, extractOrPushError, @@ -115,30 +115,55 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper { ); }); - /** - * Se mantiene la compatibilidad con el contrato actual. - * Cuando defaultTaxes tenga un contrato final estable, aquí conviene - * mapearlo a CustomerTaxes/Collection con su schema semántico real. - */ - toPatchField(dto.default_taxes).ifSet((_defaultTaxes) => { - errors.push({ - path: "default_taxes", - message: "default_taxes mapping is not implemented yet", - }); + toPatchField(dto.payment_method_id).ifSetOrNull((paymentMethodId) => { + customerPatchProps.paymentMethodId = extractOrPushError( + maybeFromNullableResult(paymentMethodId, (value) => UniqueID.create(value)), + "payment_method_id", + errors + ); }); + toPatchField(dto.payment_method_id).ifSetOrNull((paymentMethodId) => { + customerPatchProps.paymentMethodId = extractOrPushError( + maybeFromNullableResult(paymentMethodId, (value) => UniqueID.create(value)), + "payment_method_id", + errors + ); + }); + + toPatchField(dto.tax_regime_code).ifSetOrNull((taxRegimeCode) => { + console.log("Hay taxRegimeCode", taxRegimeCode); + customerPatchProps.taxRegimeCode = extractOrPushError( + maybeFromNullableResult(taxRegimeCode, (value) => Result.ok(String(value))), + "tax_regime_code", + errors + ); + }); + + toPatchField(dto.uses_equivalence_surcharge).ifSet((usesEquivalenceSurcharge) => { + customerPatchProps.usesEquivalenceSurcharge = usesEquivalenceSurcharge; + }); + + toPatchField(dto.uses_retention).ifSet((usesRetention) => { + customerPatchProps.usesRetention = usesRetention; + }); + + this.throwIfValidationErrors(errors); + const addressPatchProps = this.mapPostalAddress(dto.address, errors); + + this.throwIfValidationErrors(errors); + if (addressPatchProps) { customerPatchProps.address = addressPatchProps; } this.mapContact(dto.contact, customerPatchProps, errors); - if (errors.length > 0) { - return Result.fail( - new ValidationErrorCollection("Customer props mapping failed (update)", errors) - ); - } + this.throwIfValidationErrors(errors); + + console.log("dto => ", dto); + console.log("customerPatchProps => ", customerPatchProps); return Result.ok(customerPatchProps); } catch (err: unknown) { @@ -280,4 +305,10 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper { ); }); } + + private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { + if (errors.length > 0) { + throw new ValidationErrorCollection("Proforma props mapping failed", errors); + } + } } diff --git a/modules/customers/src/api/application/services/catalog-resolver/build-customer-tax-resolver.ts b/modules/customers/src/api/application/services/catalog-resolver/build-customer-tax-resolver.ts new file mode 100644 index 00000000..b79311af --- /dev/null +++ b/modules/customers/src/api/application/services/catalog-resolver/build-customer-tax-resolver.ts @@ -0,0 +1,16 @@ +import type { ITaxDefinitionPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; + +import { TaxDefinitionToTaxMapper } from "../../mappers"; + +import { CustomerTaxResolver } from "./customer-tax-resolver"; + +export function buildCustomerTaxResolver(deps: { + taxDefinitionFinder: ITaxDefinitionPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; +}): CustomerTaxResolver { + return new CustomerTaxResolver({ + taxDefinitionFinder: deps.taxDefinitionFinder, + taxRegimeFinder: deps.taxRegimeFinder, + taxMapper: new TaxDefinitionToTaxMapper(), + }); +} diff --git a/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.ts b/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.ts new file mode 100644 index 00000000..9888a21e --- /dev/null +++ b/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.ts @@ -0,0 +1,56 @@ +import type { IPaymentMethodPublicFinder, IPaymentTermPublicFinder } from "@erp/catalogs/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { type Maybe, Result } from "@repo/rdx-utils"; + +export interface EnsureCustomerPaymentMethodByIdParams { + companyId: UniqueID; + id: Maybe; +} + +/** + * Resuelve formas de pago externas al agregado de customer usando servicios públicos de `catalogs`. + * + * Este servicio pertenece a Application porque coordina dependencias entre módulos + * antes de crear o actualizar una customer. + * + * No accede a repositorios de `catalogs`, no usa Sequelize y no debe utilizarse + * desde mappers de persistencia. Su responsabilidad es convertir contratos públicos + * de catálogos (`payment-methods`) en Value Objects propios del + * dominio de `customer`. + * + */ +export class CustomerPaymentResolver { + public constructor( + private readonly deps: { + paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; + } + ) {} + + // Solo asegurarnos que existe ese plazo de apgo + public async ensurePaymentTermById(params: { + companyId: UniqueID; + id: Maybe; + }): Promise> { + if (params.id.isNone()) { + return Result.ok(undefined); + } + + const id = params.id.unwrap(); + + const result = await this.deps.paymentTermFinder.findByIdInCompany({ + companyId: params.companyId, + id, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.fail(new Error(`Payment term not found: ${id.toString()}`)); + } + + return Result.ok(undefined); + } +} diff --git a/modules/customers/src/api/application/services/catalog-resolver/customer-tax-resolver.ts b/modules/customers/src/api/application/services/catalog-resolver/customer-tax-resolver.ts new file mode 100644 index 00000000..29adcc74 --- /dev/null +++ b/modules/customers/src/api/application/services/catalog-resolver/customer-tax-resolver.ts @@ -0,0 +1,59 @@ +import type { ITaxDefinitionPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { type Maybe, Result } from "@repo/rdx-utils"; + +import type { TaxDefinitionToTaxMapper } from "../../mappers"; + +export interface EnsureCustomerTaxRegimeByCodeParams { + companyId: UniqueID; + code: Maybe; +} + +/** + * Resuelve datos fiscales externos al agregado de customer usando servicios públicos de `catalogs`. + * + * Este servicio pertenece a Application porque coordina dependencias entre módulos + * antes de crear o actualizar una customer. + * + * No accede a repositorios de `catalogs`, no usa Sequelize y no debe utilizarse + * desde mappers de persistencia. Su responsabilidad es convertir contratos públicos + * de catálogos (`tax-definitions` y `tax-regimes`) en Value Objects propios del + * dominio de `customer`. + * + */ +export class CustomerTaxResolver { + public constructor( + private readonly deps: { + taxDefinitionFinder: ITaxDefinitionPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; + taxMapper: TaxDefinitionToTaxMapper; + } + ) {} + + // Solo asegurarnos que existe un TaxRegime con ese "code" + public async ensureTaxRegimeByCode(params: { + companyId: UniqueID; + code: Maybe; + }): Promise> { + if (params.code.isNone()) { + return Result.ok(undefined); + } + + const code = params.code.unwrap(); + + const result = await this.deps.taxRegimeFinder.findByCodeInCompany({ + companyId: params.companyId, + code, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.fail(new Error(`Tax regime not found: ${code}`)); + } + + return Result.ok(undefined); + } +} diff --git a/modules/customers/src/api/application/services/catalog-resolver/index.ts b/modules/customers/src/api/application/services/catalog-resolver/index.ts new file mode 100644 index 00000000..29291629 --- /dev/null +++ b/modules/customers/src/api/application/services/catalog-resolver/index.ts @@ -0,0 +1,3 @@ +export * from "./build-customer-tax-resolver"; +export * from "./customer-payment-resolver"; +export * from "./customer-tax-resolver"; diff --git a/modules/customers/src/api/application/use-cases/create-customer.use-case.ts b/modules/customers/src/api/application/use-cases/create-customer.use-case.ts index 18d2f29b..fac5d958 100644 --- a/modules/customers/src/api/application/use-cases/create-customer.use-case.ts +++ b/modules/customers/src/api/application/use-cases/create-customer.use-case.ts @@ -5,6 +5,7 @@ import { Result } from "@repo/rdx-utils"; import type { CreateCustomerRequestDTO } from "../../../common"; import type { ICreateCustomerInputMapper } from "../mappers"; import type { ICustomerCreator } from "../services"; +import type { ICustomerFullReadModelAssembler } from "../services/assemblers"; import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders"; type CreateCustomerUseCaseInput = { @@ -12,47 +13,46 @@ type CreateCustomerUseCaseInput = { dto: CreateCustomerRequestDTO; }; -type CreateCustomerUseCaseDeps = { - dtoMapper: ICreateCustomerInputMapper; - creator: ICustomerCreator; - fullSnapshotBuilder: ICustomerFullSnapshotBuilder; - transactionManager: ITransactionManager; -}; - export class CreateCustomerUseCase { - private readonly dtoMapper: ICreateCustomerInputMapper; - private readonly creator: ICustomerCreator; - private readonly fullSnapshotBuilder: ICustomerFullSnapshotBuilder; - private readonly transactionManager: ITransactionManager; - - constructor(deps: CreateCustomerUseCaseDeps) { - this.dtoMapper = deps.dtoMapper; - this.creator = deps.creator; - this.fullSnapshotBuilder = deps.fullSnapshotBuilder; - this.transactionManager = deps.transactionManager; - } + constructor( + private readonly deps: { + dtoMapper: ICreateCustomerInputMapper; + creator: ICustomerCreator; + fullReadModelAssembler: ICustomerFullReadModelAssembler; + fullSnapshotBuilder: ICustomerFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} public execute(params: CreateCustomerUseCaseInput) { const { dto, companyId } = params; - const mappedPropsResult = this.dtoMapper.map(dto, { companyId }); + const mappedPropsResult = this.deps.dtoMapper.map(dto, { companyId }); if (mappedPropsResult.isFailure) { return mappedPropsResult; } const { props, id } = mappedPropsResult.data; - return this.transactionManager.complete(async (transaction: unknown) => { + return this.deps.transactionManager.complete(async (transaction: unknown) => { try { - const createResult = await this.creator.create({ companyId, id, props, transaction }); + const createResult = await this.deps.creator.create({ companyId, id, props, transaction }); if (createResult.isFailure) { return createResult; } - const newCustomer = createResult.data; + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ + companyId, + customer: createResult.data, + transaction, + }); - const snapshot = this.fullSnapshotBuilder.toOutput(newCustomer); + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); + } + + const snapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data); return Result.ok(snapshot); } catch (error: unknown) { diff --git a/modules/customers/src/api/application/use-cases/index.ts b/modules/customers/src/api/application/use-cases/index.ts index 237ab7c8..70d48393 100644 --- a/modules/customers/src/api/application/use-cases/index.ts +++ b/modules/customers/src/api/application/use-cases/index.ts @@ -1,4 +1,4 @@ export * from "./create-customer.use-case"; export * from "./get-customer-by-id.use-case"; export * from "./list-customers.use-case"; -export * from "./update/update-customer.use-case"; +export * from "./update-customer.use-case"; diff --git a/modules/customers/src/api/application/use-cases/update-customer.use-case.ts b/modules/customers/src/api/application/use-cases/update-customer.use-case.ts new file mode 100644 index 00000000..01fa45cf --- /dev/null +++ b/modules/customers/src/api/application/use-cases/update-customer.use-case.ts @@ -0,0 +1,75 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { UpdateCustomerByIdRequestDTO } from "../../../common/dto"; +import type { CustomerPatchProps } from "../../domain"; +import type { IUpdateCustomerInputMapper } from "../mappers"; +import type { ICustomerUpdater } from "../services"; +import type { ICustomerFullReadModelAssembler } from "../services/assemblers"; +import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders"; + +type UpdateCustomerUseCaseInput = { + companyId: UniqueID; + customer_id: string; + dto: UpdateCustomerByIdRequestDTO; +}; + +export class UpdateCustomerUseCase { + constructor( + private readonly deps: { + dtoMapper: IUpdateCustomerInputMapper; + updater: ICustomerUpdater; + fullReadModelAssembler: ICustomerFullReadModelAssembler; + fullSnapshotBuilder: ICustomerFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: UpdateCustomerUseCaseInput) { + const { companyId, customer_id, dto } = params; + + const idOrError = UniqueID.create(customer_id); + if (idOrError.isFailure) { + return Result.fail(idOrError.error); + } + const id = idOrError.data; + + // Mapear DTO → props de dominio + const patchPropsResult = this.deps.dtoMapper.map(dto, { companyId }); + if (patchPropsResult.isFailure) { + return patchPropsResult; + } + + const patchProps: CustomerPatchProps = patchPropsResult.data; + + return this.deps.transactionManager.complete(async (transaction: unknown) => { + try { + const updateResult = await this.deps.updater.update({ + companyId, + id, + props: patchProps, + transaction, + }); + + if (updateResult.isFailure) { + return Result.fail(updateResult.error); + } + + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ + companyId, + customer: updateResult.data, + transaction, + }); + + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); + } + + return Result.ok(this.deps.fullSnapshotBuilder.toOutput(readModelResult.data)); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customers/src/api/application/use-cases/update/index.ts b/modules/customers/src/api/application/use-cases/update/index.ts deleted file mode 100644 index db3e8d69..00000000 --- a/modules/customers/src/api/application/use-cases/update/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./update-customer.use-case"; 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 deleted file mode 100644 index 5b5ed017..00000000 --- a/modules/customers/src/api/application/use-cases/update/update-customer.use-case.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ITransactionManager } from "@erp/core/api"; -import { UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; - -import type { UpdateCustomerByIdRequestDTO } from "../../../../common/dto"; -import type { CustomerPatchProps } from "../../../domain"; -import type { IUpdateCustomerInputMapper } from "../../mappers"; -import type { ICustomerUpdater } from "../../services"; -import type { ICustomerFullSnapshotBuilder } from "../../snapshot-builders"; - -type UpdateCustomerUseCaseInput = { - companyId: UniqueID; - customer_id: string; - dto: UpdateCustomerByIdRequestDTO; -}; - -type UpdateCustomerUseCaseDeps = { - dtoMapper: IUpdateCustomerInputMapper; - updater: ICustomerUpdater; - fullSnapshotBuilder: ICustomerFullSnapshotBuilder; - transactionManager: ITransactionManager; -}; - -export class UpdateCustomerUseCase { - 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; - - const idOrError = UniqueID.create(customer_id); - 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) { - return patchPropsResult; - } - - const patchProps: CustomerPatchProps = patchPropsResult.data; - - return this.transactionManager.complete(async (transaction: unknown) => { - try { - const updateResult = await this.updater.update({ - companyId, - id, - props: patchProps, - transaction, - }); - - if (updateResult.isFailure) { - return Result.fail(updateResult.error); - } - - return Result.ok(this.fullSnapshotBuilder.toOutput(updateResult.data)); - } catch (error: unknown) { - return Result.fail(error as Error); - } - }); - } -} diff --git a/modules/customers/src/api/index.ts b/modules/customers/src/api/index.ts index 041a3d40..b16ce9b9 100644 --- a/modules/customers/src/api/index.ts +++ b/modules/customers/src/api/index.ts @@ -10,7 +10,7 @@ export * from "./infrastructure/persistence/sequelize"; export const customersAPIModule: IModuleServer = { name: "customers", version: "1.0.0", - dependencies: [], + dependencies: ["catalogs"], /** * Fase de SETUP diff --git a/modules/customers/src/api/infrastructure/di/customer-catalog-deps.di.ts b/modules/customers/src/api/infrastructure/di/customer-catalog-deps.di.ts new file mode 100644 index 00000000..8e9eeffb --- /dev/null +++ b/modules/customers/src/api/infrastructure/di/customer-catalog-deps.di.ts @@ -0,0 +1,62 @@ +import type { CatalogsPublicServicesType } from "@erp/catalogs/api"; +import type { ModuleParams } from "@erp/core/api"; + +type CustomerCatalogsDeps = { + taxDefinition: { + finder: CatalogsPublicServicesType["taxDefinitions"]["finder"]; + }; + taxRegime: { + finder: CatalogsPublicServicesType["taxRegimes"]["finder"]; + }; + paymentMethod: { + finder: CatalogsPublicServicesType["paymentMethods"]["finder"]; + }; + paymentTerm: { + finder: CatalogsPublicServicesType["paymentTerms"]["finder"]; + }; +}; + +export function resolveCustomerCatalogsDeps(params: ModuleParams): CustomerCatalogsDeps { + const taxDefinition = + params.getService("catalogs:taxDefinitions"); + + if (!taxDefinition?.finder) { + throw new Error("Missing public service: catalogs:taxDefinitions.finder"); + } + + const taxRegime = + params.getService("catalogs:taxRegimes"); + + if (!taxRegime?.finder) { + throw new Error("Missing public service: catalogs:taxRegimes.finder"); + } + + const paymentMethod = + params.getService("catalogs:paymentMethods"); + + if (!paymentMethod?.finder) { + throw new Error("Missing public service: catalogs:paymentMethods.finder"); + } + + const paymentTerm = + params.getService("catalogs:paymentTerms"); + + if (!paymentTerm?.finder) { + throw new Error("Missing public service: catalogs:paymentTerms.finder"); + } + + return { + taxDefinition: { + finder: taxDefinition.finder, + }, + taxRegime: { + finder: taxRegime.finder, + }, + paymentMethod: { + finder: paymentMethod.finder, + }, + paymentTerm: { + finder: paymentTerm.finder, + }, + }; +} diff --git a/modules/customers/src/api/infrastructure/di/customer-persistence-mappers.di.ts b/modules/customers/src/api/infrastructure/di/customer-persistence-mappers.di.ts index 552ec51b..aba445a9 100644 --- a/modules/customers/src/api/infrastructure/di/customer-persistence-mappers.di.ts +++ b/modules/customers/src/api/infrastructure/di/customer-persistence-mappers.di.ts @@ -1,5 +1,3 @@ -import type { ICatalogs } from "@erp/core/api"; - import { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../persistence"; export interface ICustomerPersistenceMappers { @@ -9,13 +7,9 @@ export interface ICustomerPersistenceMappers { //createMapper: CreateCustomerInputMapper; } -export const buildCustomerPersistenceMappers = ( - catalogs: ICatalogs -): ICustomerPersistenceMappers => { - const { taxCatalog } = catalogs; - +export const buildCustomerPersistenceMappers = (): ICustomerPersistenceMappers => { // Mappers para el repositorio - const domainMapper = new SequelizeCustomerDomainMapper({ taxCatalog }); + const domainMapper = new SequelizeCustomerDomainMapper(); const summaryMapper = new SequelizeCustomerSummaryMapper(); // Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado 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 117fdf68..534a7f6a 100644 --- a/modules/customers/src/api/infrastructure/di/customer-public-services.ts +++ b/modules/customers/src/api/infrastructure/di/customer-public-services.ts @@ -1,4 +1,4 @@ -import { type SetupParams, buildCatalogs } from "@erp/core/api"; +import type { SetupParams } from "@erp/core/api"; import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; @@ -10,6 +10,7 @@ import { } from "../../application"; import type { ICustomerCreateProps } from "../../domain"; +import { resolveCustomerCatalogsDeps } from "./customer-catalog-deps.di"; import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di"; import { buildCustomerRepository } from "./customer-repositories.di"; import type { CustomersInternalDeps } from "./customers.di"; @@ -19,10 +20,10 @@ export function buildCustomerPublicServices( deps: CustomersInternalDeps ): ICustomerPublicServices { const { database } = params; - const catalogs = buildCatalogs(); + const catalogs = resolveCustomerCatalogsDeps(params); // Infrastructure - const persistenceMappers = buildCustomerPersistenceMappers(catalogs); + const persistenceMappers = buildCustomerPersistenceMappers(); const repository = buildCustomerRepository({ database, mappers: persistenceMappers }); const finder = buildCustomerFinder({ repository }); diff --git a/modules/customers/src/api/infrastructure/di/customers.di.ts b/modules/customers/src/api/infrastructure/di/customers.di.ts index b333f68c..a554da07 100644 --- a/modules/customers/src/api/infrastructure/di/customers.di.ts +++ b/modules/customers/src/api/infrastructure/di/customers.di.ts @@ -1,4 +1,4 @@ -import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api"; +import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; import { type CreateCustomerUseCase, @@ -15,7 +15,10 @@ import { buildListCustomersUseCase, buildUpdateCustomerUseCase, } from "../../application"; +import { buildCustomerReadModelAssemblers } from "../../application/di"; +import { buildCustomerCatalogResolvers } from "../../application/di/customer-catalog-resolvers.di"; +import { resolveCustomerCatalogsDeps } from "./customer-catalog-deps.di"; import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di"; import { buildCustomerRepository } from "./customer-repositories.di"; @@ -39,21 +42,33 @@ export type CustomersInternalDeps = { export function buildCustomersDependencies(params: ModuleParams): CustomersInternalDeps { const { database } = params; - // Infrastructure + const catalogs = resolveCustomerCatalogsDeps(params); + const transactionManager = buildTransactionManager(database); - const catalogs = buildCatalogs(); - const persistenceMappers = buildCustomerPersistenceMappers(catalogs); + const persistenceMappers = buildCustomerPersistenceMappers(); const repository = buildCustomerRepository({ database, mappers: persistenceMappers }); - //const numberService = buildCustomerNumberGenerator(); - // Application helpers - const inputMappers = buildCustomerInputMappers(catalogs); + const inputMappers = buildCustomerInputMappers(); + const snapshotBuilders = buildCustomerSnapshotBuilders(); + + const catalogResolvers = buildCustomerCatalogResolvers({ + taxDefinitionFinder: catalogs.taxDefinition.finder, + taxRegimeFinder: catalogs.taxRegime.finder, + paymentMethodFinder: catalogs.paymentMethod.finder, + paymentTermFinder: catalogs.paymentTerm.finder, + }); + + const readModelAssemblers = buildCustomerReadModelAssemblers({ + paymentMethodFinder: catalogs.paymentMethod.finder, + taxRegimeFinder: catalogs.taxRegime.finder, + paymentTermFinder: catalogs.paymentTerm.finder, + }); + const finder = buildCustomerFinder({ repository }); const creator = buildCustomerCreator({ repository }); const updater = buildCustomerUpdater({ repository }); - const snapshotBuilders = buildCustomerSnapshotBuilders(); //const documentGeneratorPipeline = buildCustomerDocumentService(params); // Internal use cases (factories) @@ -69,6 +84,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter getCustomerById: () => buildGetCustomerByIdUseCase({ finder, + fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), @@ -77,6 +93,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter buildCreateCustomerUseCase({ creator, dtoMapper: inputMappers.createInputMapper, + fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), @@ -85,6 +102,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter buildUpdateCustomerUseCase({ updater, dtoMapper: inputMappers.updateInputMapper, + fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), 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 2fd546af..6100e014 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 @@ -1,4 +1,3 @@ -import type { TaxCatalogProvider } from "@erp/core"; import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import { City, @@ -32,13 +31,6 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< CustomerCreationAttributes, Customer > { - private readonly taxCatalog: TaxCatalogProvider; - - constructor(params: { taxCatalog: TaxCatalogProvider }) { - super(); - this.taxCatalog = params.taxCatalog; - } - public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result { try { const errors: ValidationErrorDetail[] = []; diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts index 6bb48b02..f7f02189 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts @@ -67,11 +67,13 @@ export class CustomerRepository const dtoResult = this.domainMapper.mapToPersistence(customer); const { id, ...updatePayload } = dtoResult.data; + + console.log(dtoResult.data); + const [affected] = await CustomerModel.update(updatePayload, { where: { id /*, version */ }, - //fields: Object.keys(updatePayload), transaction, - individualHooks: true, + //individualHooks: true, }); if (affected === 0) { 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 ca865f6d..c685049f 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 @@ -4,11 +4,10 @@ export const CreateCustomerRequestSchema = z.object({ id: z.string().nonempty(), reference: z.string().optional(), - is_company: z.string().toLowerCase().default("true"), + is_company: z.boolean().default(true), name: z.string(), trade_name: z.string().optional(), tin: z.string().optional(), - default_taxes: z.string().default(""), street: z.string().optional(), street2: z.string().optional(), @@ -32,8 +31,8 @@ export const CreateCustomerRequestSchema = z.object({ 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(), + uses_equivalence_surcharge: z.boolean().default(false), + uses_retention: z.boolean().default(false), language_code: z.string().toLowerCase().default("es"), currency_code: z.string().toUpperCase().default("EUR"), diff --git a/modules/customers/src/web/create/controllers/use-customer-create-page.controller.ts b/modules/customers/src/web/create/controllers/use-customer-create-page.controller.ts index 70f1da38..2da45c95 100644 --- a/modules/customers/src/web/create/controllers/use-customer-create-page.controller.ts +++ b/modules/customers/src/web/create/controllers/use-customer-create-page.controller.ts @@ -1,9 +1,12 @@ -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useCustomerCreateController } from "./use-customer-create.controller"; export const useCustomerCreatePageController = () => { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const returnTo = searchParams.get("returnTo") ?? "/customers"; const createCtrl = useCustomerCreateController({ onCreated: (customer) => navigate(`/customers/${customer.id}`), @@ -11,5 +14,6 @@ export const useCustomerCreatePageController = () => { return { createCtrl, + returnTo, }; }; diff --git a/modules/customers/src/web/create/controllers/use-customer-create.controller.ts b/modules/customers/src/web/create/controllers/use-customer-create.controller.ts index 4a3fec8d..47ced575 100644 --- a/modules/customers/src/web/create/controllers/use-customer-create.controller.ts +++ b/modules/customers/src/web/create/controllers/use-customer-create.controller.ts @@ -6,6 +6,7 @@ import type { FieldErrors } from "react-hook-form"; import { useTranslation } from "../../i18n"; import type { CreateCustomerParams, Customer } from "../../shared"; import { useCustomerCreateMutation } from "../../shared/hooks/use-customer-create-mutation"; +import { mapCustomerToCustomerCreateForm } from "../adapters"; import { type CustomerCreateForm, CustomerCreateFormSchema, @@ -47,13 +48,19 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle }; const submitHandler = form.handleSubmit( - async (formData) => { + async (formData: CustomerCreateForm) => { const params: CreateCustomerParams = buildCreateCustomerParams(formData); try { // Enviamos cambios al servidor const created = await mutateAsync(params); // payload es CustomerCreatePayload + // Ha ido bien -> actualizamos form con datos reales + // keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render. + form.reset(mapCustomerToCustomerCreateForm(created), { + keepDirty: false, + }); + if (options?.successToasts !== false) { showSuccessToast( t("create.feedback.success.title"), diff --git a/modules/customers/src/web/create/entities/customer-create-form.entity.ts b/modules/customers/src/web/create/entities/customer-create-form.entity.ts index 4e2cd2f4..61ecca56 100644 --- a/modules/customers/src/web/create/entities/customer-create-form.entity.ts +++ b/modules/customers/src/web/create/entities/customer-create-form.entity.ts @@ -19,7 +19,6 @@ export interface CustomerCreateForm { name: string; tradeName: string; tin: string; - defaultTaxes: string[]; street: string; street2: string; diff --git a/modules/customers/src/web/create/entities/customer-create-form.schema.ts b/modules/customers/src/web/create/entities/customer-create-form.schema.ts index c21cdcb2..b073ef39 100644 --- a/modules/customers/src/web/create/entities/customer-create-form.schema.ts +++ b/modules/customers/src/web/create/entities/customer-create-form.schema.ts @@ -1,3 +1,13 @@ +import { + CountryCodeSchema, + CurrencyCodeSchema, + LandPhoneSchema, + LanguageCodeSchema, + MobilePhoneSchema, + PostalCodeSchema, + TinSchema, + WebsiteSchema, +} from "@erp/core"; import { z } from "zod/v4"; /** @@ -17,32 +27,31 @@ import { z } from "zod/v4"; export const CustomerCreateFormSchema = z.object({ reference: z.string(), isCompany: z.boolean(), + name: z.string().min(1, "El nombre es obligatorio"), tradeName: z.string(), - tin: z.string(), - - defaultTaxes: z.array(z.string()), + tin: TinSchema.or(z.literal("")), street: z.string(), street2: z.string(), city: z.string(), province: z.string(), - postalCode: z.string(), - country: z.string().min(1, "El país es obligatorio"), + postalCode: PostalCodeSchema.or(z.literal("")), + country: CountryCodeSchema.or(z.literal("")), - primaryEmail: z.email("Email inválido"), - secondaryEmail: z.email("Email inválido"), + primaryEmail: z.email("Email inválido").or(z.literal("")), + secondaryEmail: z.email("Email inválido").or(z.literal("")), - primaryPhone: z.string(), - secondaryPhone: z.string(), - primaryMobile: z.string(), - secondaryMobile: z.string(), + primaryPhone: LandPhoneSchema.or(z.literal("")), + secondaryPhone: LandPhoneSchema.or(z.literal("")), + primaryMobile: MobilePhoneSchema.or(z.literal("")), + secondaryMobile: MobilePhoneSchema.or(z.literal("")), - fax: z.string(), - website: z.url("URL inválida"), + fax: LandPhoneSchema.or(z.literal("")), + website: WebsiteSchema.or(z.literal("")), - legalRecord: z.string(), + legalRecord: z.string().or(z.literal("")), - languageCode: z.string().min(1, "El idioma es obligatorio"), - currencyCode: z.string().min(1, "La moneda es obligatoria"), + languageCode: LanguageCodeSchema, + currencyCode: CurrencyCodeSchema, }); diff --git a/modules/customers/src/web/create/ui/blocks/customer-create-header.tsx b/modules/customers/src/web/create/ui/blocks/customer-create-header.tsx new file mode 100644 index 00000000..9d763df7 --- /dev/null +++ b/modules/customers/src/web/create/ui/blocks/customer-create-header.tsx @@ -0,0 +1,122 @@ +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 CustomerCreateHeaderLabels { + title: string; + back: string; + modified: string; + cancel: string; + save: string; + saving: string; + moreActions: string; + keyboardShortcuts: string; + delete: string; +} + +export interface CustomerCreateHeaderProps { + formId?: string; + + labels: CustomerCreateHeaderLabels; + + onCancel?: () => void; + onDuplicate?: () => void; + onDelete?: () => void; + + disabled?: boolean; + readOnly?: boolean; + isSaving?: boolean; + hasChanges?: boolean; + + className?: string; + children?: ReactNode; +} + +export const CustomerCreateHeader = ({ + formId, + labels, + onCancel, + onDuplicate, + onDelete, + disabled = false, + readOnly = false, + isSaving = false, + hasChanges = false, + className, + children, +}: CustomerCreateHeaderProps) => { + const computedDisabled = disabled || isSaving; + + const secondaryActions: FormSecondaryAction[] = [ + /*{ + id: "duplicate", + label: labels.duplicate, + icon:
diff --git a/modules/customers/src/web/update/utils/build-customer-update-default.ts b/modules/customers/src/web/update/utils/build-customer-update-default.ts index 73695d06..193da5e4 100644 --- a/modules/customers/src/web/update/utils/build-customer-update-default.ts +++ b/modules/customers/src/web/update/utils/build-customer-update-default.ts @@ -8,8 +8,6 @@ export const buildCustomerUpdateDefault = (): CustomerUpdateForm => { tradeName: "", tin: "", - defaultTaxes: [], - street: "", street2: "", city: "", @@ -29,6 +27,13 @@ export const buildCustomerUpdateDefault = (): CustomerUpdateForm => { legalRecord: "", + paymentMethodId: "", + paymentTermId: "", + taxRegimeCode: "", + + usesEquivalenceSurcharge: false, + usesRetention: false, + languageCode: "es", currencyCode: "EUR", }; diff --git a/modules/customers/src/web/update/utils/build-customer.update-patch.ts b/modules/customers/src/web/update/utils/build-customer.update-patch.ts index 60a37847..e27085f9 100644 --- a/modules/customers/src/web/update/utils/build-customer.update-patch.ts +++ b/modules/customers/src/web/update/utils/build-customer.update-patch.ts @@ -47,10 +47,6 @@ export const buildCustomerUpdatePatch = ( patch.tin = toNullableText(formData.tin); } - if (dirtyFields.defaultTaxes) { - patch.defaultTaxes = formData.defaultTaxes; - } - if (dirtyFields.street) { patch.street = toNullableText(formData.street); } @@ -111,6 +107,26 @@ export const buildCustomerUpdatePatch = ( patch.legalRecord = toNullableText(formData.legalRecord); } + if (dirtyFields.paymentMethodId) { + patch.paymentMethodId = toNullableText(formData.paymentMethodId); + } + + if (dirtyFields.paymentTermId) { + patch.paymentTermId = toNullableText(formData.paymentTermId); + } + + if (dirtyFields.taxRegimeCode) { + patch.taxRegimeCode = toNullableText(formData.taxRegimeCode); + } + + if (dirtyFields.usesEquivalenceSurcharge) { + patch.usesEquivalenceSurcharge = formData.usesEquivalenceSurcharge; + } + + if (dirtyFields.usesRetention) { + patch.usesRetention = formData.usesRetention; + } + if (dirtyFields.languageCode) { patch.languageCode = toRequiredText(formData.languageCode); } @@ -119,6 +135,8 @@ export const buildCustomerUpdatePatch = ( patch.currencyCode = toRequiredText(formData.currencyCode); } + console.log(dirtyFields, patch); + return patch; }; diff --git a/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts b/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts index 302675ca..e27fae79 100644 --- a/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts +++ b/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts @@ -46,10 +46,6 @@ export const buildUpdateCustomerByIdParams = ( data.tin = patchData.tin; } - if (patchData.defaultTaxes !== undefined) { - data.default_taxes = patchData.defaultTaxes?.toString(); - } - if (patchData.legalRecord !== undefined) { data.legal_record = patchData.legalRecord; } @@ -62,6 +58,27 @@ export const buildUpdateCustomerByIdParams = ( data.currency_code = patchData.currencyCode; } + // --- legal --- + if (patchData.paymentMethodId !== undefined) { + data.payment_method_id = patchData.paymentMethodId; + } + + if (patchData.paymentTermId !== undefined) { + data.payment_term_id = patchData.paymentTermId; + } + + if (patchData.taxRegimeCode !== undefined) { + data.tax_regime_code = patchData.taxRegimeCode; + } + + if (patchData.usesEquivalenceSurcharge !== undefined) { + data.uses_equivalence_surcharge = patchData.usesEquivalenceSurcharge; + } + + if (patchData.usesRetention !== undefined) { + data.uses_retention = patchData.usesRetention; + } + // --- address --- const address: Record = {}; diff --git a/modules/customers/src/web/view/controllers/use-customer-view-page.controller.ts b/modules/customers/src/web/view/controllers/use-customer-view-page.controller.ts index a48a0acb..a5c38b29 100644 --- a/modules/customers/src/web/view/controllers/use-customer-view-page.controller.ts +++ b/modules/customers/src/web/view/controllers/use-customer-view-page.controller.ts @@ -1,9 +1,18 @@ +import { useUrlParamId } from "@erp/core/hooks"; +import { useSearchParams } from "react-router-dom"; + import { useCustomerViewController } from "./use-customer-view.controller"; export function useCustomerViewPageController() { - const viewCtrl = useCustomerViewController(); + const customerId = useUrlParamId(); + const [searchParams] = useSearchParams(); + + const viewCtrl = useCustomerViewController(customerId); + + const returnTo = searchParams.get("returnTo") ?? "/customers"; return { viewCtrl, + returnTo, }; } 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 222a0936..5cc23f0b 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,23 +1,24 @@ -import { useState } from "react"; - import { useCustomerGetQuery } from "../../shared/hooks"; -export const useCustomerViewController = (initialCustomerId = "") => { - const [customerId, setCustomerId] = useState(initialCustomerId); - - const query = useCustomerGetQuery(customerId); +export const useCustomerViewController = (customerId?: string) => { + const { + data: customerData, + isLoading, + isError: isLoadError, + error: loadError, + refetch, + } = useCustomerGetQuery({ + id: customerId, + enabled: Boolean(customerId), + }); return { - data: query.data, - isLoading: query.isLoading, - isFetching: query.isFetching, + // carga de datos + customerData, + isLoading, + isLoadError, + loadError, - isError: query.isError, - error: query.error, - - refetch: query.refetch, - - customerId, - setCustomerId, + refetch, }; }; diff --git a/modules/customers/src/web/view/ui/blocks/customer-view-header.tsx b/modules/customers/src/web/view/ui/blocks/customer-view-header.tsx new file mode 100644 index 00000000..de353ebe --- /dev/null +++ b/modules/customers/src/web/view/ui/blocks/customer-view-header.tsx @@ -0,0 +1,110 @@ +import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; +import { + CancelActionButton, + FormActionsBar, + type FormSecondaryAction, + FormSecondaryActionsMenu, +} from "@repo/rdx-ui/components"; +import type { ReactNode } from "react"; + +export interface CustomerViewHeaderLabels { + title: string; + back: string; + modified: string; + cancel: string; + save: string; + saving: string; + moreActions: string; + keyboardShortcuts: string; + delete: string; +} + +export interface CustomerViewHeaderProps { + labels: CustomerViewHeaderLabels; + + onCancel?: () => void; + onDuplicate?: () => void; + onDelete?: () => void; + + disabled?: boolean; + readOnly?: boolean; + isSaving?: boolean; + hasChanges?: boolean; + + className?: string; + children?: ReactNode; +} + +export const CustomerViewHeader = ({ + labels, + onCancel, + onDuplicate, + onDelete, + disabled = false, + readOnly = false, + isSaving = false, + hasChanges = false, + className, + children, +}: CustomerViewHeaderProps) => { + const computedDisabled = disabled || isSaving; + + const secondaryActions: FormSecondaryAction[] = [ + /*{ + id: "duplicate", + label: labels.duplicate, + icon:
- } - rightSlot={ -
- - -
- } - title={ -
- {customerData?.name}{" "} - {customerData?.tradeName && ( - ({customerData.tradeName}) - )} -
- } - /> - + if (!viewCtrl.customerData) + return ( - {/* Main Content Grid */} -
- {/* Información Básica */} - - - - - {t("view.sections.basic_info")} - - - -
-
- {t("view.fields.name")} -
-
{customerData?.name}
-
-
-
- {t("view.fields.reference")} -
-
- {customerData?.reference} -
-
-
-
- {t("view.fields.legal_record")} -
-
{customerData?.legalRecord}
-
-
-
- {t("view.fields.default_taxes")} -
-
- {customerData?.defaultTaxes.map((tax: string) => ( - - {tax} - - ))} -
-
-
-
+ + + ); - {/* Dirección */} - - - - - {t("view.sections.address")} - - - + return ( +
+ + +
+ {/* Información Básica */} + + + + + {t("view.sections.basic_info")} + + + +
+
{t("view.fields.name")}
+
{viewCtrl.customerData?.name}
+
+
+
+ {t("view.fields.reference")} +
+
+ {viewCtrl.customerData?.reference} +
+
+
+
+ {t("view.fields.legal_record")} +
+
+ {viewCtrl.customerData?.legalRecord} +
+
+
+
+ + {/* Dirección */} + + + + + {t("view.sections.address")} + + + +
+
+ {t("view.fields.street")} +
+
+ {viewCtrl.customerData?.street} + {viewCtrl.customerData?.street2 && ( + <> +
+ {viewCtrl.customerData?.street2} + + )} +
+
+
- {t("view.fields.street")} + {t("view.fields.city")} +
+
{viewCtrl.customerData?.city}
+
+
+
+ {t("view.fields.postal_code")}
- {customerData?.street} - {customerData?.street2 && ( - <> -
- {customerData?.street2} - - )} + {viewCtrl.customerData?.postalCode}
-
-
-
- {t("view.fields.city")} -
-
{customerData?.city}
-
-
-
- {t("view.fields.postal_code")} -
-
{customerData?.postalCode}
-
+
+
+
+
+ {t("view.fields.province")} +
+
+ {viewCtrl.customerData?.province} +
-
-
-
- {t("view.fields.province")} -
-
{customerData?.province}
-
-
-
- {t("view.fields.country")} -
-
{customerData?.country}
-
+
+
+ {t("view.fields.country")} +
+
{viewCtrl.customerData?.country}
- - +
+ + - {/* Información de Contacto */} - - - - - {t("view.sections.contact_info")} - - - -
- {/* Contacto Principal */} -
-

- {t("view.sections.primary_contact")} -

- {customerData?.primaryEmail && ( -
- -
-
- {t("view.fields.email")} -
-
- {customerData?.primaryEmail} -
-
+ {/* Información de Contacto */} + + + + + {t("view.sections.contact_info")} + + + +
+ {/* Contacto Principal */} +
+

+ {t("view.sections.primary_contact")} +

+ {viewCtrl.customerData?.primaryEmail && ( +
+ +
+
+ {t("view.fields.email")} +
+
+ {viewCtrl.customerData?.primaryEmail} +
- )} - {customerData?.primaryMobile && ( -
- -
-
- {t("view.fields.mobile")} -
-
- {customerData?.primaryMobile} -
-
+
+ )} + {viewCtrl.customerData?.primaryMobile && ( +
+ +
+
+ {t("view.fields.mobile")} +
+
+ {viewCtrl.customerData?.primaryMobile} +
- )} - {customerData?.primaryPhone && ( -
- -
-
- {t("view.fields.phone")} -
-
- {customerData?.primaryPhone} -
-
-
- )} -
- - {/* Contacto Secundario */} -
-

- {t("view.sections.secondary_contact")} -

- {customerData?.secondaryEmail && ( -
- -
-
- {t("view.fields.email")} -
-
- {customerData?.secondaryEmail} -
-
-
- )} - {customerData?.secondaryMobile && ( -
- -
-
- {t("view.fields.mobile")} -
-
- {customerData?.secondaryMobile} -
-
-
- )} - {customerData?.secondaryPhone && ( -
- -
-
- {t("view.fields.phone")} -
-
- {customerData?.secondaryPhone} -
-
-
- )} -
- - {/* Otros Contactos */} - {(customerData?.website || customerData?.fax) && ( -
-

- {t("view.sections.other_contacts")} -

-
- {customerData?.website && ( -
- -
-
- {t("view.fields.website")} -
-
- - {customerData?.website} - -
-
-
- )} - {customerData?.fax && ( -
- -
-
- {t("view.fields.fax")} -
-
{customerData?.fax}
-
-
- )} +
+ )} + {viewCtrl.customerData?.primaryPhone && ( +
+ +
+
+ {t("view.fields.phone")} +
+
+ {viewCtrl.customerData?.primaryPhone} +
)}
- - - {/* Preferencias */} - - - - - {t("view.sections.preferences")} - - - -
-
- -
-
- {t("view.fields.language_code")} -
-
{customerData?.languageCode}
+ {/* Contacto Secundario */} +
+

+ {t("view.sections.secondary_contact")} +

+ {viewCtrl.customerData?.secondaryEmail && ( +
+ +
+
+ {t("view.fields.email")} +
+
+ {viewCtrl.customerData?.secondaryEmail} +
+
+
+ )} + {viewCtrl.customerData?.secondaryMobile && ( +
+ +
+
+ {t("view.fields.mobile")} +
+
+ {viewCtrl.customerData?.secondaryMobile} +
+
+
+ )} + {viewCtrl.customerData?.secondaryPhone && ( +
+ +
+
+ {t("view.fields.phone")} +
+
+ {viewCtrl.customerData?.secondaryPhone} +
+
+
+ )} +
+ + {/* Otros Contactos */} + {(viewCtrl.customerData?.website || viewCtrl.customerData?.fax) && ( +
+

+ {t("view.sections.other_contacts")} +

+
+ {viewCtrl.customerData?.website && ( +
+ +
+
+ {t("view.fields.website")} +
+
+ + {viewCtrl.customerData?.website} + +
+
+
+ )} + {viewCtrl.customerData?.fax && ( +
+ +
+
+ {t("view.fields.fax")} +
+
+ {viewCtrl.customerData?.fax} +
+
+
+ )}
-
- -
-
- {t("view.fields.currency_code")} -
-
{customerData?.currencyCode}
-
+ )} +
+ + + + {/* Preferencias */} + + + + + {t("view.sections.preferences")} + + + +
+
+ +
+
+ {t("view.fields.language_code")} +
+
+ {viewCtrl.customerData?.languageCode} +
- - -
- - +
+ +
+
+ {t("view.fields.currency_code")} +
+
+ {viewCtrl.customerData?.currencyCode} +
+
+
+
+ + +
+
); }; diff --git a/modules/factuges/package.json b/modules/factuges/package.json index 43734965..d06f37d8 100644 --- a/modules/factuges/package.json +++ b/modules/factuges/package.json @@ -1,6 +1,6 @@ { "name": "@erp/factuges", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, @@ -23,6 +23,7 @@ "dependencies": { "@erp/auth": "workspace:*", "@erp/core": "workspace:*", + "@erp/catalogs": "workspace:*", "@erp/customer-invoices": "workspace:*", "@erp/customers": "workspace:*", "@repo/i18next": "workspace:*", diff --git a/modules/factuges/src/api/application/di/factuges-catalog-resolvers.di.ts b/modules/factuges/src/api/application/di/factuges-catalog-resolvers.di.ts new file mode 100644 index 00000000..b2de8fb8 --- /dev/null +++ b/modules/factuges/src/api/application/di/factuges-catalog-resolvers.di.ts @@ -0,0 +1,35 @@ +import type { + IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, + ITaxDefinitionPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; + +import { FactuGESPaymentResolver } from "../services"; +import type { FactuGESPaymentMethodReferenceProvider } from "../services/json-data"; + +export interface IFactuGESCatalogResolvers { + //taxResolver: FactuGESTaxResolver; + paymentResolver: FactuGESPaymentResolver; +} + +export function buildFactuGESCatalogResolvers(params: { + taxDefinitionFinder: ITaxDefinitionPublicFinder; + taxRegimeFinder: ITaxRegimePublicFinder; + paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; + paymentReferenceProvider: FactuGESPaymentMethodReferenceProvider; +}): IFactuGESCatalogResolvers { + return { + /*taxResolver: buildFactuGESTaxResolver({ + taxDefinitionFinder: params.taxDefinitionFinder, + taxRegimeFinder: params.taxRegimeFinder, + }),*/ + + paymentResolver: new FactuGESPaymentResolver({ + paymentMethodFinder: params.paymentMethodFinder, + paymentTermFinder: params.paymentTermFinder, + paymentReferenceProvider: params.paymentReferenceProvider, + }), + }; +} 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 index 3b518806..b2dca8e6 100644 --- a/modules/factuges/src/api/application/di/factuges-input-mappers.di.ts +++ b/modules/factuges/src/api/application/di/factuges-input-mappers.di.ts @@ -1,5 +1,3 @@ -import type { ICatalogs } from "@erp/core/api"; - import { CreateProformaFromFactugesInputMapper, type ICreateProformaFromFactugesInputMapper, @@ -9,14 +7,9 @@ export interface IFactugesInputMappers { createInputMapper: ICreateProformaFromFactugesInputMapper; } -export const buildFactugesInputMappers = (catalogs: ICatalogs): IFactugesInputMappers => { - const { taxCatalog, paymentCatalog } = catalogs; - +export const buildFactugesInputMappers = (): IFactugesInputMappers => { // Mappers el DTO a las props validadas (FactugesProps) y luego construir agregado - const createInputMapper = new CreateProformaFromFactugesInputMapper({ - taxCatalog, - paymentCatalog, - }); + const createInputMapper = new CreateProformaFromFactugesInputMapper(); 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 index d5b3fea1..71971f5e 100644 --- a/modules/factuges/src/api/application/di/factuges-use-cases.di.ts +++ b/modules/factuges/src/api/application/di/factuges-use-cases.di.ts @@ -1,4 +1,4 @@ -import type { ICatalogs, ITransactionManager } from "@erp/core/api"; +import type { ITransactionManager } from "@erp/core/api"; import type { IProformaPublicServices } from "@erp/customer-invoices/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; @@ -6,6 +6,8 @@ import type { ICreateProformaFromFactugesInputMapper } from "../mappers"; import type { IFactuGESProformaFinder, IFactuGESProformaLinker } from "../services"; import { CreateProformaFromFactugesUseCase } from "../use-cases"; +import type { IFactuGESCatalogResolvers } from "./factuges-catalog-resolvers.di"; + export function buildCreateProformaFromFactugesUseCase(deps: { linker: IFactuGESProformaLinker; finder: IFactuGESProformaFinder; @@ -13,8 +15,8 @@ export function buildCreateProformaFromFactugesUseCase(deps: { customerServices: ICustomerPublicServices; proformaServices: IProformaPublicServices; }; + catalogResolvers: IFactuGESCatalogResolvers; dtoMapper: ICreateProformaFromFactugesInputMapper; - catalogs: ICatalogs; transactionManager: ITransactionManager; }) { const { @@ -22,18 +24,16 @@ export function buildCreateProformaFromFactugesUseCase(deps: { finder, dtoMapper, transactionManager, + catalogResolvers, publicServices: { customerServices, proformaServices }, } = deps; - const { taxCatalog, paymentCatalog } = deps.catalogs; - return new CreateProformaFromFactugesUseCase({ linker, finder, customerServices, proformaServices, dtoMapper, - taxCatalog, - paymentCatalog, + catalogResolvers, transactionManager, }); } 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 index 6ecc57ad..4d6d9067 100644 --- 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 @@ -1,5 +1,10 @@ -import type { JsonPaymentCatalogProvider, JsonTaxCatalogProvider } from "@erp/core"; -import { DiscountPercentage, Tax } from "@erp/core/api"; +import { + DiscountPercentage, + Tax, + type TaxCalculationBehavior, + type TaxGroup, + TaxPercentage, +} from "@erp/core/api"; import { InvoiceAmount, InvoiceSerie, @@ -113,7 +118,6 @@ export type FactugesProformaPayload = { paymentLookup: ProformaPaymentLookup; customerDraft: ProformaCustomerDraft; proformaDraft: ProformaDraft; - paymentDraft: ProformaPaymentDraft; }; export interface ICreateProformaFromFactugesInputMapper { @@ -126,17 +130,6 @@ export interface ICreateProformaFromFactugesInputMapper { export class CreateProformaFromFactugesInputMapper implements ICreateProformaFromFactugesInputMapper { - private readonly taxCatalog: JsonTaxCatalogProvider; - private readonly paymentCatalog: JsonPaymentCatalogProvider; - - constructor(params: { - taxCatalog: JsonTaxCatalogProvider; - paymentCatalog: JsonPaymentCatalogProvider; - }) { - this.taxCatalog = params.taxCatalog; - this.paymentCatalog = params.paymentCatalog; - } - public map( dto: CreateProformaFromFactugesRequestDTO, params: { companyId: UniqueID } @@ -152,13 +145,7 @@ export class CreateProformaFromFactugesInputMapper errors, }); - const customerProps = this.mapCustomerProps(dto.customer, { - companyId, - currencyCode, - errors, - }); - - const paymentProps = this.mapPaymentProps(dto, { + const customerProps = this.mapCustomerProps(dto, { companyId, currencyCode, errors, @@ -171,11 +158,10 @@ export class CreateProformaFromFactugesInputMapper tin: customerProps.tin, }, paymentLookup: { - factuges_id: paymentProps?.factuges_id, + factuges_id: dto.payment_method_id, }, customerDraft: customerProps, proformaDraft: proformaProps, - paymentDraft: paymentProps, }); } catch (err: unknown) { const error = isValidationErrorCollection(err) @@ -185,35 +171,6 @@ export class CreateProformaFromFactugesInputMapper } } - // TODO: revisar!!!! - private mapPaymentProps( - dto: CreateProformaFromFactugesRequestDTO, - params: { - companyId: UniqueID; - currencyCode: CurrencyCode; - errors: ValidationErrorDetail[]; - } - ): ProformaPaymentDraft | undefined { - const errors: ValidationErrorDetail[] = []; - const { companyId } = params; - - const payment_method_id = String(dto.payment_method_id); - const paymentOrNot = this.paymentCatalog.findByFactuGESId(payment_method_id); - - if (paymentOrNot.isNone()) { - errors.push({ - path: "payment_method_id", - message: "Forma de pago no encontrada", - }); - } else { - return { - payment_id: paymentOrNot.unwrap().id, - factuges_id: paymentOrNot.unwrap().factuges_id, - description: paymentOrNot.unwrap().description, - }; - } - } - private mapProformaProps( dto: CreateProformaFromFactugesRequestDTO, params: { @@ -378,93 +335,94 @@ export class CreateProformaFromFactugesInputMapper } private mapCustomerProps( - dto: CreateProformaFromFactugesRequestDTO["customer"], + dto: CreateProformaFromFactugesRequestDTO, params: { companyId: UniqueID; currencyCode: CurrencyCode; errors: ValidationErrorDetail[]; } ): ProformaCustomerDraft { + const { customer } = dto; 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 isCompany = customer.is_company === "1"; + const name = extractOrPushError(Name.create(customer.name), "name", errors); + const tinNumber = extractOrPushError(TINNumber.create(customer.tin), "tin", errors); const street = extractOrPushError( - maybeFromNullableResult(dto.street, (value) => Street.create(value)), + maybeFromNullableResult(customer.street, (value) => Street.create(value)), "street", errors ); const city = extractOrPushError( - maybeFromNullableResult(dto.city, (value) => City.create(value)), + maybeFromNullableResult(customer.city, (value) => City.create(value)), "city", errors ); const province = extractOrPushError( - maybeFromNullableResult(dto.province, (value) => Province.create(value)), + maybeFromNullableResult(customer.province, (value) => Province.create(value)), "province", errors ); const postalCode = extractOrPushError( - maybeFromNullableResult(dto.postal_code, (value) => PostalCode.create(value)), + maybeFromNullableResult(customer.postal_code, (value) => PostalCode.create(value)), "postal_code", errors ); const country = extractOrPushError( - maybeFromNullableResult(dto.country, (value) => Country.create(value)), + maybeFromNullableResult(customer.country, (value) => Country.create(value)), "country", errors ); const primaryEmailAddress = extractOrPushError( - maybeFromNullableResult(dto.email_primary, (value) => EmailAddress.create(value)), + maybeFromNullableResult(customer.email_primary, (value) => EmailAddress.create(value)), "email_primary", errors ); const secondaryEmailAddress = extractOrPushError( - maybeFromNullableResult(dto.email_secondary, (value) => EmailAddress.create(value)), + maybeFromNullableResult(customer.email_secondary, (value) => EmailAddress.create(value)), "email_secondary", errors ); const primaryPhoneNumber = extractOrPushError( - maybeFromNullableResult(dto.phone_primary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(customer.phone_primary, (value) => PhoneNumber.create(value)), "phone_primary", errors ); const secondaryPhoneNumber = extractOrPushError( - maybeFromNullableResult(dto.phone_secondary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(customer.phone_secondary, (value) => PhoneNumber.create(value)), "phone_secondary", errors ); const primaryMobileNumber = extractOrPushError( - maybeFromNullableResult(dto.mobile_primary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(customer.mobile_primary, (value) => PhoneNumber.create(value)), "mobile_primary", errors ); const secondaryMobileNumber = extractOrPushError( - maybeFromNullableResult(dto.mobile_secondary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(customer.mobile_secondary, (value) => PhoneNumber.create(value)), "mobile_secondary", errors ); const website = extractOrPushError( - maybeFromNullableResult(dto.website, (value) => URLAddress.create(value)), + maybeFromNullableResult(customer.website, (value) => URLAddress.create(value)), "website", errors ); const languageCode = extractOrPushError( - LanguageCode.create(dto.language_code), + LanguageCode.create(customer.language_code), "language_code", errors ); @@ -480,19 +438,10 @@ export class CreateProformaFromFactugesInputMapper country: country!, }; - /*const customerTaxes = this.mapCustomerTaxesProps(dto, { - errors, - });*/ - const customerProps: ProformaCustomerDraft = { - //companyId, - //status: status!, - isCompany: isCompany, name: name!, tin: tinNumber!, - //tradeName: Maybe.none(), - //reference: Maybe.none(), address: postalAddressProps!, @@ -669,62 +618,118 @@ export class CreateProformaFromFactugesInputMapper rec: Maybe.none(), }; - // Normaliza: "" -> [] - // IVA por defecto => iva_21 - const taxStrCodes = [itemDTO.iva_code === "" ? "iva_21" : itemDTO.iva_code]; + const iva = extractOrPushError( + this.mapTaxToDomain({ + code: itemDTO.iva_code, + percentageValue: Number(itemDTO.iva_percentage_value), + group: "iva", + calculationBehavior: "additive", + fieldPath: `items[${itemIndex}].iva`, + }), + `items[${itemIndex}].iva`, + errors + ); - if (itemDTO.rec_code) taxStrCodes.push(itemDTO.rec_code); - if (itemDTO.retention_code) taxStrCodes.push(itemDTO.retention_code); + const rec = extractOrPushError( + this.mapTaxToDomain({ + code: itemDTO.rec_code, + percentageValue: Number(itemDTO.rec_percentage_value), + group: "surcharge", + calculationBehavior: "additive", + fieldPath: `items[${itemIndex}].rec`, + }), + `items[${itemIndex}].rec`, + errors + ); - taxStrCodes.forEach((strCode, taxIndex) => { - const taxResult = Tax.createFromCode(strCode, this.taxCatalog); + const retention = extractOrPushError( + this.mapTaxToDomain({ + code: itemDTO.retention_code, + percentageValue: Number(itemDTO.retention_percentage_value), + group: "retention", + calculationBehavior: "subtractive", + fieldPath: `items[${itemIndex}].retention`, + }), + `items[${itemIndex}].retention`, + errors + ); - if (!taxResult.isSuccess) { + if (iva) { + if (taxesProps.iva.isSome()) { errors.push({ - path: `items[${itemIndex}].taxes[${taxIndex}]`, - message: taxResult.error.message, + path: `items[${itemIndex}].taxes`, + message: "Multiple taxes for group VAT are not allowed", }); - return; } + taxesProps.iva = iva!; + } - 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 (rec) { + if (taxesProps.rec.isSome()) { + errors.push({ + path: `items[${itemIndex}].taxes`, + message: "Multiple taxes for group rec are not allowed", + }); } + taxesProps.rec!; + } - 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 (retention) { + if (taxesProps.retention.isSome()) { + errors.push({ + path: `items[${itemIndex}].taxes`, + message: "Multiple taxes for group retention are not allowed", + }); } - - 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); - } - }); + taxesProps.retention!; + } this.throwIfValidationErrors(errors); return taxesProps; } + private mapTaxToDomain(params: { + code: string | null; + percentageValue: number | null; + percentageScale?: number; + group: TaxGroup; + calculationBehavior: TaxCalculationBehavior; + fieldPath: string; + }): Result, Error> { + if (params.code === null || params.code.trim() === "") { + return Result.ok(Maybe.none()); + } + + if (params.percentageValue === null) { + return Result.fail( + new Error(`${params.fieldPath}.percentage_value is required when tax code is present`) + ); + } + + const percentageResult = TaxPercentage.create({ + value: params.percentageValue, + }); + + if (percentageResult.isFailure) { + return Result.fail(percentageResult.error); + } + + const taxResult = Tax.create({ + code: params.code, + name: params.code, + rate: percentageResult.data, + group: params.group, + calculationBehavior: params.calculationBehavior, + }); + + if (taxResult.isFailure) { + return Result.fail(taxResult.error); + } + + return Result.ok(Maybe.some(taxResult.data)); + } + 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/models/factuges-payment-method-read.model.ts b/modules/factuges/src/api/application/models/factuges-payment-method-read.model.ts new file mode 100644 index 00000000..c638e313 --- /dev/null +++ b/modules/factuges/src/api/application/models/factuges-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 a la proforma / issued-invoice + */ + +export interface FactuGESPaymentMethodReadModel { + id: UniqueID; + name: string; + description: Maybe; +} diff --git a/modules/factuges/src/api/application/models/factuges-tax-regime-full-read.model.ts b/modules/factuges/src/api/application/models/factuges-tax-regime-full-read.model.ts new file mode 100644 index 00000000..a35c6a5c --- /dev/null +++ b/modules/factuges/src/api/application/models/factuges-tax-regime-full-read.model.ts @@ -0,0 +1,8 @@ +/** + * Datos del régimen de pago que completan a la proforma / issued-invoice + */ + +export interface FactuGESTaxRegimeFullReadModel { + code: string; + description: string; +} diff --git a/modules/factuges/src/api/application/models/index.ts b/modules/factuges/src/api/application/models/index.ts new file mode 100644 index 00000000..c678850c --- /dev/null +++ b/modules/factuges/src/api/application/models/index.ts @@ -0,0 +1,2 @@ +export * from "./factuges-payment-method-read.model"; +export * from "./factuges-tax-regime-full-read.model"; diff --git a/modules/factuges/src/api/application/services/catalog-resolver/factuges-payment-resolver.ts b/modules/factuges/src/api/application/services/catalog-resolver/factuges-payment-resolver.ts new file mode 100644 index 00000000..d9aa8f68 --- /dev/null +++ b/modules/factuges/src/api/application/services/catalog-resolver/factuges-payment-resolver.ts @@ -0,0 +1,78 @@ +import type { IPaymentMethodPublicFinder, IPaymentTermPublicFinder } from "@erp/catalogs/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { FactuGESPaymentMethodReferenceProvider } from "../json-data"; + +export type ResolvedPaymentMethod = { + id: UniqueID; + factugesId: string; +}; + +/** + * Resuelve formas de pago externas al agregado de FactuGES usando servicios públicos de `catalogs`. + * + * Este servicio pertenece a Application porque coordina dependencias entre módulos + * antes de crear o actualizar una FactuGES. + * + * No accede a repositorios de `catalogs`, no usa Sequelize y no debe utilizarse + * desde mappers de persistencia. Su responsabilidad es convertir contratos públicos + * de catálogos (`payment-methods`) en Value Objects propios del + * dominio de `customer-invoices`. + * + * Las facturas emitidas no deben depender de este resolver para reconstruirse: + * son documentos históricos y deben contener en persistencia los snapshots fiscales + * necesarios. + */ +export class FactuGESPaymentResolver { + public constructor( + private readonly deps: { + paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; + paymentReferenceProvider: FactuGESPaymentMethodReferenceProvider; + } + ) {} + + public async resolvePaymentMethodFromFactuGESId(params: { + companyId: UniqueID; + factugesId: string; + }): Promise> { + const reference = this.deps.paymentReferenceProvider.findByFactuGESId(params.factugesId); + + if (reference.isNone()) { + return Result.fail(new Error(`FactuGES payment reference not found: ${params.factugesId}`)); + } + + const paymentReference = reference.unwrap(); + + if (paymentReference.company_id !== params.companyId.toString()) { + return Result.fail( + new Error("FactuGES payment reference does not belong to current company.") + ); + } + + const paymentMethodId = UniqueID.create(paymentReference.id); + + if (paymentMethodId.isFailure) { + return Result.fail(paymentMethodId.error); + } + + const paymentMethod = await this.deps.paymentMethodFinder.existsByIdInCompany({ + companyId: params.companyId, + id: paymentMethodId.data, + }); + + if (paymentMethod.isFailure) { + return Result.fail(paymentMethod.error); + } + + if (!paymentMethod.data) { + return Result.fail(new Error(`Payment method not found: ${paymentReference.id}`)); + } + + return Result.ok({ + id: paymentMethodId.data, + factugesId: paymentReference.factuges_id, + }); + } +} diff --git a/modules/factuges/src/api/application/services/catalog-resolver/index.ts b/modules/factuges/src/api/application/services/catalog-resolver/index.ts new file mode 100644 index 00000000..2ad6a50a --- /dev/null +++ b/modules/factuges/src/api/application/services/catalog-resolver/index.ts @@ -0,0 +1 @@ +export * from "./factuges-payment-resolver"; diff --git a/modules/factuges/src/api/application/services/index.ts b/modules/factuges/src/api/application/services/index.ts index 57dd9530..8b881e7e 100644 --- a/modules/factuges/src/api/application/services/index.ts +++ b/modules/factuges/src/api/application/services/index.ts @@ -1,2 +1,3 @@ +export * from "./catalog-resolver"; export * from "./factuges-proforma-finder"; export * from "./factuges-proforma-linker"; diff --git a/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.factory.ts b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.factory.ts new file mode 100644 index 00000000..4a3868d7 --- /dev/null +++ b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.factory.ts @@ -0,0 +1,10 @@ +import factuGESPaymentReferenceJson from "./factuges-payment-method-reference.json"; +import { FactuGESPaymentMethodReferenceProvider } from "./factuges-payment-method-reference.provider"; +import type { FactuGESPaymentReference } from "./types"; + +export const createFactuGESPaymentReferenceProvider = + (): FactuGESPaymentMethodReferenceProvider => { + return new FactuGESPaymentMethodReferenceProvider( + factuGESPaymentReferenceJson as FactuGESPaymentReference[] + ); + }; diff --git a/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.json b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.json new file mode 100644 index 00000000..23bc0c43 --- /dev/null +++ b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.json @@ -0,0 +1,30 @@ +[ + { + "id": "019c2834-a766-7787-a626-fa89cac3a8a1", + "company_id": "5e4dc5b3-96b9-4968-9490-14bd032fec5f", + "factuges_id": "6", + "description": "TRANSFERENCIA", + "group": "General" + }, + { + "id": "57ed228f-88bd-431d-b5e6-0ed9cff01684", + "company_id": "5e4dc5b3-96b9-4968-9490-14bd032fec5f", + "factuges_id": "14", + "description": "DOMICILIACION BANCARIA", + "group": "General" + }, + { + "id": "336e477f-9260-4cb7-b6fd-76f3b088a395", + "company_id": "5e4dc5b3-96b9-4968-9490-14bd032fec5f", + "factuges_id": "15", + "description": "TRANSFERENCIA BANCARIA", + "group": "General" + }, + { + "id": "126e477f-9260-4cb7-b6fd-76f3b088a395", + "company_id": "5e4dc5b3-96b9-4968-9490-14bd032fec5f", + "factuges_id": "21", + "description": "30/60/90/120", + "group": "General" + } +] \ No newline at end of file diff --git a/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.provider.ts b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.provider.ts new file mode 100644 index 00000000..5a01517e --- /dev/null +++ b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/factuges-payment-method-reference.provider.ts @@ -0,0 +1,53 @@ +import { JsonCollectionProvider } from "@erp/core"; +import { Maybe } from "@repo/rdx-utils"; + +import type { FactuGESPaymentOption, FactuGESPaymentReference } from "./types"; + +const normalizeFactuGESId = (value: string): string => value.trim(); + +const normalizeId = (value: string): string => value.trim().toLowerCase(); + +export class FactuGESPaymentMethodReferenceProvider extends JsonCollectionProvider { + private readonly byFactuGESId: ReadonlyMap; + private readonly byId: ReadonlyMap; + + public constructor(items: readonly FactuGESPaymentReference[]) { + super(items); + + this.byFactuGESId = this.buildUniqueIndex("factuges_id", (item) => + normalizeFactuGESId(item.factuges_id) + ); + + this.byId = this.buildUniqueIndex("id", (item) => normalizeId(item.id)); + } + + public findByFactuGESId(factuGESId: string): Maybe { + const item = this.byFactuGESId.get(normalizeFactuGESId(factuGESId)); + + return item ? Maybe.some(item) : Maybe.none(); + } + + public findById(id: string): Maybe { + const item = this.byId.get(normalizeId(id)); + + return item ? Maybe.some(item) : Maybe.none(); + } + + /** + * Proyección específica para campos de selección. + * + * No pertenece a la clase abstracta porque depende de la semántica + * concreta de esta fuente de referencia. + */ + public toSelectOptions(): readonly FactuGESPaymentOption[] { + return this.getAll().map((item) => ({ + label: item.description, + value: item.id, + group: item.group, + })); + } + + public getGroups(): readonly string[] { + return [...new Set(this.getAll().map((item) => item.group))]; + } +} diff --git a/modules/factuges/src/api/application/services/json-data/factuges-payment-method/index.ts b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/index.ts new file mode 100644 index 00000000..26cf1c92 --- /dev/null +++ b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/index.ts @@ -0,0 +1,3 @@ +export * from "./factuges-payment-method-reference.factory"; +export * from "./factuges-payment-method-reference.provider"; +export * from "./types"; diff --git a/modules/factuges/src/api/application/services/json-data/factuges-payment-method/types.ts b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/types.ts new file mode 100644 index 00000000..de59e864 --- /dev/null +++ b/modules/factuges/src/api/application/services/json-data/factuges-payment-method/types.ts @@ -0,0 +1,13 @@ +export type FactuGESPaymentReference = { + id: string; + company_id: string; + factuges_id: string; + description: string; + group: string; +}; + +export type FactuGESPaymentOption = { + label: string; + value: string; + group: string; +}; diff --git a/modules/factuges/src/api/application/services/json-data/index.ts b/modules/factuges/src/api/application/services/json-data/index.ts new file mode 100644 index 00000000..9bf09095 --- /dev/null +++ b/modules/factuges/src/api/application/services/json-data/index.ts @@ -0,0 +1 @@ +export * from "./factuges-payment-method"; 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 index b778cbc2..c9977ae5 100644 --- 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 @@ -1,4 +1,3 @@ -import type { JsonPaymentCatalogProvider, JsonTaxCatalogProvider } from "@erp/core"; import { type ITransactionManager, isEntityNotFoundError } from "@erp/core/api"; import type { IProformaPublicServices } from "@erp/customer-invoices/api"; import { @@ -12,7 +11,6 @@ import type { ICustomerPublicServices } from "@erp/customers/api"; import { type Customer, CustomerStatus, - CustomerTaxes, type ICustomerCreateProps, } from "@erp/customers/api/domain"; import { @@ -27,6 +25,7 @@ import { Maybe, Result } from "@repo/rdx-utils"; import type { Transaction } from "sequelize"; import type { CreateProformaFromFactugesRequestDTO } from "../../../common"; +import type { IFactuGESCatalogResolvers } from "../di/factuges-catalog-resolvers.di"; import type { FactugesProformaPayload, ICreateProformaFromFactugesInputMapper } from "../mappers"; import type { IFactuGESProformaFinder, IFactuGESProformaLinker } from "../services"; @@ -41,53 +40,23 @@ export type CreateProformaFromFactugesResponseDTO = { export type ResolvedPaymentMethod = { id: UniqueID; - description: string; factugesId: string; }; -export interface IFactugesPaymentMethodResolver { - resolve( - lookup: FactugesProformaPayload["paymentLookup"], - context: { - companyId: UniqueID; - transaction: Transaction; - } - ): Promise>; -} - -type CreateProformaFromFactugesUseCaseDeps = { - linker: IFactuGESProformaLinker; - finder: IFactuGESProformaFinder; - customerServices: ICustomerPublicServices; - proformaServices: IProformaPublicServices; - dtoMapper: ICreateProformaFromFactugesInputMapper; - taxCatalog: JsonTaxCatalogProvider; - paymentCatalog: JsonPaymentCatalogProvider; - transactionManager: ITransactionManager; -}; - type CreateProformaProps = Parameters["1"]; export class CreateProformaFromFactugesUseCase { - private readonly linker: IFactuGESProformaLinker; - private readonly finder: IFactuGESProformaFinder; - private readonly dtoMapper: ICreateProformaFromFactugesInputMapper; - private readonly customerServices: ICustomerPublicServices; - private readonly proformaServices: IProformaPublicServices; - private readonly paymentCatalog: JsonPaymentCatalogProvider; - private readonly taxCatalog: JsonTaxCatalogProvider; - private readonly transactionManager: ITransactionManager; - - constructor(deps: CreateProformaFromFactugesUseCaseDeps) { - this.linker = deps.linker; - this.finder = deps.finder; - this.customerServices = deps.customerServices; - this.proformaServices = deps.proformaServices; - this.dtoMapper = deps.dtoMapper; - this.paymentCatalog = deps.paymentCatalog; - this.taxCatalog = deps.taxCatalog; - this.transactionManager = deps.transactionManager; - } + constructor( + private readonly deps: { + linker: IFactuGESProformaLinker; + finder: IFactuGESProformaFinder; + dtoMapper: ICreateProformaFromFactugesInputMapper; + customerServices: ICustomerPublicServices; + proformaServices: IProformaPublicServices; + catalogResolvers: IFactuGESCatalogResolvers; + transactionManager: ITransactionManager; + } + ) {} public async execute( params: CreateProformaFromFactugesUseCaseInput @@ -95,23 +64,22 @@ export class CreateProformaFromFactugesUseCase { const { dto, companyId } = params; // 1) Mapear DTO → props - const mappedPropsResult = this.dtoMapper.map(dto, { companyId }); + const mappedPropsResult = this.deps.dtoMapper.map(dto, { companyId }); if (mappedPropsResult.isFailure) { return Result.fail(mappedPropsResult.error); } - const { customerLookup, paymentLookup, customerDraft, proformaDraft, paymentDraft } = - mappedPropsResult.data; + const { customerLookup, paymentLookup, customerDraft, proformaDraft } = mappedPropsResult.data; // 2) Comprobar si la proforma ya existe (idempotencia) - const proformaExists = await this.finder.existsProformaByFactuGESId( + const proformaExists = await this.deps.finder.existsProformaByFactuGESId( companyId, proformaDraft.factugesID ); if (proformaExists.isSuccess && proformaExists.data) { // 2.1) Recuperar el ID de la proforma que ya existe - const proformaIdResult = await this.finder.findProformaIdByFactuGESId( + const proformaIdResult = await this.deps.finder.findProformaIdByFactuGESId( companyId, proformaDraft.factugesID ); @@ -127,7 +95,7 @@ export class CreateProformaFromFactugesUseCase { } // 3) Si no existe la proforma, la creamos dentro de una transacción. - return this.transactionManager.complete(async (transaction: Transaction) => { + return this.deps.transactionManager.complete(async (transaction: Transaction) => { try { const customerResult = await this.resolveCustomer(customerLookup, customerDraft, { companyId, @@ -143,6 +111,7 @@ export class CreateProformaFromFactugesUseCase { companyId, transaction, }); + if (paymentResult.isFailure) { return Result.fail(paymentResult.error); } @@ -166,7 +135,7 @@ export class CreateProformaFromFactugesUseCase { const newId = UniqueID.generateNewID(); - const createResult = await this.proformaServices.createProforma( + const createResult = await this.deps.proformaServices.createProforma( newId, createPropsResult.data, { @@ -180,7 +149,7 @@ export class CreateProformaFromFactugesUseCase { } // Guardar la relación entre la proforma generada y la factura de FactuGES - await this.linker.create({ + await this.deps.linker.create({ companyId, factuGESId: proformaDraft.factugesID, proformaId: createResult.data.id, @@ -195,7 +164,7 @@ export class CreateProformaFromFactugesUseCase { return Result.fail(validationResult.error); } - const readResult = await this.proformaServices.getProformaSnapshotById( + const readResult = await this.deps.proformaServices.getProformaSnapshotById( createResult.data.id, { companyId, @@ -377,16 +346,53 @@ export class CreateProformaFromFactugesUseCase { const recipient = Maybe.none(); const linkedInvoiceId = Maybe.none(); const paymentMethodId = Maybe.some(payment.id); + const paymentTermId = Maybe.none(); + const taxRegimeCode = Maybe.some("01"); + + const items: CreateProformaProps["items"] = proformaDraft.items.map((draftItem) => { + const { taxes: drafTaxes } = draftItem; + + return { + quantity: draftItem.quantity, + unitAmount: draftItem.unitAmount, + description: draftItem.description, + itemDiscountPercentage: draftItem.itemDiscountPercentage, + taxes: { + ivaCode: drafTaxes.iva.match( + (iva) => Maybe.some(iva.code), + () => Maybe.none() + ), + recCode: drafTaxes.rec.match( + (rec) => Maybe.some(rec.code), + () => Maybe.none() + ), + retentionCode: drafTaxes.retention.match( + (retention) => Maybe.some(retention.code), + () => Maybe.none() + ), + }, + }; + }); return Result.ok({ - ...proformaDraft, + globalDiscountPercentage: proformaDraft.globalDiscountPercentage, + description: proformaDraft.description, + invoiceDate: proformaDraft.invoiceDate, + languageCode: proformaDraft.languageCode, + currencyCode: proformaDraft.currencyCode, + notes: proformaDraft.notes, + operationDate: proformaDraft.operationDate, + series: proformaDraft.series, + reference: proformaDraft.reference, + items, companyId, customerId, status: defaultStatus, - paymentMethodId, linkedInvoiceId, recipient, - }); + paymentMethodId, + taxRegimeCode, + } satisfies CreateProformaProps); } /** @@ -412,7 +418,7 @@ export class CreateProformaFromFactugesUseCase { ): Promise> { const { companyId, transaction } = context; - const existingCustomerResult = await this.customerServices.findCustomerByTIN( + const existingCustomerResult = await this.deps.customerServices.findCustomerByTIN( customerLookup.tin, { companyId, transaction } ); @@ -430,41 +436,34 @@ export class CreateProformaFromFactugesUseCase { return Result.fail(createPropsResult.error); } - return this.customerServices.createCustomer(UniqueID.generateNewID(), createPropsResult.data, { - companyId, - transaction, - }); + return this.deps.customerServices.createCustomer( + UniqueID.generateNewID(), + createPropsResult.data, + { + companyId, + transaction, + } + ); } - private resolvePayment( + private async resolvePayment( paymentLookup: FactugesProformaPayload["paymentLookup"], context: { companyId: UniqueID; transaction: Transaction } - ): Result { - const payment = this.paymentCatalog.findByFactuGESId(paymentLookup.factuges_id); + ): Promise> { + const paymentResult = + await this.deps.catalogResolvers.paymentResolver.resolvePaymentMethodFromFactuGESId({ + companyId: context.companyId, + factugesId: paymentLookup.factuges_id, + }); - if (payment.isNone()) { - return Result.fail(new Error("La forma de pago de FactuGES no existe.")); - } - - const paymentItem = payment.unwrap(); - - if (paymentItem.company_id !== context.companyId.toString()) { - return Result.fail( - new Error("La forma de pago de FactuGES no pertenece a la compañía actual.") - ); - } - - const idResult = UniqueID.create(paymentItem.id); - - if (idResult.isFailure) { - return Result.fail(idResult.error); + if (paymentResult.isFailure) { + return Result.fail(paymentResult.error); } return Result.ok({ - id: idResult.data, - description: paymentItem.description, - factugesId: paymentItem.factuges_id, - }); + id: paymentResult.data.id, + factugesId: paymentResult.data.factugesId, + } satisfies ResolvedPaymentMethod); } private buildCustomerCreateProps( @@ -478,18 +477,19 @@ export class CreateProformaFromFactugesUseCase { const status = CustomerStatus.createActive(); - const defaultTaxes = CustomerTaxes.fromKey("iva_21;#;#", this.taxCatalog); - - if (defaultTaxes.isFailure) { - return Result.fail(defaultTaxes.error); - } - const tin = Maybe.some(customerDraft.tin); const tradeName = Maybe.none(); const reference = Maybe.none(); const fax = Maybe.none(); const legalRecord = Maybe.none(); + const paymentMethodId = Maybe.none(); + const paymentTermId = Maybe.none(); + const taxRegimeCode = Maybe.some("01"); + + const usesEquivalenceSurcharge = false; + const usesRetention = false; + return Result.ok({ ...customerDraft, companyId, @@ -499,7 +499,12 @@ export class CreateProformaFromFactugesUseCase { reference, fax, legalRecord, - defaultTaxes: defaultTaxes.data, - }); + + paymentMethodId, + paymentTermId, + taxRegimeCode, + usesEquivalenceSurcharge, + usesRetention, + } satisfies ICustomerCreateProps); } } diff --git a/modules/factuges/src/api/index.ts b/modules/factuges/src/api/index.ts index 0a04c0a3..4de63c91 100644 --- a/modules/factuges/src/api/index.ts +++ b/modules/factuges/src/api/index.ts @@ -6,7 +6,7 @@ import { buildFactugesDependencies } from "./infraestructure/di"; export const factugesAPIModule: IModuleServer = { name: "factuges", version: "1.0.0", - dependencies: ["customers", "customer-invoices"], + dependencies: ["catalogs", "customers", "customer-invoices"], /** * Fase de SETUP diff --git a/modules/factuges/src/api/infraestructure/di/factuges-catalog-deps.di.ts b/modules/factuges/src/api/infraestructure/di/factuges-catalog-deps.di.ts new file mode 100644 index 00000000..faf098a6 --- /dev/null +++ b/modules/factuges/src/api/infraestructure/di/factuges-catalog-deps.di.ts @@ -0,0 +1,62 @@ +import type { CatalogsPublicServicesType } from "@erp/catalogs/api"; +import type { ModuleParams } from "@erp/core/api"; + +type ProformaCatalogsDeps = { + taxDefinition: { + finder: CatalogsPublicServicesType["taxDefinitions"]["finder"]; + }; + taxRegime: { + finder: CatalogsPublicServicesType["taxRegimes"]["finder"]; + }; + paymentMethod: { + finder: CatalogsPublicServicesType["paymentMethods"]["finder"]; + }; + paymentTerm: { + finder: CatalogsPublicServicesType["paymentTerms"]["finder"]; + }; +}; + +export function resolveFactuGESCatalogsDeps(params: ModuleParams): ProformaCatalogsDeps { + const taxDefinition = + params.getService("catalogs:taxDefinitions"); + + if (!taxDefinition?.finder) { + throw new Error("Missing public service: catalogs:taxDefinitions.finder"); + } + + const taxRegime = + params.getService("catalogs:taxRegimes"); + + if (!taxRegime?.finder) { + throw new Error("Missing public service: catalogs:taxRegimes.finder"); + } + + const paymentMethod = + params.getService("catalogs:paymentMethods"); + + if (!paymentMethod?.finder) { + throw new Error("Missing public service: catalogs:paymentMethods.finder"); + } + + const paymentTerm = + params.getService("catalogs:paymentTerms"); + + if (!paymentTerm?.finder) { + throw new Error("Missing public service: catalogs:paymentTerms.finder"); + } + + return { + taxDefinition: { + finder: taxDefinition.finder, + }, + taxRegime: { + finder: taxRegime.finder, + }, + paymentMethod: { + finder: paymentMethod.finder, + }, + paymentTerm: { + finder: paymentTerm.finder, + }, + }; +} diff --git a/modules/factuges/src/api/infraestructure/di/factuges.di.ts b/modules/factuges/src/api/infraestructure/di/factuges.di.ts index fcb3f078..11e88e15 100644 --- a/modules/factuges/src/api/infraestructure/di/factuges.di.ts +++ b/modules/factuges/src/api/infraestructure/di/factuges.di.ts @@ -1,4 +1,4 @@ -import { type SetupParams, buildCatalogs, buildTransactionManager } from "@erp/core/api"; +import { type SetupParams, buildTransactionManager } from "@erp/core/api"; import type { IProformaPublicServices } from "@erp/customer-invoices/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; @@ -6,10 +6,13 @@ import { buildCreateProformaFromFactugesUseCase, buildFactugesInputMappers, } from "../../application/di"; +import { buildFactuGESCatalogResolvers } from "../../application/di/factuges-catalog-resolvers.di"; import { buildFactuGESFinder } from "../../application/di/factuges-finder.di"; import { buildFactuGESLinker } from "../../application/di/factuges-linker.di"; +import { createFactuGESPaymentReferenceProvider } from "../../application/services/json-data"; import type { CreateProformaFromFactugesUseCase } from "../../application/use-cases"; +import { resolveFactuGESCatalogsDeps } from "./factuges-catalog-deps.di"; import { buildFactuGESPersistenceMappers } from "./factuges-persistence-mappers.di"; import { buildFactugesRepository } from "./factuges-repositories.di"; @@ -25,15 +28,25 @@ export type FactugesInternalDeps = { export function buildFactugesDependencies(params: SetupParams): FactugesInternalDeps { const { database } = params; + const catalogs = resolveFactuGESCatalogsDeps(params); + // Infrastructure const transactionManager = buildTransactionManager(database); - - const catalogs = buildCatalogs(); - const inputMappers = buildFactugesInputMappers(catalogs); + const inputMappers = buildFactugesInputMappers(); const persistenceMappers = buildFactuGESPersistenceMappers(); const repository = buildFactugesRepository({ database, mappers: persistenceMappers }); + const paymentReferenceProvider = createFactuGESPaymentReferenceProvider(); + + const catalogResolvers = buildFactuGESCatalogResolvers({ + taxDefinitionFinder: catalogs.taxDefinition.finder, + taxRegimeFinder: catalogs.taxRegime.finder, + paymentMethodFinder: catalogs.paymentMethod.finder, + paymentTermFinder: catalogs.paymentTerm.finder, + paymentReferenceProvider: paymentReferenceProvider, + }); + // Application helpers const finder = buildFactuGESFinder({ repository }); const linker = buildFactuGESLinker({ repository }); @@ -49,8 +62,8 @@ export function buildFactugesDependencies(params: SetupParams): FactugesInternal linker, finder, dtoMapper: inputMappers.createInputMapper, + catalogResolvers: catalogResolvers, publicServices, - catalogs, transactionManager, }), }, diff --git a/modules/supplier-invoices/package.json b/modules/supplier-invoices/package.json index 875d1bf9..6396f9f9 100644 --- a/modules/supplier-invoices/package.json +++ b/modules/supplier-invoices/package.json @@ -1,7 +1,7 @@ { "name": "@erp/supplier-invoices", "description": "Supplier invoices", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/supplier/package.json b/modules/supplier/package.json index 47d4ff74..ec5c6adb 100644 --- a/modules/supplier/package.json +++ b/modules/supplier/package.json @@ -1,7 +1,7 @@ { "name": "@erp/suppliers", "description": "Suppliers", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/package.json b/package.json index 2701467c..897177fd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "uecko-erp-2025", "private": true, - "version": "0.6.9", + "version": "0.7.3", "workspaces": [ "apps/*", "modules/*", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index a06cd8c9..c3b1cee5 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@repo/i18next", - "version": "0.0.0", + "version": "0.7.3", "private": true, "type": "module", "scripts": { diff --git a/packages/rdx-criteria/package.json b/packages/rdx-criteria/package.json index fdc3ef9a..317b0569 100644 --- a/packages/rdx-criteria/package.json +++ b/packages/rdx-criteria/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-criteria", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/rdx-ddd/package.json b/packages/rdx-ddd/package.json index 100eac90..f14b4dfb 100644 --- a/packages/rdx-ddd/package.json +++ b/packages/rdx-ddd/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-ddd", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/rdx-ddd/src/errors/validation-error-collection.ts b/packages/rdx-ddd/src/errors/validation-error-collection.ts index fa24fa85..95f8a46d 100644 --- a/packages/rdx-ddd/src/errors/validation-error-collection.ts +++ b/packages/rdx-ddd/src/errors/validation-error-collection.ts @@ -62,7 +62,7 @@ export class ValidationErrorCollection extends BaseError<"domain"> { } /** Crear a partir de varios DomainValidationError */ - static fromErrors( + static fromDomainErrors( message: string, errors: DomainValidationError[], options?: ErrorOptions & { metadata?: Record } diff --git a/packages/rdx-logger/package.json b/packages/rdx-logger/package.json index 819928ca..65c7dd69 100644 --- a/packages/rdx-logger/package.json +++ b/packages/rdx-logger/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-logger", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/rdx-ui/package.json b/packages/rdx-ui/package.json index c3ca5659..47bd824c 100644 --- a/packages/rdx-ui/package.json +++ b/packages/rdx-ui/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-ui", - "version": "0.6.9", + "version": "0.7.3", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/rdx-ui/src/components/form/form-section-grid.tsx b/packages/rdx-ui/src/components/form/form-section-grid.tsx index cbec32f2..07ff3d86 100644 --- a/packages/rdx-ui/src/components/form/form-section-grid.tsx +++ b/packages/rdx-ui/src/components/form/form-section-grid.tsx @@ -9,7 +9,7 @@ interface FormSectionGridProps { export const FormSectionGrid = ({ children, className }: FormSectionGridProps) => { return ( - + {children} ); diff --git a/packages/rdx-ui/src/components/form/select-field.tsx b/packages/rdx-ui/src/components/form/select-field.tsx index f209d146..2d827994 100644 --- a/packages/rdx-ui/src/components/form/select-field.tsx +++ b/packages/rdx-ui/src/components/form/select-field.tsx @@ -8,6 +8,7 @@ import { SelectTrigger, } from "@repo/shadcn-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; +import { XIcon } from "lucide-react"; import React from "react"; import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form"; @@ -20,6 +21,8 @@ export type SelectFieldItem = { export type SelectFieldValue = string | null; +export type SelectFieldEmptyValue = null | string; + export type SelectFieldProps = { name: FieldPath; @@ -31,6 +34,11 @@ export type SelectFieldProps = { required?: boolean; readOnly?: boolean; + clearable?: boolean; + clearLabel?: string; + clearButtonLabel?: string; + emptyValue?: SelectFieldEmptyValue; + placeholder?: string; items?: SelectFieldItem[]; @@ -44,6 +52,8 @@ export type SelectFieldProps = { inputClassName?: string; }; +const EMPTY_SELECT_VALUE = "__rdx_select_empty__"; + export function SelectField({ name, @@ -55,6 +65,11 @@ export function SelectField({ required = false, readOnly = false, + clearable = false, + clearLabel = "Sin selección", + clearButtonLabel = "Quitar selección", + emptyValue = null, + placeholder, items = [], @@ -105,36 +120,69 @@ export function SelectField({