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.
This commit is contained in:
David Arranz 2026-10-07 18:30:41 +02:00
parent e0f52da54f
commit f8d0979059
32 changed files with 435 additions and 49 deletions

View File

@ -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 (
<div className='min-h-10 whitespace-pre-wrap px-3 py-2 text-sm'>
{original.localized_description ?? original.description}
</div>
);
}
return (
<FormTextAreaField
readOnly={original?.id_article}
autoSize
name={`items.${index}.description`}
/>

View File

@ -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" });

View File

@ -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<IGetListDataProviderParams, "filters" | "resource"> & {
@ -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<IGetQuote_Response_DTO>({
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",

View File

@ -8,4 +8,5 @@ module.exports = {
"^@shared/(.*)$": "<rootDir>/../shared/lib/$1",
"^@/(.*)$": "<rootDir>/src/$1",
},
testPathIgnorePatterns: ["<rootDir>/src/config/environments/test.ts"],
};

View File

@ -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"
}
}
}

View File

@ -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: {} },
};

View File

@ -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<never, IUseCaseError>
| Result<CatalogArticle[], never>;
/** Application boundary used by other contexts; it deliberately hides Sequelize models. */
export class GetCatalogArticlesByIdsUseCase
implements IUseCase<readonly ArticleIdentifier[], Promise<GetCatalogArticlesByIdsResult>>
{
public constructor(private readonly props: { adapter: ISequelizeAdapter; repositoryManager: IRepositoryManager }) {}
async execute(idsArticle: readonly ArticleIdentifier[]): Promise<GetCatalogArticlesByIdsResult> {
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<ICatalogRepository>("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));
}
}
}

View File

@ -1 +1,2 @@
export * from "./ListArticlesUseCase";
export * from "./GetCatalogArticlesByIds.useCase";

View File

@ -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);
});
});

View File

@ -55,6 +55,10 @@ export class ArticleIdentifier extends NullableValueObject<number> {
_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<ArticleIdentifier>(new ArticleIdentifier(ArticleIdentifier.sanitize(_value)));
}

View File

@ -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<ICatalogArticleProps> {
public static create(props: ICatalogArticleProps, id: UniqueID): Result<CatalogArticle, IDomainError> {
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());
}
}

View File

@ -1,2 +1,3 @@
export * from "./Article";
export * from "./ArticleIdentifier";
export * from "./CatalogArticle";

View File

@ -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<any> {
getById(id: UniqueID): Promise<Article | null>;
getByArticleId(idArticle: ArticleIdentifier): Promise<CatalogArticle | null>;
getByArticleIds(idsArticle: readonly ArticleIdentifier[]): Promise<CatalogArticle[]>;
findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<Article>>;
}

View File

@ -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<string, any>;
};
export class CatalogRepository
extends SequelizeRepository<Article>
implements ICatalogRepository
{
export class CatalogRepository extends SequelizeRepository<Article> 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<ICollection<any>> {
public async getByArticleId(idArticle: ArticleIdentifier): Promise<CatalogArticle | null> {
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<CatalogArticle[]> {
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<string, any[]>();
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<ICollection<any>> {
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),
});
});
};

View File

@ -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,
});
};

View File

@ -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";

View File

@ -0,0 +1,26 @@
import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
export class CatalogArticle_Model extends Model<InferAttributes<CatalogArticle_Model>, InferCreationAttributes<CatalogArticle_Model>> {
static async sync(): Promise<any> { return Promise.resolve(); }
static associate(_connection: Sequelize) {}
declare id: string;
declare catalog_name: string;
declare id_article: string;
declare reference: CreationOptional<string>;
declare points: CreationOptional<number>;
declare retail_price: CreationOptional<number>;
declare deleted_at: CreationOptional<Date | null>;
}
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;
};

View File

@ -0,0 +1,20 @@
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
export class CatalogArticleTranslation_Model extends Model<InferAttributes<CatalogArticleTranslation_Model>, InferCreationAttributes<CatalogArticleTranslation_Model>> {
static async sync(): Promise<any> { 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;
};

View File

@ -1 +1,3 @@
export * from "./article.model";
export * from "./catalogArticle.model";
export * from "./catalogArticleTranslation.model";

View File

@ -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<CreateQuoteResponseOrError> {
@ -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<IQuoteRepository>("Quote");
}
private async _validateCatalogItems(items: ICreateQuote_Request_DTO["items"]): Promise<Result<boolean, IUseCaseError>> {
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);
}
}

View File

@ -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<UpdateQuoteResponseOrError> {
@ -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<IQuoteRepository>("Quote");
}
private async _validateCatalogItems(items: IUpdateQuote_Request_DTO["items"]): Promise<Result<boolean, IUseCaseError>> {
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);
}
}

View File

@ -1 +1,2 @@
export * from "./QuoteService";
export * from "./resolveLocalizedQuoteDescriptions";

View File

@ -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<Map<string, string>> => {
const result = new Map<string, string>();
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;
};

View File

@ -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;
}

View File

@ -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),

View File

@ -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 = <Quote>result.object;
return this.ok<IGetQuote_Response_DTO>(this.presenter.map(quote, this.context));
const language = this.context.user?.language ?? Language.createDefaultCode().object;
return this.ok<IGetQuote_Response_DTO>(await this.presenter.map(quote, this.context, language));
} catch (e: unknown) {
return this.fail(e as IServerError);
}

View File

@ -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(
{

View File

@ -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<IGetQuote_Response_DTO>;
}
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<IGetQuote_Response_DTO> => {
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<QuoteItem>,
context: ISalesContext
context: ISalesContext,
localizedDescriptions: Map<string, string>
): 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(),

View File

@ -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(
{

View File

@ -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<string>;
@ -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<QuoteItem>, context: ISalesContext): any[] =>
const quoteItemPresenter = (
items: ICollection<QuoteItem>,
context: ISalesContext,
localizedDescriptions: Map<string, string>
): 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(),

View File

@ -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),

View File

@ -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;