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",
|
accessorKey: "description",
|
||||||
header: t("quotes.form_fields.items.description.label"),
|
header: t("quotes.form_fields.items.description.label"),
|
||||||
cell: ({ row: { index, original } }) => {
|
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 (
|
return (
|
||||||
<FormTextAreaField
|
<FormTextAreaField
|
||||||
readOnly={original?.id_article}
|
|
||||||
autoSize
|
autoSize
|
||||||
name={`items.${index}.description`}
|
name={`items.${index}.description`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -311,7 +311,13 @@ export const QuoteEdit = () => {
|
|||||||
const onSubmit = async (data: QuoteDataForm, shouldRedirect: boolean) => {
|
const onSubmit = async (data: QuoteDataForm, shouldRedirect: boolean) => {
|
||||||
// Transformación del form -> typo de request
|
// 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) => {
|
onError: (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast({ title: t("common.error"), description: error.message, variant: "destructive" });
|
toast({ title: t("common.error"), description: error.message, variant: "destructive" });
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
} from "@shared/contexts";
|
} from "@shared/contexts";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
|
|
||||||
export type UseQuotesListParams = Omit<IGetListDataProviderParams, "filters" | "resource"> & {
|
export type UseQuotesListParams = Omit<IGetListDataProviderParams, "filters" | "resource"> & {
|
||||||
@ -45,6 +46,7 @@ export type UseQuotesReportParamsType = {
|
|||||||
export const useQuotes = () => {
|
export const useQuotes = () => {
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const keys = useQueryKey();
|
const keys = useQueryKey();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
const getQuotePDFDownloadURL = useCallback(
|
const getQuotePDFDownloadURL = useCallback(
|
||||||
(id: string) => `${dataSource.getApiUrl()}/quotes/${id}/report`,
|
(id: string) => `${dataSource.getApiUrl()}/quotes/${id}/report`,
|
||||||
@ -224,7 +226,7 @@ export const useQuotes = () => {
|
|||||||
|
|
||||||
useOne: (id?: string, params?: UseQuotesGetParamsType) =>
|
useOne: (id?: string, params?: UseQuotesGetParamsType) =>
|
||||||
useOne<IGetQuote_Response_DTO>({
|
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: () =>
|
queryFn: () =>
|
||||||
dataSource.getOne({
|
dataSource.getOne({
|
||||||
resource: "quotes",
|
resource: "quotes",
|
||||||
|
|||||||
@ -8,4 +8,5 @@ module.exports = {
|
|||||||
"^@shared/(.*)$": "<rootDir>/../shared/lib/$1",
|
"^@shared/(.*)$": "<rootDir>/../shared/lib/$1",
|
||||||
"^@/(.*)$": "<rootDir>/src/$1",
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
},
|
},
|
||||||
|
testPathIgnorePatterns: ["<rootDir>/src/config/environments/test.ts"],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "eslint --ignore-path .gitignore . --ext .ts",
|
"lint": "eslint --ignore-path .gitignore . --ext .ts",
|
||||||
"lint:fix": "npm run lint -- --fix",
|
"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"
|
"clean": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -101,4 +101,4 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"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 "./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;
|
_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)));
|
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 "./Article";
|
||||||
export * from "./ArticleIdentifier";
|
export * from "./ArticleIdentifier";
|
||||||
|
export * from "./CatalogArticle";
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { IRepository } from "@/contexts/common/domain";
|
import { IRepository } from "@/contexts/common/domain";
|
||||||
import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
|
import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
|
||||||
import { Article } from "../entities";
|
import { Article, ArticleIdentifier, CatalogArticle } from "../entities";
|
||||||
|
|
||||||
export interface ICatalogRepository extends IRepository<any> {
|
export interface ICatalogRepository extends IRepository<any> {
|
||||||
getById(id: UniqueID): Promise<Article | null>;
|
getById(id: UniqueID): Promise<Article | null>;
|
||||||
|
getByArticleId(idArticle: ArticleIdentifier): Promise<CatalogArticle | null>;
|
||||||
|
getByArticleIds(idsArticle: readonly ArticleIdentifier[]): Promise<CatalogArticle[]>;
|
||||||
findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<Article>>;
|
findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<Article>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,24 @@
|
|||||||
|
import { ISequelizeAdapter, SequelizeRepository } from "@/contexts/common/infrastructure/sequelize";
|
||||||
import {
|
import {
|
||||||
ISequelizeAdapter,
|
Description,
|
||||||
SequelizeRepository,
|
ICollection,
|
||||||
} from "@/contexts/common/infrastructure/sequelize";
|
IQueryCriteria,
|
||||||
import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts";
|
Language,
|
||||||
import { Transaction } from "sequelize";
|
Name,
|
||||||
|
Quantity,
|
||||||
|
Slug,
|
||||||
|
UniqueID,
|
||||||
|
UnitPrice,
|
||||||
|
} from "@shared/contexts";
|
||||||
|
import { Op, Transaction } from "sequelize";
|
||||||
import { ICatalogContext } from ".";
|
import { ICatalogContext } from ".";
|
||||||
import { ICatalogRepository } from "../domain";
|
import { ICatalogRepository } from "../domain";
|
||||||
import { Article } from "../domain/entities";
|
import {
|
||||||
|
Article,
|
||||||
|
ArticleIdentifier,
|
||||||
|
CatalogArticle,
|
||||||
|
CatalogArticleTranslation,
|
||||||
|
} from "../domain/entities";
|
||||||
import { IArticleMapper, createArticleMapper } from "./mappers/article.mapper";
|
import { IArticleMapper, createArticleMapper } from "./mappers/article.mapper";
|
||||||
|
|
||||||
export type QueryParams = {
|
export type QueryParams = {
|
||||||
@ -14,10 +26,7 @@ export type QueryParams = {
|
|||||||
filters: Record<string, any>;
|
filters: Record<string, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CatalogRepository
|
export class CatalogRepository extends SequelizeRepository<Article> implements ICatalogRepository {
|
||||||
extends SequelizeRepository<Article>
|
|
||||||
implements ICatalogRepository
|
|
||||||
{
|
|
||||||
protected mapper: IArticleMapper;
|
protected mapper: IArticleMapper;
|
||||||
|
|
||||||
public constructor(props: {
|
public constructor(props: {
|
||||||
@ -40,12 +49,77 @@ export class CatalogRepository
|
|||||||
return this.mapper.mapToDomain(rawArticle);
|
return this.mapper.mapToDomain(rawArticle);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findAll(
|
public async getByArticleId(idArticle: ArticleIdentifier): Promise<CatalogArticle | null> {
|
||||||
queryCriteria?: IQueryCriteria,
|
const articles = await this.getByArticleIds([idArticle]);
|
||||||
): Promise<ICollection<any>> {
|
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(
|
const { rows, count } = await this._findAll(
|
||||||
"Article_Model",
|
"Article_Model",
|
||||||
queryCriteria,
|
queryCriteria
|
||||||
/*{
|
/*{
|
||||||
include: [], // esto es para quitar las asociaciones al hacer la consulta
|
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 adapter = context.adapter;
|
||||||
const repoManager = context.repositoryManager;
|
const repoManager = context.repositoryManager;
|
||||||
|
|
||||||
repoManager.registerRepository(
|
repoManager.registerRepository("Article", (params = { transaction: null }) => {
|
||||||
"Article",
|
const { transaction } = params;
|
||||||
(params = { transaction: null }) => {
|
|
||||||
const { transaction } = params;
|
|
||||||
|
|
||||||
return new CatalogRepository({
|
return new CatalogRepository({
|
||||||
transaction,
|
transaction,
|
||||||
adapter,
|
adapter,
|
||||||
mapper: createArticleMapper(context),
|
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";
|
export * from "./Catalog.context";
|
||||||
import { IRepositoryManager } from "@/contexts/common/domain";
|
|
||||||
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
|
|
||||||
|
|
||||||
export interface ICatalogContext {
|
|
||||||
adapter: ISequelizeAdapter;
|
|
||||||
repositoryManager: IRepositoryManager;
|
|
||||||
services: IApplicationService;
|
|
||||||
}
|
|
||||||
|
|
||||||
export * from "./express";
|
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 "./article.model";
|
||||||
|
export * from "./catalogArticle.model";
|
||||||
|
export * from "./catalogArticleTranslation.model";
|
||||||
|
|||||||
@ -45,11 +45,13 @@ export class CreateQuoteUseCase
|
|||||||
private _repositoryManager: IRepositoryManager;
|
private _repositoryManager: IRepositoryManager;
|
||||||
private _dealer: Dealer;
|
private _dealer: Dealer;
|
||||||
private _transaction: SequelizeBusinessTransactionType;
|
private _transaction: SequelizeBusinessTransactionType;
|
||||||
|
private _catalogArticles?: ISalesContext["catalogArticles"];
|
||||||
|
|
||||||
constructor(context: ISalesContext) {
|
constructor(context: ISalesContext) {
|
||||||
this._adapter = context.adapter;
|
this._adapter = context.adapter;
|
||||||
this._repositoryManager = context.repositoryManager;
|
this._repositoryManager = context.repositoryManager;
|
||||||
this._dealer = context.dealer!;
|
this._dealer = context.dealer!;
|
||||||
|
this._catalogArticles = context.catalogArticles;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(request: ICreateQuote_Request_DTO): Promise<CreateQuoteResponseOrError> {
|
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();
|
this._transaction = this._adapter.startTransaction();
|
||||||
const quoteRepository = this._getQuoteRepository();
|
const quoteRepository = this._getQuoteRepository();
|
||||||
|
|
||||||
@ -311,4 +316,26 @@ export class CreateQuoteUseCase
|
|||||||
private _getQuoteRepository() {
|
private _getQuoteRepository() {
|
||||||
return this._repositoryManager.getRepository<IQuoteRepository>("Quote");
|
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 _adapter: ISequelizeAdapter;
|
||||||
private _repositoryManager: IRepositoryManager;
|
private _repositoryManager: IRepositoryManager;
|
||||||
private _dealer?: Dealer;
|
private _dealer?: Dealer;
|
||||||
|
private _catalogArticles?: ISalesContext["catalogArticles"];
|
||||||
|
|
||||||
constructor(context: ISalesContext) {
|
constructor(context: ISalesContext) {
|
||||||
this._adapter = context.adapter;
|
this._adapter = context.adapter;
|
||||||
this._repositoryManager = context.repositoryManager;
|
this._repositoryManager = context.repositoryManager;
|
||||||
this._dealer = context.dealer;
|
this._dealer = context.dealer;
|
||||||
|
this._catalogArticles = context.catalogArticles;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(request: IUpdateQuoteUseCaseRequest): Promise<UpdateQuoteResponseOrError> {
|
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
|
// regla de negocio -> no poder modificar un quote enviado
|
||||||
|
|
||||||
@ -303,4 +308,26 @@ export class UpdateQuoteUseCase
|
|||||||
private _getQuoteRepository() {
|
private _getQuoteRepository() {
|
||||||
return this._repositoryManager.getRepository<IQuoteRepository>("Quote");
|
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 "./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 { ICommonContext } from "@/contexts/common/infrastructure";
|
||||||
import { Dealer, IQuoteReferenceGeneratorService } from "../domain";
|
import { Dealer, IQuoteReferenceGeneratorService } from "../domain";
|
||||||
|
import { GetCatalogArticlesByIdsUseCase } from "@/contexts/catalog/application";
|
||||||
|
|
||||||
export interface ISalesContext extends ICommonContext {
|
export interface ISalesContext extends ICommonContext {
|
||||||
services?: {
|
services?: {
|
||||||
QuoteReferenceGeneratorService: IQuoteReferenceGeneratorService;
|
QuoteReferenceGeneratorService: IQuoteReferenceGeneratorService;
|
||||||
};
|
};
|
||||||
dealer?: Dealer;
|
dealer?: Dealer;
|
||||||
|
catalogArticles?: GetCatalogArticlesByIdsUseCase;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { CreateQuoteUseCase } from "@/contexts/sales/application";
|
import { CreateQuoteUseCase } from "@/contexts/sales/application";
|
||||||
import { registerQuoteRepository } from "../../../../Quote.repository";
|
import { registerQuoteRepository } from "../../../../Quote.repository";
|
||||||
import { ISalesContext } from "../../../../Sales.context";
|
import { ISalesContext } from "../../../../Sales.context";
|
||||||
|
import { createCatalogArticleLookup } from "@/contexts/catalog/infrastructure";
|
||||||
import { CreateQuoteController } from "./CreateQuote.controller";
|
import { CreateQuoteController } from "./CreateQuote.controller";
|
||||||
import { CreateQuotePresenter } from "./presenter";
|
import { CreateQuotePresenter } from "./presenter";
|
||||||
|
|
||||||
export const createQuoteController = (context: ISalesContext) => {
|
export const createQuoteController = (context: ISalesContext) => {
|
||||||
registerQuoteRepository(context);
|
registerQuoteRepository(context);
|
||||||
|
context.catalogArticles = createCatalogArticleLookup(context);
|
||||||
return new CreateQuoteController(
|
return new CreateQuoteController(
|
||||||
{
|
{
|
||||||
useCase: new CreateQuoteUseCase(context),
|
useCase: new CreateQuoteUseCase(context),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { IInfrastructureError, InfrastructureError } from "@/contexts/common/inf
|
|||||||
import { GetQuoteUseCase } from "@/contexts/sales/application";
|
import { GetQuoteUseCase } from "@/contexts/sales/application";
|
||||||
import { Quote } from "@/contexts/sales/domain";
|
import { Quote } from "@/contexts/sales/domain";
|
||||||
import { IGetQuote_Response_DTO, ensureIdIsValid } from "@shared/contexts";
|
import { IGetQuote_Response_DTO, ensureIdIsValid } from "@shared/contexts";
|
||||||
|
import { Language } from "@shared/contexts";
|
||||||
import { ISalesContext } from "../../../../Sales.context";
|
import { ISalesContext } from "../../../../Sales.context";
|
||||||
import { IGetQuotePresenter } from "./presenter/GetQuote.presenter";
|
import { IGetQuotePresenter } from "./presenter/GetQuote.presenter";
|
||||||
|
|
||||||
@ -55,7 +56,8 @@ export class GetQuoteController extends ExpressController {
|
|||||||
|
|
||||||
const quote = <Quote>result.object;
|
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) {
|
} catch (e: unknown) {
|
||||||
return this.fail(e as IServerError);
|
return this.fail(e as IServerError);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,11 @@ import { registerQuoteRepository } from "../../../../Quote.repository";
|
|||||||
import { ISalesContext } from "../../../../Sales.context";
|
import { ISalesContext } from "../../../../Sales.context";
|
||||||
import { GetQuoteController } from "./GetQuote.controller";
|
import { GetQuoteController } from "./GetQuote.controller";
|
||||||
import { GetQuotePresenter } from "./presenter";
|
import { GetQuotePresenter } from "./presenter";
|
||||||
|
import { createCatalogArticleLookup } from "@/contexts/catalog/infrastructure";
|
||||||
|
|
||||||
export const getQuoteController = (context: ISalesContext) => {
|
export const getQuoteController = (context: ISalesContext) => {
|
||||||
registerQuoteRepository(context);
|
registerQuoteRepository(context);
|
||||||
|
context.catalogArticles = createCatalogArticleLookup(context);
|
||||||
|
|
||||||
return new GetQuoteController(
|
return new GetQuoteController(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,14 +5,21 @@ import {
|
|||||||
} from "@shared/contexts";
|
} from "@shared/contexts";
|
||||||
import { Quote, QuoteItem } from "../../../../../../domain";
|
import { Quote, QuoteItem } from "../../../../../../domain";
|
||||||
import { ISalesContext } from "../../../../../Sales.context";
|
import { ISalesContext } from "../../../../../Sales.context";
|
||||||
|
import { Language } from "@shared/contexts";
|
||||||
|
import { resolveLocalizedQuoteDescriptions } from "@/contexts/sales/application/services";
|
||||||
|
|
||||||
export interface IGetQuotePresenter {
|
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 = {
|
export const GetQuotePresenter: IGetQuotePresenter = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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 {
|
return {
|
||||||
id: quote.id.toString(),
|
id: quote.id.toString(),
|
||||||
status: quote.status.toString(),
|
status: quote.status.toString(),
|
||||||
@ -41,7 +48,7 @@ export const GetQuotePresenter: IGetQuotePresenter = {
|
|||||||
|
|
||||||
total_price: quote.totalPrice.convertScale(2).toObject(),
|
total_price: quote.totalPrice.convertScale(2).toObject(),
|
||||||
|
|
||||||
items: quoteItemPresenter(quote.items, context),
|
items: quoteItemPresenter(quote.items, context, localizedDescriptions),
|
||||||
dealer_id: quote.dealerId.toString(),
|
dealer_id: quote.dealerId.toString(),
|
||||||
|
|
||||||
date_sent: quote.dateSent.toISO8601(),
|
date_sent: quote.dateSent.toISO8601(),
|
||||||
@ -52,12 +59,16 @@ export const GetQuotePresenter: IGetQuotePresenter = {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const quoteItemPresenter = (
|
const quoteItemPresenter = (
|
||||||
items: ICollection<QuoteItem>,
|
items: ICollection<QuoteItem>,
|
||||||
context: ISalesContext
|
context: ISalesContext,
|
||||||
|
localizedDescriptions: Map<string, string>
|
||||||
): IGetQuote_QuoteItem_Response_DTO[] =>
|
): IGetQuote_QuoteItem_Response_DTO[] =>
|
||||||
items.totalCount > 0
|
items.totalCount > 0
|
||||||
? items.items.map((item: QuoteItem) => ({
|
? items.items.map((item: QuoteItem) => ({
|
||||||
id_article: item.idArticle.toString(),
|
id_article: item.idArticle.toString(),
|
||||||
description: item.description.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(),
|
quantity: item.quantity.convertScale(2).toObject(),
|
||||||
unit_price: item.unitPrice.convertScale(2).toObject(),
|
unit_price: item.unitPrice.convertScale(2).toObject(),
|
||||||
subtotal_price: item.subtotalPrice.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 { ISalesContext } from "../../../../Sales.context";
|
||||||
import { ReportQuotePresenter } from "./reporter/ReportQuote.reporter";
|
import { ReportQuotePresenter } from "./reporter/ReportQuote.reporter";
|
||||||
import { ReportQuoteController } from "./ReportQuote.controller";
|
import { ReportQuoteController } from "./ReportQuote.controller";
|
||||||
|
import { createCatalogArticleLookup } from "@/contexts/catalog/infrastructure";
|
||||||
|
|
||||||
export const reportQuoteController = (context: ISalesContext) => {
|
export const reportQuoteController = (context: ISalesContext) => {
|
||||||
registerQuoteRepository(context);
|
registerQuoteRepository(context);
|
||||||
|
context.catalogArticles = createCatalogArticleLookup(context);
|
||||||
|
|
||||||
return new ReportQuoteController(
|
return new ReportQuoteController(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import path from "path";
|
|||||||
import puppeteer from "puppeteer";
|
import puppeteer from "puppeteer";
|
||||||
import report from "puppeteer-report";
|
import report from "puppeteer-report";
|
||||||
import { reportLabels, resolveReportLanguage } from "./ReportQuote.labels";
|
import { reportLabels, resolveReportLanguage } from "./ReportQuote.labels";
|
||||||
|
import { Language } from "@shared/contexts";
|
||||||
|
import { resolveLocalizedQuoteDescriptions } from "@/contexts/sales/application/services";
|
||||||
|
|
||||||
export interface IReportQuoteReporter {
|
export interface IReportQuoteReporter {
|
||||||
toHTML: (quote: Quote, context: ISalesContext) => Promise<string>;
|
toHTML: (quote: Quote, context: ISalesContext) => Promise<string>;
|
||||||
@ -65,6 +67,12 @@ export const ReportQuotePresenter: IReportQuoteReporter = {
|
|||||||
const map = async (quote: Quote, context: ISalesContext) => {
|
const map = async (quote: Quote, context: ISalesContext) => {
|
||||||
const { dealer } = context;
|
const { dealer } = context;
|
||||||
const langCode = resolveReportLanguage(quote.language.toString());
|
const langCode = resolveReportLanguage(quote.language.toString());
|
||||||
|
const reportLanguage = Language.createFromCode(langCode).object;
|
||||||
|
const localizedDescriptions = await resolveLocalizedQuoteDescriptions(
|
||||||
|
quote.items.toArray(),
|
||||||
|
reportLanguage,
|
||||||
|
context.catalogArticles
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: quote.id.toString(),
|
id: quote.id.toString(),
|
||||||
@ -103,7 +111,7 @@ const map = async (quote: Quote, context: ISalesContext) => {
|
|||||||
|
|
||||||
total_price: quote.totalPrice.toFormat(),
|
total_price: quote.totalPrice.toFormat(),
|
||||||
|
|
||||||
items: quoteItemPresenter(quote.items, context),
|
items: quoteItemPresenter(quote.items, context, localizedDescriptions),
|
||||||
|
|
||||||
dealer: {
|
dealer: {
|
||||||
id: dealer?.id.toString(),
|
id: dealer?.id.toString(),
|
||||||
@ -121,11 +129,17 @@ const map = async (quote: Quote, context: ISalesContext) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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.totalCount > 0
|
||||||
? items.items.map((item: QuoteItem) => ({
|
? items.items.map((item: QuoteItem) => ({
|
||||||
id_article: item.idArticle.toString(),
|
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(),
|
quantity: item.quantity.toFormat(),
|
||||||
unit_price: item.unitPrice.toFormat(),
|
unit_price: item.unitPrice.toFormat(),
|
||||||
subtotal_price: item.subtotalPrice.toFormat(),
|
subtotal_price: item.subtotalPrice.toFormat(),
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { UpdateQuoteUseCase } from "@/contexts/sales/application";
|
import { UpdateQuoteUseCase } from "@/contexts/sales/application";
|
||||||
import { registerQuoteRepository } from "../../../../Quote.repository";
|
import { registerQuoteRepository } from "../../../../Quote.repository";
|
||||||
import { ISalesContext } from "../../../../Sales.context";
|
import { ISalesContext } from "../../../../Sales.context";
|
||||||
|
import { createCatalogArticleLookup } from "@/contexts/catalog/infrastructure";
|
||||||
import { UpdateQuoteController } from "./UpdateQuote.controller";
|
import { UpdateQuoteController } from "./UpdateQuote.controller";
|
||||||
import { UpdateQuotePresenter } from "./presenter/UpdateQuote.presenter";
|
import { UpdateQuotePresenter } from "./presenter/UpdateQuote.presenter";
|
||||||
|
|
||||||
export const updateQuoteController = (context: ISalesContext) => {
|
export const updateQuoteController = (context: ISalesContext) => {
|
||||||
registerQuoteRepository(context);
|
registerQuoteRepository(context);
|
||||||
|
context.catalogArticles = createCatalogArticleLookup(context);
|
||||||
return new UpdateQuoteController(
|
return new UpdateQuoteController(
|
||||||
{
|
{
|
||||||
useCase: new UpdateQuoteUseCase(context),
|
useCase: new UpdateQuoteUseCase(context),
|
||||||
|
|||||||
@ -33,6 +33,8 @@ export interface IGetQuote_Response_DTO {
|
|||||||
|
|
||||||
export interface IGetQuote_QuoteItem_Response_DTO {
|
export interface IGetQuote_QuoteItem_Response_DTO {
|
||||||
id_article: string;
|
id_article: string;
|
||||||
|
/** Runtime projection for the authenticated user's language; never persist this field. */
|
||||||
|
localized_description?: string;
|
||||||
quantity: IQuantity_DTO;
|
quantity: IQuantity_DTO;
|
||||||
description: string;
|
description: string;
|
||||||
unit_price: IMoney_DTO;
|
unit_price: IMoney_DTO;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user