This commit is contained in:
David Arranz 2026-06-15 20:27:11 +02:00
parent 6c11dd7027
commit f6f57359d5
52 changed files with 964 additions and 560 deletions

View File

@ -1,4 +1,4 @@
export * from "./mappers"; export * from "./mappers";
export * from "./models/"; export * from "./models/";
export * from "./services"; export * from "./services";
export * from "./services/";

View File

@ -1,6 +1,7 @@
export * from "./di"; export * from "./di";
export * from "./mappers"; export * from "./mappers";
export * from "./models"; export * from "./models";
export * from "./public";
export * from "./repositories"; export * from "./repositories";
export * from "./services"; export * from "./services";
export * from "./snapshot-builders"; export * from "./snapshot-builders";

View File

@ -0,0 +1,3 @@
export * from "./mappers";
export * from "./models";
export * from "./services";

View File

@ -0,0 +1,3 @@
export * from "./payment-term-public-model.mapper";

View File

@ -0,0 +1,14 @@
import type { PaymentTerm } from "../../../domain";
import type { PaymentTermPublicModel } from "../models";
export class PaymentTermPublicModelMapper {
public toPublicModel(paymentTerm: PaymentTerm): PaymentTermPublicModel {
return {
id: paymentTerm.id,
companyId: paymentTerm.companyId,
name: paymentTerm.name.toPrimitive(),
description: paymentTerm.description.map((value) => value.toPrimitive()),
isActive: paymentTerm.isActive,
};
}
}

View File

@ -0,0 +1 @@
export * from "./payment-term-public.model";

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
export interface PaymentTermPublicModel {
id: UniqueID;
companyId: UniqueID;
name: string;
description: Maybe<string>;
isActive: boolean;
}

View File

@ -0,0 +1 @@
export * from "./payment-term-public-finder";

View File

