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