diff --git a/apps/server/package.json b/apps/server/package.json index bbba5b10..14d91a29 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,6 +38,7 @@ "@erp/customers": "workspace:*", "@erp/customer-invoices": "workspace:*", "@erp/factuges": "workspace:*", + "@erp/suppliers": "workspace:*", "@repo/rdx-logger": "workspace:*", "@repo/rdx-utils": "workspace:*", "bcrypt": "^5.1.1", diff --git a/apps/server/src/register-modules.ts b/apps/server/src/register-modules.ts index d09c85e7..4edc28b5 100644 --- a/apps/server/src/register-modules.ts +++ b/apps/server/src/register-modules.ts @@ -1,6 +1,7 @@ import customerInvoicesAPIModule from "@erp/customer-invoices/api"; import customersAPIModule from "@erp/customers/api"; import factuGESAPIModule from "@erp/factuges/api"; +import suppliersAPIModule from "@erp/suppliers/api"; import { registerModule } from "./lib"; @@ -9,4 +10,5 @@ export const registerModules = () => { registerModule(customersAPIModule); registerModule(customerInvoicesAPIModule); registerModule(factuGESAPIModule); + registerModule(suppliersAPIModule); }; diff --git a/modules/supplier/package.json b/modules/supplier/package.json new file mode 100644 index 00000000..ec254afb --- /dev/null +++ b/modules/supplier/package.json @@ -0,0 +1,33 @@ +{ + "name": "@erp/suppliers", + "description": "Suppliers", + "version": "0.5.0", + "private": true, + "type": "module", + "sideEffects": false, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "clean": "rimraf .turbo node_modules dist" + }, + "exports": { + ".": "./src/common/index.ts", + "./common": "./src/common/index.ts", + "./api": "./src/api/index.ts" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "typescript": "^5.9.3" + }, + "dependencies": { + "@erp/auth": "workspace:*", + "@erp/core": "workspace:*", + "@repo/i18next": "workspace:*", + "@repo/rdx-criteria": "workspace:*", + "@repo/rdx-ddd": "workspace:*", + "@repo/rdx-logger": "workspace:*", + "@repo/rdx-utils": "workspace:*", + "express": "^4.18.2", + "sequelize": "^6.37.5", + "zod": "^4.1.11" + } +} \ No newline at end of file diff --git a/modules/supplier/src/api/application/di/index.ts b/modules/supplier/src/api/application/di/index.ts new file mode 100644 index 00000000..946b8110 --- /dev/null +++ b/modules/supplier/src/api/application/di/index.ts @@ -0,0 +1,6 @@ +export * from "./supplier-creator.di"; +export * from "./supplier-finder.di"; +export * from "./supplier-input-mappers.di"; +export * from "./supplier-snapshot-builders.di"; +export * from "./supplier-updater.di"; +export * from "./supplier-use-cases.di"; diff --git a/modules/supplier/src/api/application/di/supplier-creator.di.ts b/modules/supplier/src/api/application/di/supplier-creator.di.ts new file mode 100644 index 00000000..ff2a2a15 --- /dev/null +++ b/modules/supplier/src/api/application/di/supplier-creator.di.ts @@ -0,0 +1,12 @@ +import type { ISupplierRepository } from "../repositories"; +import { type ISupplierCreator, SupplierCreator } from "../services"; + +export const buildSupplierCreator = (params: { + repository: ISupplierRepository; +}): ISupplierCreator => { + const { repository } = params; + + return new SupplierCreator({ + repository, + }); +}; diff --git a/modules/supplier/src/api/application/di/supplier-finder.di.ts b/modules/supplier/src/api/application/di/supplier-finder.di.ts new file mode 100644 index 00000000..152a858e --- /dev/null +++ b/modules/supplier/src/api/application/di/supplier-finder.di.ts @@ -0,0 +1,8 @@ +import type { ISupplierRepository } from "../repositories"; +import { type ISupplierFinder, SupplierFinder } from "../services"; + +export function buildSupplierFinder(params: { repository: ISupplierRepository }): ISupplierFinder { + const { repository } = params; + + return new SupplierFinder(repository); +} diff --git a/modules/supplier/src/api/application/di/supplier-input-mappers.di.ts b/modules/supplier/src/api/application/di/supplier-input-mappers.di.ts new file mode 100644 index 00000000..c6944591 --- /dev/null +++ b/modules/supplier/src/api/application/di/supplier-input-mappers.di.ts @@ -0,0 +1,26 @@ +import type { ICatalogs } from "@erp/core/api"; + +import { + CreateSupplierInputMapper, + type ICreateSupplierInputMapper, + type IUpdateSupplierInputMapper, + UpdateSupplierInputMapper, +} from "../mappers"; + +export interface ISupplierInputMappers { + createInputMapper: ICreateSupplierInputMapper; + updateInputMapper: IUpdateSupplierInputMapper; +} + +export const buildSupplierInputMappers = (catalogs: ICatalogs): ISupplierInputMappers => { + const { taxCatalog } = catalogs; + + // Mappers el DTO a las props validadas (SupplierProps) y luego construir agregado + const createInputMapper = new CreateSupplierInputMapper({ taxCatalog }); + const updateInputMapper = new UpdateSupplierInputMapper(); + + return { + createInputMapper, + updateInputMapper, + }; +}; diff --git a/modules/supplier/src/api/application/di/supplier-snapshot-builders.di.ts b/modules/supplier/src/api/application/di/supplier-snapshot-builders.di.ts new file mode 100644 index 00000000..7a698bd9 --- /dev/null +++ b/modules/supplier/src/api/application/di/supplier-snapshot-builders.di.ts @@ -0,0 +1,11 @@ +import { SupplierFullSnapshotBuilder, SupplierSummarySnapshotBuilder } from "../snapshot-builders"; + +export function buildSupplierSnapshotBuilders() { + const fullSnapshotBuilder = new SupplierFullSnapshotBuilder(); + const summarySnapshotBuilder = new SupplierSummarySnapshotBuilder(); + + return { + full: fullSnapshotBuilder, + summary: summarySnapshotBuilder, + }; +} diff --git a/modules/supplier/src/api/application/di/supplier-updater.di.ts b/modules/supplier/src/api/application/di/supplier-updater.di.ts new file mode 100644 index 00000000..96b1abef --- /dev/null +++ b/modules/supplier/src/api/application/di/supplier-updater.di.ts @@ -0,0 +1,12 @@ +import type { ISupplierRepository } from "../repositories"; +import { type ISupplierUpdater, SupplierUpdater } from "../services"; + +export const buildSupplierUpdater = (params: { + repository: ISupplierRepository; +}): ISupplierUpdater => { + const { repository } = params; + + return new SupplierUpdater({ + repository, + }); +}; diff --git a/modules/supplier/src/api/application/di/supplier-use-cases.di.ts b/modules/supplier/src/api/application/di/supplier-use-cases.di.ts new file mode 100644 index 00000000..b1ab6061 --- /dev/null +++ b/modules/supplier/src/api/application/di/supplier-use-cases.di.ts @@ -0,0 +1,95 @@ +import type { ITransactionManager } from "@erp/core/api"; + +import type { ICreateSupplierInputMapper, IUpdateSupplierInputMapper } from "../mappers"; +import type { ISupplierCreator, ISupplierFinder, ISupplierUpdater } from "../services"; +import type { + ISupplierFullSnapshotBuilder, + ISupplierSummarySnapshotBuilder, +} from "../snapshot-builders"; +import { + CreateSupplierUseCase, + GetSupplierByIdUseCase, + ListSuppliersUseCase, + UpdateSupplierUseCase, +} from "../use-cases"; + +export function buildGetSupplierByIdUseCase(deps: { + finder: ISupplierFinder; + fullSnapshotBuilder: ISupplierFullSnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new GetSupplierByIdUseCase(deps.finder, deps.fullSnapshotBuilder, deps.transactionManager); +} + +export function buildListSuppliersUseCase(deps: { + finder: ISupplierFinder; + summarySnapshotBuilder: ISupplierSummarySnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new ListSuppliersUseCase( + deps.finder, + deps.summarySnapshotBuilder, + deps.transactionManager + ); +} + +export function buildCreateSupplierUseCase(deps: { + creator: ISupplierCreator; + dtoMapper: ICreateSupplierInputMapper; + fullSnapshotBuilder: ISupplierFullSnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new CreateSupplierUseCase({ + dtoMapper: deps.dtoMapper, + creator: deps.creator, + fullSnapshotBuilder: deps.fullSnapshotBuilder, + transactionManager: deps.transactionManager, + }); +} + +export function buildUpdateSupplierUseCase(deps: { + updater: ISupplierUpdater; + dtoMapper: IUpdateSupplierInputMapper; + fullSnapshotBuilder: ISupplierFullSnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new UpdateSupplierUseCase({ + dtoMapper: deps.dtoMapper, + updater: deps.updater, + fullSnapshotBuilder: deps.fullSnapshotBuilder, + transactionManager: deps.transactionManager, + }); +} + +/*export function buildReportSupplierUseCase(deps: { + finder: ISupplierFinder; + fullSnapshotBuilder: ISupplierFullSnapshotBuilder; + reportSnapshotBuilder: ISupplierReportSnapshotBuilder; + documentService: SupplierDocumentGeneratorService; + transactionManager: ITransactionManager; +}) { + return new ReportSupplierUseCase( + deps.finder, + deps.fullSnapshotBuilder, + deps.reportSnapshotBuilder, + deps.documentService, + deps.transactionManager + ); +}*/ + +/* + +export function buildDeleteSupplierUseCase(deps: { finder: ISupplierFinder }) { + return new DeleteSupplierUseCase(deps.finder); +} + +export function buildIssueSupplierUseCase(deps: { finder: ISupplierFinder }) { + return new IssueSupplierUseCase(deps.finder); +} + +export function buildChangeStatusSupplierUseCase(deps: { + finder: ISupplierFinder; + transactionManager: ITransactionManager; +}) { + return new ChangeStatusSupplierUseCase(deps.finder, deps.transactionManager); +}*/ diff --git a/modules/supplier/src/api/application/index.ts b/modules/supplier/src/api/application/index.ts new file mode 100644 index 00000000..21fd64a4 --- /dev/null +++ b/modules/supplier/src/api/application/index.ts @@ -0,0 +1,6 @@ +export * from "./di"; +export * from "./models"; +export * from "./repositories"; +export * from "./services"; +export * from "./snapshot-builders"; +export * from "./use-cases"; diff --git a/modules/supplier/src/api/application/mappers/create-supplier-input.mapper.ts b/modules/supplier/src/api/application/mappers/create-supplier-input.mapper.ts new file mode 100644 index 00000000..92c8dc0b --- /dev/null +++ b/modules/supplier/src/api/application/mappers/create-supplier-input.mapper.ts @@ -0,0 +1,217 @@ +import type { JsonTaxCatalogProvider } from "@erp/core"; +import { + City, + Country, + CurrencyCode, + DomainError, + EmailAddress, + LanguageCode, + Name, + PhoneNumber, + type PostalAddressProps, + PostalCode, + Province, + Street, + TINNumber, + URLAddress, + UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, +} from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { CreateSupplierRequestDTO } from "../../../common"; +import { type ISupplierCreateProps, SupplierStatus } from "../../domain"; + +export interface ICreateSupplierInputMapper { + map( + dto: CreateSupplierRequestDTO, + params: { companyId: UniqueID } + ): Result<{ id: UniqueID; props: ISupplierCreateProps }>; +} + +export class CreateSupplierInputMapper implements ICreateSupplierInputMapper { + private readonly taxCatalog: JsonTaxCatalogProvider; + + constructor(params: { taxCatalog: JsonTaxCatalogProvider }) { + this.taxCatalog = params.taxCatalog; + } + + public map( + dto: CreateSupplierRequestDTO, + params: { companyId: UniqueID } + ): Result<{ id: UniqueID; props: ISupplierCreateProps }> { + try { + const errors: ValidationErrorDetail[] = []; + const { companyId } = params; + + const supplierId = extractOrPushError(UniqueID.create(dto.id), "id", errors); + + const status = SupplierStatus.active(); + const isCompany = dto.is_company === "true"; + + const reference = extractOrPushError( + maybeFromNullableResult(dto.reference, (value) => Name.create(value)), + "reference", + errors + ); + + const name = extractOrPushError(Name.create(dto.name), "name", errors); + + const tradeName = extractOrPushError( + maybeFromNullableResult(dto.trade_name, (value) => Name.create(value)), + "trade_name", + errors + ); + + const tinNumber = extractOrPushError( + maybeFromNullableResult(dto.tin, (value) => TINNumber.create(value)), + "tin", + errors + ); + + const street = extractOrPushError( + maybeFromNullableResult(dto.street, (value) => Street.create(value)), + "street", + errors + ); + + const street2 = extractOrPushError( + maybeFromNullableResult(dto.street2, (value) => Street.create(value)), + "street2", + errors + ); + + const city = extractOrPushError( + maybeFromNullableResult(dto.city, (value) => City.create(value)), + "city", + errors + ); + + const province = extractOrPushError( + maybeFromNullableResult(dto.province, (value) => Province.create(value)), + "province", + errors + ); + + const postalCode = extractOrPushError( + maybeFromNullableResult(dto.postal_code, (value) => PostalCode.create(value)), + "postal_code", + errors + ); + + const country = extractOrPushError( + maybeFromNullableResult(dto.country, (value) => Country.create(value)), + "country", + errors + ); + + const primaryEmailAddress = extractOrPushError( + maybeFromNullableResult(dto.email_primary, (value) => EmailAddress.create(value)), + "email_primary", + errors + ); + + const secondaryEmailAddress = extractOrPushError( + maybeFromNullableResult(dto.email_secondary, (value) => EmailAddress.create(value)), + "email_secondary", + errors + ); + + const primaryPhoneNumber = extractOrPushError( + maybeFromNullableResult(dto.phone_primary, (value) => PhoneNumber.create(value)), + "phone_primary", + errors + ); + + const secondaryPhoneNumber = extractOrPushError( + maybeFromNullableResult(dto.phone_secondary, (value) => PhoneNumber.create(value)), + "phone_secondary", + errors + ); + + const primaryMobileNumber = extractOrPushError( + maybeFromNullableResult(dto.mobile_primary, (value) => PhoneNumber.create(value)), + "mobile_primary", + errors + ); + + const secondaryMobileNumber = extractOrPushError( + maybeFromNullableResult(dto.mobile_secondary, (value) => PhoneNumber.create(value)), + "mobile_secondary", + errors + ); + + const faxNumber = extractOrPushError( + maybeFromNullableResult(dto.fax, (value) => PhoneNumber.create(value)), + "fax", + errors + ); + + const website = extractOrPushError( + maybeFromNullableResult(dto.website, (value) => URLAddress.create(value)), + "website", + errors + ); + + const languageCode = extractOrPushError( + LanguageCode.create(dto.language_code), + "language_code", + errors + ); + + const currencyCode = extractOrPushError( + CurrencyCode.create(dto.currency_code), + "currency_code", + errors + ); + + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection("Supplier props mapping failed", errors)); + } + + const postalAddressProps: PostalAddressProps = { + street: street!, + street2: street2!, + city: city!, + postalCode: postalCode!, + province: province!, + country: country!, + }; + + const supplierProps: ISupplierCreateProps = { + companyId, + status: status!, + reference: reference!, + + isCompany: isCompany, + name: name!, + tradeName: tradeName!, + tin: tinNumber!, + + address: postalAddressProps!, + + emailPrimary: primaryEmailAddress!, + emailSecondary: secondaryEmailAddress!, + + phonePrimary: primaryPhoneNumber!, + phoneSecondary: secondaryPhoneNumber!, + + mobilePrimary: primaryMobileNumber!, + mobileSecondary: secondaryMobileNumber!, + + fax: faxNumber!, + website: website!, + + languageCode: languageCode!, + currencyCode: currencyCode!, + }; + + return Result.ok({ id: supplierId!, props: supplierProps }); + } catch (err: unknown) { + return Result.fail(new DomainError("Supplier props mapping failed", { cause: err })); + } + } +} diff --git a/modules/supplier/src/api/application/mappers/index.ts b/modules/supplier/src/api/application/mappers/index.ts new file mode 100644 index 00000000..1012e0d0 --- /dev/null +++ b/modules/supplier/src/api/application/mappers/index.ts @@ -0,0 +1,2 @@ +export * from "./create-supplier-input.mapper"; +export * from "./update-supplier-input.mapper"; diff --git a/modules/supplier/src/api/application/mappers/update-supplier-input.mapper.ts b/modules/supplier/src/api/application/mappers/update-supplier-input.mapper.ts new file mode 100644 index 00000000..6472590c --- /dev/null +++ b/modules/supplier/src/api/application/mappers/update-supplier-input.mapper.ts @@ -0,0 +1,262 @@ +import { + City, + Country, + CurrencyCode, + DomainError, + EmailAddress, + LanguageCode, + Name, + PhoneNumber, + type PostalAddressPatchProps, + PostalCode, + Province, + Street, + TINNumber, + URLAddress, + type UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, +} from "@repo/rdx-ddd"; +import { Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; + +import type { UpdateSupplierByIdRequestDTO } from "../../../common"; +import type { SupplierPatchProps } from "../../domain"; + +/** + * UpdateSupplierInputMapper + * Convierte el DTO a las props validadas (SupplierProps). + * No construye directamente el agregado. + * Tri-estado: + * - campo omitido → no se cambia + * - campo con valor null/"" → se quita el valor -> set(None()), + * - campo con valor no-vacío → se pone el nuevo valor -> set(Some(VO)). + * + * @param dto - DTO con los datos a cambiar en el cliente + * @returns Cambios en las propiedades del cliente + * + */ + +export interface IUpdateSupplierInputMapper { + map( + dto: UpdateSupplierByIdRequestDTO, + params: { companyId: UniqueID } + ): Result; +} + +export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { + public map(dto: UpdateSupplierByIdRequestDTO, params: { companyId: UniqueID }) { + try { + const errors: ValidationErrorDetail[] = []; + const supplierPatchProps: SupplierPatchProps = {}; + + toPatchField(dto.reference).ifSet((reference) => { + supplierPatchProps.reference = extractOrPushError( + maybeFromNullableResult(reference, (value) => Name.create(value)), + "reference", + errors + ); + }); + + toPatchField(dto.is_company).ifSet((is_company) => { + if (isNullishOrEmpty(is_company)) { + errors.push({ path: "is_company", message: "is_company cannot be empty" }); + return; + } + supplierPatchProps.isCompany = extractOrPushError( + Result.ok(Boolean(is_company!)), + "is_company", + errors + ); + }); + + toPatchField(dto.name).ifSet((name) => { + if (isNullishOrEmpty(name)) { + errors.push({ path: "name", message: "Name cannot be empty" }); + return; + } + supplierPatchProps.name = extractOrPushError(Name.create(name!), "name", errors); + }); + + toPatchField(dto.trade_name).ifSet((trade_name) => { + supplierPatchProps.tradeName = extractOrPushError( + maybeFromNullableResult(trade_name, (value) => Name.create(value)), + "trade_name", + errors + ); + }); + + toPatchField(dto.tin).ifSet((tin) => { + supplierPatchProps.tin = extractOrPushError( + maybeFromNullableResult(tin, (value) => TINNumber.create(value)), + "tin", + errors + ); + }); + + toPatchField(dto.email_primary).ifSet((email_primary) => { + supplierPatchProps.emailPrimary = extractOrPushError( + maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)), + "email_primary", + errors + ); + }); + + toPatchField(dto.email_secondary).ifSet((email_secondary) => { + supplierPatchProps.emailSecondary = extractOrPushError( + maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)), + "email_secondary", + errors + ); + }); + + toPatchField(dto.mobile_primary).ifSet((mobile_primary) => { + supplierPatchProps.mobilePrimary = extractOrPushError( + maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)), + "mobile_primary", + errors + ); + }); + + toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => { + supplierPatchProps.mobilePrimary = extractOrPushError( + maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)), + "mobile_secondary", + errors + ); + }); + + toPatchField(dto.phone_primary).ifSet((phone_primary) => { + supplierPatchProps.phonePrimary = extractOrPushError( + maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)), + "phone_primary", + errors + ); + }); + + toPatchField(dto.phone_secondary).ifSet((phone_secondary) => { + supplierPatchProps.phoneSecondary = extractOrPushError( + maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)), + "phone_secondary", + errors + ); + }); + + toPatchField(dto.fax).ifSet((fax) => { + supplierPatchProps.fax = extractOrPushError( + maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)), + "fax", + errors + ); + }); + + toPatchField(dto.website).ifSet((website) => { + supplierPatchProps.website = extractOrPushError( + maybeFromNullableResult(website, (value) => URLAddress.create(value)), + "website", + errors + ); + }); + + toPatchField(dto.language_code).ifSet((languageCode) => { + if (isNullishOrEmpty(languageCode)) { + errors.push({ path: "language_code", message: "Language code cannot be empty" }); + return; + } + + supplierPatchProps.languageCode = extractOrPushError( + LanguageCode.create(languageCode!), + "language_code", + errors + ); + }); + + toPatchField(dto.currency_code).ifSet((currencyCode) => { + if (isNullishOrEmpty(currencyCode)) { + errors.push({ path: "currency_code", message: "Currency code cannot be empty" }); + return; + } + + supplierPatchProps.currencyCode = extractOrPushError( + CurrencyCode.create(currencyCode!), + "currency_code", + errors + ); + }); + + // PostalAddress + const addressPatchProps = this.mapPostalAddress(dto, errors); + if (addressPatchProps) { + supplierPatchProps.address = addressPatchProps; + } + + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Supplier props mapping failed (update)", errors) + ); + } + + return Result.ok(supplierPatchProps); + } catch (err: unknown) { + return Result.fail(new DomainError("Supplier props mapping failed", { cause: err })); + } + } + + public mapPostalAddress( + dto: UpdateSupplierByIdRequestDTO, + errors: ValidationErrorDetail[] + ): PostalAddressPatchProps | undefined { + const postalAddressPatchProps: PostalAddressPatchProps = {}; + + toPatchField(dto.street).ifSet((street) => { + postalAddressPatchProps.street = extractOrPushError( + maybeFromNullableResult(street, (value) => Street.create(value)), + "street", + errors + ); + }); + + toPatchField(dto.street2).ifSet((street2) => { + postalAddressPatchProps.street2 = extractOrPushError( + maybeFromNullableResult(street2, (value) => Street.create(value)), + "street2", + errors + ); + }); + + toPatchField(dto.city).ifSet((city) => { + postalAddressPatchProps.city = extractOrPushError( + maybeFromNullableResult(city, (value) => City.create(value)), + "city", + errors + ); + }); + + toPatchField(dto.province).ifSet((province) => { + postalAddressPatchProps.province = extractOrPushError( + maybeFromNullableResult(province, (value) => Province.create(value)), + "province", + errors + ); + }); + + toPatchField(dto.postal_code).ifSet((postalCode) => { + postalAddressPatchProps.postalCode = extractOrPushError( + maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)), + "postal_code", + errors + ); + }); + + toPatchField(dto.country).ifSet((country) => { + postalAddressPatchProps.country = extractOrPushError( + maybeFromNullableResult(country, (value) => Country.create(value)), + "country", + errors + ); + }); + + return Object.keys(postalAddressPatchProps).length > 0 ? postalAddressPatchProps : undefined; + } +} diff --git a/modules/supplier/src/api/application/models/index.ts b/modules/supplier/src/api/application/models/index.ts new file mode 100644 index 00000000..98127709 --- /dev/null +++ b/modules/supplier/src/api/application/models/index.ts @@ -0,0 +1 @@ +export * from "./supplier-summary"; diff --git a/modules/supplier/src/api/application/models/supplier-summary.ts b/modules/supplier/src/api/application/models/supplier-summary.ts new file mode 100644 index 00000000..2e1d78aa --- /dev/null +++ b/modules/supplier/src/api/application/models/supplier-summary.ts @@ -0,0 +1,39 @@ +import type { + CurrencyCode, + EmailAddress, + LanguageCode, + Name, + PhoneNumber, + PostalAddress, + TINNumber, + URLAddress, + UniqueID, +} from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; + +export type SupplierSummary = { + id: UniqueID; + companyId: UniqueID; + isActive: boolean; + reference: Maybe; + + isCompany: boolean; + name: Name; + tradeName: Maybe; + tin: Maybe; + + address: PostalAddress; + + emailPrimary: Maybe; + emailSecondary: Maybe; + phonePrimary: Maybe; + phoneSecondary: Maybe; + mobilePrimary: Maybe; + mobileSecondary: Maybe; + + fax: Maybe; + website: Maybe; + + languageCode: LanguageCode; + currencyCode: CurrencyCode; +}; diff --git a/modules/supplier/src/api/application/repositories/index.ts b/modules/supplier/src/api/application/repositories/index.ts new file mode 100644 index 00000000..8b61f9e1 --- /dev/null +++ b/modules/supplier/src/api/application/repositories/index.ts @@ -0,0 +1 @@ +export * from './supplier-repository.interface'; diff --git a/modules/supplier/src/api/application/repositories/supplier-repository.interface.ts b/modules/supplier/src/api/application/repositories/supplier-repository.interface.ts new file mode 100644 index 00000000..2e083583 --- /dev/null +++ b/modules/supplier/src/api/application/repositories/supplier-repository.interface.ts @@ -0,0 +1,82 @@ +import type { Criteria } from "@repo/rdx-criteria/server"; +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; +import type { Collection, Result } from "@repo/rdx-utils"; + +import type { Supplier } from "../../domain/aggregates"; +import type { SupplierSummary } from "../models"; + +/** + * Interfaz del repositorio para el agregado `Supplier`. + * El escopado multitenant está representado por `companyId`. + */ +export interface ISupplierRepository { + /** + * + * Crea un nuevo proveedor + * + * @param supplier - El proveedor nuevo a guardar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + create(supplier: Supplier, transaction: unknown): Promise>; + + /** + * Actualiza un proveedor existente. + * + * @param supplier - El proveedor a actualizar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + update(supplier: Supplier, transaction: unknown): Promise>; + + /** + * Comprueba si existe un Supplier con un `id` dentro de una `company`. + */ + existsByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise>; + + /** + * Recupera un Supplier por su ID y companyId. + * Devuelve un `NotFoundError` si no se encuentra. + */ + getByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise>; + + /** + * Recupera un Supplier por su TIN y companyId. + * Devuelve un `NotFoundError` si no se encuentra. + */ + getByTINInCompany( + companyId: UniqueID, + tin: TINNumber, + transaction?: unknown + ): Promise>; + + /** + * Recupera múltiples suppliers dentro de una empresa + * según un criterio dinámico (búsqueda, paginación, etc.). + * El resultado está encapsulado en un objeto `Collection`. + */ + findByCriteriaInCompany( + companyId: UniqueID, + criteria: Criteria, + transaction: unknown + ): Promise, Error>>; + + /** + * Elimina un Supplier por su ID, dentro de una empresa. + * Retorna `void` si se elimina correctamente, o `NotFoundError` si no existía. + * + */ + deleteByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise>; +} diff --git a/modules/supplier/src/api/application/services/index.ts b/modules/supplier/src/api/application/services/index.ts new file mode 100644 index 00000000..b73a5e84 --- /dev/null +++ b/modules/supplier/src/api/application/services/index.ts @@ -0,0 +1,4 @@ +export * from "./supplier-creator.service"; +export * from "./supplier-finder.service"; +export * from "./supplier-public-services.interface"; +export * from "./supplier-updater.service"; diff --git a/modules/supplier/src/api/application/services/supplier-creator.service.ts b/modules/supplier/src/api/application/services/supplier-creator.service.ts new file mode 100644 index 00000000..0f214f1c --- /dev/null +++ b/modules/supplier/src/api/application/services/supplier-creator.service.ts @@ -0,0 +1,70 @@ +import { DuplicateEntityError } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { type ISupplierCreateProps, Supplier } from "../../domain"; +import type { ISupplierRepository } from "../repositories"; +import { SupplierNotExistsInCompanySpecification } from "../specs"; + +export interface ISupplierCreator { + create(params: { + companyId: UniqueID; + id: UniqueID; + props: ISupplierCreateProps; + unknown: unknown; + }): Promise>; +} + +type SupplierCreatorDeps = { + repository: ISupplierRepository; +}; + +export class SupplierCreator implements ISupplierCreator { + private readonly repository: ISupplierRepository; + + constructor(deps: SupplierCreatorDeps) { + this.repository = deps.repository; + } + + async create(params: { + companyId: UniqueID; + id: UniqueID; + props: ISupplierCreateProps; + unknown: unknown; + }): Promise> { + const { companyId, id, props, unknown } = params; + + // 1. Verificar unicidad + const spec = new SupplierNotExistsInCompanySpecification(this.repository, companyId, unknown); + + const isNew = await spec.isSatisfiedBy(id); + + if (!isNew) { + return Result.fail(new DuplicateEntityError("Supplier", "id", String(id))); + } + + // 2. Crear agregado + const createResult = Supplier.create( + { + ...props, + companyId, + }, + id + ); + + if (createResult.isFailure) { + return createResult; + } + + const newSupplier = createResult.data; + + // 3. Persistir agregado + const saveResult = await this.repository.create(newSupplier, unknown); + + if (saveResult.isFailure) { + return Result.fail(saveResult.error); + } + + return Result.ok(newSupplier); + } +} diff --git a/modules/supplier/src/api/application/services/supplier-finder.service.ts b/modules/supplier/src/api/application/services/supplier-finder.service.ts new file mode 100644 index 00000000..5996eeb9 --- /dev/null +++ b/modules/supplier/src/api/application/services/supplier-finder.service.ts @@ -0,0 +1,69 @@ +import type { Criteria } from "@repo/rdx-criteria/server"; +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; +import type { Collection, Result } from "@repo/rdx-utils"; + +import type { Supplier } from "../../domain"; +import type { SupplierSummary } from "../models"; +import type { ISupplierRepository } from "../repositories"; + +export interface ISupplierFinder { + findSupplierById( + companyId: UniqueID, + supplierId: UniqueID, + unknown?: unknown + ): Promise>; + + findSupplierByTIN( + companyId: UniqueID, + tin: TINNumber, + unknown?: unknown + ): Promise>; + + supplierExists( + companyId: UniqueID, + invoiceId: UniqueID, + unknown?: unknown + ): Promise>; + + findSuppliersByCriteria( + companyId: UniqueID, + criteria: Criteria, + unknown?: unknown + ): Promise, Error>>; +} + +export class SupplierFinder implements ISupplierFinder { + constructor(private readonly repository: ISupplierRepository) {} + + async findSupplierById( + companyId: UniqueID, + supplierId: UniqueID, + unknown?: unknown + ): Promise> { + return this.repository.getByIdInCompany(companyId, supplierId, unknown); + } + + findSupplierByTIN( + companyId: UniqueID, + tin: TINNumber, + unknown?: unknown + ): Promise> { + return this.repository.getByTINInCompany(companyId, tin, unknown); + } + + async supplierExists( + companyId: UniqueID, + supplierId: UniqueID, + unknown?: unknown + ): Promise> { + return this.repository.existsByIdInCompany(companyId, supplierId, unknown); + } + + async findSuppliersByCriteria( + companyId: UniqueID, + criteria: Criteria, + unknown?: unknown + ): Promise, Error>> { + return this.repository.findByCriteriaInCompany(companyId, criteria, unknown); + } +} diff --git a/modules/supplier/src/api/application/services/supplier-public-services.interface.ts b/modules/supplier/src/api/application/services/supplier-public-services.interface.ts new file mode 100644 index 00000000..2938ad31 --- /dev/null +++ b/modules/supplier/src/api/application/services/supplier-public-services.interface.ts @@ -0,0 +1,22 @@ +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +import type { ISupplierCreateProps, Supplier } from "../../domain"; + +export interface ISupplierServicesContext { + transaction: unknown; + companyId: UniqueID; +} + +export interface ISupplierPublicServices { + findSupplierByTIN: ( + tin: TINNumber, + context: ISupplierServicesContext + ) => Promise>; + + createSupplier: ( + id: UniqueID, + props: ISupplierCreateProps, + context: ISupplierServicesContext + ) => Promise>; +} diff --git a/modules/supplier/src/api/application/services/supplier-updater.service.ts b/modules/supplier/src/api/application/services/supplier-updater.service.ts new file mode 100644 index 00000000..796c1e10 --- /dev/null +++ b/modules/supplier/src/api/application/services/supplier-updater.service.ts @@ -0,0 +1,60 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { Supplier, SupplierPatchProps } from "../../domain"; +import type { ISupplierRepository } from "../repositories"; + +export interface ISupplierUpdater { + update(params: { + companyId: UniqueID; + id: UniqueID; + props: SupplierPatchProps; + transaction: unknown; + }): Promise>; +} + +type SupplierUpdaterDeps = { + repository: ISupplierRepository; +}; + +export class SupplierUpdater implements ISupplierUpdater { + private readonly repository: ISupplierRepository; + + constructor(deps: SupplierUpdaterDeps) { + this.repository = deps.repository; + } + + async update(params: { + companyId: UniqueID; + id: UniqueID; + props: SupplierPatchProps; + transaction: unknown; + }): Promise> { + const { companyId, id, props, transaction } = params; + + // Recuperar agregado existente + const existingResult = await this.repository.getByIdInCompany(companyId, id, transaction); + + if (existingResult.isFailure) { + return Result.fail(existingResult.error); + } + + const supplier = existingResult.data; + + // Aplicar cambios en el agregado + const updateResult = supplier.update(props); + + if (updateResult.isFailure) { + return Result.fail(updateResult.error); + } + + // Persistir cambios + const saveResult = await this.repository.update(supplier, transaction); + + if (saveResult.isFailure) { + return Result.fail(saveResult.error); + } + + return Result.ok(supplier); + } +} diff --git a/modules/supplier/src/api/application/snapshot-builders/domain/index.ts b/modules/supplier/src/api/application/snapshot-builders/domain/index.ts new file mode 100644 index 00000000..75c665ed --- /dev/null +++ b/modules/supplier/src/api/application/snapshot-builders/domain/index.ts @@ -0,0 +1,2 @@ +export * from "./supplier-snapshot.interface"; +export * from "./supplier-snapshot-builder"; diff --git a/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts b/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts new file mode 100644 index 00000000..711e6ad1 --- /dev/null +++ b/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts @@ -0,0 +1,55 @@ +import type { ISnapshotBuilder } from "@erp/core/api"; +import { maybeToEmptyString } from "@repo/rdx-ddd"; + +import type { Supplier } from "../../../domain"; + +import type { ISupplierFullSnapshot } from "./supplier-snapshot.interface"; + +export interface ISupplierFullSnapshotBuilder + extends ISnapshotBuilder {} + +export class SupplierFullSnapshotBuilder implements ISupplierFullSnapshotBuilder { + toOutput(supplier: Supplier): ISupplierFullSnapshot { + const address = supplier.address.toPrimitive(); + + return { + id: supplier.id.toPrimitive(), + company_id: supplier.companyId.toPrimitive(), + status: supplier.isActive ? "active" : "inactive", + reference: maybeToEmptyString(supplier.reference, (value) => value.toPrimitive()), + + is_company: String(supplier.isCompany), + name: supplier.name.toPrimitive(), + trade_name: maybeToEmptyString(supplier.tradeName, (value) => value.toPrimitive()), + tin: maybeToEmptyString(supplier.tin, (value) => value.toPrimitive()), + + street: maybeToEmptyString(address.street, (value) => value.toPrimitive()), + street2: maybeToEmptyString(address.street2, (value) => value.toPrimitive()), + city: maybeToEmptyString(address.city, (value) => value.toPrimitive()), + province: maybeToEmptyString(address.province, (value) => value.toPrimitive()), + postal_code: maybeToEmptyString(address.postalCode, (value) => value.toPrimitive()), + country: maybeToEmptyString(address.country, (value) => value.toPrimitive()), + + email_primary: maybeToEmptyString(supplier.emailPrimary, (value) => value.toPrimitive()), + email_secondary: maybeToEmptyString(supplier.emailSecondary, (value) => value.toPrimitive()), + + phone_primary: maybeToEmptyString(supplier.phonePrimary, (value) => value.toPrimitive()), + phone_secondary: maybeToEmptyString(supplier.phoneSecondary, (value) => value.toPrimitive()), + + mobile_primary: maybeToEmptyString(supplier.mobilePrimary, (value) => value.toPrimitive()), + mobile_secondary: maybeToEmptyString(supplier.mobileSecondary, (value) => + value.toPrimitive() + ), + + fax: maybeToEmptyString(supplier.fax, (value) => value.toPrimitive()), + website: maybeToEmptyString(supplier.website, (value) => value.toPrimitive()), + + language_code: supplier.languageCode.toPrimitive(), + currency_code: supplier.currencyCode.toPrimitive(), + + metadata: { + entity: "supplier", + }, + }; + } +} diff --git a/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot.interface.ts b/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot.interface.ts new file mode 100644 index 00000000..b1fc23d7 --- /dev/null +++ b/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot.interface.ts @@ -0,0 +1,35 @@ +export interface ISupplierFullSnapshot { + id: string; + company_id: string; + status: string; + reference: string; + + is_company: string; + name: string; + trade_name: string; + tin: string; + + street: string; + street2: string; + city: string; + province: string; + postal_code: string; + country: string; + + email_primary: string; + email_secondary: string; + + phone_primary: string; + phone_secondary: string; + + mobile_primary: string; + mobile_secondary: string; + + fax: string; + website: string; + + language_code: string; + currency_code: string; + + metadata?: Record; +} diff --git a/modules/supplier/src/api/application/snapshot-builders/index.ts b/modules/supplier/src/api/application/snapshot-builders/index.ts new file mode 100644 index 00000000..b7726c46 --- /dev/null +++ b/modules/supplier/src/api/application/snapshot-builders/index.ts @@ -0,0 +1,2 @@ +export * from "./domain"; +export * from "./summary"; diff --git a/modules/supplier/src/api/application/snapshot-builders/summary/index.ts b/modules/supplier/src/api/application/snapshot-builders/summary/index.ts new file mode 100644 index 00000000..40739896 --- /dev/null +++ b/modules/supplier/src/api/application/snapshot-builders/summary/index.ts @@ -0,0 +1,2 @@ +export * from "./supplier-summary-snapshot.interface"; +export * from "./supplier-summary-snapshot-builder"; diff --git a/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts b/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts new file mode 100644 index 00000000..04f2a7b1 --- /dev/null +++ b/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts @@ -0,0 +1,49 @@ +import type { ISnapshotBuilder } from "@erp/core/api"; +import { maybeToEmptyString } from "@repo/rdx-ddd"; + +import type { SupplierSummary } from "../../models"; + +import type { ISupplierSummarySnapshot } from "./supplier-summary-snapshot.interface"; + +export interface ISupplierSummarySnapshotBuilder + extends ISnapshotBuilder {} + +export class SupplierSummarySnapshotBuilder implements ISupplierSummarySnapshotBuilder { + toOutput(supplier: SupplierSummary): ISupplierSummarySnapshot { + const { address } = supplier; + + return { + id: supplier.id.toString(), + company_id: supplier.companyId.toString(), + status: supplier.isActive ? "active" : "inactive", + reference: maybeToEmptyString(supplier.reference, (value) => value.toString()), + + is_company: String(supplier.isCompany), + name: supplier.name.toString(), + trade_name: maybeToEmptyString(supplier.tradeName, (value) => value.toString()), + tin: maybeToEmptyString(supplier.tin, (value) => value.toString()), + + street: maybeToEmptyString(address.street, (value) => value.toString()), + street2: maybeToEmptyString(address.street2, (value) => value.toString()), + city: maybeToEmptyString(address.city, (value) => value.toString()), + postal_code: maybeToEmptyString(address.postalCode, (value) => value.toString()), + province: maybeToEmptyString(address.province, (value) => value.toString()), + country: maybeToEmptyString(address.country, (value) => value.toString()), + + email_primary: maybeToEmptyString(supplier.emailPrimary, (value) => value.toString()), + email_secondary: maybeToEmptyString(supplier.emailSecondary, (value) => value.toString()), + + phone_primary: maybeToEmptyString(supplier.phonePrimary, (value) => value.toString()), + phone_secondary: maybeToEmptyString(supplier.phoneSecondary, (value) => value.toString()), + + mobile_primary: maybeToEmptyString(supplier.mobilePrimary, (value) => value.toString()), + mobile_secondary: maybeToEmptyString(supplier.mobileSecondary, (value) => value.toString()), + + fax: maybeToEmptyString(supplier.fax, (value) => value.toString()), + website: maybeToEmptyString(supplier.website, (value) => value.toString()), + + language_code: supplier.languageCode.code, + currency_code: supplier.currencyCode.code, + }; + } +} diff --git a/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot.interface.ts b/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot.interface.ts new file mode 100644 index 00000000..9ed24311 --- /dev/null +++ b/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot.interface.ts @@ -0,0 +1,35 @@ +export type ISupplierSummarySnapshot = { + id: string; + company_id: string; + status: string; + reference: string; + + is_company: string; + name: string; + trade_name: string; + tin: string; + + street: string; + street2: string; + city: string; + postal_code: string; + province: string; + country: string; + + email_primary: string; + email_secondary: string; + + phone_primary: string; + phone_secondary: string; + + mobile_primary: string; + mobile_secondary: string; + + fax: string; + website: string; + + language_code: string; + currency_code: string; + + metadata?: Record; +}; diff --git a/modules/supplier/src/api/application/specs/index.ts b/modules/supplier/src/api/application/specs/index.ts new file mode 100644 index 00000000..ced8a334 --- /dev/null +++ b/modules/supplier/src/api/application/specs/index.ts @@ -0,0 +1 @@ +export * from "./supplier-not-exists.spec"; diff --git a/modules/supplier/src/api/application/specs/supplier-not-exists.spec.ts b/modules/supplier/src/api/application/specs/supplier-not-exists.spec.ts new file mode 100644 index 00000000..e7f859db --- /dev/null +++ b/modules/supplier/src/api/application/specs/supplier-not-exists.spec.ts @@ -0,0 +1,35 @@ +import { CompositeSpecification, type UniqueID } from "@repo/rdx-ddd"; + +import type { ISupplierRepository } from "../../application"; + +export class SupplierNotExistsInCompanySpecification extends CompositeSpecification { + constructor( + private readonly repository: ISupplierRepository, + private readonly companyId: UniqueID, + private readonly transaction?: unknown + ) { + super(); + } + + public async isSatisfiedBy(supplierId: UniqueID): Promise { + const existsCheck = await this.repository.existsByIdInCompany( + this.companyId, + supplierId, + this.transaction + ); + + if (existsCheck.isFailure) { + throw existsCheck.error; + } + + const supplierExists = existsCheck.data; + /*logger.debug( + `supplierExists => ${supplierExists}, ${JSON.stringify({ supplierId, companyId: this.companyId }, null, 2)}`, + { + label: "SupplierNotExistsInCompanySpecification", + } + );*/ + + return supplierExists === false; + } +} diff --git a/modules/supplier/src/api/application/use-cases/activate-supplier.use-case.ts b/modules/supplier/src/api/application/use-cases/activate-supplier.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier/src/api/application/use-cases/create-supplier.use-case.ts b/modules/supplier/src/api/application/use-cases/create-supplier.use-case.ts new file mode 100644 index 00000000..f40a5116 --- /dev/null +++ b/modules/supplier/src/api/application/use-cases/create-supplier.use-case.ts @@ -0,0 +1,65 @@ +import type { ITransactionManager } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { CreateSupplierRequestDTO } from "../../../common"; +import type { ISupplierCreator } from "../services"; +import type { + ICreateSupplierInputMapper, + ISupplierFullSnapshotBuilder, +} from "../snapshot-builders"; + +type CreateSupplierUseCaseInput = { + companyId: UniqueID; + dto: CreateSupplierRequestDTO; +}; + +type CreateSupplierUseCaseDeps = { + dtoMapper: ICreateSupplierInputMapper; + creator: ISupplierCreator; + fullSnapshotBuilder: ISupplierFullSnapshotBuilder; + transactionManager: ITransactionManager; +}; + +export class CreateSupplierUseCase { + private readonly dtoMapper: ICreateSupplierInputMapper; + private readonly creator: ISupplierCreator; + private readonly fullSnapshotBuilder: ISupplierFullSnapshotBuilder; + private readonly transactionManager: ITransactionManager; + + constructor(deps: CreateSupplierUseCaseDeps) { + this.dtoMapper = deps.dtoMapper; + this.creator = deps.creator; + this.fullSnapshotBuilder = deps.fullSnapshotBuilder; + this.transactionManager = deps.transactionManager; + } + + public execute(params: CreateSupplierUseCaseInput) { + const { dto, companyId } = params; + + const mappedPropsResult = this.dtoMapper.map(dto, { companyId }); + if (mappedPropsResult.isFailure) { + return mappedPropsResult; + } + + const { props, id } = mappedPropsResult.data; + + return this.transactionManager.complete(async (transaction: unknown) => { + try { + const createResult = await this.creator.create({ companyId, id, props, transaction }); + + if (createResult.isFailure) { + return createResult; + } + + const newSupplier = createResult.data; + + const snapshot = this.fullSnapshotBuilder.toOutput(newSupplier); + + return Result.ok(snapshot); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/supplier/src/api/application/use-cases/deactivate-supplier.use-case.ts b/modules/supplier/src/api/application/use-cases/deactivate-supplier.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier/src/api/application/use-cases/get-supplier.use-case.ts b/modules/supplier/src/api/application/use-cases/get-supplier.use-case.ts new file mode 100644 index 00000000..5c880afa --- /dev/null +++ b/modules/supplier/src/api/application/use-cases/get-supplier.use-case.ts @@ -0,0 +1,50 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { ISupplierFinder } from "../services"; +import type { ISupplierFullSnapshotBuilder } from "../snapshot-builders"; + +type GetSupplierUseCaseInput = { + companyId: UniqueID; + supplier_id: string; +}; + +export class GetSupplierByIdUseCase { + constructor( + private readonly finder: ISupplierFinder, + private readonly fullSnapshotBuilder: ISupplierFullSnapshotBuilder, + private readonly transactionManager: ITransactionManager + ) {} + + public execute(params: GetSupplierUseCaseInput) { + const { supplier_id, companyId } = params; + + const idOrError = UniqueID.create(supplier_id); + if (idOrError.isFailure) { + return Result.fail(idOrError.error); + } + + const supplierId = idOrError.data; + + return this.transactionManager.complete(async (transaction) => { + try { + const supplierResult = await this.finder.findSupplierById( + companyId, + supplierId, + transaction + ); + + if (supplierResult.isFailure) { + return Result.fail(supplierResult.error); + } + + const fullSnapshot = this.fullSnapshotBuilder.toOutput(supplierResult.data); + + return Result.ok(fullSnapshot); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/supplier/src/api/application/use-cases/index.ts b/modules/supplier/src/api/application/use-cases/index.ts new file mode 100644 index 00000000..6486bce2 --- /dev/null +++ b/modules/supplier/src/api/application/use-cases/index.ts @@ -0,0 +1,6 @@ +export * from './activate-supplier.use-case'; +export * from './create-supplier.use-case'; +export * from './deactivate-supplier.use-case'; +export * from './get-supplier.use-case'; +export * from './list-suppliers.use-case'; +export * from './update-supplier.use-case'; diff --git a/modules/supplier/src/api/application/use-cases/list-suppliers.use-case.ts b/modules/supplier/src/api/application/use-cases/list-suppliers.use-case.ts new file mode 100644 index 00000000..3375cbc4 --- /dev/null +++ b/modules/supplier/src/api/application/use-cases/list-suppliers.use-case.ts @@ -0,0 +1,55 @@ +import type { ITransactionManager } from "@erp/core/api"; +import type { Criteria } from "@repo/rdx-criteria/server"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { ISupplierFinder } from "../services"; +import type { ISupplierSummarySnapshotBuilder } from "../snapshot-builders/summary"; + +type ListSuppliersUseCaseInput = { + companyId: UniqueID; + criteria: Criteria; +}; + +export class ListSuppliersUseCase { + constructor( + private readonly finder: ISupplierFinder, + private readonly summarySnapshotBuilder: ISupplierSummarySnapshotBuilder, + private readonly transactionManager: ITransactionManager + ) {} + + public execute(params: ListSuppliersUseCaseInput) { + const { criteria, companyId } = params; + + return this.transactionManager.complete(async (transaction: unknown) => { + try { + const result = await this.finder.findSuppliersByCriteria(companyId, criteria, transaction); + + if (result.isFailure) { + return Result.fail(result.error); + } + + const suppliers = result.data; + const totalSuppliers = suppliers.total(); + + const items = suppliers.map((item) => this.summarySnapshotBuilder.toOutput(item)); + + const snapshot = { + page: criteria.pageNumber, + per_page: criteria.pageSize, + total_pages: Math.ceil(totalSuppliers / criteria.pageSize), + total_items: totalSuppliers, + items: items, + metadata: { + entity: "suppliers", + criteria: criteria.toJSON(), + }, + }; + + return Result.ok(snapshot); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/supplier/src/api/application/use-cases/update-supplier.use-case.ts b/modules/supplier/src/api/application/use-cases/update-supplier.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier/src/api/create-supplier-module.sh b/modules/supplier/src/api/create-supplier-module.sh new file mode 100644 index 00000000..34ace2d2 --- /dev/null +++ b/modules/supplier/src/api/create-supplier-module.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODULE_ROOT="./" + +DIRS=( + "$MODULE_ROOT" + "$MODULE_ROOT/application" + "$MODULE_ROOT/application/di" + "$MODULE_ROOT/application/dtos" + "$MODULE_ROOT/application/mappers" + "$MODULE_ROOT/application/repositories" + "$MODULE_ROOT/application/services" + "$MODULE_ROOT/application/use-cases" + "$MODULE_ROOT/domain" + "$MODULE_ROOT/domain/aggregates" + "$MODULE_ROOT/domain/collections" + "$MODULE_ROOT/domain/errors" + "$MODULE_ROOT/domain/value-objects" + "$MODULE_ROOT/infrastructure" + "$MODULE_ROOT/infrastructure/di" + "$MODULE_ROOT/infrastructure/express" + "$MODULE_ROOT/infrastructure/express/controllers" + "$MODULE_ROOT/infrastructure/persistence" + "$MODULE_ROOT/infrastructure/persistence/sequelize" + "$MODULE_ROOT/infrastructure/persistence/sequelize/mappers" + "$MODULE_ROOT/infrastructure/persistence/sequelize/models" + "$MODULE_ROOT/infrastructure/persistence/sequelize/repositories" +) + +FILES=( + "$MODULE_ROOT/application/repositories/supplier-repository.interface.ts" + + "$MODULE_ROOT/application/services/supplier-creator.service.ts" + "$MODULE_ROOT/application/services/supplier-updater.service.ts" + "$MODULE_ROOT/application/services/supplier-finder.service.ts" + + "$MODULE_ROOT/application/use-cases/create-supplier.use-case.ts" + "$MODULE_ROOT/application/use-cases/update-supplier.use-case.ts" + "$MODULE_ROOT/application/use-cases/get-supplier.use-case.ts" + "$MODULE_ROOT/application/use-cases/list-suppliers.use-case.ts" + "$MODULE_ROOT/application/use-cases/activate-supplier.use-case.ts" + "$MODULE_ROOT/application/use-cases/deactivate-supplier.use-case.ts" + + "$MODULE_ROOT/domain/aggregates/supplier.aggregate.ts" + "$MODULE_ROOT/domain/collections/supplier-taxes.collection.ts" + "$MODULE_ROOT/domain/errors/supplier-domain.errors.ts" + "$MODULE_ROOT/domain/value-objects/supplier-status.value-object.ts" + + "$MODULE_ROOT/infrastructure/express/supplier.routes.ts" + "$MODULE_ROOT/infrastructure/persistence/sequelize/models/supplier-sequelize.model.ts" + "$MODULE_ROOT/infrastructure/persistence/sequelize/mappers/sequelize-supplier-domain.mapper.ts" + "$MODULE_ROOT/infrastructure/persistence/sequelize/mappers/supplier-summary.mapper.ts" + "$MODULE_ROOT/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts" +) + +mkdir -p "${DIRS[@]}" +touch "${FILES[@]}" + +create_index() { + local dir="$1" + local index_file="$dir/index.ts" + + : > "$index_file" + + mapfile -t ts_files < <( + find "$dir" -maxdepth 1 -type f -name "*.ts" ! -name "index.ts" \ + | sort + ) + + mapfile -t subdirs < <( + find "$dir" -maxdepth 1 -mindepth 1 -type d \ + | sort + ) + + for file in "${ts_files[@]}"; do + local base + base="$(basename "$file" .ts)" + echo "export * from './$base';" >> "$index_file" + done + + for subdir in "${subdirs[@]}"; do + local name + name="$(basename "$subdir")" + echo "export * from './$name';" >> "$index_file" + done +} + +for dir in "${DIRS[@]}"; do + create_index "$dir" +done + +echo "Módulo supplier creado correctamente en $MODULE_ROOT" \ No newline at end of file diff --git a/modules/supplier/src/api/domain/aggregates/index.ts b/modules/supplier/src/api/domain/aggregates/index.ts new file mode 100644 index 00000000..de720f10 --- /dev/null +++ b/modules/supplier/src/api/domain/aggregates/index.ts @@ -0,0 +1 @@ +export * from './supplier.aggregate'; diff --git a/modules/supplier/src/api/domain/aggregates/supplier.aggregate.ts b/modules/supplier/src/api/domain/aggregates/supplier.aggregate.ts new file mode 100644 index 00000000..c04ade7b --- /dev/null +++ b/modules/supplier/src/api/domain/aggregates/supplier.aggregate.ts @@ -0,0 +1,250 @@ +import { + AggregateRoot, + type CurrencyCode, + type EmailAddress, + type LanguageCode, + type Name, + type PhoneNumber, + type PostalAddress, + type PostalAddressPatchProps, + type PostalAddressProps, + type TINNumber, + type URLAddress, + type UniqueID, +} from "@repo/rdx-ddd"; +import { type Maybe, Result } from "@repo/rdx-utils"; + +import { SupplierStatus } from "../value-objects/supplier-status.value-object"; + +/** + * Props de creación + */ +export interface ISupplierCreateProps { + companyId: UniqueID; + status?: SupplierStatus; + reference: Maybe; + + isCompany: boolean; + name: Name; + tradeName: Maybe; + tin: Maybe; + + address: PostalAddressProps; + + emailPrimary: Maybe; + emailSecondary: Maybe; + + phonePrimary: Maybe; + phoneSecondary: Maybe; + + mobilePrimary: Maybe; + mobileSecondary: Maybe; + + fax: Maybe; + website: Maybe; + + languageCode: LanguageCode; + currencyCode: CurrencyCode; +} + +export type SupplierPatchProps = Partial< + Omit +> & { + address?: PostalAddressPatchProps; +}; + +// Supplier +export interface ISupplier { + // comportamiento + update(partialSupplier: SupplierPatchProps): Result; + + // propiedades (getters) + readonly isIndividual: boolean; + readonly isCompany: boolean; + readonly isActive: boolean; + + readonly companyId: UniqueID; + + readonly reference: Maybe; + readonly name: Name; + readonly tradeName: Maybe; + readonly tin: Maybe; + + readonly address: PostalAddress; + + readonly emailPrimary: Maybe; + readonly emailSecondary: Maybe; + + readonly phonePrimary: Maybe; + readonly phoneSecondary: Maybe; + readonly mobilePrimary: Maybe; + readonly mobileSecondary: Maybe; + + readonly fax: Maybe; + readonly website: Maybe; + + readonly languageCode: LanguageCode; + readonly currencyCode: CurrencyCode; +} + +type SupplierInternalProps = Omit & { + readonly address: PostalAddress; +}; + +/** + * Aggregate Root: Supplier + */ +export class Supplier extends AggregateRoot implements ISupplier { + static create(props: ISupplierCreateProps, id?: UniqueID): Result { + const validationResult = Supplier.validateCreateProps(props); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + const { address, ...internalProps } = props; + + const postalAddressResult = PostalAddress.create(address); + + if (postalAddressResult.isFailure) { + return Result.fail(postalAddressResult.error); + } + + const contact = new Supplier( + { + ...internalProps, + address: postalAddressResult.data, + }, + id + ); + + // Reglas de negocio / validaciones + // ... + // ... + + // Disparar eventos de dominio + // ... + // ... + + return Result.ok(contact); + } + + private static validateCreateProps(props: ISupplierCreateProps): Result { + return Result.ok(); + } + + // Rehidratación desde persistencia + static rehydrate(props: SupplierInternalProps, id: UniqueID): Supplier { + return new Supplier(props, id); + } + + // ------------------------- + // BEHAVIOUR + // ------------------------- + + public update(partialSupplier: SupplierPatchProps): Result { + const { address: partialAddress, ...rest } = partialSupplier; + + Object.assign(this.props, rest); + + if (partialAddress) { + const addressResult = this.address.update(partialAddress); + + if (addressResult.isFailure) { + return Result.fail(addressResult.error); + } + } + + return Result.ok(); + } + + public activate(): Result { + this.props.status = SupplierStatus.active(); + return Result.ok(this); + } + + public deactivate(): Result { + this.props.status = SupplierStatus.inactive(); + return Result.ok(this); + } + + // ------------------------- + // GETTERS + // ------------------------- + + public get isIndividual(): boolean { + return !this.props.isCompany; + } + + public get isCompany(): boolean { + return this.props.isCompany; + } + + public get isActive(): boolean { + return this.props.status.isActive(); + } + + public get companyId(): UniqueID { + return this.props.companyId; + } + + public get reference(): Maybe { + return this.props.reference; + } + + public get name(): Name { + return this.props.name; + } + + public get tradeName(): Maybe { + return this.props.tradeName; + } + + public get tin(): Maybe { + return this.props.tin; + } + + public get address(): PostalAddress { + return this.props.address; + } + + public get emailPrimary(): Maybe { + return this.props.emailPrimary; + } + + public get emailSecondary(): Maybe { + return this.props.emailSecondary; + } + + public get phonePrimary(): Maybe { + return this.props.phonePrimary; + } + + public get phoneSecondary(): Maybe { + return this.props.phoneSecondary; + } + + public get mobilePrimary(): Maybe { + return this.props.mobilePrimary; + } + + public get mobileSecondary(): Maybe { + return this.props.mobileSecondary; + } + + public get fax(): Maybe { + return this.props.fax; + } + + public get website(): Maybe { + return this.props.website; + } + + public get languageCode(): LanguageCode { + return this.props.languageCode; + } + + public get currencyCode(): CurrencyCode { + return this.props.currencyCode; + } +} diff --git a/modules/supplier/src/api/domain/collections/index.ts b/modules/supplier/src/api/domain/collections/index.ts new file mode 100644 index 00000000..8b464466 --- /dev/null +++ b/modules/supplier/src/api/domain/collections/index.ts @@ -0,0 +1 @@ +export * from './supplier-taxes.collection'; diff --git a/modules/supplier/src/api/domain/collections/supplier-taxes.collection.ts b/modules/supplier/src/api/domain/collections/supplier-taxes.collection.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier/src/api/domain/errors/index.ts b/modules/supplier/src/api/domain/errors/index.ts new file mode 100644 index 00000000..d8e0678c --- /dev/null +++ b/modules/supplier/src/api/domain/errors/index.ts @@ -0,0 +1 @@ +export * from "./supplier-domain.errors"; diff --git a/modules/supplier/src/api/domain/errors/supplier-domain.errors.ts b/modules/supplier/src/api/domain/errors/supplier-domain.errors.ts new file mode 100644 index 00000000..303deca7 --- /dev/null +++ b/modules/supplier/src/api/domain/errors/supplier-domain.errors.ts @@ -0,0 +1,8 @@ +import { DomainError } from "@repo/rdx-ddd"; + +export class SupplierNotFoundError extends DomainError { + public readonly code = "SUPPLIER_ID" as const; +} + +export const isSupplierNotFoundError = (e: unknown): e is SupplierNotFoundError => + e instanceof SupplierNotFoundError; diff --git a/modules/supplier/src/api/domain/index.ts b/modules/supplier/src/api/domain/index.ts new file mode 100644 index 00000000..9dc38f73 --- /dev/null +++ b/modules/supplier/src/api/domain/index.ts @@ -0,0 +1,4 @@ +export * from './aggregates'; +export * from './collections'; +export * from './errors'; +export * from './value-objects'; diff --git a/modules/supplier/src/api/domain/value-objects/index.ts b/modules/supplier/src/api/domain/value-objects/index.ts new file mode 100644 index 00000000..07c48e7a --- /dev/null +++ b/modules/supplier/src/api/domain/value-objects/index.ts @@ -0,0 +1 @@ +export * from './supplier-status.value-object'; diff --git a/modules/supplier/src/api/domain/value-objects/supplier-status.value-object.ts b/modules/supplier/src/api/domain/value-objects/supplier-status.value-object.ts new file mode 100644 index 00000000..8fb3f757 --- /dev/null +++ b/modules/supplier/src/api/domain/value-objects/supplier-status.value-object.ts @@ -0,0 +1,52 @@ +import { ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +/** + * Estados permitidos + */ +export enum SUPPLIER_STATUS { + ACTIVE = "ACTIVE", + INACTIVE = "INACTIVE", +} + +interface ISupplierStatusProps { + value: SUPPLIER_STATUS; +} + +export class SupplierStatus extends ValueObject { + private constructor(props: ISupplierStatusProps) { + super(props); + } + + public static create(value: string): Result { + if (!Object.values(SUPPLIER_STATUS).includes(value as SUPPLIER_STATUS)) { + return Result.fail(new Error(`Invalid supplier status: ${value}`)); + } + + return Result.ok(new SupplierStatus({ value: value as SUPPLIER_STATUS })); + } + + public static active(): SupplierStatus { + return new SupplierStatus({ value: SUPPLIER_STATUS.ACTIVE }); + } + + public static inactive(): SupplierStatus { + return new SupplierStatus({ value: SUPPLIER_STATUS.INACTIVE }); + } + + public isActive(): boolean { + return this.props.value === SUPPLIER_STATUS.ACTIVE; + } + + public isInactive(): boolean { + return this.props.value === SUPPLIER_STATUS.INACTIVE; + } + + public toPrimitive(): string { + return this.props.value; + } + + getProps(): string { + return this.props.value; + } +} diff --git a/modules/supplier/src/api/index.ts b/modules/supplier/src/api/index.ts new file mode 100644 index 00000000..60fb1c3b --- /dev/null +++ b/modules/supplier/src/api/index.ts @@ -0,0 +1,78 @@ +import type { IModuleServer } from "@erp/core/api"; + +import type { ISupplierPublicServices } from "./application"; +import { models, suppliersRouter } from "./infrastructure"; +import { buildSupplierPublicServices, buildSuppliersDependencies } from "./infrastructure/di"; + +export * from "./infrastructure/persistence/sequelize"; + +export const suppliersAPIModule: IModuleServer = { + name: "suppliers", + version: "1.0.0", + dependencies: [], + + /** + * Fase de SETUP + * ---------------- + * - Construye el dominio (una sola vez) + * - Define qué expone el módulo + * - NO conecta infraestructura + */ + async setup(params) { + const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; + + // 1) Dominio interno + const internal = buildSuppliersDependencies(params); + + // 2) Servicios públicos (Application Services) + const suppliersServices: ISupplierPublicServices = buildSupplierPublicServices( + params, + internal + ); + + logger.info("🚀 Suppliers module dependencies registered", { + label: this.name, + }); + + return { + // Modelos Sequelize del módulo + models, + + // Servicios expuestos a otros módulos + services: { + general: suppliersServices, // 'suppliers:general' + }, + + // Implementación privada del módulo + internal, + }; + }, + + /** + * Fase de START + * ------------- + * - Conecta el módulo al runtime + * - Puede usar servicios e internals ya construidos + * - NO construye dominio + */ + async start(params) { + const { app, baseRoutePath, logger, getInternal } = params; + + // Registro de rutas HTTP + suppliersRouter(params); + + logger.info("🚀 Suppliers module started", { + label: this.name, + }); + }, + + /** + * Warmup opcional (si lo necesitas en el futuro) + * ---------------------------------------------- + * warmup(params) { + * ... + * } + */ +}; + +export default suppliersAPIModule; diff --git a/modules/supplier/src/api/infrastructure/di/index.ts b/modules/supplier/src/api/infrastructure/di/index.ts new file mode 100644 index 00000000..93d404a1 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/di/index.ts @@ -0,0 +1,2 @@ +export * from "./supplier-public-services"; +export * from "./suppliers.di"; diff --git a/modules/supplier/src/api/infrastructure/di/supplier-persistence-mappers.di.ts b/modules/supplier/src/api/infrastructure/di/supplier-persistence-mappers.di.ts new file mode 100644 index 00000000..d6f2f544 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/di/supplier-persistence-mappers.di.ts @@ -0,0 +1,30 @@ +import type { ICatalogs } from "@erp/core/api"; + +import { SequelizeSupplierDomainMapper, SequelizeSupplierSummaryMapper } from "../persistence"; + +export interface ISupplierPersistenceMappers { + domainMapper: SequelizeSupplierDomainMapper; + summaryMapper: SequelizeSupplierSummaryMapper; + + //createMapper: CreateSupplierInputMapper; +} + +export const buildSupplierPersistenceMappers = ( + catalogs: ICatalogs +): ISupplierPersistenceMappers => { + const { taxCatalog } = catalogs; + + // Mappers para el repositorio + const domainMapper = new SequelizeSupplierDomainMapper({ taxCatalog }); + const summaryMapper = new SequelizeSupplierSummaryMapper(); + + // Mappers el DTO a las props validadas (SupplierProps) y luego construir agregado + //const createMapper = new CreateSupplierInputMapper(); + + return { + domainMapper, + summaryMapper, + + //createMapper, + }; +}; diff --git a/modules/supplier/src/api/infrastructure/di/supplier-public-services.ts b/modules/supplier/src/api/infrastructure/di/supplier-public-services.ts new file mode 100644 index 00000000..35d5d57e --- /dev/null +++ b/modules/supplier/src/api/infrastructure/di/supplier-public-services.ts @@ -0,0 +1,65 @@ +import { type SetupParams, buildCatalogs } from "@erp/core/api"; +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { + type ISupplierPublicServices, + type ISupplierServicesContext, + buildSupplierCreator, + buildSupplierFinder, +} from "../../application"; +import type { ISupplierCreateProps } from "../../domain"; + +import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di"; +import { buildSupplierRepository } from "./supplier-repositories.di"; +import type { SuppliersInternalDeps } from "./suppliers.di"; + +export function buildSupplierPublicServices( + params: SetupParams, + deps: SuppliersInternalDeps +): ISupplierPublicServices { + const { database } = params; + const catalogs = buildCatalogs(); + + // Infrastructure + const persistenceMappers = buildSupplierPersistenceMappers(catalogs); + const repository = buildSupplierRepository({ database, mappers: persistenceMappers }); + + const finder = buildSupplierFinder({ repository }); + const creator = buildSupplierCreator({ repository }); + + return { + findSupplierByTIN: async (tin: TINNumber, context: ISupplierServicesContext) => { + const { companyId, transaction } = context; + + const supplierResult = await finder.findSupplierByTIN(companyId, tin, transaction); + + if (supplierResult.isFailure) { + return Result.fail(supplierResult.error); + } + + return Result.ok(supplierResult.data); + }, + + createSupplier: async ( + id: UniqueID, + props: ISupplierCreateProps, + context: ISupplierServicesContext + ) => { + const { companyId, transaction } = context; + + const supplierResult = await creator.create({ + companyId, + id, + props, + transaction, + }); + + if (supplierResult.isFailure) { + return Result.fail(supplierResult.error); + } + + return Result.ok(supplierResult.data); + }, + }; +} diff --git a/modules/supplier/src/api/infrastructure/di/supplier-repositories.di.ts b/modules/supplier/src/api/infrastructure/di/supplier-repositories.di.ts new file mode 100644 index 00000000..56904dc4 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/di/supplier-repositories.di.ts @@ -0,0 +1,14 @@ +import type { Sequelize } from "sequelize"; + +import { SupplierRepository } from "../persistence"; + +import type { ISupplierPersistenceMappers } from "./supplier-persistence-mappers.di"; + +export const buildSupplierRepository = (params: { + database: Sequelize; + mappers: ISupplierPersistenceMappers; +}) => { + const { database, mappers } = params; + + return new SupplierRepository(mappers.domainMapper, mappers.summaryMapper, database); +}; diff --git a/modules/supplier/src/api/infrastructure/di/suppliers.di.ts b/modules/supplier/src/api/infrastructure/di/suppliers.di.ts new file mode 100644 index 00000000..8e8ad2a9 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/di/suppliers.di.ts @@ -0,0 +1,88 @@ +import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api"; + +import { + type CreateSupplierUseCase, + type GetSupplierByIdUseCase, + type ListSuppliersUseCase, + buildCreateSupplierUseCase, + buildGetSupplierByIdUseCase, + buildListSuppliersUseCase, + buildSupplierCreator, + buildSupplierFinder, + buildSupplierInputMappers, + buildSupplierSnapshotBuilders, + buildSupplierUpdater, +} from "../../application"; + +import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di"; +import { buildSupplierRepository } from "./supplier-repositories.di"; + +export type SuppliersInternalDeps = { + useCases: { + listSuppliers: () => ListSuppliersUseCase; + getSupplierById: () => GetSupplierByIdUseCase; + createSupplier: () => CreateSupplierUseCase; + //updateSupplier: () => UpdateSupplierUseCase; + + /* + + deleteSupplier: () => DeleteSupplierUseCase; + changeStatusSupplier: () => ChangeStatusSupplierUseCase;*/ + }; +}; + +export function buildSuppliersDependencies(params: ModuleParams): SuppliersInternalDeps { + const { database } = params; + + // Infrastructure + const transactionManager = buildTransactionManager(database); + const catalogs = buildCatalogs(); + const persistenceMappers = buildSupplierPersistenceMappers(catalogs); + + const repository = buildSupplierRepository({ database, mappers: persistenceMappers }); + //const numberService = buildSupplierNumberGenerator(); + + // Application helpers + const inputMappers = buildSupplierInputMappers(catalogs); + const finder = buildSupplierFinder({ repository }); + const creator = buildSupplierCreator({ repository }); + const updater = buildSupplierUpdater({ repository }); + + const snapshotBuilders = buildSupplierSnapshotBuilders(); + //const documentGeneratorPipeline = buildSupplierDocumentService(params); + + // Internal use cases (factories) + return { + useCases: { + listSuppliers: () => + buildListSuppliersUseCase({ + finder, + summarySnapshotBuilder: snapshotBuilders.summary, + transactionManager, + }), + + getSupplierById: () => + buildGetSupplierByIdUseCase({ + finder, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + }), + + createSupplier: () => + buildCreateSupplierUseCase({ + creator, + dtoMapper: inputMappers.createInputMapper, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + }), + + /*updateSupplier: () => + buildUpdateSupplierUseCase({ + updater, + dtoMapper: inputMappers.updateInputMapper, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + }),*/ + }, + }; +} diff --git a/modules/supplier/src/api/infrastructure/express/controllers/create-supplier.controller.ts b/modules/supplier/src/api/infrastructure/express/controllers/create-supplier.controller.ts new file mode 100644 index 00000000..0aa05d80 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/controllers/create-supplier.controller.ts @@ -0,0 +1,39 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { CreateSupplierRequestDTO } from "../../../../common/dto"; +import type { CreateSupplierUseCase } from "../../../application"; +import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; + +export class CreateSupplierController extends ExpressController { + public constructor(private readonly useCase: CreateSupplierUseCase) { + super(); + this.errorMapper = suppliersApiErrorMapper; + + // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + const dto = this.req.body as CreateSupplierRequestDTO; + + const result = await this.useCase.execute({ dto, companyId }); + + return result.match( + (data) => this.created(data), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/supplier/src/api/infrastructure/express/controllers/delete-supplier.controller.ts b/modules/supplier/src/api/infrastructure/express/controllers/delete-supplier.controller.ts new file mode 100644 index 00000000..8448004d --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/controllers/delete-supplier.controller.ts @@ -0,0 +1,38 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { DeleteSupplierUseCase } from "../../../application"; +import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; + +export class DeleteSupplierController extends ExpressController { + public constructor(private readonly useCase: DeleteSupplierUseCase) { + super(); + this.errorMapper = suppliersApiErrorMapper; + + // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + async executeImpl(): Promise { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + const { supplier_id } = this.req.params; + + const result = await this.useCase.execute({ supplier_id, companyId }); + + return result.match( + (data) => this.ok(data), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/supplier/src/api/infrastructure/express/controllers/get-supplier.controller.ts b/modules/supplier/src/api/infrastructure/express/controllers/get-supplier.controller.ts new file mode 100644 index 00000000..af6862e4 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/controllers/get-supplier.controller.ts @@ -0,0 +1,38 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { GetSupplierUseCase } from "../../../application"; +import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; + +export class GetSupplierController extends ExpressController { + public constructor(private readonly useCase: GetSupplierUseCase) { + super(); + this.errorMapper = suppliersApiErrorMapper; + + // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + const { supplier_id } = this.req.params; + + const result = await this.useCase.execute({ supplier_id, companyId }); + + return result.match( + (data) => this.ok(data), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/supplier/src/api/infrastructure/express/controllers/index.ts b/modules/supplier/src/api/infrastructure/express/controllers/index.ts new file mode 100644 index 00000000..760c9201 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/controllers/index.ts @@ -0,0 +1,5 @@ +export * from "./create-supplier.controller"; +export * from "./delete-supplier.controller"; +export * from "./get-supplier.controller"; +export * from "./list-suppliers.controller"; +export * from "./update-supplier.controller"; diff --git a/modules/supplier/src/api/infrastructure/express/controllers/list-suppliers.controller.ts b/modules/supplier/src/api/infrastructure/express/controllers/list-suppliers.controller.ts new file mode 100644 index 00000000..5abe6a6c --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/controllers/list-suppliers.controller.ts @@ -0,0 +1,54 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; +import { Criteria } from "@repo/rdx-criteria/server"; + +import type { ListSuppliersUseCase } from "../../../application"; +import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; + +export class ListSuppliersController extends ExpressController { + public constructor(private readonly listSuppliers: ListSuppliersUseCase) { + super(); + this.errorMapper = suppliersApiErrorMapper; + + // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + private getCriteriaWithDefaultOrder() { + if (this.criteria.hasOrder()) { + return this.criteria; + } + + const { q: quicksearch, filters, pageSize, pageNumber } = this.criteria.toPrimitives(); + return Criteria.fromPrimitives(filters, "name", "ASC", pageSize, pageNumber, quicksearch); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const criteria = this.getCriteriaWithDefaultOrder(); + const result = await this.listSuppliers.execute({ criteria, companyId }); + + return result.match( + (data) => + this.ok(data, { + "X-Total-Count": String(data.total_items), + "Pagination-Count": String(data.total_pages), + "Pagination-Page": String(data.page), + "Pagination-Limit": String(data.per_page), + }), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/supplier/src/api/infrastructure/express/controllers/update-supplier.controller.ts b/modules/supplier/src/api/infrastructure/express/controllers/update-supplier.controller.ts new file mode 100644 index 00000000..81c1e4f9 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/controllers/update-supplier.controller.ts @@ -0,0 +1,40 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { UpdateSupplierByIdRequestDTO } from "../../../../common/dto"; +import type { UpdateSupplierUseCase } from "../../../application"; +import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; + +export class UpdateSupplierController extends ExpressController { + public constructor(private readonly useCase: UpdateSupplierUseCase) { + super(); + this.errorMapper = suppliersApiErrorMapper; + + // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + const { supplier_id } = this.req.params; + const dto = this.req.body as UpdateSupplierByIdRequestDTO; + + const result = await this.useCase.execute({ supplier_id, companyId, dto }); + + return result.match( + (data) => this.ok(data), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/supplier/src/api/infrastructure/express/index.ts b/modules/supplier/src/api/infrastructure/express/index.ts new file mode 100644 index 00000000..8955e695 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/index.ts @@ -0,0 +1 @@ +export * from "./suppliers.routes"; diff --git a/modules/supplier/src/api/infrastructure/express/supplier-api-error-mapper.ts b/modules/supplier/src/api/infrastructure/express/supplier-api-error-mapper.ts new file mode 100644 index 00000000..90ec7dfb --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/supplier-api-error-mapper.ts @@ -0,0 +1,17 @@ +import { ApiErrorMapper, type ErrorToApiRule, NotFoundApiError } from "@erp/core/api"; + +import { type SupplierNotFoundError, isSupplierNotFoundError } from "../../domain"; + +// Crea una regla específica (prioridad alta para sobreescribir mensajes) +const supplierNotFoundRule: ErrorToApiRule = { + priority: 120, + matches: (e) => isSupplierNotFoundError(e), + build: (e) => + new NotFoundApiError( + (e as SupplierNotFoundError).message || "Supplier with the provided id not exists." + ), +}; + +// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra +export const suppliersApiErrorMapper: ApiErrorMapper = + ApiErrorMapper.default().register(supplierNotFoundRule); diff --git a/modules/supplier/src/api/infrastructure/express/suppliers.routes.ts b/modules/supplier/src/api/infrastructure/express/suppliers.routes.ts new file mode 100644 index 00000000..a93fac4b --- /dev/null +++ b/modules/supplier/src/api/infrastructure/express/suppliers.routes.ts @@ -0,0 +1,112 @@ +import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api"; +import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api"; +import { type NextFunction, type Request, type Response, Router } from "express"; + +import { + CreateSupplierRequestSchema, + GetSupplierByIdRequestSchema, + SupplierListRequestSchema, + UpdateSupplierByIdParamsRequestSchema, + UpdateSupplierByIdRequestSchema, +} from "../../../common"; +import type { SuppliersInternalDeps } from "../di"; + +import { + CreateSupplierController, + GetSupplierController, + ListSuppliersController, + UpdateSupplierController, +} from "./controllers"; + +export const suppliersRouter = (params: StartParams) => { + const { app, config, getInternal } = params; + + // Recuperamos el dominio interno del módulo + const deps = getInternal("suppliers"); + + const router: Router = Router({ mergeParams: true }); + + // ---------------------------------------------- + + // 🔐 Autenticación + Tenancy para TODO el router + if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") { + router.use( + (req: Request, res: Response, next: NextFunction) => + mockUser(req as RequestWithAuth, res, next) // Debe ir antes de las rutas protegidas + ); + } + + //router.use(/*authenticateJWT(),*/ enforceTenant() /*checkTabContext*/); + router.use([ + (req: Request, res: Response, next: NextFunction) => + requireAuthenticated()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas + + (req: Request, res: Response, next: NextFunction) => + requireCompanyContext()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas + ]); + + // ---------------------------------------------- + + router.get( + "/", + //checkTabContext, + + validateRequest(SupplierListRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.listSuppliers(); + const controller = new ListSuppliersController(useCase /*, deps.presenters.list */); + return controller.execute(req, res, next); + } + ); + + router.get( + "/:supplier_id", + //checkTabContext, + + validateRequest(GetSupplierByIdRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.getSupplierById(); + const controller = new GetSupplierController(useCase); + return controller.execute(req, res, next); + } + ); + + router.post( + "/", + //checkTabContext, + + validateRequest(CreateSupplierRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.createSupplier(); + const controller = new CreateSupplierController(useCase); + return controller.execute(req, res, next); + } + ); + + router.put( + "/:supplier_id", + //checkTabContext, + + validateRequest(UpdateSupplierByIdParamsRequestSchema, "params"), + validateRequest(UpdateSupplierByIdRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.updateSupplier(); + const controller = new UpdateSupplierController(useCase); + return controller.execute(req, res, next); + } + ); + + /*router.delete( + "/:supplier_id", + //checkTabContext, + + validateRequest(DeleteSupplierByIdRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.build.delete(); + const controller = new DeleteSupplierController(useCase); + return controller.execute(req, res, next); + } + );*/ + + app.use(`${config.server.apiBasePath}/suppliers`, router); +}; diff --git a/modules/supplier/src/api/infrastructure/index.ts b/modules/supplier/src/api/infrastructure/index.ts new file mode 100644 index 00000000..3e34d105 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/index.ts @@ -0,0 +1,3 @@ +export * from "./di"; +export * from "./express"; +export * from "./persistence"; diff --git a/modules/supplier/src/api/infrastructure/persistence/index.ts b/modules/supplier/src/api/infrastructure/persistence/index.ts new file mode 100644 index 00000000..9c2c79b5 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/index.ts @@ -0,0 +1 @@ +export * from './sequelize'; diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/index.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/index.ts new file mode 100644 index 00000000..df42019b --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/index.ts @@ -0,0 +1,8 @@ +import supplierModelInit from "./models/sequelize-supplier.model"; + +export * from "./mappers"; +export * from "./models"; +export * from "./repositories"; + +// Array de inicializadores para que registerModels() lo use +export const models = [supplierModelInit]; diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts new file mode 100644 index 00000000..99253278 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-supplier.mapper"; diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts new file mode 100644 index 00000000..425dd242 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts @@ -0,0 +1,265 @@ +import type { TaxCatalogProvider } from "@erp/core"; +import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; +import { + City, + Country, + CurrencyCode, + EmailAddress, + LanguageCode, + Name, + PhoneNumber, + PostalAddress, + PostalCode, + Province, + Street, + TINNumber, + URLAddress, + UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, + maybeToNullable, +} from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { type ISupplierCreateProps, Supplier, SupplierStatus } from "../../../../../domain"; +import type { SupplierCreationAttributes, SupplierModel } from "../../../sequelize"; + +export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< + SupplierModel, + SupplierCreationAttributes, + Supplier +> { + private readonly taxCatalog: TaxCatalogProvider; + + constructor(params: { taxCatalog: TaxCatalogProvider }) { + super(); + this.taxCatalog = params.taxCatalog; + } + + public mapToDomain(source: SupplierModel, params?: MapperParamsType): Result { + try { + const errors: ValidationErrorDetail[] = []; + + const supplierId = extractOrPushError(UniqueID.create(source.id), "id", errors); + const companyId = extractOrPushError( + UniqueID.create(source.company_id), + "company_id", + errors + ); + + const isCompany = source.is_company; + const status = extractOrPushError(SupplierStatus.create(source.status), "status", errors); + const reference = extractOrPushError( + maybeFromNullableResult(source.reference, (value) => Name.create(value)), + "reference", + errors + ); + + const name = extractOrPushError(Name.create(source.name), "name", errors); + + const tradeName = extractOrPushError( + maybeFromNullableResult(source.trade_name, (value) => Name.create(value)), + "trade_name", + errors + ); + + const tinNumber = extractOrPushError( + maybeFromNullableResult(source.tin, (value) => TINNumber.create(value)), + "tin", + errors + ); + + const street = extractOrPushError( + maybeFromNullableResult(source.street, (value) => Street.create(value)), + "street", + errors + ); + + const street2 = extractOrPushError( + maybeFromNullableResult(source.street2, (value) => Street.create(value)), + "street2", + errors + ); + + const city = extractOrPushError( + maybeFromNullableResult(source.city, (value) => City.create(value)), + "city", + errors + ); + + const province = extractOrPushError( + maybeFromNullableResult(source.province, (value) => Province.create(value)), + "province", + errors + ); + + const postalCode = extractOrPushError( + maybeFromNullableResult(source.postal_code, (value) => PostalCode.create(value)), + "postal_code", + errors + ); + + const country = extractOrPushError( + maybeFromNullableResult(source.country, (value) => Country.create(value)), + "country", + errors + ); + + const emailPrimaryAddress = extractOrPushError( + maybeFromNullableResult(source.email_primary, (value) => EmailAddress.create(value)), + "email_primary", + errors + ); + + const emailSecondaryAddress = extractOrPushError( + maybeFromNullableResult(source.email_secondary, (value) => EmailAddress.create(value)), + "email_secondary", + errors + ); + + const phonePrimaryNumber = extractOrPushError( + maybeFromNullableResult(source.phone_primary, (value) => PhoneNumber.create(value)), + "phone_primary", + errors + ); + + const phoneSecondaryNumber = extractOrPushError( + maybeFromNullableResult(source.phone_secondary, (value) => PhoneNumber.create(value)), + "phone_secondary", + errors + ); + + const mobilePrimaryNumber = extractOrPushError( + maybeFromNullableResult(source.mobile_primary, (value) => PhoneNumber.create(value)), + "mobile_primary", + errors + ); + + const mobileSecondaryNumber = extractOrPushError( + maybeFromNullableResult(source.mobile_secondary, (value) => PhoneNumber.create(value)), + "mobile_secondary", + errors + ); + + const faxNumber = extractOrPushError( + maybeFromNullableResult(source.fax, (value) => PhoneNumber.create(value)), + "fax", + errors + ); + + const website = extractOrPushError( + maybeFromNullableResult(source.website, (value) => URLAddress.create(value)), + "website", + errors + ); + + const languageCode = extractOrPushError( + LanguageCode.create(source.language_code), + "language_code", + errors + ); + + const currencyCode = extractOrPushError( + CurrencyCode.create(source.currency_code), + "currency_code", + errors + ); + + // Now, create the PostalAddress VO + const postalAddressProps = { + street: street!, + street2: street2!, + city: city!, + postalCode: postalCode!, + province: province!, + country: country!, + }; + + const postalAddress = extractOrPushError( + PostalAddress.create(postalAddressProps), + "address", + errors + ); + + // Si hubo errores de mapeo, devolvemos colección de validación + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection("Supplier props mapping failed", errors)); + } + + const supplierProps: ISupplierCreateProps = { + companyId: companyId!, + status: status!, + reference: reference!, + + isCompany: isCompany, + name: name!, + tradeName: tradeName!, + tin: tinNumber!, + + address: postalAddress!, + + emailPrimary: emailPrimaryAddress!, + emailSecondary: emailSecondaryAddress!, + phonePrimary: phonePrimaryNumber!, + phoneSecondary: phoneSecondaryNumber!, + mobilePrimary: mobilePrimaryNumber!, + mobileSecondary: mobileSecondaryNumber!, + fax: faxNumber!, + website: website!, + + languageCode: languageCode!, + currencyCode: currencyCode!, + }; + + return Supplier.create(supplierProps, supplierId); + } catch (err: unknown) { + return Result.fail(err as Error); + } + } + + public mapToPersistence( + source: Supplier, + params?: MapperParamsType + ): Result { + const supplierValues: Partial = { + id: source.id.toPrimitive(), + company_id: source.companyId.toPrimitive(), + + reference: maybeToNullable(source.reference, (reference) => reference.toPrimitive()), + is_company: source.isCompany, + name: source.name.toPrimitive(), + trade_name: maybeToNullable(source.tradeName, (tradeName) => tradeName.toPrimitive()), + tin: maybeToNullable(source.tin, (tin) => tin.toPrimitive()), + + email_primary: maybeToNullable(source.emailPrimary, (email) => email.toPrimitive()), + email_secondary: maybeToNullable(source.emailSecondary, (email) => email.toPrimitive()), + phone_primary: maybeToNullable(source.phonePrimary, (phone) => phone.toPrimitive()), + phone_secondary: maybeToNullable(source.phoneSecondary, (phone) => phone.toPrimitive()), + mobile_primary: maybeToNullable(source.mobilePrimary, (mobile) => mobile.toPrimitive()), + mobile_secondary: maybeToNullable(source.mobileSecondary, (mobile) => mobile.toPrimitive()), + fax: maybeToNullable(source.fax, (fax) => fax.toPrimitive()), + website: maybeToNullable(source.website, (website) => website.toPrimitive()), + + status: source.isActive ? "active" : "inactive", + language_code: source.languageCode.toPrimitive(), + currency_code: source.currencyCode.toPrimitive(), + }; + + if (source.address) { + Object.assign(supplierValues, { + street: maybeToNullable(source.address.street, (street) => street.toPrimitive()), + street2: maybeToNullable(source.address.street2, (street2) => street2.toPrimitive()), + city: maybeToNullable(source.address.city, (city) => city.toPrimitive()), + province: maybeToNullable(source.address.province, (province) => province.toPrimitive()), + postal_code: maybeToNullable(source.address.postalCode, (postalCode) => + postalCode.toPrimitive() + ), + country: maybeToNullable(source.address.country, (country) => country.toPrimitive()), + }); + } + + return Result.ok(supplierValues as SupplierCreationAttributes); + } +} diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/index.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/index.ts new file mode 100644 index 00000000..b7726c46 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/index.ts @@ -0,0 +1,2 @@ +export * from "./domain"; +export * from "./summary"; diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/index.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/index.ts new file mode 100644 index 00000000..1fef7d8a --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-supplier-summary.mapper"; diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts new file mode 100644 index 00000000..3e73c38a --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts @@ -0,0 +1,209 @@ +import { type MapperParamsType, SequelizeQueryMapper } from "@erp/core/api"; +import { + City, + Country, + CurrencyCode, + EmailAddress, + LanguageCode, + Name, + PhoneNumber, + PostalAddress, + PostalCode, + Province, + Street, + TINNumber, + URLAddress, + UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, +} from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { SupplierSummary } from "../../../../../application"; +import { SupplierStatus } from "../../../../../domain"; +import type { SupplierModel } from "../../models"; + +export class SequelizeSupplierSummaryMapper extends SequelizeQueryMapper< + SupplierModel, + SupplierSummary +> { + public mapToReadModel( + raw: SupplierModel, + params?: MapperParamsType + ): Result { + const errors: ValidationErrorDetail[] = []; + + // 1) Valores escalares (atributos generales) + const supplierId = extractOrPushError(UniqueID.create(raw.id), "id", errors); + const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); + + const isCompany = raw.is_company; + const status = extractOrPushError(SupplierStatus.create(raw.status), "status", errors); + const reference = extractOrPushError( + maybeFromNullableResult(raw.reference, (value) => Name.create(value)), + "reference", + errors + ); + + const name = extractOrPushError(Name.create(raw.name), "name", errors); + + const tradeName = extractOrPushError( + maybeFromNullableResult(raw.trade_name, (value) => Name.create(value)), + "trade_name", + errors + ); + + const tinNumber = extractOrPushError( + maybeFromNullableResult(raw.tin, (value) => TINNumber.create(value)), + "tin", + errors + ); + + const street = extractOrPushError( + maybeFromNullableResult(raw.street, (value) => Street.create(value)), + "street", + errors + ); + + const street2 = extractOrPushError( + maybeFromNullableResult(raw.street2, (value) => Street.create(value)), + "street2", + errors + ); + + const city = extractOrPushError( + maybeFromNullableResult(raw.city, (value) => City.create(value)), + "city", + errors + ); + + const province = extractOrPushError( + maybeFromNullableResult(raw.province, (value) => Province.create(value)), + "province", + errors + ); + + const postalCode = extractOrPushError( + maybeFromNullableResult(raw.postal_code, (value) => PostalCode.create(value)), + "postal_code", + errors + ); + + const country = extractOrPushError( + maybeFromNullableResult(raw.country, (value) => Country.create(value)), + "country", + errors + ); + + const emailPrimaryAddress = extractOrPushError( + maybeFromNullableResult(raw.email_primary, (value) => EmailAddress.create(value)), + "email_primary", + errors + ); + + const emailSecondaryAddress = extractOrPushError( + maybeFromNullableResult(raw.email_secondary, (value) => EmailAddress.create(value)), + "email_secondary", + errors + ); + + const phonePrimaryNumber = extractOrPushError( + maybeFromNullableResult(raw.phone_primary, (value) => PhoneNumber.create(value)), + "phone_primary", + errors + ); + + const phoneSecondaryNumber = extractOrPushError( + maybeFromNullableResult(raw.phone_secondary, (value) => PhoneNumber.create(value)), + "phone_secondary", + errors + ); + + const mobilePrimaryNumber = extractOrPushError( + maybeFromNullableResult(raw.mobile_primary, (value) => PhoneNumber.create(value)), + "mobile_primary", + errors + ); + + const mobileSecondaryNumber = extractOrPushError( + maybeFromNullableResult(raw.mobile_secondary, (value) => PhoneNumber.create(value)), + "mobile_secondary", + errors + ); + + const faxNumber = extractOrPushError( + maybeFromNullableResult(raw.fax, (value) => PhoneNumber.create(value)), + "fax", + errors + ); + + const website = extractOrPushError( + maybeFromNullableResult(raw.website, (value) => URLAddress.create(value)), + "website", + errors + ); + + const languageCode = extractOrPushError( + LanguageCode.create(raw.language_code), + "language_code", + errors + ); + + const currencyCode = extractOrPushError( + CurrencyCode.create(raw.currency_code), + "currency_code", + errors + ); + + // Valores compuestos (objetos de valor / agregados) + const postalAddressProps = { + street: street!, + street2: street2!, + city: city!, + postalCode: postalCode!, + province: province!, + country: country!, + }; + + const postalAddress = extractOrPushError( + PostalAddress.create(postalAddressProps), + "address", + errors + ); + + // Si hubo errores de mapeo, devolvemos colección de validación + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Supplier mapping failed [mapToDTO]", errors) + ); + } + + return Result.ok({ + id: supplierId!, + companyId: companyId!, + isActive: status!.isActive(), + reference: reference!, + + isCompany: isCompany, + name: name!, + tradeName: tradeName!, + tin: tinNumber!, + + address: postalAddress!, + + emailPrimary: emailPrimaryAddress!, + emailSecondary: emailSecondaryAddress!, + phonePrimary: phonePrimaryNumber!, + phoneSecondary: phoneSecondaryNumber!, + mobilePrimary: mobilePrimaryNumber!, + mobileSecondary: mobileSecondaryNumber!, + fax: faxNumber!, + website: website!, + + languageCode: languageCode!, + currencyCode: currencyCode!, + }); + } +} diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/models/index.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/models/index.ts new file mode 100644 index 00000000..71b75209 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/models/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-supplier.model"; diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts new file mode 100644 index 00000000..f1133b19 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts @@ -0,0 +1,236 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type Sequelize, +} from "sequelize"; + +export type SupplierCreationAttributes = InferCreationAttributes & {}; + +export class SupplierModel extends Model< + InferAttributes, + InferCreationAttributes +> { + // To avoid table creation + /*static async sync(): Promise { + return Promise.resolve(); + }*/ + + declare id: string; + declare company_id: string; + declare reference: CreationOptional; + + declare is_company: boolean; + declare name: string; + declare trade_name: CreationOptional; + declare tin: CreationOptional; + + declare street: CreationOptional; + declare street2: CreationOptional; + declare city: CreationOptional; + declare province: CreationOptional; + declare postal_code: CreationOptional; + declare country: CreationOptional; + + // Correos electrónicos + declare email_primary: CreationOptional; + declare email_secondary: CreationOptional; + + // Teléfonos fijos + declare phone_primary: CreationOptional; + declare phone_secondary: CreationOptional; + + // Móviles + declare mobile_primary: CreationOptional; + declare mobile_secondary: CreationOptional; + + declare fax: CreationOptional; + declare website: CreationOptional; + + declare status: string; + declare language_code: CreationOptional; + declare currency_code: CreationOptional; + + static associate(_database: Sequelize) {} + + static hooks(_database: Sequelize) {} +} + +export default (database: Sequelize) => { + SupplierModel.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + company_id: { + type: DataTypes.UUID, + allowNull: false, + }, + reference: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + is_company: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + trade_name: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + tin: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + + street: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + street2: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + city: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + province: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + postal_code: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + country: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + + email_primary: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + validate: { + isEmail: true, + }, + }, + email_secondary: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + validate: { + isEmail: true, + }, + }, + + phone_primary: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + phone_secondary: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + + mobile_primary: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + mobile_secondary: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + + fax: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + website: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + validate: { + isUrl: true, + }, + }, + + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "active", + }, + + language_code: { + type: DataTypes.STRING(2), + allowNull: false, + defaultValue: "es", + }, + + currency_code: { + type: new DataTypes.STRING(3), + allowNull: false, + defaultValue: "EUR", + }, + }, + { + sequelize: database, + modelName: "SupplierModel", + tableName: "suppliers", + + underscored: true, + paranoid: true, // softs deletes + timestamps: true, + + createdAt: "created_at", + updatedAt: "updated_at", + deletedAt: "deleted_at", + + indexes: [ + { + name: "idx_suppliers_company_name", + fields: ["company_id", "deleted_at", "name"], + }, + { name: "idx_name", fields: ["name"] }, // <- para ordenación + { name: "idx_tin", fields: ["tin"] }, // <- para servicios externos + { name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get + + { + name: "ft_supplier", + type: "FULLTEXT", + fields: ["name", "trade_name", "reference", "tin", "email_primary", "mobile_primary"], + }, + ], + + whereMergeStrategy: "and", // <- cómo tratar el merge de un scope + + defaultScope: {}, + + scopes: {}, + } + ); + return SupplierModel; +}; diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/index.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/index.ts new file mode 100644 index 00000000..ce8c3b81 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/index.ts @@ -0,0 +1 @@ +export * from './sequelize-supplier.repository'; diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts b/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts new file mode 100644 index 00000000..c0fa8449 --- /dev/null +++ b/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts @@ -0,0 +1,334 @@ +import { + EntityNotFoundError, + InfrastructureRepositoryError, + SequelizeRepository, + translateSequelizeError, +} from "@erp/core/api"; +import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; +import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; +import { type Collection, Result } from "@repo/rdx-utils"; +import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize"; + +import type { ISupplierRepository, SupplierSummary } from "../../../../application"; +import type { Supplier } from "../../../../domain"; +import type { SequelizeSupplierDomainMapper, SequelizeSupplierSummaryMapper } from "../../mappers"; +import { SupplierModel } from "../models"; + +export class SupplierRepository + extends SequelizeRepository + implements ISupplierRepository +{ + constructor( + private readonly domainMapper: SequelizeSupplierDomainMapper, + private readonly summaryMapper: SequelizeSupplierSummaryMapper, + database: Sequelize + ) { + super({ database }); + } + + /** + * + * Crea un nuevo proveedor + * + * @param supplier - El proveedor nuevo a guardar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async create(supplier: Supplier, transaction?: Transaction): Promise> { + try { + const dtoResult = this.domainMapper.mapToPersistence(supplier); + + if (dtoResult.isFailure) { + return Result.fail(dtoResult.error); + } + + const dto = dtoResult.data; + + await SupplierModel.create(dto, { + include: [{ all: true }], + transaction, + }); + + return Result.ok(); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + + /** + * Actualiza un proveedor existente. + * + * @param supplier - El proveedor a actualizar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async update(supplier: Supplier, transaction?: Transaction): Promise> { + try { + const dtoResult = this.domainMapper.mapToPersistence(supplier); + + const { id, ...updatePayload } = dtoResult.data; + const [affected] = await SupplierModel.update(updatePayload, { + where: { id /*, version */ }, + //fields: Object.keys(updatePayload), + transaction, + individualHooks: true, + }); + + if (affected === 0) { + return Result.fail( + new InfrastructureRepositoryError("Concurrency conflict or not found update supplier") + ); + } + + return Result.ok(); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + /** + * Comprueba si existe un Supplier con un `id` dentro de una `company`. + * + * @param companyId - Identificador UUID de la empresa a la que pertenece el proveedor. + * @param id - Identificador UUID del proveedor. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async existsByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: Transaction + ): Promise> { + try { + const count = await SupplierModel.count({ + where: { id: id.toString(), company_id: companyId.toString() }, + transaction, + }); + return Result.ok(Boolean(count > 0)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Recupera un proveedor por su ID y companyId. + * + * @param companyId - Identificador UUID de la empresa a la que pertenece el proveedor. + * @param id - Identificador UUID del proveedor. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async getByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + // Normalización defensiva de order/include + const normalizedOrder = Array.isArray(options.order) + ? options.order + : options.order + ? [options.order] + : []; + + const normalizedInclude = Array.isArray(options.include) + ? options.include + : options.include + ? [options.include] + : []; + + const mergedOptions: FindOptions> = { + ...options, + where: { + ...(options.where ?? {}), + id: id.toString(), + company_id: companyId.toString(), + }, + order: normalizedOrder, + include: normalizedInclude, + transaction, + }; + + const row = await SupplierModel.findOne(mergedOptions); + + if (!row) { + return Result.fail(new EntityNotFoundError("Supplier", "id", id.toString())); + } + + const supplier = this.domainMapper.mapToDomain(row); + return supplier; + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Recupera un proveedor por su ID y companyId. + * + * @param companyId - Identificador UUID de la empresa a la que pertenece el proveedor. + * @param tin - TIN del proveedor. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async getByTINInCompany( + companyId: UniqueID, + tin: TINNumber, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + // Normalización defensiva de order/include + const normalizedOrder = Array.isArray(options.order) + ? options.order + : options.order + ? [options.order] + : []; + + const normalizedInclude = Array.isArray(options.include) + ? options.include + : options.include + ? [options.include] + : []; + + const mergedOptions: FindOptions> = { + ...options, + where: { + ...(options.where ?? {}), + tin: tin.toString(), + company_id: companyId.toString(), + }, + order: normalizedOrder, + include: normalizedInclude, + transaction, + }; + + const row = await SupplierModel.findOne(mergedOptions); + + if (!row) { + return Result.fail(new EntityNotFoundError("Supplier", "tin", tin.toString())); + } + + const supplier = this.domainMapper.mapToDomain(row); + return supplier; + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Recupera múltiples suppliers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.). + * + * @param companyId - Identificador UUID de la empresa a la que pertenece el proveedor. + * @param criteria - Criterios de búsqueda. + * @param transaction - Transacción activa para la operación. + * @returns Result, Error> + * + * @see Criteria + */ + async findByCriteriaInCompany( + companyId: UniqueID, + criteria: Criteria, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise, Error>> { + try { + const criteriaConverter = new CriteriaToSequelizeConverter(); + const query = criteriaConverter.convert(criteria, { + searchableFields: [ + "name", + "trade_name", + "reference", + "tin", + "email_primary", + "mobile_primary", + ], + allowedFields: [ + "name", + "trade_name", + "reference", + "tin", + "email_primary", + "mobile_primary", + ], + enableFullText: true, + database: this.database, + strictMode: true, // fuerza error si ORDER BY no permitido + }); + + // Normalización defensiva de order/include + const normalizedOrder = Array.isArray(options.order) + ? options.order + : options.order + ? [options.order] + : []; + + const normalizedInclude = Array.isArray(options.include) + ? options.include + : options.include + ? [options.include] + : []; + + query.where = { + ...query.where, + company_id: companyId.toString(), + deleted_at: null, + }; + + query.order = [...(query.order as OrderItem[]), ...normalizedOrder]; + + query.include = normalizedInclude; + + // Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento) + /* + const { rows, count } = await SupplierModel.findAndCountAll({ + ...query, + transaction, + });*/ + const [rows, count] = await Promise.all([ + SupplierModel.findAll({ + ...query, + transaction, + }), + SupplierModel.count({ + where: query.where, + distinct: true, // evita duplicados por LEFT JOIN + transaction, + }), + ]); + + return this.summaryMapper.mapToReadModelCollection(rows, count); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + + /** + * + * Elimina o marca como eliminado un proveedor. + * + * @param companyId - Identificador UUID de la empresa a la que pertenece el proveedor. + * @param id - UUID del proveedor a eliminar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async deleteByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: Transaction + ): Promise> { + try { + const deleted = await SupplierModel.destroy({ + where: { id: id.toString(), company_id: companyId.toString() }, + transaction, + }); + + if (deleted === 0) { + return Result.fail(new EntityNotFoundError("Supplier", "id", id.toString())); + } + + return Result.ok(true); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } +} diff --git a/modules/supplier/src/common/dto/index.ts b/modules/supplier/src/common/dto/index.ts new file mode 100644 index 00000000..346dac3b --- /dev/null +++ b/modules/supplier/src/common/dto/index.ts @@ -0,0 +1,2 @@ +export * from "./request"; +export * from "./response"; diff --git a/modules/supplier/src/common/dto/request/create-supplier.request.dto.ts b/modules/supplier/src/common/dto/request/create-supplier.request.dto.ts new file mode 100644 index 00000000..a40872c7 --- /dev/null +++ b/modules/supplier/src/common/dto/request/create-supplier.request.dto.ts @@ -0,0 +1,33 @@ +import { z } from "zod/v4"; + +export const CreateSupplierRequestSchema = z.object({ + id: z.string().nonempty(), + + reference: z.string().optional(), + is_company: z.string().toLowerCase().default("true"), + name: z.string(), + trade_name: z.string().optional(), + tin: z.string().optional(), + + street: z.string().optional(), + street2: z.string().optional(), + city: z.string().optional(), + province: z.string().optional(), + postal_code: z.string().optional(), + country: z.string().toLowerCase().default("es").optional(), + + email_primary: z.string().optional(), + email_secondary: z.string().optional(), + phone_primary: z.string().optional(), + phone_secondary: z.string().optional(), + mobile_primary: z.string().optional(), + mobile_secondary: z.string().optional(), + + fax: z.string().optional(), + website: z.string().optional(), + + language_code: z.string().toLowerCase().default("es"), + currency_code: z.string().toUpperCase().default("EUR"), +}); + +export type CreateSupplierRequestDTO = z.infer; diff --git a/modules/supplier/src/common/dto/request/delete-supplier-by-id.request.dto.ts b/modules/supplier/src/common/dto/request/delete-supplier-by-id.request.dto.ts new file mode 100644 index 00000000..64373e05 --- /dev/null +++ b/modules/supplier/src/common/dto/request/delete-supplier-by-id.request.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v4"; + +/** + * Este DTO es utilizado por el endpoint: + * `DELETE /suppliers/:id` (eliminar una factura por ID). + * + */ + +export const DeleteSupplierByIdRequestSchema = z.object({ + supplier_id: z.string(), +}); + +export type DeleteSupplierByIdRequestDTO = z.infer; diff --git a/modules/supplier/src/common/dto/request/get-supplier-by-id.request.dto.ts b/modules/supplier/src/common/dto/request/get-supplier-by-id.request.dto.ts new file mode 100644 index 00000000..5286f1f0 --- /dev/null +++ b/modules/supplier/src/common/dto/request/get-supplier-by-id.request.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v4"; + +/** + * Este DTO es utilizado por el endpoint: + * `GET /suppliers/:supplier_id` (consultar una factura por ID). + * + */ + +export const GetSupplierByIdRequestSchema = z.object({ + supplier_id: z.string(), +}); + +export type GetSupplierByIdRequestDTO = z.infer; diff --git a/modules/supplier/src/common/dto/request/index.ts b/modules/supplier/src/common/dto/request/index.ts new file mode 100644 index 00000000..467c0e4b --- /dev/null +++ b/modules/supplier/src/common/dto/request/index.ts @@ -0,0 +1,5 @@ +export * from "./create-supplier.request.dto"; +export * from "./delete-supplier-by-id.request.dto"; +export * from "./get-supplier-by-id.request.dto"; +export * from "./supplier-list.request.dto"; +export * from "./update-supplier-by-id.request.dto"; diff --git a/modules/supplier/src/common/dto/request/supplier-list.request.dto.ts b/modules/supplier/src/common/dto/request/supplier-list.request.dto.ts new file mode 100644 index 00000000..78b38793 --- /dev/null +++ b/modules/supplier/src/common/dto/request/supplier-list.request.dto.ts @@ -0,0 +1,5 @@ +import { CriteriaSchema } from "@erp/core"; +import type { z } from "zod/v4"; + +export const SupplierListRequestSchema = CriteriaSchema; +export type SupplierListRequestDTO = z.infer; diff --git a/modules/supplier/src/common/dto/request/update-supplier-by-id.request.dto.ts b/modules/supplier/src/common/dto/request/update-supplier-by-id.request.dto.ts new file mode 100644 index 00000000..53e7c026 --- /dev/null +++ b/modules/supplier/src/common/dto/request/update-supplier-by-id.request.dto.ts @@ -0,0 +1,36 @@ +import { z } from "zod/v4"; + +export const UpdateSupplierByIdParamsRequestSchema = z.object({ + supplier_id: z.string(), +}); + +export const UpdateSupplierByIdRequestSchema = z.object({ + reference: z.string().optional(), + + is_company: z.string().optional(), + name: z.string().optional(), + trade_name: z.string().optional(), + tin: z.string().optional(), + + street: z.string().optional(), + street2: z.string().optional(), + city: z.string().optional(), + province: z.string().optional(), + postal_code: z.string().optional(), + country: z.string().optional(), + + email_primary: z.string().optional(), + email_secondary: z.string().optional(), + phone_primary: z.string().optional(), + phone_secondary: z.string().optional(), + mobile_primary: z.string().optional(), + mobile_secondary: z.string().optional(), + + fax: z.string().optional(), + website: z.string().optional(), + + language_code: z.string().optional(), + currency_code: z.string().optional(), +}); + +export type UpdateSupplierByIdRequestDTO = Partial>; diff --git a/modules/supplier/src/common/dto/response/create-supplier.result.dto.ts b/modules/supplier/src/common/dto/response/create-supplier.result.dto.ts new file mode 100644 index 00000000..e45ba41b --- /dev/null +++ b/modules/supplier/src/common/dto/response/create-supplier.result.dto.ts @@ -0,0 +1,7 @@ +import { + type GetSupplierByIdResponseDTO, + GetSupplierByIdResponseSchema, +} from "./get-supplier-by-id.response.dto"; + +export const CreateSupplierResponseSchema = GetSupplierByIdResponseSchema; +export type SupplierCreationResponseDTO = GetSupplierByIdResponseDTO; diff --git a/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts b/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts new file mode 100644 index 00000000..de721ed0 --- /dev/null +++ b/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts @@ -0,0 +1,41 @@ +import { MetadataSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const GetSupplierByIdResponseSchema = z.object({ + id: z.uuid(), + company_id: z.uuid(), + reference: z.string(), + + is_company: z.string(), + name: z.string(), + trade_name: z.string(), + tin: z.string(), + + street: z.string(), + street2: z.string(), + city: z.string(), + province: z.string(), + postal_code: z.string(), + country: z.string(), + + email_primary: z.string(), + email_secondary: z.string(), + phone_primary: z.string(), + phone_secondary: z.string(), + mobile_primary: z.string(), + mobile_secondary: z.string(), + + fax: z.string(), + website: z.string(), + + legal_record: z.string(), + + default_taxes: z.array(z.string()), + status: z.string(), + language_code: z.string(), + currency_code: z.string(), + + metadata: MetadataSchema.optional(), +}); + +export type GetSupplierByIdResponseDTO = z.infer; diff --git a/modules/supplier/src/common/dto/response/index.ts b/modules/supplier/src/common/dto/response/index.ts new file mode 100644 index 00000000..6c44db8f --- /dev/null +++ b/modules/supplier/src/common/dto/response/index.ts @@ -0,0 +1,4 @@ +export * from "./create-supplier.result.dto"; +export * from "./get-supplier-by-id.response.dto"; +export * from "./list-suppliers.response.dto"; +export * from "./update-supplier-by-id.response.dto"; diff --git a/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts b/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts new file mode 100644 index 00000000..134d3c0d --- /dev/null +++ b/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts @@ -0,0 +1,39 @@ +import { MetadataSchema, createPaginatedListSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const ListSuppliersResponseSchema = createPaginatedListSchema( + z.object({ + id: z.uuid(), + company_id: z.uuid(), + status: z.string(), + reference: z.string(), + + is_company: z.string(), + name: z.string(), + trade_name: z.string(), + tin: z.string(), + + street: z.string(), + street2: z.string(), + city: z.string(), + province: z.string(), + postal_code: z.string(), + country: z.string(), + + email_primary: z.string(), + email_secondary: z.string(), + phone_primary: z.string(), + phone_secondary: z.string(), + mobile_primary: z.string(), + mobile_secondary: z.string(), + fax: z.string(), + website: z.string(), + + language_code: z.string(), + currency_code: z.string(), + + metadata: MetadataSchema.optional(), + }) +); + +export type ListSuppliersResponseDTO = z.infer; diff --git a/modules/supplier/src/common/dto/response/update-supplier-by-id.response.dto.ts b/modules/supplier/src/common/dto/response/update-supplier-by-id.response.dto.ts new file mode 100644 index 00000000..603edbef --- /dev/null +++ b/modules/supplier/src/common/dto/response/update-supplier-by-id.response.dto.ts @@ -0,0 +1,37 @@ +import { MetadataSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const UpdateSupplierByIdResponseSchema = z.object({ + id: z.uuid(), + company_id: z.uuid(), + reference: z.string(), + + is_company: z.string(), + name: z.string(), + trade_name: z.string(), + tin: z.string(), + + street: z.string(), + street2: z.string(), + city: z.string(), + province: z.string(), + postal_code: z.string(), + country: z.string(), + + email_primary: z.string(), + email_secondary: z.string(), + phone_primary: z.string(), + phone_secondary: z.string(), + mobile_primary: z.string(), + mobile_secondary: z.string(), + + fax: z.string(), + website: z.string(), + + language_code: z.string(), + currency_code: z.string(), + + metadata: MetadataSchema.optional(), +}); + +export type UpdateSupplierByIdResponseDTO = z.infer; diff --git a/modules/supplier/src/common/index.ts b/modules/supplier/src/common/index.ts new file mode 100644 index 00000000..0392b1b4 --- /dev/null +++ b/modules/supplier/src/common/index.ts @@ -0,0 +1 @@ +export * from "./dto"; diff --git a/modules/supplier/tsconfig.json b/modules/supplier/tsconfig.json new file mode 100644 index 00000000..4b17fbe2 --- /dev/null +++ b/modules/supplier/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@erp/suppliers/*": ["./src/*"] + }, + + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be904869..78acfccb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@erp/factuges': specifier: workspace:* version: link:../../modules/factuges + '@erp/suppliers': + specifier: workspace:* + version: link:../../modules/supplier '@repo/rdx-logger': specifier: workspace:* version: link:../../packages/rdx-logger @@ -691,6 +694,46 @@ importers: specifier: ^5.9.3 version: 5.9.3 + modules/supplier: + dependencies: + '@erp/auth': + specifier: workspace:* + version: link:../auth + '@erp/core': + specifier: workspace:* + version: link:../core + '@repo/i18next': + specifier: workspace:* + version: link:../../packages/i18n + '@repo/rdx-criteria': + specifier: workspace:* + version: link:../../packages/rdx-criteria + '@repo/rdx-ddd': + specifier: workspace:* + version: link:../../packages/rdx-ddd + '@repo/rdx-logger': + specifier: workspace:* + version: link:../../packages/rdx-logger + '@repo/rdx-utils': + specifier: workspace:* + version: link:../../packages/rdx-utils + express: + specifier: ^4.18.2 + version: 4.21.2 + sequelize: + specifier: ^6.37.5 + version: 6.37.7(mysql2@3.15.3)(pg-hstore@2.3.4) + zod: + specifier: ^4.1.11 + version: 4.1.12 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + modules/supplier-invoices: dependencies: '@erp/auth':