From f8d0979059f96a6493f017d11db0664613ae5d1e Mon Sep 17 00:00:00 2001 From: David Arranz Date: Wed, 7 Oct 2026 18:30:41 +0200 Subject: [PATCH] feat: enhance quote management with localized descriptions and catalog integration - Updated QuoteDetailsCardEditor to display localized descriptions for articles. - Modified QuoteEdit to exclude localized descriptions from the payload sent to the server. - Implemented GetCatalogArticlesByIdsUseCase for fetching catalog articles by IDs. - Enhanced ICatalogRepository with methods for retrieving articles by ID. - Introduced CatalogArticle and CatalogArticleTranslation entities for better catalog representation. - Added resolveLocalizedQuoteDescriptions service to resolve descriptions based on user language. - Updated sales context to include catalog article lookup functionality. - Refactored quote controllers and presenters to utilize localized descriptions. - Added tests for ArticleIdentifier validation and new catalog entities. - Created test configuration for isolated testing environments. --- .../editors/QuoteDetailsCardEditor.tsx | 8 +- client/src/app/quotes/edit.tsx | 8 +- client/src/app/quotes/hooks/useQuotes.tsx | 4 +- server/jest.config.js | 1 + server/package.json | 4 +- server/src/config/environments/test.ts | 26 ++++ .../GetCatalogArticlesByIds.useCase.ts | 31 +++++ .../src/contexts/catalog/application/index.ts | 1 + .../domain/entities/ArticleIdentifier.test.ts | 16 +++ .../domain/entities/ArticleIdentifier.ts | 4 + .../catalog/domain/entities/CatalogArticle.ts | 42 ++++++ .../contexts/catalog/domain/entities/index.ts | 1 + .../repository/CatalogRepository.interface.ts | 4 +- .../infrastructure/Catalog.repository.ts | 121 ++++++++++++++---- .../infrastructure/CatalogArticleLookup.ts | 12 ++ .../contexts/catalog/infrastructure/index.ts | 12 +- .../sequelize/catalogArticle.model.ts | 26 ++++ .../catalogArticleTranslation.model.ts | 20 +++ .../catalog/infrastructure/sequelize/index.ts | 2 + .../application/Quote/CreateQuote.useCase.ts | 27 ++++ .../application/Quote/UpdateQuote.useCase.ts | 27 ++++ .../sales/application/services/index.ts | 1 + .../resolveLocalizedQuoteDescriptions.ts | 31 +++++ .../sales/infrastructure/Sales.context.ts | 2 + .../controllers/quotes/createQuote/index.ts | 2 + .../quotes/getQuote/GetQuote.controller.ts | 4 +- .../controllers/quotes/getQuote/index.ts | 2 + .../getQuote/presenter/GetQuote.presenter.ts | 19 ++- .../controllers/quotes/reportQuote/index.ts | 2 + .../reporter/ReportQuote.reporter.ts | 20 ++- .../controllers/quotes/updateQuote/index.ts | 2 + .../GetQuote.dto/IGetQuote_Response.dto.ts | 2 + 32 files changed, 435 insertions(+), 49 deletions(-) create mode 100644 server/src/config/environments/test.ts create mode 100644 server/src/contexts/catalog/application/GetCatalogArticlesByIds.useCase.ts create mode 100644 server/src/contexts/catalog/domain/entities/ArticleIdentifier.test.ts create mode 100644 server/src/contexts/catalog/domain/entities/CatalogArticle.ts create mode 100644 server/src/contexts/catalog/infrastructure/CatalogArticleLookup.ts create mode 100644 server/src/contexts/catalog/infrastructure/sequelize/catalogArticle.model.ts create mode 100644 server/src/contexts/catalog/infrastructure/sequelize/catalogArticleTranslation.model.ts create mode 100644 server/src/contexts/sales/application/services/resolveLocalizedQuoteDescriptions.ts diff --git a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx index 8207c12..91e84e6 100644 --- a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx +++ b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx @@ -81,9 +81,15 @@ export const QuoteDetailsCardEditor = ({ accessorKey: "description", header: t("quotes.form_fields.items.description.label"), cell: ({ row: { index, original } }) => { + if (original?.id_article) { + return ( +
+ {original.localized_description ?? original.description} +
+ ); + } return ( diff --git a/client/src/app/quotes/edit.tsx b/client/src/app/quotes/edit.tsx index d21de69..9c54924 100644 --- a/client/src/app/quotes/edit.tsx +++ b/client/src/app/quotes/edit.tsx @@ -311,7 +311,13 @@ export const QuoteEdit = () => { const onSubmit = async (data: QuoteDataForm, shouldRedirect: boolean) => { // Transformación del form -> typo de request - mutate(data, { + // localized_description is a server-side projection, never a writable snapshot field. + const payload = { + ...data, + items: data.items.map(({ localized_description: _localizedDescription, ...item }) => item), + }; + + mutate(payload, { onError: (error) => { console.error(error); toast({ title: t("common.error"), description: error.message, variant: "destructive" }); diff --git a/client/src/app/quotes/hooks/useQuotes.tsx b/client/src/app/quotes/hooks/useQuotes.tsx index e4b0c7d..130cac0 100644 --- a/client/src/app/quotes/hooks/useQuotes.tsx +++ b/client/src/app/quotes/hooks/useQuotes.tsx @@ -19,6 +19,7 @@ import { } from "@shared/contexts"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; import slugify from "slugify"; export type UseQuotesListParams = Omit & { @@ -45,6 +46,7 @@ export type UseQuotesReportParamsType = { export const useQuotes = () => { const dataSource = useDataSource(); const keys = useQueryKey(); + const { i18n } = useTranslation(); const getQuotePDFDownloadURL = useCallback( (id: string) => `${dataSource.getApiUrl()}/quotes/${id}/report`, @@ -224,7 +226,7 @@ export const useQuotes = () => { useOne: (id?: string, params?: UseQuotesGetParamsType) => useOne({ - queryKey: keys().data().resource("quotes").action("one").id(id).params().get(), + queryKey: keys().data().resource("quotes").action("one").id(id).params({ language: i18n.language }).get(), queryFn: () => dataSource.getOne({ resource: "quotes", diff --git a/server/jest.config.js b/server/jest.config.js index 940a000..79c3778 100644 --- a/server/jest.config.js +++ b/server/jest.config.js @@ -8,4 +8,5 @@ module.exports = { "^@shared/(.*)$": "/../shared/lib/$1", "^@/(.*)$": "/src/$1", }, + testPathIgnorePatterns: ["/src/config/environments/test.ts"], }; diff --git a/server/package.json b/server/package.json index 79c2f72..826b13d 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,7 @@ "build": "tsc", "lint": "eslint --ignore-path .gitignore . --ext .ts", "lint:fix": "npm run lint -- --fix", - "test": "jest --config=./jest.config.ts --rootDir=./src/ --verbose", + "test": "jest --config=./jest.config.js --verbose", "clean": "rm -rf node_modules" }, "devDependencies": { @@ -101,4 +101,4 @@ "engines": { "node": ">=18" } -} \ No newline at end of file +} diff --git a/server/src/config/environments/test.ts b/server/src/config/environments/test.ts new file mode 100644 index 0000000..9923597 --- /dev/null +++ b/server/src/config/environments/test.ts @@ -0,0 +1,26 @@ +/* Test-only configuration. It never targets the development or production database by default. */ +module.exports = { + jwt: { + secret_key: process.env.TEST_JWT_SECRET || "test-jwt-secret", + refresh_secret_key: process.env.TEST_JWT_REFRESH_SECRET || "test-jwt-refresh-secret", + token_expiration: "1h", + refresh_token_expiration: "1h", + }, + database: { + host: process.env.TEST_MYSQL_HOST || "127.0.0.1", + port: Number(process.env.TEST_MYSQL_PORT || 3306), + dialect: "mysql", + database: process.env.TEST_MYSQL_DATABASE || "uecko_test", + username: process.env.TEST_MYSQL_USER || "uecko_test", + password: process.env.TEST_MYSQL_PASSWORD || "", + }, + server: { hostname: "127.0.0.1", port: 0, cors_origin: "*" }, + defaults: { + dealer_logos_upload_path: "/tmp/uecko-test/dealer-logos", + dealer_logo_placeholder: "/tmp/uecko-test/logo-placeholder.png", + support: { from: "test@example.invalid", subject: "Test" }, + }, + admin: { name: "Test admin", email: "admin@example.invalid", password: "test", language: "es" }, + sample_dealer: { name: "Test dealer", email: "dealer@example.invalid", password: "test", language: "en" }, + nodemailer: { brevo: {} }, +}; diff --git a/server/src/contexts/catalog/application/GetCatalogArticlesByIds.useCase.ts b/server/src/contexts/catalog/application/GetCatalogArticlesByIds.useCase.ts new file mode 100644 index 0000000..712e6bb --- /dev/null +++ b/server/src/contexts/catalog/application/GetCatalogArticlesByIds.useCase.ts @@ -0,0 +1,31 @@ +import { IUseCase, IUseCaseError, UseCaseError } from "@/contexts/common/application"; +import { IRepositoryManager } from "@/contexts/common/domain"; +import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; +import { Result } from "@shared/contexts"; +import { ArticleIdentifier, CatalogArticle, ICatalogRepository } from "../domain"; + +export type GetCatalogArticlesByIdsResult = + | Result + | Result; + +/** Application boundary used by other contexts; it deliberately hides Sequelize models. */ +export class GetCatalogArticlesByIdsUseCase + implements IUseCase> +{ + public constructor(private readonly props: { adapter: ISequelizeAdapter; repositoryManager: IRepositoryManager }) {} + + async execute(idsArticle: readonly ArticleIdentifier[]): Promise { + if (!idsArticle.length) return Result.ok([]); + const transaction = this.props.adapter.startTransaction(); + try { + let articles: CatalogArticle[] = []; + await transaction.complete(async (transaction) => { + const repository = this.props.repositoryManager.getRepository("Article"); + articles = await repository({ transaction }).getByArticleIds(idsArticle); + }); + return Result.ok(articles); + } catch (error: unknown) { + return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, "Catalog lookup error", error as any)); + } + } +} diff --git a/server/src/contexts/catalog/application/index.ts b/server/src/contexts/catalog/application/index.ts index 62cdbfa..bdedc3c 100644 --- a/server/src/contexts/catalog/application/index.ts +++ b/server/src/contexts/catalog/application/index.ts @@ -1 +1,2 @@ export * from "./ListArticlesUseCase"; +export * from "./GetCatalogArticlesByIds.useCase"; diff --git a/server/src/contexts/catalog/domain/entities/ArticleIdentifier.test.ts b/server/src/contexts/catalog/domain/entities/ArticleIdentifier.test.ts new file mode 100644 index 0000000..83d8a27 --- /dev/null +++ b/server/src/contexts/catalog/domain/entities/ArticleIdentifier.test.ts @@ -0,0 +1,16 @@ +import { ArticleIdentifier } from "./ArticleIdentifier"; + +describe("ArticleIdentifier", () => { + it("accepts positive safe integer identifiers", () => { + expect(ArticleIdentifier.create("123").isSuccess).toBe(true); + expect(ArticleIdentifier.create(Number.MAX_SAFE_INTEGER).isSuccess).toBe(true); + }); + + it("rejects zero, decimals, non-numeric, negative and unsafe identifiers", () => { + expect(ArticleIdentifier.create(0).isFailure).toBe(true); + expect(ArticleIdentifier.create(1.5).isFailure).toBe(true); + expect(ArticleIdentifier.create("article").isFailure).toBe(true); + expect(ArticleIdentifier.create(-1).isFailure).toBe(true); + expect(ArticleIdentifier.create(Number.MAX_SAFE_INTEGER + 1).isFailure).toBe(true); + }); +}); diff --git a/server/src/contexts/catalog/domain/entities/ArticleIdentifier.ts b/server/src/contexts/catalog/domain/entities/ArticleIdentifier.ts index c4087e7..83c8504 100644 --- a/server/src/contexts/catalog/domain/entities/ArticleIdentifier.ts +++ b/server/src/contexts/catalog/domain/entities/ArticleIdentifier.ts @@ -55,6 +55,10 @@ export class ArticleIdentifier extends NullableValueObject { _value = validationResult.object; } + if (_value !== null && (!Number.isSafeInteger(_value) || _value <= 0)) { + return Result.fail(new Error(`${_options.label} must be a positive safe integer`)); + } + return Result.ok(new ArticleIdentifier(ArticleIdentifier.sanitize(_value))); } diff --git a/server/src/contexts/catalog/domain/entities/CatalogArticle.ts b/server/src/contexts/catalog/domain/entities/CatalogArticle.ts new file mode 100644 index 0000000..faed77b --- /dev/null +++ b/server/src/contexts/catalog/domain/entities/CatalogArticle.ts @@ -0,0 +1,42 @@ +import { AggregateRoot, Description, IDomainError, Language, Name, Quantity, Result, Slug, UniqueID, UnitPrice } from "@shared/contexts"; +import { ArticleIdentifier } from "./ArticleIdentifier"; + +export interface ICatalogArticleTranslationProps { + language: Language; + description: Description; +} + +export class CatalogArticleTranslation { + public constructor(public readonly props: ICatalogArticleTranslationProps) {} + + get language(): Language { return this.props.language; } + get description(): Description { return this.props.description; } +} + +export interface ICatalogArticleProps { + catalog_name: Name; + id_article: ArticleIdentifier; + reference: Slug; + points: Quantity; + retail_price: UnitPrice; + deleted_at: Date | null; + translations: CatalogArticleTranslation[]; +} + +/** Full catalog aggregate used by internal lookups. Unlike Article, it is not a localized view row. */ +export class CatalogArticle extends AggregateRoot { + public static create(props: ICatalogArticleProps, id: UniqueID): Result { + return Result.ok(new CatalogArticle(props, id)); + } + + private constructor(props: ICatalogArticleProps, id: UniqueID) { super(props, id); } + + get id(): UniqueID { return this._id; } + get idArticle(): ArticleIdentifier { return this.props.id_article; } + get translations(): readonly CatalogArticleTranslation[] { return this.props.translations; } + get deletedAt(): Date | null { return this.props.deleted_at; } + + public getTranslation(language: Language): CatalogArticleTranslation | undefined { + return this.props.translations.find((translation) => translation.language.toString() === language.toString()); + } +} diff --git a/server/src/contexts/catalog/domain/entities/index.ts b/server/src/contexts/catalog/domain/entities/index.ts index 5ff5aef..885c231 100644 --- a/server/src/contexts/catalog/domain/entities/index.ts +++ b/server/src/contexts/catalog/domain/entities/index.ts @@ -1,2 +1,3 @@ export * from "./Article"; export * from "./ArticleIdentifier"; +export * from "./CatalogArticle"; diff --git a/server/src/contexts/catalog/domain/repository/CatalogRepository.interface.ts b/server/src/contexts/catalog/domain/repository/CatalogRepository.interface.ts index b33fb38..8719324 100644 --- a/server/src/contexts/catalog/domain/repository/CatalogRepository.interface.ts +++ b/server/src/contexts/catalog/domain/repository/CatalogRepository.interface.ts @@ -1,8 +1,10 @@ import { IRepository } from "@/contexts/common/domain"; import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts"; -import { Article } from "../entities"; +import { Article, ArticleIdentifier, CatalogArticle } from "../entities"; export interface ICatalogRepository extends IRepository { getById(id: UniqueID): Promise
; + getByArticleId(idArticle: ArticleIdentifier): Promise; + getByArticleIds(idsArticle: readonly ArticleIdentifier[]): Promise; findAll(queryCriteria?: IQueryCriteria): Promise>; } diff --git a/server/src/contexts/catalog/infrastructure/Catalog.repository.ts b/server/src/contexts/catalog/infrastructure/Catalog.repository.ts index 2b27231..ad5de53 100644 --- a/server/src/contexts/catalog/infrastructure/Catalog.repository.ts +++ b/server/src/contexts/catalog/infrastructure/Catalog.repository.ts @@ -1,12 +1,24 @@ +import { ISequelizeAdapter, SequelizeRepository } from "@/contexts/common/infrastructure/sequelize"; import { - ISequelizeAdapter, - SequelizeRepository, -} from "@/contexts/common/infrastructure/sequelize"; -import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts"; -import { Transaction } from "sequelize"; + Description, + ICollection, + IQueryCriteria, + Language, + Name, + Quantity, + Slug, + UniqueID, + UnitPrice, +} from "@shared/contexts"; +import { Op, Transaction } from "sequelize"; import { ICatalogContext } from "."; import { ICatalogRepository } from "../domain"; -import { Article } from "../domain/entities"; +import { + Article, + ArticleIdentifier, + CatalogArticle, + CatalogArticleTranslation, +} from "../domain/entities"; import { IArticleMapper, createArticleMapper } from "./mappers/article.mapper"; export type QueryParams = { @@ -14,10 +26,7 @@ export type QueryParams = { filters: Record; }; -export class CatalogRepository - extends SequelizeRepository
- implements ICatalogRepository -{ +export class CatalogRepository extends SequelizeRepository
implements ICatalogRepository { protected mapper: IArticleMapper; public constructor(props: { @@ -40,12 +49,77 @@ export class CatalogRepository return this.mapper.mapToDomain(rawArticle); } - public async findAll( - queryCriteria?: IQueryCriteria, - ): Promise> { + public async getByArticleId(idArticle: ArticleIdentifier): Promise { + const articles = await this.getByArticleIds([idArticle]); + return articles[0] ?? null; + } + + /** Reads base tables intentionally: quote/report lookups must include soft-deleted articles. */ + public async getByArticleIds( + idsArticle: readonly ArticleIdentifier[] + ): Promise { + const ids = [...new Set(idsArticle.filter((id) => !id.isNull()).map((id) => id.toPrimitive()))]; + if (!ids.length) return []; + + const articleModel = this._adapter.getModel("CatalogArticle_Model"); + const translationModel = this._adapter.getModel("CatalogArticleTranslation_Model"); + const articles = await articleModel.findAll({ + where: { id_article: { [Op.in]: ids } }, + transaction: this._transaction, + }); + if (!articles.length) return []; + + const catalogIds = articles.map((article: any) => article.id); + const translations = await translationModel.findAll({ + where: { catalog_id: { [Op.in]: catalogIds } }, + transaction: this._transaction, + }); + const translationsByCatalogId = new Map(); + translations.forEach((translation: any) => { + const entries = translationsByCatalogId.get(translation.catalog_id) ?? []; + entries.push(translation); + translationsByCatalogId.set(translation.catalog_id, entries); + }); + + return articles.map((article: any) => + this.mapCatalogArticle(article, translationsByCatalogId.get(article.id) ?? []) + ); + } + + private mapCatalogArticle(source: any, rawTranslations: any[]): CatalogArticle { + const translations = rawTranslations.flatMap((translation) => { + if (translation.description === null || translation.description === undefined) return []; + const language = Language.createFromCode(translation.lang_code); + const description = Description.create(translation.description); + return language.isSuccess && description.isSuccess + ? [ + new CatalogArticleTranslation({ + language: language.object, + description: description.object, + }), + ] + : []; + }); + const result = CatalogArticle.create( + { + catalog_name: Name.create(source.catalog_name).object, + id_article: ArticleIdentifier.create(source.id_article).object, + reference: Slug.create(source.reference).object, + points: Quantity.create({ amount: source.points, scale: 2 }).object, + retail_price: UnitPrice.create({ amount: source.retail_price, scale: 2 }).object, + deleted_at: source.deleted_at ?? null, + translations, + }, + UniqueID.create(source.id).object + ); + if (result.isFailure) throw result.error; + return result.object; + } + + public async findAll(queryCriteria?: IQueryCriteria): Promise> { const { rows, count } = await this._findAll( "Article_Model", - queryCriteria, + queryCriteria /*{ include: [], // esto es para quitar las asociaciones al hacer la consulta }*/ @@ -59,16 +133,13 @@ export const registerCatalogRepository = (context: ICatalogContext) => { const adapter = context.adapter; const repoManager = context.repositoryManager; - repoManager.registerRepository( - "Article", - (params = { transaction: null }) => { - const { transaction } = params; + repoManager.registerRepository("Article", (params = { transaction: null }) => { + const { transaction } = params; - return new CatalogRepository({ - transaction, - adapter, - mapper: createArticleMapper(context), - }); - }, - ); + return new CatalogRepository({ + transaction, + adapter, + mapper: createArticleMapper(context), + }); + }); }; diff --git a/server/src/contexts/catalog/infrastructure/CatalogArticleLookup.ts b/server/src/contexts/catalog/infrastructure/CatalogArticleLookup.ts new file mode 100644 index 0000000..b17b69b --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/CatalogArticleLookup.ts @@ -0,0 +1,12 @@ +import { GetCatalogArticlesByIdsUseCase } from "../application"; +import { ICatalogContext } from "./Catalog.context"; +import { registerCatalogRepository } from "./Catalog.repository"; + +/** Composition helper: consumers receive an application use case, never catalog Sequelize models. */ +export const createCatalogArticleLookup = (context: ICatalogContext): GetCatalogArticlesByIdsUseCase => { + registerCatalogRepository(context); + return new GetCatalogArticlesByIdsUseCase({ + adapter: context.adapter, + repositoryManager: context.repositoryManager, + }); +}; diff --git a/server/src/contexts/catalog/infrastructure/index.ts b/server/src/contexts/catalog/infrastructure/index.ts index ef5b5c5..dfefa6b 100644 --- a/server/src/contexts/catalog/infrastructure/index.ts +++ b/server/src/contexts/catalog/infrastructure/index.ts @@ -1,11 +1,3 @@ -import { IApplicationService } from "@/contexts/common/application/services/ApplicationService"; -import { IRepositoryManager } from "@/contexts/common/domain"; -import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; - -export interface ICatalogContext { - adapter: ISequelizeAdapter; - repositoryManager: IRepositoryManager; - services: IApplicationService; -} - +export * from "./Catalog.context"; export * from "./express"; +export * from "./CatalogArticleLookup"; diff --git a/server/src/contexts/catalog/infrastructure/sequelize/catalogArticle.model.ts b/server/src/contexts/catalog/infrastructure/sequelize/catalogArticle.model.ts new file mode 100644 index 0000000..49b6b08 --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/sequelize/catalogArticle.model.ts @@ -0,0 +1,26 @@ +import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize"; + +export class CatalogArticle_Model extends Model, InferCreationAttributes> { + static async sync(): Promise { return Promise.resolve(); } + static associate(_connection: Sequelize) {} + declare id: string; + declare catalog_name: string; + declare id_article: string; + declare reference: CreationOptional; + declare points: CreationOptional; + declare retail_price: CreationOptional; + declare deleted_at: CreationOptional; +} + +export default (sequelize: Sequelize) => { + CatalogArticle_Model.init({ + id: { type: DataTypes.UUID, primaryKey: true }, + catalog_name: { type: DataTypes.STRING, allowNull: false }, + id_article: { type: DataTypes.BIGINT.UNSIGNED, allowNull: false }, + reference: DataTypes.STRING, + points: DataTypes.BIGINT.UNSIGNED, + retail_price: DataTypes.BIGINT, + deleted_at: DataTypes.DATE, + }, { sequelize, tableName: "catalog", timestamps: true, paranoid: false }); + return CatalogArticle_Model; +}; diff --git a/server/src/contexts/catalog/infrastructure/sequelize/catalogArticleTranslation.model.ts b/server/src/contexts/catalog/infrastructure/sequelize/catalogArticleTranslation.model.ts new file mode 100644 index 0000000..0610b5f --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/sequelize/catalogArticleTranslation.model.ts @@ -0,0 +1,20 @@ +import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize"; + +export class CatalogArticleTranslation_Model extends Model, InferCreationAttributes> { + static async sync(): Promise { return Promise.resolve(); } + static associate(_connection: Sequelize) {} + declare id: string; + declare catalog_id: string; + declare lang_code: string; + declare description: string | null; +} + +export default (sequelize: Sequelize) => { + CatalogArticleTranslation_Model.init({ + id: { type: DataTypes.STRING, primaryKey: true }, + catalog_id: DataTypes.UUID, + lang_code: { type: DataTypes.STRING(2), allowNull: false }, + description: DataTypes.STRING, + }, { sequelize, tableName: "catalog_translations", timestamps: false }); + return CatalogArticleTranslation_Model; +}; diff --git a/server/src/contexts/catalog/infrastructure/sequelize/index.ts b/server/src/contexts/catalog/infrastructure/sequelize/index.ts index bf079f1..05da429 100644 --- a/server/src/contexts/catalog/infrastructure/sequelize/index.ts +++ b/server/src/contexts/catalog/infrastructure/sequelize/index.ts @@ -1 +1,3 @@ export * from "./article.model"; +export * from "./catalogArticle.model"; +export * from "./catalogArticleTranslation.model"; diff --git a/server/src/contexts/sales/application/Quote/CreateQuote.useCase.ts b/server/src/contexts/sales/application/Quote/CreateQuote.useCase.ts index fd4d13d..19874f1 100644 --- a/server/src/contexts/sales/application/Quote/CreateQuote.useCase.ts +++ b/server/src/contexts/sales/application/Quote/CreateQuote.useCase.ts @@ -45,11 +45,13 @@ export class CreateQuoteUseCase private _repositoryManager: IRepositoryManager; private _dealer: Dealer; private _transaction: SequelizeBusinessTransactionType; + private _catalogArticles?: ISalesContext["catalogArticles"]; constructor(context: ISalesContext) { this._adapter = context.adapter; this._repositoryManager = context.repositoryManager; this._dealer = context.dealer!; + this._catalogArticles = context.catalogArticles; } async execute(request: ICreateQuote_Request_DTO): Promise { @@ -69,6 +71,9 @@ export class CreateQuoteUseCase ); } + const catalogValidation = await this._validateCatalogItems(request.items ?? []); + if (catalogValidation.isFailure) return Result.fail(catalogValidation.error); + this._transaction = this._adapter.startTransaction(); const quoteRepository = this._getQuoteRepository(); @@ -311,4 +316,26 @@ export class CreateQuoteUseCase private _getQuoteRepository() { return this._repositoryManager.getRepository("Quote"); } + + private async _validateCatalogItems(items: ICreateQuote_Request_DTO["items"]): Promise> { + const ids: ArticleIdentifier[] = []; + for (const item of items) { + const idOrError = ArticleIdentifier.create(item.id_article); + if (idOrError.isFailure) { + return Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, idOrError.error.message)); + } + if (!idOrError.object.isNull()) ids.push(idOrError.object); + } + if (!ids.length) return Result.ok(true); + if (!this._catalogArticles) { + return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, "Catalog lookup is not configured")); + } + const lookup = await this._catalogArticles.execute(ids); + if (lookup.isFailure) return Result.fail(lookup.error); + const found = new Set(lookup.object.map((article) => article.idArticle.toString())); + const missing = ids.find((id) => !found.has(id.toString())); + return missing + ? Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, `Catalog article ${missing.toString()} not found`)) + : Result.ok(true); + } } diff --git a/server/src/contexts/sales/application/Quote/UpdateQuote.useCase.ts b/server/src/contexts/sales/application/Quote/UpdateQuote.useCase.ts index f91c4bb..03e392d 100644 --- a/server/src/contexts/sales/application/Quote/UpdateQuote.useCase.ts +++ b/server/src/contexts/sales/application/Quote/UpdateQuote.useCase.ts @@ -53,11 +53,13 @@ export class UpdateQuoteUseCase private _adapter: ISequelizeAdapter; private _repositoryManager: IRepositoryManager; private _dealer?: Dealer; + private _catalogArticles?: ISalesContext["catalogArticles"]; constructor(context: ISalesContext) { this._adapter = context.adapter; this._repositoryManager = context.repositoryManager; this._dealer = context.dealer; + this._catalogArticles = context.catalogArticles; } async execute(request: IUpdateQuoteUseCaseRequest): Promise { @@ -83,6 +85,9 @@ export class UpdateQuoteUseCase ); } + const catalogValidation = await this._validateCatalogItems(quoteDTO.items ?? []); + if (catalogValidation.isFailure) return Result.fail(catalogValidation.error); + // // // regla de negocio -> no poder modificar un quote enviado @@ -303,4 +308,26 @@ export class UpdateQuoteUseCase private _getQuoteRepository() { return this._repositoryManager.getRepository("Quote"); } + + private async _validateCatalogItems(items: IUpdateQuote_Request_DTO["items"]): Promise> { + const ids: ArticleIdentifier[] = []; + for (const item of items) { + const idOrError = ArticleIdentifier.create(item.id_article); + if (idOrError.isFailure) { + return Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, idOrError.error.message)); + } + if (!idOrError.object.isNull()) ids.push(idOrError.object); + } + if (!ids.length) return Result.ok(true); + if (!this._catalogArticles) { + return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, "Catalog lookup is not configured")); + } + const lookup = await this._catalogArticles.execute(ids); + if (lookup.isFailure) return Result.fail(lookup.error); + const found = new Set(lookup.object.map((article) => article.idArticle.toString())); + const missing = ids.find((id) => !found.has(id.toString())); + return missing + ? Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, `Catalog article ${missing.toString()} not found`)) + : Result.ok(true); + } } diff --git a/server/src/contexts/sales/application/services/index.ts b/server/src/contexts/sales/application/services/index.ts index 2af5997..cf94882 100644 --- a/server/src/contexts/sales/application/services/index.ts +++ b/server/src/contexts/sales/application/services/index.ts @@ -1 +1,2 @@ export * from "./QuoteService"; +export * from "./resolveLocalizedQuoteDescriptions"; diff --git a/server/src/contexts/sales/application/services/resolveLocalizedQuoteDescriptions.ts b/server/src/contexts/sales/application/services/resolveLocalizedQuoteDescriptions.ts new file mode 100644 index 0000000..1a88c14 --- /dev/null +++ b/server/src/contexts/sales/application/services/resolveLocalizedQuoteDescriptions.ts @@ -0,0 +1,31 @@ +import { ArticleIdentifier } from "@/contexts/catalog/domain"; +import { Language } from "@shared/contexts"; +import { QuoteItem } from "../../domain"; +import { GetCatalogArticlesByIdsUseCase } from "@/contexts/catalog/application"; + +/** Sales owns the fallback to the persisted QuoteItem snapshot. */ +export const resolveLocalizedQuoteDescriptions = async ( + items: readonly QuoteItem[], + language: Language, + catalogArticles?: GetCatalogArticlesByIdsUseCase +): Promise> => { + const result = new Map(); + const catalogItems = items.filter((item) => !item.idArticle.isNull()); + if (!catalogItems.length) return result; + if (!catalogArticles) { + throw new Error("Catalog article lookup is not configured"); + } + + const ids = catalogItems.map((item) => item.idArticle) as ArticleIdentifier[]; + const lookup = await catalogArticles.execute(ids); + if (lookup.isFailure) throw lookup.error; + + const articlesById = new Map( + lookup.object.map((article) => [article.idArticle.toString(), article]) + ); + catalogItems.forEach((item) => { + const translation = articlesById.get(item.idArticle.toString())?.getTranslation(language); + if (translation) result.set(item.idArticle.toString(), translation.description.toString()); + }); + return result; +}; diff --git a/server/src/contexts/sales/infrastructure/Sales.context.ts b/server/src/contexts/sales/infrastructure/Sales.context.ts index b721ecd..f0c9de2 100644 --- a/server/src/contexts/sales/infrastructure/Sales.context.ts +++ b/server/src/contexts/sales/infrastructure/Sales.context.ts @@ -1,9 +1,11 @@ import { ICommonContext } from "@/contexts/common/infrastructure"; import { Dealer, IQuoteReferenceGeneratorService } from "../domain"; +import { GetCatalogArticlesByIdsUseCase } from "@/contexts/catalog/application"; export interface ISalesContext extends ICommonContext { services?: { QuoteReferenceGeneratorService: IQuoteReferenceGeneratorService; }; dealer?: Dealer; + catalogArticles?: GetCatalogArticlesByIdsUseCase; } diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/createQuote/index.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/createQuote/index.ts index 9f8dd5d..c01b7c5 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/createQuote/index.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/createQuote/index.ts @@ -1,11 +1,13 @@ import { CreateQuoteUseCase } from "@/contexts/sales/application"; import { registerQuoteRepository } from "../../../../Quote.repository"; import { ISalesContext } from "../../../../Sales.context"; +import { createCatalogArticleLookup } from "@/contexts/catalog/infrastructure"; import { CreateQuoteController } from "./CreateQuote.controller"; import { CreateQuotePresenter } from "./presenter"; export const createQuoteController = (context: ISalesContext) => { registerQuoteRepository(context); + context.catalogArticles = createCatalogArticleLookup(context); return new CreateQuoteController( { useCase: new CreateQuoteUseCase(context), diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/GetQuote.controller.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/GetQuote.controller.ts index 5188b99..2dc3440 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/GetQuote.controller.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/GetQuote.controller.ts @@ -6,6 +6,7 @@ import { IInfrastructureError, InfrastructureError } from "@/contexts/common/inf import { GetQuoteUseCase } from "@/contexts/sales/application"; import { Quote } from "@/contexts/sales/domain"; import { IGetQuote_Response_DTO, ensureIdIsValid } from "@shared/contexts"; +import { Language } from "@shared/contexts"; import { ISalesContext } from "../../../../Sales.context"; import { IGetQuotePresenter } from "./presenter/GetQuote.presenter"; @@ -55,7 +56,8 @@ export class GetQuoteController extends ExpressController { const quote = result.object; - return this.ok(this.presenter.map(quote, this.context)); + const language = this.context.user?.language ?? Language.createDefaultCode().object; + return this.ok(await this.presenter.map(quote, this.context, language)); } catch (e: unknown) { return this.fail(e as IServerError); } diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/index.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/index.ts index ed535c9..b45bc9a 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/index.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/index.ts @@ -3,9 +3,11 @@ import { registerQuoteRepository } from "../../../../Quote.repository"; import { ISalesContext } from "../../../../Sales.context"; import { GetQuoteController } from "./GetQuote.controller"; import { GetQuotePresenter } from "./presenter"; +import { createCatalogArticleLookup } from "@/contexts/catalog/infrastructure"; export const getQuoteController = (context: ISalesContext) => { registerQuoteRepository(context); + context.catalogArticles = createCatalogArticleLookup(context); return new GetQuoteController( { diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter.ts index 0a86d64..48fa4af 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/getQuote/presenter/GetQuote.presenter.ts @@ -5,14 +5,21 @@ import { } from "@shared/contexts"; import { Quote, QuoteItem } from "../../../../../../domain"; import { ISalesContext } from "../../../../../Sales.context"; +import { Language } from "@shared/contexts"; +import { resolveLocalizedQuoteDescriptions } from "@/contexts/sales/application/services"; export interface IGetQuotePresenter { - map: (quote: Quote, context: ISalesContext) => IGetQuote_Response_DTO; + map: (quote: Quote, context: ISalesContext, language: Language) => Promise; } export const GetQuotePresenter: IGetQuotePresenter = { // eslint-disable-next-line @typescript-eslint/no-unused-vars - map: (quote: Quote, context: ISalesContext): IGetQuote_Response_DTO => { + map: async (quote: Quote, context: ISalesContext, language: Language): Promise => { + const localizedDescriptions = await resolveLocalizedQuoteDescriptions( + quote.items.toArray(), + language, + context.catalogArticles + ); return { id: quote.id.toString(), status: quote.status.toString(), @@ -41,7 +48,7 @@ export const GetQuotePresenter: IGetQuotePresenter = { total_price: quote.totalPrice.convertScale(2).toObject(), - items: quoteItemPresenter(quote.items, context), + items: quoteItemPresenter(quote.items, context, localizedDescriptions), dealer_id: quote.dealerId.toString(), date_sent: quote.dateSent.toISO8601(), @@ -52,12 +59,16 @@ export const GetQuotePresenter: IGetQuotePresenter = { // eslint-disable-next-line @typescript-eslint/no-unused-vars const quoteItemPresenter = ( items: ICollection, - context: ISalesContext + context: ISalesContext, + localizedDescriptions: Map ): IGetQuote_QuoteItem_Response_DTO[] => items.totalCount > 0 ? items.items.map((item: QuoteItem) => ({ id_article: item.idArticle.toString(), description: item.description.toString(), + localized_description: item.idArticle.isNull() + ? item.description.toString() + : localizedDescriptions.get(item.idArticle.toString()) ?? item.description.toString(), quantity: item.quantity.convertScale(2).toObject(), unit_price: item.unitPrice.convertScale(2).toObject(), subtotal_price: item.subtotalPrice.convertScale(2).toObject(), diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/index.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/index.ts index 57e3747..b583460 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/index.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/index.ts @@ -3,9 +3,11 @@ import { registerQuoteRepository } from "../../../../Quote.repository"; import { ISalesContext } from "../../../../Sales.context"; import { ReportQuotePresenter } from "./reporter/ReportQuote.reporter"; import { ReportQuoteController } from "./ReportQuote.controller"; +import { createCatalogArticleLookup } from "@/contexts/catalog/infrastructure"; export const reportQuoteController = (context: ISalesContext) => { registerQuoteRepository(context); + context.catalogArticles = createCatalogArticleLookup(context); return new ReportQuoteController( { diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.ts index fe1c75e..f544daa 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.ts @@ -8,6 +8,8 @@ import path from "path"; import puppeteer from "puppeteer"; import report from "puppeteer-report"; import { reportLabels, resolveReportLanguage } from "./ReportQuote.labels"; +import { Language } from "@shared/contexts"; +import { resolveLocalizedQuoteDescriptions } from "@/contexts/sales/application/services"; export interface IReportQuoteReporter { toHTML: (quote: Quote, context: ISalesContext) => Promise; @@ -65,6 +67,12 @@ export const ReportQuotePresenter: IReportQuoteReporter = { const map = async (quote: Quote, context: ISalesContext) => { const { dealer } = context; const langCode = resolveReportLanguage(quote.language.toString()); + const reportLanguage = Language.createFromCode(langCode).object; + const localizedDescriptions = await resolveLocalizedQuoteDescriptions( + quote.items.toArray(), + reportLanguage, + context.catalogArticles + ); return { id: quote.id.toString(), @@ -103,7 +111,7 @@ const map = async (quote: Quote, context: ISalesContext) => { total_price: quote.totalPrice.toFormat(), - items: quoteItemPresenter(quote.items, context), + items: quoteItemPresenter(quote.items, context, localizedDescriptions), dealer: { id: dealer?.id.toString(), @@ -121,11 +129,17 @@ const map = async (quote: Quote, context: ISalesContext) => { }; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const quoteItemPresenter = (items: ICollection, context: ISalesContext): any[] => +const quoteItemPresenter = ( + items: ICollection, + context: ISalesContext, + localizedDescriptions: Map +): any[] => items.totalCount > 0 ? items.items.map((item: QuoteItem) => ({ id_article: item.idArticle.toString(), - description: item.description.toString(), + description: item.idArticle.isNull() + ? item.description.toString() + : localizedDescriptions.get(item.idArticle.toString()) ?? item.description.toString(), quantity: item.quantity.toFormat(), unit_price: item.unitPrice.toFormat(), subtotal_price: item.subtotalPrice.toFormat(), diff --git a/server/src/contexts/sales/infrastructure/express/controllers/quotes/updateQuote/index.ts b/server/src/contexts/sales/infrastructure/express/controllers/quotes/updateQuote/index.ts index c6e17b0..5579c43 100644 --- a/server/src/contexts/sales/infrastructure/express/controllers/quotes/updateQuote/index.ts +++ b/server/src/contexts/sales/infrastructure/express/controllers/quotes/updateQuote/index.ts @@ -1,11 +1,13 @@ import { UpdateQuoteUseCase } from "@/contexts/sales/application"; import { registerQuoteRepository } from "../../../../Quote.repository"; import { ISalesContext } from "../../../../Sales.context"; +import { createCatalogArticleLookup } from "@/contexts/catalog/infrastructure"; import { UpdateQuoteController } from "./UpdateQuote.controller"; import { UpdateQuotePresenter } from "./presenter/UpdateQuote.presenter"; export const updateQuoteController = (context: ISalesContext) => { registerQuoteRepository(context); + context.catalogArticles = createCatalogArticleLookup(context); return new UpdateQuoteController( { useCase: new UpdateQuoteUseCase(context), diff --git a/shared/lib/contexts/sales/application/dto/Quote/GetQuote.dto/IGetQuote_Response.dto.ts b/shared/lib/contexts/sales/application/dto/Quote/GetQuote.dto/IGetQuote_Response.dto.ts index c1119bd..7c0fadd 100644 --- a/shared/lib/contexts/sales/application/dto/Quote/GetQuote.dto/IGetQuote_Response.dto.ts +++ b/shared/lib/contexts/sales/application/dto/Quote/GetQuote.dto/IGetQuote_Response.dto.ts @@ -33,6 +33,8 @@ export interface IGetQuote_Response_DTO { export interface IGetQuote_QuoteItem_Response_DTO { id_article: string; + /** Runtime projection for the authenticated user's language; never persist this field. */ + localized_description?: string; quantity: IQuantity_DTO; description: string; unit_price: IMoney_DTO;