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:
parent
e0f52da54f
commit
f8d0979059
@ -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`}
|
||||
/>
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -8,4 +8,5 @@ module.exports = {
|
||||
"^@shared/(.*)$": "<rootDir>/../shared/lib/$1",
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
},
|
||||
testPathIgnorePatterns: ["<rootDir>/src/config/environments/test.ts"],
|
||||
};
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
server/src/config/environments/test.ts
Normal file
26
server/src/config/environments/test.ts
Normal 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: {} },
|
||||
};
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export * from "./ListArticlesUseCase";
|
||||
export * from "./GetCatalogArticlesByIds.useCase";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./Article";
|
||||
export * from "./ArticleIdentifier";
|
||||
export * from "./CatalogArticle";
|
||||
|
||||
@ -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>>;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -1 +1,3 @@
|
||||
export * from "./article.model";
|
||||
export * from "./catalogArticle.model";
|
||||
export * from "./catalogArticleTranslation.model";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./QuoteService";
|
||||
export * from "./resolveLocalizedQuoteDescriptions";
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user