@ -0,0 +1,86 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { PaymentTermPublicModelMapper } from "../mappers";
import type { PaymentTermPublicModel } from "../models";
import type { IPaymentTermRepository } from "../repositories";
export interface FindPaymentTermByIdInCompanyParams {
companyId: UniqueID;
id: UniqueID;
transaction?: unknown;
}
export interface IPaymentTermPublicFinder {
existsByIdInCompany(params: FindPaymentTermByIdInCompanyParams): Promise<Result<boolean, Error>>;
getByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<PaymentTermPublicModel, Error>>;
findByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<Maybe<PaymentTermPublicModel>, Error>>;
}
export class PaymentTermPublicFinder implements IPaymentTermPublicFinder {
public constructor(
private readonly deps: {
repository: IPaymentTermRepository;
mapper: PaymentTermPublicModelMapper;
}
) {}
public async existsByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<boolean, Error>> {
const result = await this.deps.repository.existsByIdInCompany(
params.companyId,
params.id,
params.transaction
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.data);
}
public async getByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<PaymentTermPublicModel, Error>> {
const result = await this.deps.repository.getByIdInCompany(
params.companyId,
params.id,
params.transaction
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(this.deps.mapper.toPublicModel(result.data));
}
public async findByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<Maybe<PaymentTermPublicModel>, Error>> {
const result = await this.deps.repository.findByIdInCompany(
params.companyId,
params.id,
params.transaction
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(
result.data.match(
(paymentTerm) => Maybe.some(this.deps.mapper.toPublicModel(paymentTerm)),
() => Maybe.none<PaymentTermPublicModel>()
)
);
}
}

View File

@ -1,8 +1,8 @@
import type { IPaymentTermCreator } from "./payment-term-creator.service"; import type { IPaymentTermCreator } from "../../services";
import type { IPaymentTermDeleter } from "./payment-term-deleter.service"; import type { IPaymentTermDeleter } from "../../services";
import type { IPaymentTermFinder } from "./payment-term-finder.service"; import type { IPaymentTermFinder } from "../../services";
import type { IPaymentTermStatusChanger } from "./payment-term-status-changer.service"; import type { IPaymentTermStatusChanger } from "../../services";
import type { IPaymentTermUpdater } from "./payment-term-updater.service"; import type { IPaymentTermUpdater } from "../../services";
export interface IPaymentTermPublicServices { export interface IPaymentTermPublicServices {
finder: IPaymentTermFinder; finder: IPaymentTermFinder;

View File

@ -1,6 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server"; import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils"; import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { PaymentTerm } from "../../../domain"; import type { PaymentTerm } from "../../../domain";
import type { PaymentTermSummary } from "../models/payment-term-summary.model"; import type { PaymentTermSummary } from "../models/payment-term-summary.model";
@ -23,6 +23,11 @@ export interface IPaymentTermRepository {
id: UniqueID, id: UniqueID,
transaction?: unknown transaction?: unknown
): Promise<Result<PaymentTerm, Error>>; ): Promise<Result<PaymentTerm, Error>>;
findByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<PaymentTerm>, Error>>;
findByCriteriaInCompany( findByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,

View File

@ -1,6 +1,7 @@
export * from "../public/services/payment-term-public-services";
export * from "./payment-term-creator.service"; export * from "./payment-term-creator.service";
export * from "./payment-term-deleter.service"; export * from "./payment-term-deleter.service";
export * from "./payment-term-finder.service"; export * from "./payment-term-finder.service";
export * from "./payment-term-public-services";
export * from "./payment-term-status-changer.service"; export * from "./payment-term-status-changer.service";
export * from "./payment-term-updater.service"; export * from "./payment-term-updater.service";

View File

@ -16,6 +16,7 @@ import {
} from "./infrastructure/di/catalogs.di"; } from "./infrastructure/di/catalogs.di";
export * from "./application/payment-methods/public"; export * from "./application/payment-methods/public";
export * from "./application/payment-terms/public";
export * from "./application/tax-definitions/public"; export * from "./application/tax-definitions/public";
export * from "./application/tax-regimes/public"; export * from "./application/tax-regimes/public";

View File

@ -191,9 +191,12 @@ export class SequelizePaymentMethodRepository
): Promise<Result<Collection<PaymentMethodSummary>, Error>> { ): Promise<Result<Collection<PaymentMethodSummary>, Error>> {
try { try {
const criteriaConverter = new CriteriaToSequelizeConverter(); const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, { const criteriaQuery = criteriaConverter.convert(criteria, {
mappings: { mappings: {
isActive: "is_active", isActive: {
type: "root",
column: "is_active",
},
}, },
searchableFields: [], searchableFields: [],
sortableFields: ["name"], sortableFields: ["name"],
@ -202,19 +205,19 @@ export class SequelizePaymentMethodRepository
strictMode: true, // fuerza error si ORDER BY no permitido strictMode: true, // fuerza error si ORDER BY no permitido
}); });
query.where = { criteriaQuery.where = {
...query.where, ...criteriaQuery.where,
company_id: companyId.toString(), company_id: companyId.toString(),
deleted_at: null, deleted_at: null,
}; };
const [rows, count] = await Promise.all([ const [rows, count] = await Promise.all([
PaymentMethodModel.findAll({ PaymentMethodModel.findAll({
...query, ...criteriaQuery,
transaction, transaction,
}), }),
PaymentMethodModel.count({ PaymentMethodModel.count({
where: query.where, where: criteriaQuery.where,
distinct: true, // evita duplicados por LEFT JOIN distinct: true, // evita duplicados por LEFT JOIN
transaction, transaction,
}), }),

View File

@ -6,7 +6,7 @@ import {
} from "@erp/core/api"; } from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils"; import { type Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize"; import type { Sequelize, Transaction } from "sequelize";
import type { PaymentTermSummary } from "../../../../../application/payment-terms/models/payment-term-summary.model"; import type { PaymentTermSummary } from "../../../../../application/payment-terms/models/payment-term-summary.model";
@ -118,7 +118,10 @@ export class SequelizePaymentTermRepository
const query = criteriaConverter.convert(criteria, { const query = criteriaConverter.convert(criteria, {
searchableFields: [], searchableFields: [],
mappings: { mappings: {
isActive: "is_active", isActive: {
type: "root",
column: "is_active",
},
}, },
sortableFields: ["name"], sortableFields: ["name"],
enableFullText: true, enableFullText: true,
@ -151,6 +154,35 @@ export class SequelizePaymentTermRepository
} }
} }
async findByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: Transaction
): Promise<Result<Maybe<PaymentTerm>, Error>> {
try {
const row = await PaymentTermModel.findOne({
where: {
id: id.toString(),
company_id: companyId.toString(),
},
transaction,
});
if (!row) {
return Result.ok(Maybe.none<PaymentTerm>());
}
const mappedResult = this.domainMapper.mapToDomain(row);
if (mappedResult.isFailure) {
return Result.fail(mappedResult.error);
}
return Result.ok(Maybe.some(mappedResult.data));
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
async deleteByIdInCompany( async deleteByIdInCompany(
companyId: UniqueID, companyId: UniqueID,
id: UniqueID, id: UniqueID,

View File

@ -188,7 +188,10 @@ export class SequelizeTaxDefinitionRepository
const criteriaConverter = new CriteriaToSequelizeConverter(); const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, { const query = criteriaConverter.convert(criteria, {
mappings: { mappings: {
isActive: "is_active", isActive: {
type: "root",
column: "is_active",
},
}, },
searchableFields: ["code", "name", "description", "invoice_note"], searchableFields: ["code", "name", "description", "invoice_note"],
sortableFields: ["code", "name"], sortableFields: ["code", "name"],

View File

@ -204,7 +204,10 @@ export class SequelizeTaxRegimeRepository
const criteriaConverter = new CriteriaToSequelizeConverter(); const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, { const query = criteriaConverter.convert(criteria, {
mappings: { mappings: {
isActive: "is_active", isActive: {
type: "root",
column: "is_active",
},
}, },
searchableFields: [], searchableFields: [],
sortableFields: ["code"], sortableFields: ["code"],

View File

@ -1,167 +0,0 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import { Op, type OrderItem, type WhereOptions, literal } from "sequelize";
// Campos físicos (DB) que permitimos filtrar/ordenar
const ALLOWED_FILTERS = {
id: "id",
customerId: "customer_id",
invoiceSeries: "invoice_series",
invoiceNumber: "invoice_number",
invoiceDate: "invoice_date",
status: "status",
currencyCode: "currency_code",
// Rango por total (en unidades menores)
totalAmountValue: "total_amount_value",
} as const;
const ALLOWED_SORT: Record<string, string | string[]> = {
// Sort "invoiceDate" realmente ordena por (invoice_date DESC, invoice_series ASC, invoice_number DESC, id DESC)
invoiceDate: ["invoice_date"],
invoiceNumber: ["invoice_number"],
invoiceSeries: ["invoice_series"],
status: ["status"],
createdAt: ["created_at"],
updatedAt: ["updated_at"],
};
// Proyección mínima para el listado (evita N+1 y payloads grandes)
export const DEFAULT_LIST_ATTRIBUTES = [
"id",
"company_id",
"customer_id",
"invoice_series",
"invoice_number",
"invoice_date",
"status",
"total_amount_value",
"total_amount_scale",
"currency_code",
// Agregamos itemsCount por subconsulta (no es columna real)
] as const;
type Sanitized = {
where: WhereOptions;
order: OrderItem[];
limit: number;
offset: number;
attributes: (string | any)[];
// keyset opcional
keyset?: {
after?: { invoiceDate: string; invoiceSeries: string; invoiceNumber: number; id: string };
};
};
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 25;
export function sanitizeListCriteria(criteria: Criteria): Sanitized {
const { filters = {}, sort = [], pagination = {}, search } = (criteria ?? {}) as any;
// LIMIT/OFFSET
const rawLimit = Number(pagination?.limit ?? DEFAULT_LIMIT);
const rawOffset = Number(pagination?.offset ?? 0);
const limit = Number.isFinite(rawLimit)
? Math.max(1, Math.min(MAX_LIMIT, rawLimit))
: DEFAULT_LIMIT;
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
// WHERE base siempre por seguridad a nivel superior (company en repo)
const where: WhereOptions = {};
// Búsqueda libre (q) → aplicar sobre campos concretos (series/number/status)
if (typeof search?.q === "string" && search.q.trim() !== "") {
const q = `%${search.q.trim()}%`;
Object.assign(where, {
[Op.or]: [
{ invoice_series: { [Op.like]: q } },
{ status: { [Op.like]: q } },
// Buscar por número como texto
literal(`CAST(invoice_number AS CHAR) LIKE ${escapeLikeLiteral(q)}`),
],
});
}
// Filtros permitidos
for (const [key, value] of Object.entries(filters)) {
const column = (ALLOWED_FILTERS as any)[key];
if (!column) {
throw forbiddenFilter(key);
}
if (value == null) continue;
// Operadores sencillos permitidos
if (typeof value === "object" && !Array.isArray(value)) {
const v = value as any;
const ops: any = {};
if (v.eq !== undefined) ops[Op.eq] = v.eq;
if (v.ne !== undefined) ops[Op.ne] = v.ne;
if (v.in !== undefined && Array.isArray(v.in)) ops[Op.in] = v.in;
if (v.like !== undefined) ops[Op.like] = `%${String(v.like)}%`;
if (v.gte !== undefined) ops[Op.gte] = v.gte;
if (v.lte !== undefined) ops[Op.lte] = v.lte;
if (Object.keys(ops).length === 0) continue;
(where as any)[column] = ops;
} else {
(where as any)[column] = value;
}
}
// ORDER (determinista)
const order: OrderItem[] = [];
const sortArray = Array.isArray(sort)
? sort
: String(sort || "")
.split(",")
.filter(Boolean);
if (sortArray.length === 0) {
// orden por defecto: invoice_date desc, invoice_series asc, invoice_number desc, id desc
order.push(
["invoice_date", "DESC"],
["invoice_series", "ASC"],
["invoice_number", "DESC"],
["id", "DESC"]
);
} else {
for (const part of sortArray) {
const desc = String(part).startsWith("-");
const key = desc ? String(part).slice(1) : String(part);
const allowed = ALLOWED_SORT[key];
if (!allowed) throw forbiddenSort(key);
const cols = Array.isArray(allowed) ? allowed : [allowed];
for (const c of cols) {
order.push([c, desc ? "DESC" : "ASC"]);
}
}
// tiebreaker final
order.push(["id", "DESC"]);
}
// Atributos (proyección)
const attributes: any[] = [...DEFAULT_LIST_ATTRIBUTES];
// itemsCount por subconsulta (evitamos include)
attributes.push([
literal(
"(SELECT COUNT(1) FROM customer_invoice_items it WHERE it.invoice_id = customer_invoices.id AND it.deleted_at IS NULL)"
),
"itemsCount",
]);
return { where, order, limit, offset, attributes };
}
// Helpers
function forbiddenFilter(field: string) {
const e = new Error(`Filter "${field}" is not allowed`);
(e as any).code = "FORBIDDEN_FILTER";
return e;
}
function forbiddenSort(field: string) {
const e = new Error(`Sort "${field}" is not allowed`);
(e as any).code = "FORBIDDEN_SORT";
return e;
}
function escapeLikeLiteral(v: string) {
// Simple escapado para usar en literal; asume conexión confiable. Para máxima seguridad usa replacements.
return `'${v.replace(/'/g, "''")}'`;
}

View File

@ -1,73 +0,0 @@
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { useTranslation } from "../../../../../i18n";
import type { ProformaList, ProformaListRow } from "../../../../shared";
interface ProformasGridProps {
data?: ProformaList;
loading: boolean;
fetching?: boolean;
columns: ColumnDef<ProformaListRow, unknown>[];
pageIndex: number;
pageSize: number;
onPageChange: (pageIndex: number) => void;
onPageSizeChange: (size: number) => void;
onRowClick?: (proformaId: string) => void;
}
export const ProformasGrid = ({
data,
loading,
fetching,
columns,
pageIndex,
pageSize,
onPageChange,
onPageSizeChange,
onRowClick,
}: ProformasGridProps) => {
const { t } = useTranslation();
const { items, totalItems } = data || { items: [], totalItems: 0 };
if (loading) {
return (
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: totalItems ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
);
}
return (
<div className="mx-auto space-y-4">
{/*
* ─── CAPA DE FADE ──────────────────────────────────────────────────────
* Div absolutamente posicionado sobre el área scrollable.
* - pointer-events: none → no bloquea interacciones.
* - linear-gradient → de transparente a bg-card (blanco/oscuro).
* - z-10 → queda por DEBAJO de la columna sticky (z-20).
* - Solo visible cuando aún hay contenido a la derecha.
* ────────────────────────────────────────────────────────────────────────
*/}
<DataTable
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={(row) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={totalItems}
/>
</div>
);
};

View File

@ -1,5 +1,3 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
import { import {
CancelActionButton, CancelActionButton,

View File

@ -36,6 +36,7 @@
"dependencies": { "dependencies": {
"@erp/auth": "workspace:*", "@erp/auth": "workspace:*",
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@repo/i18next": "workspace:*", "@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*", "@repo/rdx-criteria": "workspace:*",

View File

@ -0,0 +1,20 @@
import type { Maybe } from "@repo/rdx-utils";
import type { Customer } from "../../domain";
import type { CustomerPaymentMethodReadModel } from "./customer-payment-method-read.model";
import type { CustomerPaymentTermReadModel } from "./customer-payment-term-read.model";
import type { CustomerTaxRegimeFullReadModel } from "./customer-tax-regime-full-read.model";
/**
* Modelo de un cliente con datos accesorios.
*
* Combina el agregado con datos auxiliares necesarios para
* construir la respuesta API generada por el snapshot builder.
*/
export interface CustomerFullReadModel {
customer: Customer;
paymentMethod: Maybe<CustomerPaymentMethodReadModel>;
paymentTerm: Maybe<CustomerPaymentTermReadModel>;
taxRegime: Maybe<CustomerTaxRegimeFullReadModel>;
}

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
/**
* Datos del método de pago que completan al cliente
*/
export interface CustomerPaymentMethodReadModel {
id: UniqueID;
name: string;
description: Maybe<string>;
}

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
/**
* Datos del plazo de pago que completan al cliente
*/
export interface CustomerPaymentTermReadModel {
id: UniqueID;
name: string;
description: Maybe<string>;
}

View File

@ -0,0 +1,8 @@
/**
* Datos del régimen de pago que completan al cliente
*/
export interface CustomerTaxRegimeFullReadModel {
code: string;
description: string;
}

View File

@ -1 +1,5 @@
export * from "./customer-full-read-model";
export * from "./customer-payment-method-read.model";
export * from "./customer-payment-term-read.model";
export * from "./customer-summary"; export * from "./customer-summary";
export * from "./customer-tax-regime-full-read.model";

View File

@ -1,2 +0,0 @@
export * from "./domain";
export * from "./queries";

View File

@ -1 +0,0 @@
export * from "./list-customers.presenter";

View File

@ -1,84 +0,0 @@
import type { CriteriaDTO } from "@erp/core";
import { Presenter } from "@erp/core/api";
import type { Criteria } from "@repo/rdx-criteria/server";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import type { Collection } from "@repo/rdx-utils";
import type { ListCustomersResponseDTO } from "../../../../common/dto";
import type { CustomerListDTO } from "../../../infrastructure/mappers";
export class ListCustomersPresenter extends Presenter {
protected _mapCustomer(customer: CustomerListDTO) {
const address = customer.address.toPrimitive();
return {
id: customer.id.toPrimitive(),
company_id: customer.companyId.toPrimitive(),
reference: maybeToEmptyString(customer.reference, (value) => value.toPrimitive()),
is_company: String(customer.isCompany),
name: customer.name.toPrimitive(),
trade_name: maybeToEmptyString(customer.tradeName, (value) => value.toPrimitive()),
tin: maybeToEmptyString(customer.tin, (value) => value.toPrimitive()),
street: maybeToEmptyString(address.street, (value) => value.toPrimitive()),
street2: maybeToEmptyString(address.street2, (value) => value.toPrimitive()),
city: maybeToEmptyString(address.city, (value) => value.toPrimitive()),
province: maybeToEmptyString(address.province, (value) => value.toPrimitive()),
postal_code: maybeToEmptyString(address.postalCode, (value) => value.toPrimitive()),
country: maybeToEmptyString(address.country, (value) => value.toPrimitive()),
email_primary: maybeToEmptyString(customer.emailPrimary, (value) => value.toPrimitive()),
email_secondary: maybeToEmptyString(customer.emailSecondary, (value) => value.toPrimitive()),
phone_primary: maybeToEmptyString(customer.phonePrimary, (value) => value.toPrimitive()),
phone_secondary: maybeToEmptyString(customer.phoneSecondary, (value) => value.toPrimitive()),
mobile_primary: maybeToEmptyString(customer.mobilePrimary, (value) => value.toPrimitive()),
mobile_secondary: maybeToEmptyString(customer.mobileSecondary, (value) =>
value.toPrimitive()
),
fax: maybeToEmptyString(customer.fax, (value) => value.toPrimitive()),
website: maybeToEmptyString(customer.website, (value) => value.toPrimitive()),
status: customer.status ? "active" : "inactive",
language_code: customer.languageCode.toPrimitive(),
currency_code: customer.currencyCode.toPrimitive(),
metadata: {
entity: "customer",
//created_at: customer.createdAt.toPrimitive(),
//updated_at: customer.updatedAt.toPrimitive()
},
};
}
toOutput(params: {
customers: Collection<CustomerListDTO>;
criteria: Criteria;
}): ListCustomersResponseDTO {
const { customers, criteria } = params;
const items = customers.map((customer) => this._mapCustomer(customer));
const totalItems = customers.total();
return {
page: criteria.pageNumber,
per_page: criteria.pageSize,
total_pages: Math.ceil(totalItems / criteria.pageSize),
total_items: totalItems,
items: items,
metadata: {
entity: "customers",
criteria: criteria.toJSON() as CriteriaDTO,
//links: {
// self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,
// first: `/api/customer-invoices?page=1&per_page=${criteria.pageSize}`,
// last: `/api/customer-invoices?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`,
//},
},
};
}
}

View File

@ -0,0 +1,196 @@
import type {
IPaymentMethodPublicFinder,
IPaymentTermPublicFinder,
ITaxRegimePublicFinder,
} from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Customer } from "../../../domain";
import type {
CustomerFullReadModel,
CustomerPaymentMethodReadModel,
CustomerTaxRegimeFullReadModel,
} from "../../models";
export interface ICustomerFullReadModelAssembler {
assemble(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<CustomerFullReadModel, Error>>;
}
/**
* Enriquece un cliente recuperado con
* datos accesorios recuperados del catálogo.
*
* No modifica el agregado. Su responsabilidad es
* preparar el modelo completo que recibirá el
* snapshot builder después.
*/
export class CustomerFullReadModelAssembler implements ICustomerFullReadModelAssembler {
public constructor(
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
}
) {}
public async assemble(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<CustomerFullReadModel, Error>> {
const paymentMethod = await this.resolvePaymentMethod(params);
if (paymentMethod.isFailure) {
return Result.fail(paymentMethod.error);
}
const paymentTerm = await this.resolvePaymentTerm(params);
if (paymentTerm.isFailure) {
return Result.fail(paymentTerm.error);
}
const taxRegime = await this.resolveTaxRegime(params);
if (taxRegime.isFailure) {
return Result.fail(taxRegime.error);
}
return Result.ok({
customer: params.customer,
paymentMethod: paymentMethod.data,
paymentTerm: paymentTerm.data,
taxRegime: taxRegime.data,
});
}
private async resolvePaymentMethod(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<Maybe<CustomerPaymentMethodReadModel>, Error>> {
if (params.customer.paymentMethodId.isNone()) {
return Result.ok(Maybe.none());
}
const paymentMethodId = params.customer.paymentMethodId.unwrap();
const result = await this.deps.paymentMethodFinder.findByIdInCompany({
companyId: params.companyId,
id: paymentMethodId,
transaction: params.transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.ok(
Maybe.some({
id: paymentMethodId,
name: "",
description: Maybe.none(),
})
);
}
const paymentMethod = result.data.unwrap();
return Result.ok(
Maybe.some({
id: paymentMethod.id,
name: paymentMethod.name.toString(),
description: paymentMethod.description ?? null,
})
);
}
private async resolvePaymentTerm(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<Maybe<CustomerPaymentMethodReadModel>, Error>> {
if (params.customer.paymentTermId.isNone()) {
return Result.ok(Maybe.none());
}
const paymentTermId = params.customer.paymentTermId.unwrap();
const result = await this.deps.paymentTermFinder.findByIdInCompany({
companyId: params.companyId,
id: paymentTermId,
transaction: params.transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.ok(
Maybe.some({
id: paymentTermId,
name: "",
description: Maybe.none(),
})
);
}
const paymentTerm = result.data.unwrap();
return Result.ok(
Maybe.some({
id: paymentTerm.id,
name: paymentTerm.name.toString(),
description: paymentTerm.description ?? null,
})
);
}
private async resolveTaxRegime(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<Maybe<CustomerTaxRegimeFullReadModel>, Error>> {
if (params.customer.taxRegimeCode.isNone()) {
return Result.ok(Maybe.none());
}
const taxRegimeCode = params.customer.taxRegimeCode.unwrap();
const result = await this.deps.taxRegimeFinder.findByCodeInCompany({
companyId: params.companyId,
code: taxRegimeCode,
transaction: params.transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.ok(
Maybe.some({
code: taxRegimeCode,
description: "",
})
);
}
const taxRegime = result.data.unwrap();
return Result.ok(
Maybe.some({
code: taxRegime.code,
description: taxRegime.description,
})
);
}
}

View File

@ -0,0 +1 @@
export * from "./customer-full-read-model.assembler";

View File

@ -1,14 +1,17 @@
import type { ISnapshotBuilder } from "@erp/core/api"; import type { ISnapshotBuilder } from "@erp/core/api";
import { toNullable } from "@repo/rdx-ddd"; import { maybeToEmptyString, maybeToNullable, toNullable } from "@repo/rdx-ddd";
import type { GetCustomerByIdResponseDTO } from "../../../../common"; import type { GetCustomerByIdResponseDTO } from "../../../../common";
import type { Customer } from "../../../domain"; import type { CustomerFullReadModel } from "../../models";
export type CustomerFullSnapshot = GetCustomerByIdResponseDTO;
export interface ICustomerFullSnapshotBuilder export interface ICustomerFullSnapshotBuilder
extends ISnapshotBuilder<Customer, GetCustomerByIdResponseDTO> {} extends ISnapshotBuilder<CustomerFullReadModel, CustomerFullSnapshot> {}
export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder { export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder {
toOutput(customer: Customer): GetCustomerByIdResponseDTO { toOutput(source: CustomerFullReadModel): CustomerFullSnapshot {
const { customer, paymentMethod, taxRegime, paymentTerm } = source;
const address = customer.address.toPrimitive(); const address = customer.address.toPrimitive();
return { return {
@ -47,7 +50,24 @@ export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder
legal_record: toNullable(customer.legalRecord, (value) => value.toPrimitive()), legal_record: toNullable(customer.legalRecord, (value) => value.toPrimitive()),
default_taxes: customer.defaultTaxes.toKey(), payment_method: maybeToNullable(paymentMethod, (paymentMethod) => ({
id: paymentMethod.id.toString(),
name: paymentMethod.name,
description: maybeToEmptyString(paymentMethod.description),
})),
payment_term: maybeToNullable(paymentTerm, (paymentTerm) => ({
id: paymentTerm.id.toString(),
name: paymentTerm.name,
description: maybeToEmptyString(paymentTerm.description),
})),
tax_regime: maybeToNullable(taxRegime, (taxRegime) => taxRegime),
fiscal_defaults: {
uses_equivalence_surcharge: customer.usesEquivalenceSurcharge,
uses_retention: customer.usesRetention,
},
language_code: customer.languageCode.toPrimitive(), language_code: customer.languageCode.toPrimitive(),
currency_code: customer.currencyCode.toPrimitive(), currency_code: customer.currencyCode.toPrimitive(),

View File

@ -3,6 +3,7 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { ICustomerFinder } from "../services"; import type { ICustomerFinder } from "../services";
import type { ICustomerFullReadModelAssembler } from "../services/assemblers";
import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders"; import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders";
type GetCustomerUseCaseInput = { type GetCustomerUseCaseInput = {
@ -11,25 +12,29 @@ type GetCustomerUseCaseInput = {
}; };
export class GetCustomerByIdUseCase { export class GetCustomerByIdUseCase {
constructor( public constructor(
private readonly finder: ICustomerFinder, private readonly deps: {
private readonly fullSnapshotBuilder: ICustomerFullSnapshotBuilder, finder: ICustomerFinder;
private readonly transactionManager: ITransactionManager fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}
) {} ) {}
public execute(params: GetCustomerUseCaseInput) { public execute(params: GetCustomerUseCaseInput) {
const { customer_id, companyId } = params; const { customer_id, companyId } = params;
const idOrError = UniqueID.create(customer_id); const idOrError = UniqueID.create(customer_id);
if (idOrError.isFailure) { if (idOrError.isFailure) {
return Result.fail(idOrError.error); return Result.fail(idOrError.error);
} }
const customerId = idOrError.data; const customerId = idOrError.data;
return this.transactionManager.complete(async (transaction) => { return this.deps.transactionManager.complete(async (transaction) => {
try { try {
const customerResult = await this.finder.findCustomerById( const customerResult = await this.deps.finder.findCustomerById(
companyId, companyId,
customerId, customerId,
transaction transaction
@ -39,7 +44,17 @@ export class GetCustomerByIdUseCase {
return Result.fail(customerResult.error); return Result.fail(customerResult.error);
} }
const fullSnapshot = this.fullSnapshotBuilder.toOutput(customerResult.data); const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId,
customer: customerResult.data,
transaction,
});
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data);
return Result.ok(fullSnapshot); return Result.ok(fullSnapshot);
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -15,7 +15,7 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils"; import { type Maybe, Result } from "@repo/rdx-utils";
import { type CustomerStatus, CustomerTaxes } from "../value-objects"; import type { CustomerStatus } from "../value-objects";
export interface ICustomerCreateProps { export interface ICustomerCreateProps {
companyId: UniqueID; companyId: UniqueID;
@ -43,7 +43,11 @@ export interface ICustomerCreateProps {
legalRecord: Maybe<TextValue>; legalRecord: Maybe<TextValue>;
defaultTaxes: CustomerTaxes; paymentMethodId: Maybe<UniqueID>;
paymentTermId: Maybe<UniqueID>;
taxRegimeCode: Maybe<string>;
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
languageCode: LanguageCode; languageCode: LanguageCode;
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
@ -60,54 +64,50 @@ export interface ICustomer {
// comportamiento // comportamiento
update(partialCustomer: CustomerPatchProps): Result<void, Error>; update(partialCustomer: CustomerPatchProps): Result<void, Error>;
// propiedades (getters) isIndividual: boolean;
readonly isIndividual: boolean; isCompany: boolean;
readonly isCompany: boolean; isActive: boolean;
readonly isActive: boolean;
readonly companyId: UniqueID; companyId: UniqueID;
readonly reference: Maybe<Name>; reference: Maybe<Name>;
readonly name: Name; name: Name;
readonly tradeName: Maybe<Name>; tradeName: Maybe<Name>;
readonly tin: Maybe<TINNumber>; tin: Maybe<TINNumber>;
readonly address: PostalAddress; address: PostalAddress;
readonly emailPrimary: Maybe<EmailAddress>; emailPrimary: Maybe<EmailAddress>;
readonly emailSecondary: Maybe<EmailAddress>; emailSecondary: Maybe<EmailAddress>;
readonly phonePrimary: Maybe<PhoneNumber>; phonePrimary: Maybe<PhoneNumber>;
readonly phoneSecondary: Maybe<PhoneNumber>; phoneSecondary: Maybe<PhoneNumber>;
readonly mobilePrimary: Maybe<PhoneNumber>; mobilePrimary: Maybe<PhoneNumber>;
readonly mobileSecondary: Maybe<PhoneNumber>; mobileSecondary: Maybe<PhoneNumber>;
readonly fax: Maybe<PhoneNumber>; fax: Maybe<PhoneNumber>;
readonly website: Maybe<URLAddress>; website: Maybe<URLAddress>;
readonly legalRecord: Maybe<TextValue>; legalRecord: Maybe<TextValue>;
readonly defaultTaxes: CustomerTaxes; paymentMethodId: Maybe<UniqueID>;
paymentTermId: Maybe<UniqueID>;
taxRegimeCode: Maybe<string>;
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
readonly languageCode: LanguageCode; languageCode: LanguageCode;
readonly currencyCode: CurrencyCode; currencyCode: CurrencyCode;
} }
export type CustomerInternalProps = Omit<ICustomerCreateProps, "address" | "defaultTaxes">; export type CustomerInternalProps = Omit<ICustomerCreateProps, "address">;
export class Customer extends AggregateRoot<CustomerInternalProps> implements ICustomer { export class Customer extends AggregateRoot<CustomerInternalProps> implements ICustomer {
private _address: PostalAddress; private _address: PostalAddress;
private _defaultTaxes: CustomerTaxes;
protected constructor( protected constructor(props: CustomerInternalProps, address: PostalAddress, id?: UniqueID) {
props: CustomerInternalProps,
address: PostalAddress,
defaultTaxes: CustomerTaxes,
id?: UniqueID
) {
super(props, id); super(props, id);
this._address = address; this._address = address;
this._defaultTaxes = defaultTaxes;
} }
static create(props: ICustomerCreateProps, id?: UniqueID): Result<Customer, Error> { static create(props: ICustomerCreateProps, id?: UniqueID): Result<Customer, Error> {
@ -117,7 +117,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
return Result.fail(validationResult.error); return Result.fail(validationResult.error);
} }
const { address, defaultTaxes, ...internalProps } = props; const { address, ...internalProps } = props;
// Postal Address // Postal Address
const postalAddressResult = PostalAddress.create(address); const postalAddressResult = PostalAddress.create(address);
@ -126,19 +126,12 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
} }
const postalAddress = postalAddressResult.data; const postalAddress = postalAddressResult.data;
// Customer Taxes
const taxesResult = CustomerTaxes.create(defaultTaxes);
if (taxesResult.isFailure) {
return Result.fail(taxesResult.error);
}
const taxes = taxesResult.data;
// Reglas de negocio / validaciones // Reglas de negocio / validaciones
// ... // ...
// ... // ...
// Crear instancia de Customer // Crear instancia de Customer
const contact = new Customer(internalProps, postalAddress, taxes, id); const contact = new Customer(internalProps, postalAddress, id);
// Disparar eventos de dominio // Disparar eventos de dominio
// ... // ...
@ -152,17 +145,12 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
} }
// Rehidratación desde persistencia // Rehidratación desde persistencia
static rehydrate( static rehydrate(props: CustomerInternalProps, address: PostalAddress, id: UniqueID): Customer {
props: CustomerInternalProps, return new Customer(props, address, id);
address: PostalAddress,
defaultTaxes: CustomerTaxes,
id: UniqueID
): Customer {
return new Customer(props, address, defaultTaxes, id);
} }
public update(partialCustomer: CustomerPatchProps): Result<void, Error> { public update(partialCustomer: CustomerPatchProps): Result<void, Error> {
const { address: partialAddress, defaultTaxes: partialTaxes, ...rest } = partialCustomer; const { address: partialAddress, ...rest } = partialCustomer;
const nextProps: CustomerInternalProps = { const nextProps: CustomerInternalProps = {
...this.props, ...this.props,
@ -170,7 +158,6 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
}; };
let nextAddress = this._address; let nextAddress = this._address;
let nextDefaultTaxes = this._defaultTaxes;
if (partialAddress) { if (partialAddress) {
const nextAddressResult = PostalAddress.create({ const nextAddressResult = PostalAddress.create({
@ -185,22 +172,8 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
nextAddress = nextAddressResult.data; nextAddress = nextAddressResult.data;
} }
if (partialTaxes) {
const nextTaxesResult = CustomerTaxes.create({
...this._defaultTaxes.getProps(),
...partialTaxes,
});
if (nextTaxesResult.isFailure) {
return Result.fail(nextTaxesResult.error);
}
nextDefaultTaxes = nextTaxesResult.data;
}
Object.assign(this.props, nextProps); Object.assign(this.props, nextProps);
this._address = nextAddress; this._address = nextAddress;
this._defaultTaxes = nextDefaultTaxes;
return Result.ok(); return Result.ok();
} }
@ -279,8 +252,24 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
return this.props.legalRecord; return this.props.legalRecord;
} }
public get defaultTaxes(): CustomerTaxes { public get paymentMethodId(): Maybe<UniqueID> {
return this._defaultTaxes; return this.props.paymentMethodId;
}
public get paymentTermId(): Maybe<UniqueID> {
return this.props.paymentTermId;
}
public get taxRegimeCode(): Maybe<string> {
return this.props.taxRegimeCode;
}
public get usesEquivalenceSurcharge(): boolean {
return this.props.usesEquivalenceSurcharge;
}
public get usesRetention(): boolean {
return this.props.usesRetention;
} }
public get languageCode(): LanguageCode { public get languageCode(): LanguageCode {

View File

@ -24,12 +24,7 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { import { Customer, type CustomerInternalProps, CustomerStatus } from "../../../../../domain";
Customer,
type CustomerInternalProps,
CustomerStatus,
CustomerTaxes,
} from "../../../../../domain";
import type { CustomerCreationAttributes, CustomerModel } from "../../models"; import type { CustomerCreationAttributes, CustomerModel } from "../../models";
export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
@ -179,12 +174,30 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
errors errors
); );
const defaultTaxes = extractOrPushError( const paymentMethodId = extractOrPushError(
CustomerTaxes.fromKey(source.default_taxes, this.taxCatalog), maybeFromNullableResult(source.payment_method_id, (value) =>
"default_taxes", UniqueID.create(String(value))
),
"payment_method_id",
errors errors
); );
const paymentTermId = extractOrPushError(
maybeFromNullableResult(source.payment_term_id, (value) => UniqueID.create(String(value))),
"payment_term_id",
errors
);
// Tax regime code
const taxRegimeCode = extractOrPushError(
maybeFromNullableResult(source.tax_regime_code, (value) => Result.ok(String(value))),
"tax_regime_code",
errors
);
const usesEquivalenceSurcharge = source.uses_equivalence_surcharge;
const usesRetention = source.uses_retention;
// Now, create the PostalAddress VO // Now, create the PostalAddress VO
const postalAddressProps = { const postalAddressProps = {
street: street!, street: street!,
@ -226,16 +239,18 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
website: website!, website: website!,
legalRecord: legalRecord!, legalRecord: legalRecord!,
paymentMethodId: paymentMethodId!,
paymentTermId: paymentTermId!,
taxRegimeCode: taxRegimeCode!,
usesEquivalenceSurcharge: usesEquivalenceSurcharge,
usesRetention: usesRetention,
languageCode: languageCode!, languageCode: languageCode!,
currencyCode: currencyCode!, currencyCode: currencyCode!,
}; };
const customer = Customer.rehydrate( const customer = Customer.rehydrate(customerProps, postalAddress!, customerId!);
customerProps,
postalAddress!,
defaultTaxes!,
customerId!
);
return Result.ok(customer); return Result.ok(customer);
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(err as Error); return Result.fail(err as Error);
@ -266,7 +281,12 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
website: maybeToNullable(source.website, (website) => website.toPrimitive()), website: maybeToNullable(source.website, (website) => website.toPrimitive()),
legal_record: maybeToNullable(source.legalRecord, (legalRecord) => legalRecord.toPrimitive()), legal_record: maybeToNullable(source.legalRecord, (legalRecord) => legalRecord.toPrimitive()),
default_taxes: source.defaultTaxes.toKey(),
payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()),
payment_term_id: maybeToNullable(source.paymentTermId, (value) => value.toPrimitive()),
tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value),
uses_equivalence_surcharge: source.usesEquivalenceSurcharge,
uses_retention: source.usesRetention,
status: source.isActive ? "active" : "inactive", status: source.isActive ? "active" : "inactive",
language_code: source.languageCode.toPrimitive(), language_code: source.languageCode.toPrimitive(),

View File

@ -51,8 +51,14 @@ export class CustomerModel extends Model<
declare legal_record: CreationOptional<string | null>; declare legal_record: CreationOptional<string | null>;
declare default_taxes: string; declare payment_method_id: CreationOptional<string | null>;
declare payment_term_id: CreationOptional<string | null>;
declare tax_regime_code: CreationOptional<string | null>;
declare uses_equivalence_surcharge: CreationOptional<boolean>;
declare uses_retention: CreationOptional<boolean>;
declare status: string; declare status: string;
declare language_code: CreationOptional<string>; declare language_code: CreationOptional<string>;
declare currency_code: CreationOptional<string>; declare currency_code: CreationOptional<string>;
@ -189,10 +195,33 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
}, },
default_taxes: { payment_method_id: {
type: DataTypes.STRING, type: DataTypes.UUID,
defaultValue: null,
allowNull: true, allowNull: true,
defaultValue: null,
},
payment_term_id: {
type: DataTypes.UUID,
allowNull: true,
defaultValue: null,
},
tax_regime_code: {
type: DataTypes.STRING(2),
allowNull: true,
},
uses_equivalence_surcharge: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
uses_retention: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
}, },
status: { status: {

View File

@ -29,6 +29,12 @@ export const CreateCustomerRequestSchema = z.object({
legal_record: z.string().optional(), legal_record: z.string().optional(),
payment_method_id: z.uuid().nullable().optional(),
payment_term_id: z.uuid().nullable().optional(),
tax_regime_code: z.string().nullable().optional(),
uses_equivalence_surcharge: z.boolean().optional(),
uses_retention: z.boolean().optional(),
language_code: z.string().toLowerCase().default("es"), language_code: z.string().toLowerCase().default("es"),
currency_code: z.string().toUpperCase().default("EUR"), currency_code: z.string().toUpperCase().default("EUR"),
}); });

View File

@ -11,8 +11,6 @@ import {
} from "@erp/core"; } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { TaxCombinationCodeSchema } from "../shared";
export const UpdateCustomerByIdParamsRequestSchema = z.object({ export const UpdateCustomerByIdParamsRequestSchema = z.object({
customer_id: z.uuid(), customer_id: z.uuid(),
}); });
@ -49,7 +47,11 @@ export const UpdateCustomerByIdRequestSchema = z.object({
trade_name: z.string().nullable().optional(), trade_name: z.string().nullable().optional(),
tin: TinSchema.nullable().optional(), tin: TinSchema.nullable().optional(),
default_taxes: TaxCombinationCodeSchema.optional(), payment_method_id: z.uuid().nullable().optional(),
payment_term_id: z.uuid().nullable().optional(),
tax_regime_code: z.string().nullable().optional(),
uses_equivalence_surcharge: z.boolean().optional(),
uses_retention: z.boolean().optional(),
address: UpdateCustomerAddressPatchRequestSchema.optional(), address: UpdateCustomerAddressPatchRequestSchema.optional(),
contact: UpdateCustomerContactPatchRequestSchema.optional(), contact: UpdateCustomerContactPatchRequestSchema.optional(),

View File

@ -12,7 +12,11 @@ import {
} from "@erp/core"; } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { TaxCombinationCodeSchema } from "../shared"; import {
CustomerPaymentMethodRefSchema,
CustomerPaymentTermRefSchema,
CustomerTaxRegimeRefSchema,
} from "../shared";
import { CustomerStatusSchema } from "../shared/customer-status.dto"; import { CustomerStatusSchema } from "../shared/customer-status.dto";
export const GetCustomerByIdResponseSchema = z.object({ export const GetCustomerByIdResponseSchema = z.object({
@ -49,7 +53,13 @@ export const GetCustomerByIdResponseSchema = z.object({
legal_record: z.string().nullable(), legal_record: z.string().nullable(),
default_taxes: TaxCombinationCodeSchema, payment_method: CustomerPaymentMethodRefSchema.nullable(),
payment_term: CustomerPaymentTermRefSchema.nullable(),
tax_regime: CustomerTaxRegimeRefSchema.nullable(),
fiscal_defaults: z.object({
uses_equivalence_surcharge: z.boolean(),
uses_retention: z.boolean(),
}),
language_code: LanguageCodeSchema, language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema, currency_code: CurrencyCodeSchema,

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const CustomerPaymentMethodRefSchema = z.object({
id: z.uuid(),
name: z.string(),
description: z.string(),
});
export type CustomerPaymentMethodRefDTO = z.infer<typeof CustomerPaymentMethodRefSchema>;

View File

@ -0,0 +1,8 @@
import { z } from "zod/v4";
export const CustomerPaymentTermRefSchema = z.object({
id: z.uuid(),
description: z.string(),
});
export type CustomerPaymentTermRefDTO = z.infer<typeof CustomerPaymentTermRefSchema>;

View File

@ -0,0 +1,8 @@
import { z } from "zod/v4";
export const CustomerTaxRegimeRefSchema = z.object({
code: z.string(),
description: z.string(),
});
export type CustomerTaxRegimeRefDTO = z.infer<typeof CustomerTaxRegimeRefSchema>;

View File

@ -1,3 +1,5 @@
export * from "./customer-payment-method-ref.dto";
export * from "./customer-payment-term-ref.dto";
export * from "./customer-status.dto"; export * from "./customer-status.dto";
export * from "./customer-summary.dto"; export * from "./customer-summary.dto";
export * from "./tax-combination-code.dto"; export * from "./customer-tax-regime-ref.dto";

View File

@ -1,23 +0,0 @@
import { z } from "zod/v4";
const TAX_CODE_PATTERN = /^[a-z0-9_]+$/i;
const EMPTY_TAX_SLOT = "#";
export const TaxCombinationCodeSchema = z.string().refine(
(value) => {
const parts = value.split(";");
if (parts.length !== 3) {
return false;
}
return parts.every((part) => {
return part === EMPTY_TAX_SLOT || TAX_CODE_PATTERN.test(part);
});
},
{
message: "taxes must use format '<iva_code|#>;<rec_code|#>;<retention_code|#>'",
}
);
export type TaxCombinationCodeDTO = z.infer<typeof TaxCombinationCodeSchema>;

View File

@ -2,16 +2,14 @@ import { DataTableColumnHeader, InitialsAvatar } from "@repo/rdx-ui/components";
import { import {
Badge, Badge,
Button, Button,
DropdownMenu, Checkbox,
DropdownMenuContent, Tooltip,
DropdownMenuGroup, TooltipContent,
DropdownMenuItem, TooltipProvider,
DropdownMenuLabel, TooltipTrigger,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Building2Icon, EyeIcon, MoreHorizontalIcon, UserIcon } from "lucide-react"; import { Building2Icon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -23,16 +21,43 @@ type GridActionHandlers = {
onViewClick?: (customer: CustomerListRow) => void; onViewClick?: (customer: CustomerListRow) => void;
onSummaryClick?: (customer: CustomerListRow) => void; onSummaryClick?: (customer: CustomerListRow) => void;
onDeleteClick?: (customer: CustomerListRow) => void; onDeleteClick?: (customer: CustomerListRow) => void;
canEdit?: (proforma: CustomerListRow) => boolean;
canDelete?: (proforma: CustomerListRow) => boolean;
}; };
export function useCustomersGridColumns( export function useCustomersGridColumns(
actionHandlers: GridActionHandlers = {} actionHandlers: GridActionHandlers = {}
): ColumnDef<CustomerListRow, unknown>[] { ): ColumnDef<CustomerListRow, unknown>[] {
const { t } = useTranslation(); const { t } = useTranslation();
const { onEditClick, onViewClick, onDeleteClick, onSummaryClick } = actionHandlers;
return React.useMemo<ColumnDef<CustomerListRow, unknown>[]>( return React.useMemo<ColumnDef<CustomerListRow, unknown>[]>(
() => [ () => [
{
id: "select",
header: ({ table }) => {
const isAllSelected = table.getIsAllPageRowsSelected();
const isSomeSelected = table.getIsSomePageRowsSelected();
return (
<Checkbox
aria-checked={isSomeSelected ? "mixed" : isAllSelected}
aria-label="Seleccionar todo"
checked={isAllSelected}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
);
},
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableHiding: false,
enableSorting: false,
},
{ {
id: "customer", id: "customer",
header: ({ column }) => ( header: ({ column }) => (
@ -52,7 +77,9 @@ export function useCustomersGridColumns(
return ( return (
<button <button
className="flex items-start gap-3 text-left transition-colors hover:opacity-80 cursor-pointer" className="flex items-start gap-3 text-left transition-colors hover:opacity-80 cursor-pointer"
onClick={onSummaryClick ? () => onSummaryClick(customer) : undefined} onClick={() =>
actionHandlers.onSummaryClick ? actionHandlers.onSummaryClick(customer) : null
}
type="button" type="button"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -124,15 +151,11 @@ export function useCustomersGridColumns(
}, },
{ {
id: "actions", id: "actions",
header: ({ column }) => ( meta: {
<DataTableColumnHeader isActionsColumn: true,
className="text-right" headerClassName: "text-right",
column={column} },
title={t("list.columns.actions")} header: "Acciones",
/>
),
size: 64,
minSize: 64,
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => {
@ -140,7 +163,68 @@ export function useCustomersGridColumns(
const { primaryEmail } = customer.contact; const { primaryEmail } = customer.contact;
return ( return (
<div className="flex justify-end"> <div className="flex items-center gap-1 justify-end">
{actionHandlers.onEditClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onEditClick?.(customer);
}}
size="icon"
variant="ghost"
>
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</Button>
}
/>
<TooltipContent>Editar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Eliminar */}
{!actionHandlers.onDeleteClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-8 text-destructive/75 hover:text-destructive cursor-pointer"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onDeleteClick?.(customer);
}}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
}
/>
<TooltipContent>Eliminar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
],
[t, actionHandlers]
);
}
/*
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
aria-label={t("list.actions.view")} aria-label={t("list.actions.view")}
@ -198,11 +282,5 @@ export function useCustomersGridColumns(
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div>
); */
},
},
],
[t, onDeleteClick, onEditClick, onViewClick, onSummaryClick]
);
}

View File

@ -1,4 +1,5 @@
import { useUrlParamId } from "@erp/core/hooks"; import { useUrlParamId } from "@erp/core/hooks";
import { useSearchParams } from "react-router-dom";
import { useCustomerUpdateController } from "./use-customer-update.controller"; import { useCustomerUpdateController } from "./use-customer-update.controller";
@ -10,10 +11,14 @@ import { useCustomerUpdateController } from "./use-customer-update.controller";
export const useCustomerUpdatePageController = () => { export const useCustomerUpdatePageController = () => {
const customerId = useUrlParamId(); const customerId = useUrlParamId();
const [searchParams] = useSearchParams();
const updateCtrl = useCustomerUpdateController(customerId); const updateCtrl = useCustomerUpdateController(customerId);
const returnTo = searchParams.get("returnTo") ?? "/customers";
return { return {
updateCtrl, updateCtrl,
returnTo,
}; };
}; };

View File

@ -0,0 +1,123 @@
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
import {
CancelActionButton,
FormActionsBar,
type FormSecondaryAction,
FormSecondaryActionsMenu,
RhfSubmitActionButton,
} from "@repo/rdx-ui/components";
import type { ReactNode } from "react";
export interface CustomerUpdateHeaderLabels {
title: string;
back: string;
modified: string;
cancel: string;
save: string;
saving: string;
moreActions: string;
keyboardShortcuts: string;
duplicate: string;
delete: string;
}
export interface CustomerUpdateHeaderProps {
formId?: string;
labels: CustomerUpdateHeaderLabels;
onCancel?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
disabled?: boolean;
readOnly?: boolean;
isSaving?: boolean;
hasChanges?: boolean;
className?: string;
children?: ReactNode;
}
export const CustomerUpdateHeader = ({
formId,
labels,
onCancel,
onDuplicate,
onDelete,
disabled = false,
readOnly = false,
isSaving = false,
hasChanges = false,
className,
children,
}: CustomerUpdateHeaderProps) => {
const computedDisabled = disabled || isSaving;
const secondaryActions: FormSecondaryAction[] = [
/*{
id: "duplicate",
label: labels.duplicate,
icon: <CopyIcon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onDuplicate,
disabled: computedDisabled,
onSelect: () => onDuplicate?.(),
},
{
id: "delete",
label: labels.delete,
icon: <Trash2Icon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onDelete || readOnly,
disabled: computedDisabled,
destructive: true,
onSelect: () => onDelete?.(),
},*/
];
return (
<PageFormHeader
actions={
<FormActionsBar align="end" reverseOnMobile={false}>
<PageKeyboardShortcutsButton
className="hidden sm:flex"
label={labels.keyboardShortcuts}
shortcuts={[
{ keys: "Ctrl+S", label: labels.save },
{ keys: "Esc", label: labels.cancel },
]}
/>
<CancelActionButton
className="hidden sm:flex"
disabled={computedDisabled}
label={labels.cancel}
onCancel={() => onCancel?.()}
variant="outline"
/>
<RhfSubmitActionButton
busyLabel={labels.saving}
disabled={computedDisabled || readOnly}
formId={formId}
isBusy={isSaving}
label={labels.save}
/>
<FormSecondaryActionsMenu
actions={secondaryActions}
disabled={computedDisabled}
label={labels.moreActions}
/>
</FormActionsBar>
}
backLabel={labels.back}
className={className}
onBack={onCancel}
showStatus={hasChanges}
statusLabel={labels.modified}
title={labels.title}
>
{children}
</PageFormHeader>
);
};

View File

@ -0,0 +1 @@
export * from "./customer-update-header";

View File

@ -1,19 +1,26 @@
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components"; import { ErrorAlert, NotFoundCard } from "@erp/core/components";
import { UnsavedChangesProvider } from "@erp/core/hooks"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Spinner } from "@repo/shadcn-ui/components"; import { Spinner } from "@repo/shadcn-ui/components";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { useCustomerUpdatePageController } from "../../controllers"; import { useCustomerUpdatePageController } from "../../controllers";
import { CustomerUpdateHeader } from "../blocks";
import { CustomerEditorSkeleton } from "../components"; import { CustomerEditorSkeleton } from "../components";
import { CustomerUpdateEditorForm } from "../editor"; import { CustomerUpdateEditorForm } from "../editor";
export const CustomerUpdatePage = () => { export const CustomerUpdatePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { updateCtrl } = useCustomerUpdatePageController(); const { updateCtrl, returnTo } = useCustomerUpdatePageController();
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
const handleCancel = () => navigateBack();
if (updateCtrl.isLoading) { if (updateCtrl.isLoading) {
return <CustomerEditorSkeleton />; return <CustomerEditorSkeleton />;
@ -49,41 +56,30 @@ export const CustomerUpdatePage = () => {
); );
return ( return (
<FormProvider {...updateCtrl.form}> <div className="fixed inset-0 flex flex-col overflow-hidden">
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}> <FormProvider {...updateCtrl.form}>
<AppHeader className="space-y-4 max-w-5xl mx-auto"> <UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<PageHeader <CustomerUpdateHeader
description={t("update.description")} formId={updateCtrl.formId}
onBackClick={() => navigate("/customers/list")} hasChanges={updateCtrl.form.formState.isDirty}
rightSlot={ isSaving={updateCtrl.form.formState.isSubmitting}
/* labels={{
<FormCommitButtonGroup title: "Editar cliente",
cancel={{ back: "Volver",
to: "/customers/list", modified: "Modificada",
}} cancel: "Cancelar",
disabled={updateCtrl.isUpdating} save: "Guardar",
isLoading={updateCtrl.isUpdating} saving: "Guardando...",
onReset={updateCtrl.form.formState.isDirty ? updateCtrl.resetForm : undefined} moreActions: "Más acciones",
submit={{ keyboardShortcuts: "Ver atajos de teclado",
formId: updateCtrl.formId, duplicate: "Duplicar cliente",
}} delete: "Eliminar",
/> }}
*/ <></> onCancel={handleCancel}
} //onDelete={openDeleteDialog}
title={t("update.title")} //onDuplicate={handleDuplicate}
//onExportPdf={handleExportPdf}
/> />
</AppHeader>
<AppContent className="space-y-4 max-w-5xl mx-auto">
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("update.errors.message")
}
title={t("update.errors.title")}
/>
)}
{updateCtrl.isLoading && <Spinner />} {updateCtrl.isLoading && <Spinner />}
@ -95,8 +91,8 @@ export const CustomerUpdatePage = () => {
onSubmit={updateCtrl.onSubmit} onSubmit={updateCtrl.onSubmit}
/> />
)} )}
</AppContent> </UnsavedChangesProvider>
</UnsavedChangesProvider> </FormProvider>
</FormProvider> </div>
); );
}; };

View File

@ -34,11 +34,11 @@ export class SupplierInvoiceRepository
try { try {
const dtoResult = this.domainMapper.mapToPersistence(invoice); const dtoResult = this.domainMapper.mapToPersistence(invoice);
if (dtoResult.isFailure()) { if (dtoResult.isFailure) {
return Result.fail(dtoResult.error); return Result.fail(dtoResult.error);
} }
const dto = dtoResult.value; const dto = dtoResult.data;
const { id, taxes, ...createPayload } = dto; const { id, taxes, ...createPayload } = dto;
await SupplierInvoiceModel.create( await SupplierInvoiceModel.create(
@ -74,11 +74,11 @@ export class SupplierInvoiceRepository
try { try {
const dtoResult = this.domainMapper.mapToPersistence(invoice); const dtoResult = this.domainMapper.mapToPersistence(invoice);
if (dtoResult.isFailure()) { if (dtoResult.isFailure) {
return Result.fail(dtoResult.error); return Result.fail(dtoResult.error);
} }
const dto = dtoResult.value; const dto = dtoResult.data;
const { id, company_id, version, taxes, ...updatePayload } = dto; const { id, company_id, version, taxes, ...updatePayload } = dto;
const [updatedRows] = await SupplierInvoiceModel.update( const [updatedRows] = await SupplierInvoiceModel.update(
@ -309,14 +309,23 @@ export class SupplierInvoiceRepository
try { try {
const criteriaConverter = new CriteriaToSequelizeConverter(); const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, { const criteriaQuery = criteriaConverter.convert(criteria, {
searchableFields: ["invoice_number", "description", "internal_notes"], searchableFields: ["invoice_number", "description", "internal_notes"],
mappings: { mappings: {
invoice_number: "SupplierInvoiceModel.invoice_number", invoice_number: {
reference: "SupplierInvoiceModel.description", type: "root",
description: "SupplierInvoiceModel.internal_notes", column: "invoice_number",
},
reference: {
type: "root",
column: "reference",
},
description: {
type: "root",
column: "description",
},
}, },
allowedFields: ["invoice_date", "due_date", "id", "created_at"], sortableFields: ["invoice_date", "due_date", "id", "created_at"],
enableFullText: true, enableFullText: true,
database: this.database, database: this.database,
strictMode: true, strictMode: true,
@ -334,16 +343,16 @@ export class SupplierInvoiceRepository
? [options.include] ? [options.include]
: []; : [];
query.where = { criteriaQuery.where = {
...query.where, ...criteriaQuery.where,
...(options.where ?? {}), ...(options.where ?? {}),
company_id: companyId.toString(), company_id: companyId.toString(),
deleted_at: null, deleted_at: null,
}; };
query.order = [...(query.order as OrderItem[]), ...normalizedOrder]; criteriaQuery.order = [...(criteriaQuery.order as OrderItem[]), ...normalizedOrder];
query.include = [ criteriaQuery.include = [
...normalizedInclude, ...normalizedInclude,
{ {
model: SupplierInvoiceTaxModel, model: SupplierInvoiceTaxModel,
@ -355,11 +364,11 @@ export class SupplierInvoiceRepository
const [rows, count] = await Promise.all([ const [rows, count] = await Promise.all([
SupplierInvoiceModel.findAll({ SupplierInvoiceModel.findAll({
...query, ...criteriaQuery,
transaction, transaction,
}), }),
SupplierInvoiceModel.count({ SupplierInvoiceModel.count({
where: query.where, where: criteriaQuery.where,
distinct: true, distinct: true,
transaction, transaction,
}), }),

View File

@ -242,7 +242,7 @@ export class SupplierRepository
"email_primary", "email_primary",
"mobile_primary", "mobile_primary",
], ],
allowedFields: [ sortableFields: [
"name", "name",
"trade_name", "trade_name",
"reference", "reference",

View File

@ -692,6 +692,9 @@ importers:
'@erp/auth': '@erp/auth':
specifier: workspace:* specifier: workspace:*
version: link:../auth version: link:../auth
'@erp/catalogs':
specifier: workspace:*
version: link:../catalogs
'@erp/core': '@erp/core':
specifier: workspace:* specifier: workspace:*
version: link:../core version: link:../core