Importación desde FactuGES

This commit is contained in:
David Arranz 2026-03-16 18:45:45 +01:00
parent 5e4ad04314
commit 9a45e7ee9a
150 changed files with 2508 additions and 1460 deletions

View File

@ -35,8 +35,9 @@
"dependencies": {
"@erp/auth": "workspace:*",
"@erp/core": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/factuges": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"bcrypt": "^5.1.1",

View File

@ -132,8 +132,11 @@ async function setupModule(name: string, params: ModuleParams, stack: string[])
// 5) services (namespaced)
if (pkgApi?.services) {
await withPhase(name, "registerServices", async () => {
validateModuleServices(name, pkgApi.services);
for (const [serviceKey, serviceApi] of Object.entries(pkgApi.services!)) {
registerService(`${name}:${serviceKey}`, serviceApi);
const fullName = buildServiceName(name, serviceKey);
registerService(fullName, serviceApi);
}
});
}
@ -187,6 +190,24 @@ function trackDependencyUse(requester: string, dep: string) {
set.add(dep);
}
function buildServiceName(moduleName: string, serviceKey: string): string {
return `${moduleName}:${serviceKey}`;
}
function validateModuleServices(moduleName: string, services: Record<string, unknown>) {
for (const [serviceKey, serviceApi] of Object.entries(services)) {
if (!serviceKey || typeof serviceKey !== "string") {
throw new Error(`Invalid service key from module "${moduleName}"`);
}
const fullName = `${moduleName}:${serviceKey}`;
if (serviceApi === undefined) {
throw new Error(`Service "${fullName}" is undefined`);
}
}
}
function validateModuleDependencies() {
for (const [moduleName, pkg] of registeredModules.entries()) {
const declared = new Set(pkg.dependencies ?? []);

View File

@ -1,8 +1,6 @@
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
//import verifactuAPIModule from "@erp/verifactu/api";
import customersAPIModule from "@erp/customers/api";
import factuGESAPIModule from "@erp/factuges/api";
import { registerModule } from "./lib";
@ -10,5 +8,5 @@ export const registerModules = () => {
//registerModule(authAPIModule);
registerModule(customersAPIModule);
registerModule(customerInvoicesAPIModule);
//registerModule(verifactuAPIModule);
registerModule(factuGESAPIModule);
};

View File

@ -140,7 +140,7 @@
"noControlCharactersInRegex": "error",
"noDoubleEquals": "error",
"noDuplicateCase": "error",
"noEmptyBlockStatements": "error",
"noEmptyBlockStatements": "off",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",

File diff suppressed because one or more lines are too long

View File

@ -114,7 +114,7 @@ export class ApiErrorMapper {
}
// ────────────────────────────────────────────────────────────────────────────────
// Reglas por defecto (prioridad alta a más específicas)
// Reglas por defecto: a prioridad más alta en valor, error más específico.
// ────────────────────────────────────────────────────────────────────────────────
const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// 1) Validación múltiple (colección)
@ -165,7 +165,7 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// 5.5) Errores de FastReport inesperados
{
priority: 55,
priority: 56,
matches: (e) => isDocumentGenerationError(e),
build: (e) => {
const error = e as DocumentGenerationError;
@ -178,6 +178,7 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
return new InternalApiError(cause.message, title);
},
},
{
priority: 55,
matches: (e) => isFastReportError(e),
@ -198,10 +199,11 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// 7) Autenticación/autorización por nombre (si no tienes clases dedicadas)
{
priority: 40,
priority: 45,
matches: (e): e is Error => e instanceof Error && e.name === "UnauthorizedError",
build: (e) => new UnauthorizedApiError((e as Error).message || "Unauthorized"),
},
{
priority: 40,
matches: (e): e is Error => e instanceof Error && e.name === "ForbiddenError",
@ -223,10 +225,22 @@ function defaultFallback(e: unknown): ApiError {
return e; // ya es un ApiError
}
const message = typeof (e as any)?.message === "string" ? (e as any).message : "";
const detail = typeof (e as any)?.detail === "string" ? (e as any).detail : "";
const message =
typeof (e as Partial<{ message: string }>).message === "string"
? String((e as Partial<{ message: string }>).message)
: "";
return new InternalApiError(`${message} ${detail}`);
const detail =
typeof (e as Partial<{ detail: string }>).detail === "string"
? String((e as Partial<{ detail: string }>).detail)
: "";
const cause =
typeof (e as Partial<{ cause: string }>).cause === "string"
? String((e as Partial<{ cause: string }>).cause)
: "";
return new InternalApiError(`${message} ${detail} ${cause}`);
}
// ────────────────────────────────────────────────────────────────────────────────

View File

@ -22,9 +22,7 @@ export type ModuleSetupResult = {
internal?: Record<string, unknown>;
};
export type SetupParams = ModuleParams & {
registerService: (name: string, api: unknown) => void;
};
export type SetupParams = ModuleParams;
export type StartParams = ModuleParams & {
/**

View File

@ -1,3 +1,4 @@
export * from "./error-alert";
export * from "./form";
export * from "./not-found-card";
export * from "./page-header";

View File

@ -5,6 +5,7 @@ export * from "./use-pagination";
export * from "./use-percentage";
export * from "./use-quantity";
export * from "./use-query-key";
export * from "./use-rhf-error-focus";
export * from "./use-toggle";
export * from "./use-unsaved-changes-notifier";
export * from "./use-url-param-id";

View File

@ -0,0 +1,11 @@
import type { FieldErrors, FieldValues } from "react-hook-form";
export function useRHFErrorFocus<T extends FieldValues>() {
return (errors: FieldErrors<T>) => {
const firstKey = Object.keys(errors)[0] as keyof T | undefined;
if (firstKey) {
document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
}
};
}

View File

@ -12,6 +12,7 @@
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./api/domain": "./src/api/domain/index.ts",
"./client": "./src/web/manifest.ts",
"./globals.css": "./src/web/globals.css"
},

View File

@ -10,7 +10,12 @@ import {
import { Result } from "@repo/rdx-utils";
import type { CreateCustomerInvoiceRequestDTO } from "../../../common";
import { type IProformaProps, InvoiceNumber, InvoiceSerie, InvoiceStatus } from "../../domain";
import {
type IProformaCreateProps,
InvoiceNumber,
InvoiceSerie,
InvoiceStatus,
} from "../../domain";
import { mapDTOToCustomerInvoiceItemsProps } from "./map-dto-to-customer-invoice-items-props";
@ -66,12 +71,12 @@ export function mapDTOToCustomerInvoiceProps(dto: CreateCustomerInvoiceRequestDT
return Result.fail(new ValidationErrorCollection("Invoice dto mapping failed", errors));
}
const invoiceProps: IProformaProps = {
const invoiceProps: IProformaCreateProps = {
invoiceNumber: invoiceNumber!,
series: invoiceSeries!,
invoiceDate: invoiceDate!,
operationDate: operationDate!,
status: InvoiceStatus.createDraft(),
status: InvoiceStatus.fromDraft(),
currencyCode: currencyCode!,
};

View File

@ -1,7 +1,7 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { IProformaProps, Proforma } from "../../../domain";
import type { IProformaCreateProps, Proforma } from "../../../domain";
export interface IProformaFactory {
/**
@ -11,7 +11,7 @@ export interface IProformaFactory {
*/
createProforma(
companyId: UniqueID,
props: Omit<IProformaProps, "companyId">,
props: Omit<IProformaCreateProps, "companyId">,
proformaId?: UniqueID
): Result<Proforma, Error>;
}

View File

@ -1,14 +1,14 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import { type IProformaProps, Proforma } from "../../../domain";
import { type IProformaCreateProps, Proforma } from "../../../domain";
import type { IProformaFactory } from "./proforma-factory.interface";
export class ProformaFactory implements IProformaFactory {
createProforma(
companyId: UniqueID,
props: Omit<IProformaProps, "companyId">,
props: Omit<IProformaCreateProps, "companyId">,
proformaId?: UniqueID
): Result<Proforma, Error> {
return Proforma.create({ ...props, companyId }, proformaId);

View File

@ -17,8 +17,8 @@ import { Maybe, Result } from "@repo/rdx-utils";
import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../common";
import {
type IProformaCreateProps,
type IProformaItemProps,
type IProformaProps,
InvoiceNumber,
InvoicePaymentMethod,
type InvoiceRecipient,
@ -51,7 +51,7 @@ export interface ICreateProformaInputMapper {
map(
dto: CreateProformaRequestDTO,
params: { companyId: UniqueID }
): Result<{ id: UniqueID; props: IProformaProps }>;
): Result<{ id: UniqueID; props: IProformaCreateProps }>;
}
export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ {
@ -64,12 +64,12 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
public map(
dto: CreateProformaRequestDTO,
params: { companyId: UniqueID }
): Result<{ id: UniqueID; props: IProformaProps }> {
): Result<{ id: UniqueID; props: IProformaCreateProps }> {
const errors: ValidationErrorDetail[] = [];
const { companyId } = params;
try {
const defaultStatus = InvoiceStatus.createDraft();
const defaultStatus = InvoiceStatus.fromDraft();
const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
@ -159,13 +159,9 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
errors,
});
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice props mapping failed", errors)
);
}
this.throwIfValidationErrors(errors);
const props: IProformaProps = {
const props: IProformaCreateProps = {
companyId,
status: defaultStatus,
@ -200,6 +196,12 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
}
}
private throwIfValidationErrors(errors: ValidationErrorDetail[]): void {
if (errors.length > 0) {
throw new ValidationErrorCollection("Customer proforma props mapping failed", errors);
}
}
private mapItemsProps(
dto: CreateProformaRequestDTO,
params: {
@ -241,6 +243,8 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
errors: params.errors,
});
this.throwIfValidationErrors(params.errors);
itemsProps.push({
globalDiscountPercentage: params.globalDiscountPercentage,
languageCode: params.languageCode,
@ -321,6 +325,8 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
}
});
this.throwIfValidationErrors(errors);
return taxesProps;
}
}

View File

@ -2,19 +2,21 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { IProformaProps, Proforma } from "../../../domain";
import type { IProformaCreateProps, Proforma } from "../../../domain";
import type { IProformaFactory } from "../factories";
import type { IProformaRepository } from "../repositories";
import type { IProformaNumberGenerator } from "./proforma-number-generator.interface";
export interface IProformaCreator {
create(params: {
export interface IProformaCreatorParams {
companyId: UniqueID;
id: UniqueID;
props: IProformaProps;
props: Omit<IProformaCreateProps, "invoiceNumber">;
transaction: Transaction;
}): Promise<Result<Proforma, Error>>;
}
export interface IProformaCreator {
create(params: IProformaCreatorParams): Promise<Result<Proforma, Error>>;
}
type ProformaCreatorDeps = {
@ -37,7 +39,7 @@ export class ProformaCreator implements IProformaCreator {
async create(params: {
companyId: UniqueID;
id: UniqueID;
props: IProformaProps;
props: IProformaCreateProps;
transaction: Transaction;
}): Promise<Result<Proforma, Error>> {
const { companyId, id, props, transaction } = params;

View File

@ -39,34 +39,34 @@ export class InvoiceStatus extends ValueObject<IInvoiceStatusProps> {
return Result.ok(
value === "rejected"
? InvoiceStatus.createRejected()
? InvoiceStatus.fromRejected()
: value === "sent"
? InvoiceStatus.createSent()
? InvoiceStatus.fromSent()
: value === "issued"
? InvoiceStatus.createIssued()
? InvoiceStatus.fromIssued()
: value === "approved"
? InvoiceStatus.createApproved()
: InvoiceStatus.createDraft()
? InvoiceStatus.fromApproved()
: InvoiceStatus.fromDraft()
);
}
public static createDraft(): InvoiceStatus {
public static fromDraft(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.DRAFT });
}
public static createIssued(): InvoiceStatus {
public static fromIssued(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.ISSUED });
}
public static createSent(): InvoiceStatus {
public static fromSent(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.SENT });
}
public static createApproved(): InvoiceStatus {
public static fromApproved(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.APPROVED });
}
public static createRejected(): InvoiceStatus {
public static fromRejected(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.REJECTED });
}

View File

@ -30,7 +30,7 @@ import { ProformaItemMismatch } from "../errors";
import { type IProformaTaxTotals, ProformaTaxesCalculator } from "../services";
import { ProformaItemTaxes } from "../value-objects";
export interface IProformaProps {
export interface IProformaCreateProps {
companyId: UniqueID;
status: InvoiceStatus;
@ -100,16 +100,15 @@ export interface IProforma {
totals(): IProformaTotals;
}
export type ProformaPatchProps = Partial<Omit<IProformaProps, "companyId" | "items">> & {
export type ProformaPatchProps = Partial<Omit<IProformaCreateProps, "companyId" | "items">> & {
//items?: ProformaItems;
};
type CreateProformaProps = IProformaProps;
type InternalProformaProps = Omit<IProformaProps, "items">;
type InternalProformaProps = Omit<IProformaCreateProps, "items">;
export class Proforma extends AggregateRoot<InternalProformaProps> implements IProforma {
// Creación funcional
static create(props: CreateProformaProps, id?: UniqueID): Result<Proforma, Error> {
static create(props: IProformaCreateProps, id?: UniqueID): Result<Proforma, Error> {
const { items, ...internalProps } = props;
const proforma = new Proforma(internalProps, id);
@ -221,12 +220,12 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
// Mutabilidad
public update(
partialProforma: Partial<Omit<IProformaProps, "companyId">>
partialProforma: Partial<Omit<IProformaCreateProps, "companyId">>
): Result<Proforma, Error> {
const updatedProps = {
...this.props,
...partialProforma,
} as IProformaProps;
} as IProformaCreateProps;
return Proforma.create(updatedProps, this.id);
}

View File

@ -67,7 +67,7 @@ export class IssueCustomerInvoiceDomainService {
...proformaProps,
isProforma: false,
proformaId: Maybe.some(proforma.id),
status: InvoiceStatus.createIssued(),
status: InvoiceStatus.fromIssued(),
invoiceNumber: issueNumber,
invoiceDate: issueDate,
description: proformaProps.description.isNone() ? Maybe.some(".") : proformaProps.description,

View File

@ -1,7 +1,9 @@
import type { IModuleServer } from "@erp/core/api";
import {
type IssuedInvoicePublicServices,
type IssuedInvoicesInternalDeps,
type ProformaPublicServices,
type ProformasInternalDeps,
buildIssuedInvoiceServices,
buildIssuedInvoicesDependencies,
@ -12,6 +14,8 @@ import {
proformasRouter,
} from "./infrastructure";
export type { IssuedInvoicePublicServices, ProformaPublicServices };
export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices",
version: "1.0.0",
@ -28,12 +32,18 @@ export const customerInvoicesAPIModule: IModuleServer = {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
// 1) Dominio interno
const issuedInvoicesInternalDeps = buildIssuedInvoicesDependencies(params);
const proformasInternalDeps = buildProformasDependencies(params);
const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params);
const proformasInternal = buildProformasDependencies(params);
// 2) Servicios públicos (Application Services)
const issuedInvoicesServices = buildIssuedInvoiceServices(issuedInvoicesInternalDeps);
const proformasServices = buildProformaServices(proformasInternalDeps);
const issuedInvoicesServices: IssuedInvoicePublicServices = buildIssuedInvoiceServices(
params,
issuedInvoicesInternal
);
const proformasServices: ProformaPublicServices = buildProformaServices(
params,
proformasInternal
);
logger.info("🚀 CustomerInvoices module dependencies registered", { label: this.name });
@ -43,14 +53,14 @@ export const customerInvoicesAPIModule: IModuleServer = {
// Servicios expuestos a otros módulos
services: {
issuedInvoices: issuedInvoicesServices,
proformas: proformasServices,
issuedInvoices: issuedInvoicesServices, // 'customer-invoices:issuedInvoices'
proformas: proformasServices, // 'customer-invoices:proformas'
},
// Implementación privada del módulo
internal: {
issuedInvoices: issuedInvoicesInternalDeps,
proformas: proformasInternalDeps,
issuedInvoices: issuedInvoicesInternal,
proformas: proformasInternal,
},
};
},

View File

@ -16,7 +16,7 @@ import {
import { Result } from "@repo/rdx-utils";
import {
type IProformaProps,
type IProformaCreateProps,
IssuedInvoiceItem,
type IssuedInvoiceItemProps,
ItemAmount,
@ -68,7 +68,7 @@ export class CustomerInvoiceItemDomainMapper
const { errors, index, attributes } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<IProformaProps>;
attributes: Partial<IProformaCreateProps>;
};
const itemId = extractOrPushError(
@ -163,7 +163,7 @@ export class CustomerInvoiceItemDomainMapper
const { errors, index } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<IProformaProps>;
attributes: Partial<IProformaCreateProps>;
};
// 1) Valores escalares (atributos generales)

View File

@ -20,7 +20,7 @@ import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import {
CustomerInvoiceItems,
type IProformaProps,
type IProformaCreateProps,
InvoiceNumber,
InvoicePaymentMethod,
InvoiceSerie,
@ -249,7 +249,7 @@ export class CustomerInvoiceDomainMapper
items: itemsResults.data.getAll(),
});
const invoiceProps: IProformaProps = {
const invoiceProps: IProformaCreateProps = {
companyId: attributes.companyId!,
isProforma: attributes.isProforma,

View File

@ -15,7 +15,11 @@ import {
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { type IProformaProps, InvoiceRecipient, type Proforma } from "../../../../../../domain";
import {
type IProformaCreateProps,
InvoiceRecipient,
type Proforma,
} from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../sequelize";
export class InvoiceRecipientDomainMapper {
@ -30,7 +34,7 @@ export class InvoiceRecipientDomainMapper {
const { errors, attributes } = params as {
errors: ValidationErrorDetail[];
attributes: Partial<IProformaProps>;
attributes: Partial<IProformaCreateProps>;
};
const { isProforma } = attributes;

View File

@ -12,7 +12,7 @@ import {
import { Maybe, Result } from "@repo/rdx-utils";
import {
type IProformaProps,
type IProformaCreateProps,
type Proforma,
VerifactuRecord,
VerifactuRecordEstado,
@ -43,7 +43,7 @@ export class CustomerInvoiceVerifactuDomainMapper
): Result<Maybe<VerifactuRecord>, Error> {
const { errors, attributes } = params as {
errors: ValidationErrorDetail[];
attributes: Partial<IProformaProps>;
attributes: Partial<IProformaCreateProps>;
};
if (!source) {

View File

@ -1,66 +0,0 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { type Transaction, type WhereOptions, literal } from "sequelize";
import { InvoiceNumber, type InvoiceSerie } from "../../../../domain";
import { CustomerInvoiceModel } from "./models";
/**
* Generador de números de factura
*/
export class SequelizeInvoiceNumberGenerator implements ICustomerInvoiceNumberGenerator {
public async nextForCompany(
companyId: UniqueID,
series: Maybe<InvoiceSerie>,
transaction: Transaction
): Promise<Result<InvoiceNumber, Error>> {
const where: WhereOptions = {
company_id: companyId.toString(),
is_proforma: false,
};
series.match(
(serieVO) => {
where.series = serieVO.toString();
},
() => {
where.series = null;
}
);
try {
const lastInvoice = await CustomerInvoiceModel.findOne({
attributes: ["invoice_number"],
where,
// Orden numérico real: CAST(... AS UNSIGNED)
order: [literal("CAST(invoice_number AS UNSIGNED) DESC")],
transaction,
raw: true,
// Bloqueo opcional para evitar carreras si estás dentro de una TX
lock: transaction.LOCK.UPDATE, // requiere InnoDB y TX abierta
});
let nextValue = "001"; // valor inicial por defecto
if (lastInvoice) {
const current = Number(lastInvoice.invoice_number);
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
nextValue = String(next).padStart(3, "0");
}
const numberResult = InvoiceNumber.create(nextValue);
if (numberResult.isFailure) {
return Result.fail(numberResult.error);
}
return Result.ok(numberResult.data);
} catch (error) {
return Result.fail(
new Error(
`Error generating invoice number for company ${companyId}: ${(error as Error).message}`
)
);
}
}
}

View File

@ -1,4 +1,3 @@
export * from "./common/persistence";
export * from "./issued-invoices";
export * from "./proformas";
export * from "./renderers";

View File

@ -1,18 +1,41 @@
import type { SetupParams } from "@erp/core/api";
import { buildCatalogs, buildTransactionManager } from "@erp/core/api";
import {
buildIssuedInvoiceFinder,
buildIssuedInvoiceSnapshotBuilders,
} from "../../../application/issued-invoices";
import { buildIssuedInvoiceDocumentService } from "./issued-invoice-documents.di";
import { buildIssuedInvoicePersistenceMappers } from "./issued-invoice-persistence-mappers.di";
import { buildIssuedInvoiceRepository } from "./issued-invoice-repositories.di";
import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di";
export type IssuedInvoicesServiceslDeps = {
services: {
export type IssuedInvoicePublicServices = {
listIssuedInvoices: (filters: unknown, context: unknown) => null;
getIssuedInvoiceById: (id: unknown, context: unknown) => null;
generateIssuedInvoiceReport: (id: unknown, options: unknown, context: unknown) => null;
};
};
export function buildIssuedInvoiceServices(
params: SetupParams,
deps: IssuedInvoicesInternalDeps
): IssuedInvoicesServiceslDeps {
): IssuedInvoicePublicServices {
const { database } = params;
// Infrastructure
const transactionManager = buildTransactionManager(database);
const catalogs = buildCatalogs();
const persistenceMappers = buildIssuedInvoicePersistenceMappers(catalogs);
const repository = buildIssuedInvoiceRepository({ database, mappers: persistenceMappers });
// Application helpers
const finder = buildIssuedInvoiceFinder(repository);
const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders();
const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(params);
return {
services: {
listIssuedInvoices: (filters, context) => null,
//internal.useCases.listIssuedInvoices().execute(filters, context),
@ -21,6 +44,5 @@ export function buildIssuedInvoiceServices(
generateIssuedInvoiceReport: (id, options, context) => null,
//internal.useCases.reportIssuedInvoice().execute(id, options, context),
},
};
}

View File

@ -1,16 +1,66 @@
import { type SetupParams, buildCatalogs } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import { type IProformaCreatorParams, buildProformaCreator } from "../../../application";
import type { Proforma } from "../../../domain";
import { buildProformaNumberGenerator } from "./proforma-number-generator.di";
import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di";
import { buildProformaRepository } from "./proforma-repositories.di";
import type { ProformasInternalDeps } from "./proformas.di";
export type ProformasServicesDeps = {
services: {
type ProformaServicesContext = {
transaction: Transaction;
companyId: UniqueID;
};
export type ProformaPublicServices = {
createProforma: (
id: UniqueID,
props: IProformaCreatorParams["props"],
context: ProformaServicesContext
) => Promise<Result<Proforma, Error>>;
listProformas: (filters: unknown, context: unknown) => null;
getProformaById: (id: unknown, context: unknown) => null;
generateProformaReport: (id: unknown, options: unknown, context: unknown) => null;
};
};
export function buildProformaServices(deps: ProformasInternalDeps): ProformasServicesDeps {
export function buildProformaServices(
params: SetupParams,
deps: ProformasInternalDeps
): ProformaPublicServices {
const { database } = params;
// Infrastructure
const catalogs = buildCatalogs();
const persistenceMappers = buildProformaPersistenceMappers(catalogs);
const repository = buildProformaRepository({ database, mappers: persistenceMappers });
const numberService = buildProformaNumberGenerator();
// Application helpers
const creator = buildProformaCreator({ numberService, repository });
return {
services: {
createProforma: async (
id: UniqueID,
props: IProformaCreatorParams["props"],
context: ProformaServicesContext
) => {
const { transaction, companyId } = context;
const createResult = await creator.create({ companyId, id, props, transaction });
if (createResult.isFailure) {
return Result.fail(createResult.error);
}
return Result.ok(createResult.data);
},
listProformas: (filters, context) => null,
//internal.useCases.listProformas().execute(filters, context),
@ -19,6 +69,5 @@ export function buildProformaServices(deps: ProformasInternalDeps): ProformasSer
generateProformaReport: (id, options, context) => null,
//internal.useCases.reportProforma().execute(id, options, context),
},
};
}

View File

@ -16,8 +16,8 @@ import { Maybe, Result } from "@repo/rdx-utils";
import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../../common";
import {
type IProformaCreateProps,
type IProformaItemProps,
type IProformaProps,
InvoiceNumber,
InvoicePaymentMethod,
type InvoiceRecipient,
@ -41,7 +41,6 @@ import {
*
*/
export class CreateProformaRequestMapper {
private readonly taxCatalog: JsonTaxCatalogProvider;
private errors: ValidationErrorDetail[] = [];
@ -58,7 +57,7 @@ export class CreateProformaRequestMapper {
try {
this.errors = [];
const defaultStatus = InvoiceStatus.createDraft();
const defaultStatus = InvoiceStatus.fromDraft();
const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", this.errors);
@ -149,7 +148,7 @@ export class CreateProformaRequestMapper {
);
}
const proformaProps: Omit<IProformaProps, "items"> & { items: IProformaItemProps[] } = {
const proformaProps: Omit<IProformaCreateProps, "items"> & { items: IProformaItemProps[] } = {
companyId,
status: defaultStatus!,

View File

@ -14,7 +14,7 @@ import {
import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import {
type IProformaProps,
type IProformaCreateProps,
InvoiceNumber,
InvoicePaymentMethod,
InvoiceSerie,
@ -217,7 +217,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
items: itemsResults.data.getAll(),
});
const invoiceProps: IProformaProps = {
const invoiceProps: IProformaCreateProps = {
companyId: attributes.companyId!,
status: attributes.status!,

View File

@ -16,8 +16,8 @@ import {
import { Result } from "@repo/rdx-utils";
import {
type IProformaCreateProps,
type IProformaItemProps,
type IProformaProps,
ItemAmount,
ItemDescription,
ItemQuantity,
@ -58,7 +58,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
const { errors, index, parent } = params as {
index: number;
errors: ValidationErrorDetail[];
parent: Partial<IProformaProps>;
parent: Partial<IProformaCreateProps>;
};
const itemId = extractOrPushError(
@ -139,7 +139,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
const { errors, index } = params as {
index: number;
errors: ValidationErrorDetail[];
parent: Partial<IProformaProps>;
parent: Partial<IProformaCreateProps>;
};
// 1) Valores escalares (atributos generales)

View File

@ -14,7 +14,7 @@ import {
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { type IProformaProps, InvoiceRecipient } from "../../../../../../domain";
import { type IProformaCreateProps, InvoiceRecipient } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../../common";
export class SequelizeProformaRecipientDomainMapper {
@ -28,7 +28,7 @@ export class SequelizeProformaRecipientDomainMapper {
const { errors, parent } = params as {
errors: ValidationErrorDetail[];
parent: Partial<IProformaProps>;
parent: Partial<IProformaCreateProps>;
};
/* if (!source.current_customer) {

View File

@ -1,5 +1,4 @@
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Button,

View File

@ -28,11 +28,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src",
"../core/src/api/domain/value-objects/tax-percentage.vo.ts",
"../core/src/api/domain/value-objects/discount-percentage.vo.ts",
"../core/src/api/infrastructure/di/catalogs.di.ts"
],
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -12,6 +12,7 @@
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./api/domain": "./src/api/domain/index.ts",
"./client": "./src/web/manifest.ts",
"./globals.css": "./src/web/globals.css",
"./components": "./src/web/components/index.ts"

View File

@ -1,12 +1,17 @@
import type { ITransactionManager } from "@erp/core/api";
import type { ICreateCustomerInputMapper } from "../mappers";
import type { ICustomerCreator, ICustomerFinder } from "../services";
import type { ICreateCustomerInputMapper, IUpdateCustomerInputMapper } from "../mappers";
import type { ICustomerCreator, ICustomerFinder, ICustomerUpdater } from "../services";
import type {
ICustomerFullSnapshotBuilder,
ICustomerSummarySnapshotBuilder,
} from "../snapshot-builders";
import { CreateCustomerUseCase, GetCustomerByIdUseCase, ListCustomersUseCase } from "../use-cases";
import {
CreateCustomerUseCase,
GetCustomerByIdUseCase,
ListCustomersUseCase,
UpdateCustomerUseCase,
} from "../use-cases";
export function buildGetCustomerByIdUseCase(deps: {
finder: ICustomerFinder;
@ -42,6 +47,20 @@ export function buildCreateCustomerUseCase(deps: {
});
}
export function buildUpdateCustomerUseCase(deps: {
updater: ICustomerUpdater;
dtoMapper: IUpdateCustomerInputMapper;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new UpdateCustomerUseCase({
dtoMapper: deps.dtoMapper,
updater: deps.updater,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager,
});
}
/*export function buildReportCustomerUseCase(deps: {
finder: ICustomerFinder;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
@ -58,12 +77,7 @@ export function buildCreateCustomerUseCase(deps: {
);
}*/
/*export function buildUpdateCustomerUseCase(deps: {
finder: ICustomerFinder;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
}) {
return new UpdateCustomerUseCase(deps.finder, deps.fullSnapshotBuilder);
}
/*
export function buildDeleteCustomerUseCase(deps: { finder: ICustomerFinder }) {
return new DeleteCustomerUseCase(deps.finder);

View File

@ -22,7 +22,7 @@ import {
extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import type { CreateCustomerRequestDTO } from "../../../common";
import { CustomerStatus, type ICustomerCreateProps } from "../../domain";
@ -176,7 +176,7 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
errors
);
const defaultTaxes = new Collection<TaxCode>();
const defaultTaxes: TaxCode[] = [];
/*if (!isNullishOrEmpty(dto.default_taxes)) {
dto.default_taxes!.map((taxCode, index) => {

View File

@ -1 +1,2 @@
export * from "./create-customer-input.mapper";
export * from "./update-customer-input.mapper";

View File

@ -0,0 +1,289 @@
import {
City,
Country,
CurrencyCode,
DomainError,
EmailAddress,
LanguageCode,
Name,
PhoneNumber,
type PostalAddressPatchProps,
PostalCode,
Province,
Street,
TINNumber,
type TaxCode,
TextValue,
URLAddress,
type UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Collection, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
import type { UpdateCustomerByIdRequestDTO } from "../../../common";
import type { CustomerPatchProps } from "../../domain";
/**
* UpdateCustomerInputMapper
* Convierte el DTO a las props validadas (CustomerProps).
* No construye directamente el agregado.
* Tri-estado:
* - campo omitido no se cambia
* - campo con valor null/"" se quita el valor -> set(None()),
* - campo con valor no-vacío se pone el nuevo valor -> set(Some(VO)).
*
* @param dto - DTO con los datos a cambiar en el cliente
* @returns Cambios en las propiedades del cliente
*
*/
export interface IUpdateCustomerInputMapper {
map(
dto: UpdateCustomerByIdRequestDTO,
params: { companyId: UniqueID }
): Result<CustomerPatchProps>;
}
export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
public map(dto: UpdateCustomerByIdRequestDTO, params: { companyId: UniqueID }) {
try {
const errors: ValidationErrorDetail[] = [];
const customerPatchProps: CustomerPatchProps = {};
toPatchField(dto.reference).ifSet((reference) => {
customerPatchProps.reference = extractOrPushError(
maybeFromNullableResult(reference, (value) => Name.create(value)),
"reference",
errors
);
});
toPatchField(dto.is_company).ifSet((is_company) => {
if (isNullishOrEmpty(is_company)) {
errors.push({ path: "is_company", message: "is_company cannot be empty" });
return;
}
customerPatchProps.isCompany = extractOrPushError(
Result.ok(Boolean(is_company!)),
"is_company",
errors
);
});
toPatchField(dto.name).ifSet((name) => {
if (isNullishOrEmpty(name)) {
errors.push({ path: "name", message: "Name cannot be empty" });
return;
}
customerPatchProps.name = extractOrPushError(Name.create(name!), "name", errors);
});
toPatchField(dto.trade_name).ifSet((trade_name) => {
customerPatchProps.tradeName = extractOrPushError(
maybeFromNullableResult(trade_name, (value) => Name.create(value)),
"trade_name",
errors
);
});
toPatchField(dto.tin).ifSet((tin) => {
customerPatchProps.tin = extractOrPushError(
maybeFromNullableResult(tin, (value) => TINNumber.create(value)),
"tin",
errors
);
});
toPatchField(dto.email_primary).ifSet((email_primary) => {
customerPatchProps.emailPrimary = extractOrPushError(
maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)),
"email_primary",
errors
);
});
toPatchField(dto.email_secondary).ifSet((email_secondary) => {
customerPatchProps.emailSecondary = extractOrPushError(
maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)),
"email_secondary",
errors
);
});
toPatchField(dto.mobile_primary).ifSet((mobile_primary) => {
customerPatchProps.mobilePrimary = extractOrPushError(
maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)),
"mobile_primary",
errors
);
});
toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => {
customerPatchProps.mobilePrimary = extractOrPushError(
maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)),
"mobile_secondary",
errors
);
});
toPatchField(dto.phone_primary).ifSet((phone_primary) => {
customerPatchProps.phonePrimary = extractOrPushError(
maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)),
"phone_primary",
errors
);
});
toPatchField(dto.phone_secondary).ifSet((phone_secondary) => {
customerPatchProps.phoneSecondary = extractOrPushError(
maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)),
"phone_secondary",
errors
);
});
toPatchField(dto.fax).ifSet((fax) => {
customerPatchProps.fax = extractOrPushError(
maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)),
"fax",
errors
);
});
toPatchField(dto.website).ifSet((website) => {
customerPatchProps.website = extractOrPushError(
maybeFromNullableResult(website, (value) => URLAddress.create(value)),
"website",
errors
);
});
toPatchField(dto.legal_record).ifSet((legalRecord) => {
customerPatchProps.legalRecord = extractOrPushError(
maybeFromNullableResult(legalRecord, (value) => TextValue.create(value)),
"legal_record",
errors
);
});
toPatchField(dto.language_code).ifSet((languageCode) => {
if (isNullishOrEmpty(languageCode)) {
errors.push({ path: "language_code", message: "Language code cannot be empty" });
return;
}
customerPatchProps.languageCode = extractOrPushError(
LanguageCode.create(languageCode!),
"language_code",
errors
);
});
toPatchField(dto.currency_code).ifSet((currencyCode) => {
if (isNullishOrEmpty(currencyCode)) {
errors.push({ path: "currency_code", message: "Currency code cannot be empty" });
return;
}
customerPatchProps.currencyCode = extractOrPushError(
CurrencyCode.create(currencyCode!),
"currency_code",
errors
);
});
// Default taxes
const defaultTaxesCollection = new Collection<TaxCode>();
/*toPatchField(dto.default_taxes).ifSet((defaultTaxes) => {
customerPatchProps.defaultTaxes = defaultTaxesCollection;
if (isNullishOrEmpty(defaultTaxes)) {
return;
}
defaultTaxes!.forEach((taxCode, index) => {
const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors);
if (tax && customerPatchProps.defaultTaxes) {
customerPatchProps.defaultTaxes.add(tax);
}
});
});*/
// PostalAddress
const addressPatchProps = this.mapPostalAddress(dto, errors);
if (addressPatchProps) {
customerPatchProps.address = addressPatchProps;
}
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer props mapping failed (update)", errors)
);
}
return Result.ok(customerPatchProps);
} catch (err: unknown) {
return Result.fail(new DomainError("Customer props mapping failed", { cause: err }));
}
}
public mapPostalAddress(
dto: UpdateCustomerByIdRequestDTO,
errors: ValidationErrorDetail[]
): PostalAddressPatchProps | undefined {
const postalAddressPatchProps: PostalAddressPatchProps = {};
toPatchField(dto.street).ifSet((street) => {
postalAddressPatchProps.street = extractOrPushError(
maybeFromNullableResult(street, (value) => Street.create(value)),
"street",
errors
);
});
toPatchField(dto.street2).ifSet((street2) => {
postalAddressPatchProps.street2 = extractOrPushError(
maybeFromNullableResult(street2, (value) => Street.create(value)),
"street2",
errors
);
});
toPatchField(dto.city).ifSet((city) => {
postalAddressPatchProps.city = extractOrPushError(
maybeFromNullableResult(city, (value) => City.create(value)),
"city",
errors
);
});
toPatchField(dto.province).ifSet((province) => {
postalAddressPatchProps.province = extractOrPushError(
maybeFromNullableResult(province, (value) => Province.create(value)),
"province",
errors
);
});
toPatchField(dto.postal_code).ifSet((postalCode) => {
postalAddressPatchProps.postalCode = extractOrPushError(
maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)),
"postal_code",
errors
);
});
toPatchField(dto.country).ifSet((country) => {
postalAddressPatchProps.country = extractOrPushError(
maybeFromNullableResult(country, (value) => Country.create(value)),
"country",
errors
);
});
return Object.keys(postalAddressPatchProps).length > 0 ? postalAddressPatchProps : undefined;
}
}

View File

@ -1,5 +1,5 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Customer } from "../../domain/aggregates";
@ -48,6 +48,16 @@ export interface ICustomerRepository {
transaction: unknown
): Promise<Result<Customer, Error>>;
/**
* Recupera un Customer por su TIN y companyId.
* Devuelve un `NotFoundError` si no se encuentra.
*/
getByTINInCompany(
companyId: UniqueID,
tin: TINNumber,
transaction?: unknown
): Promise<Result<Customer, Error>>;
/**
* Recupera múltiples customers dentro de una empresa
* según un criterio dinámico (búsqueda, paginación, etc.).

View File

@ -1,5 +1,5 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
@ -10,7 +10,13 @@ import type { ICustomerRepository } from "../repositories";
export interface ICustomerFinder {
findCustomerById(
companyId: UniqueID,
invoiceId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
): Promise<Result<Customer, Error>>;
findCustomerByTIN(
companyId: UniqueID,
tin: TINNumber,
transaction?: Transaction
): Promise<Result<Customer, Error>>;
@ -38,6 +44,14 @@ export class CustomerFinder implements ICustomerFinder {
return this.repository.getByIdInCompany(companyId, customerId, transaction);
}
findCustomerByTIN(
companyId: UniqueID,
tin: TINNumber,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
return this.repository.getByTINInCompany(companyId, tin, transaction);
}
async customerExists(
companyId: UniqueID,
customerId: UniqueID,

View File

@ -2,14 +2,14 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { Customer, ICustomerCreateProps } from "../../domain";
import type { Customer, CustomerPatchProps } from "../../domain";
import type { ICustomerRepository } from "../repositories";
export interface ICustomerUpdater {
update(params: {
companyId: UniqueID;
id: UniqueID;
props: Partial<ICustomerCreateProps>;
props: CustomerPatchProps;
transaction: Transaction;
}): Promise<Result<Customer, Error>>;
}
@ -28,7 +28,7 @@ export class CustomerUpdater implements ICustomerUpdater {
async update(params: {
companyId: UniqueID;
id: UniqueID;
props: Partial<ICustomerCreateProps>;
props: CustomerPatchProps;
transaction: Transaction;
}): Promise<Result<Customer, Error>> {
const { companyId, id, props, transaction } = params;

View File

@ -1,2 +1,3 @@
export * from "./customer-creator";
export * from "./customer-finder";
export * from "./customer-updater";

View File

@ -1,279 +0,0 @@
import {
City,
Country,
CurrencyCode,
DomainError,
EmailAddress,
LanguageCode,
Name,
PhoneNumber,
type PostalAddressPatchProps,
PostalCode,
Province,
Street,
TINNumber,
TaxCode,
TextValue,
URLAddress,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Collection, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
import type { UpdateCustomerByIdRequestDTO } from "../../../../common";
import type { CustomerPatchProps } from "../../../domain";
/**
* mapDTOToUpdateCustomerPatchProps
* Convierte el DTO a las props validadas (CustomerProps).
* No construye directamente el agregado.
* Tri-estado:
* - campo omitido no se cambia
* - campo con valor null/"" se quita el valor -> set(None()),
* - campo con valor no-vacío se pone el nuevo valor -> set(Some(VO)).
*
* @param dto - DTO con los datos a cambiar en el cliente
* @returns Cambios en las propiedades del cliente
*
*/
export function mapDTOToUpdateCustomerPatchProps(dto: UpdateCustomerByIdRequestDTO) {
try {
const errors: ValidationErrorDetail[] = [];
const customerPatchProps: CustomerPatchProps = {};
toPatchField(dto.reference).ifSet((reference) => {
customerPatchProps.reference = extractOrPushError(
maybeFromNullableResult(reference, (value) => Name.create(value)),
"reference",
errors
);
});
toPatchField(dto.is_company).ifSet((is_company) => {
if (isNullishOrEmpty(is_company)) {
errors.push({ path: "is_company", message: "is_company cannot be empty" });
return;
}
customerPatchProps.isCompany = extractOrPushError(
Result.ok(Boolean(is_company!)),
"is_company",
errors
);
});
toPatchField(dto.name).ifSet((name) => {
if (isNullishOrEmpty(name)) {
errors.push({ path: "name", message: "Name cannot be empty" });
return;
}
customerPatchProps.name = extractOrPushError(Name.create(name!), "name", errors);
});
toPatchField(dto.trade_name).ifSet((trade_name) => {
customerPatchProps.tradeName = extractOrPushError(
maybeFromNullableResult(trade_name, (value) => Name.create(value)),
"trade_name",
errors
);
});
toPatchField(dto.tin).ifSet((tin) => {
customerPatchProps.tin = extractOrPushError(
maybeFromNullableResult(tin, (value) => TINNumber.create(value)),
"tin",
errors
);
});
toPatchField(dto.email_primary).ifSet((email_primary) => {
customerPatchProps.emailPrimary = extractOrPushError(
maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)),
"email_primary",
errors
);
});
toPatchField(dto.email_secondary).ifSet((email_secondary) => {
customerPatchProps.emailSecondary = extractOrPushError(
maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)),
"email_secondary",
errors
);
});
toPatchField(dto.mobile_primary).ifSet((mobile_primary) => {
customerPatchProps.mobilePrimary = extractOrPushError(
maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)),
"mobile_primary",
errors
);
});
toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => {
customerPatchProps.mobilePrimary = extractOrPushError(
maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)),
"mobile_secondary",
errors
);
});
toPatchField(dto.phone_primary).ifSet((phone_primary) => {
customerPatchProps.phonePrimary = extractOrPushError(
maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)),
"phone_primary",
errors
);
});
toPatchField(dto.phone_secondary).ifSet((phone_secondary) => {
customerPatchProps.phoneSecondary = extractOrPushError(
maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)),
"phone_secondary",
errors
);
});
toPatchField(dto.fax).ifSet((fax) => {
customerPatchProps.fax = extractOrPushError(
maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)),
"fax",
errors
);
});
toPatchField(dto.website).ifSet((website) => {
customerPatchProps.website = extractOrPushError(
maybeFromNullableResult(website, (value) => URLAddress.create(value)),
"website",
errors
);
});
toPatchField(dto.legal_record).ifSet((legalRecord) => {
customerPatchProps.legalRecord = extractOrPushError(
maybeFromNullableResult(legalRecord, (value) => TextValue.create(value)),
"legal_record",
errors
);
});
toPatchField(dto.language_code).ifSet((languageCode) => {
if (isNullishOrEmpty(languageCode)) {
errors.push({ path: "language_code", message: "Language code cannot be empty" });
return;
}
customerPatchProps.languageCode = extractOrPushError(
LanguageCode.create(languageCode!),
"language_code",
errors
);
});
toPatchField(dto.currency_code).ifSet((currencyCode) => {
if (isNullishOrEmpty(currencyCode)) {
errors.push({ path: "currency_code", message: "Currency code cannot be empty" });
return;
}
customerPatchProps.currencyCode = extractOrPushError(
CurrencyCode.create(currencyCode!),
"currency_code",
errors
);
});
// Default taxes
const defaultTaxesCollection = new Collection<TaxCode>();
toPatchField(dto.default_taxes).ifSet((defaultTaxes) => {
customerPatchProps.defaultTaxes = defaultTaxesCollection;
if (isNullishOrEmpty(defaultTaxes)) {
return;
}
defaultTaxes!.forEach((taxCode, index) => {
const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors);
if (tax && customerPatchProps.defaultTaxes) {
customerPatchProps.defaultTaxes.add(tax);
}
});
});
// PostalAddress
const addressPatchProps = mapDTOToUpdatePostalAddressPatchProps(dto, errors);
if (addressPatchProps) {
customerPatchProps.address = addressPatchProps;
}
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer props mapping failed (update)", errors)
);
}
return Result.ok(customerPatchProps);
} catch (err: unknown) {
return Result.fail(new DomainError("Customer props mapping failed", { cause: err }));
}
}
function mapDTOToUpdatePostalAddressPatchProps(
dto: UpdateCustomerByIdRequestDTO,
errors: ValidationErrorDetail[]
): PostalAddressPatchProps | undefined {
const postalAddressPatchProps: PostalAddressPatchProps = {};
toPatchField(dto.street).ifSet((street) => {
postalAddressPatchProps.street = extractOrPushError(
maybeFromNullableResult(street, (value) => Street.create(value)),
"street",
errors
);
});
toPatchField(dto.street2).ifSet((street2) => {
postalAddressPatchProps.street2 = extractOrPushError(
maybeFromNullableResult(street2, (value) => Street.create(value)),
"street2",
errors
);
});
toPatchField(dto.city).ifSet((city) => {
postalAddressPatchProps.city = extractOrPushError(
maybeFromNullableResult(city, (value) => City.create(value)),
"city",
errors
);
});
toPatchField(dto.province).ifSet((province) => {
postalAddressPatchProps.province = extractOrPushError(
maybeFromNullableResult(province, (value) => Province.create(value)),
"province",
errors
);
});
toPatchField(dto.postal_code).ifSet((postalCode) => {
postalAddressPatchProps.postalCode = extractOrPushError(
maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)),
"postal_code",
errors
);
});
toPatchField(dto.country).ifSet((country) => {
postalAddressPatchProps.country = extractOrPushError(
maybeFromNullableResult(country, (value) => Country.create(value)),
"country",
errors
);
});
return Object.keys(postalAddressPatchProps).length > 0 ? postalAddressPatchProps : undefined;
}

View File

@ -1,11 +1,13 @@
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
import type { CustomerPatchProps } from "../../../domain";
import type { CustomerApplicationService } from "../../customer-application.service";
import type { IUpdateCustomerInputMapper } from "../../mappers";
import type { ICustomerUpdater } from "../../services";
import type { ICustomerFullSnapshotBuilder } from "../../snapshot-builders";
type UpdateCustomerUseCaseInput = {
companyId: UniqueID;
@ -13,12 +15,25 @@ type UpdateCustomerUseCaseInput = {
dto: UpdateCustomerByIdRequestDTO;
};
type UpdateCustomerUseCaseDeps = {
dtoMapper: IUpdateCustomerInputMapper;
updater: ICustomerUpdater;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
};
export class UpdateCustomerUseCase {
constructor(
private readonly service: CustomerApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
private readonly dtoMapper: IUpdateCustomerInputMapper;
private readonly updater: ICustomerUpdater;
private readonly fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
private readonly transactionManager: ITransactionManager;
constructor(deps: UpdateCustomerUseCaseDeps) {
this.dtoMapper = deps.dtoMapper;
this.updater = deps.updater;
this.fullSnapshotBuilder = deps.fullSnapshotBuilder;
this.transactionManager = deps.transactionManager;
}
public execute(params: UpdateCustomerUseCaseInput) {
const { companyId, customer_id, dto } = params;
@ -27,6 +42,8 @@ export class UpdateCustomerUseCase {
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const id = idOrError.data;
// Mapear DTO → props de dominio
const patchPropsResult = this.dtoMapper.map(dto, { companyId });
if (patchPropsResult.isFailure) {
@ -37,20 +54,20 @@ export class UpdateCustomerUseCase {
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const updatedCustomer = await this.service.patchCustomerByIdInCompany(
const updateResult = await this.updater.update({
companyId,
customerId,
patchProps,
transaction
);
id,
props: patchProps,
transaction,
});
if (updatedCustomer.isFailure) {
return Result.fail(updatedCustomer.error);
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
const customerOrError = await this.service.updateCustomerInCompany(
companyId,
updatedCustomer.data,
updateResult.data,
transaction
);
const customer = customerOrError.data;

View File

@ -9,14 +9,13 @@ import {
type PostalAddressPatchProps,
type PostalAddressProps,
type TINNumber,
type TaxCode,
type TextValue,
type URLAddress,
type UniqueID,
} from "@repo/rdx-ddd";
import { type Collection, type Maybe, Result } from "@repo/rdx-utils";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { CustomerStatus } from "../value-objects";
import type { CustomerStatus, CustomerTaxesProps } from "../value-objects";
export interface ICustomerCreateProps {
companyId: UniqueID;
@ -44,14 +43,14 @@ export interface ICustomerCreateProps {
legalRecord: Maybe<TextValue>;
defaultTaxes: TaxCode[];
defaultTaxes: CustomerTaxesProps;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
}
export type CustomerPatchProps = Partial<
Omit<ICustomerCreateProps, "companyId" | "address" | "isCompany" | "status">
Omit<ICustomerCreateProps, "companyId" | "address" | "status">
> & {
address?: PostalAddressPatchProps;
};
@ -87,7 +86,7 @@ export interface ICustomer {
readonly website: Maybe<URLAddress>;
readonly legalRecord: Maybe<TextValue>;
readonly defaultTaxes: Collection<TaxCode>;
readonly defaultTaxes: CustomerTaxesProps;
readonly languageCode: LanguageCode;
readonly currencyCode: CurrencyCode;
@ -142,8 +141,6 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
if (addressResult.isFailure) {
return Result.fail(addressResult.error);
}
this.props.address = addressResult.data;
}
return Result.ok();
@ -223,7 +220,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
return this.props.legalRecord;
}
public get defaultTaxes(): Collection<TaxCode> {
public get defaultTaxes(): CustomerTaxesProps {
return this.props.defaultTaxes;
}

View File

@ -0,0 +1,65 @@
import type { Tax } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
export type CustomerTaxesProps = {
iva: Maybe<Tax>; // si existe
rec: Maybe<Tax>; // si existe
retention: Maybe<Tax>; // si existe
};
export interface ICustomerItemTaxes {
iva: Maybe<Tax>; // si existe
rec: Maybe<Tax>; // si existe
retention: Maybe<Tax>; // si existe
toKey(): string; // Clave para representar un trío.
}
export class CustomerTaxes
extends ValueObject<CustomerTaxesProps>
implements ICustomerItemTaxes
{
static create(props: CustomerTaxesProps) {
return Result.ok(new CustomerTaxes(props));
}
toKey(): string {
const ivaCode = this.props.iva.match(
(iva) => iva.code,
() => "#"
);
const recCode = this.props.rec.match(
(rec) => rec.code,
() => "#"
);
const retentionCode = this.props.retention.match(
(retention) => retention.code,
() => "#"
);
return `${ivaCode};${recCode};${retentionCode}`;
}
get iva(): Maybe<Tax> {
return this.props.iva;
}
get rec(): Maybe<Tax> {
return this.props.rec;
}
get retention(): Maybe<Tax> {
return this.props.retention;
}
getProps() {
return this.props;
}
toPrimitive() {
return this.getProps();
}
}

View File

@ -2,3 +2,4 @@ export * from "./customer-address-type.vo";
export * from "./customer-number.vo";
export * from "./customer-serie.vo";
export * from "./customer-status.vo";
export * from "./customer-taxes.vo";

View File

@ -1,10 +1,16 @@
import type { IModuleServer } from "@erp/core/api";
import { customersRouter, models } from "./infrastructure";
import { buildCustomersDependencies, buildCustomerServices, CustomersInternalDeps } from "./infrastructure/di";
import { type CustomerPublicServices, customersRouter, models } from "./infrastructure";
import {
type CustomersInternalDeps,
buildCustomerServices,
buildCustomersDependencies,
} from "./infrastructure/di";
export * from "./infrastructure/sequelize";
export type { CustomerPublicServices };
export const customersAPIModule: IModuleServer = {
name: "customers",
version: "1.0.0",
@ -21,10 +27,10 @@ export const customersAPIModule: IModuleServer = {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
// 1) Dominio interno
const customerInternalDeps = buildCustomersDependencies(params);
const internal = buildCustomersDependencies(params);
// 2) Servicios públicos (Application Services)
const customerServices = buildCustomerServices(customerInternalDeps);
const customersServices: CustomerPublicServices = buildCustomerServices(params, internal);
logger.info("🚀 Customers module dependencies registered", {
label: this.name,
@ -35,10 +41,12 @@ export const customersAPIModule: IModuleServer = {
models,
// Servicios expuestos a otros módulos
services: customerServices,
services: {
general: customersServices, // 'customers:general'
},
// Implementación privada del módulo
internal:customerInternalDeps,
internal,
};
},

View File

@ -1,24 +1,79 @@
import type { SetupParams } from "@erp/core/api";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import { buildCustomerCreator, buildCustomerFinder } from "../../application";
import type { Customer, ICustomerCreateProps } from "../../domain";
import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di";
import { buildCustomerRepository } from "./customer-repositories.di";
import type { CustomersInternalDeps } from "./customers.di";
export type CustomersServicesDeps = {
services: {
listCustomers: (filters: unknown, context: unknown) => null;
getCustomerById: (id: unknown, context: unknown) => null;
type CustomerServicesContext = {
transaction: Transaction;
companyId: UniqueID;
};
export type CustomerPublicServices = {
//listCustomers: (filters: unknown, context: unknown) => null;
findCustomerByTIN: (
tin: TINNumber,
context: CustomerServicesContext
) => Promise<Result<Customer, Error>>;
createCustomer: (
id: UniqueID,
props: ICustomerCreateProps,
context: CustomerServicesContext
) => Promise<Result<Customer, Error>>;
//generateCustomerReport: (id: unknown, options: unknown, context: unknown) => null;
};
};
export function buildCustomerServices(deps: CustomersInternalDeps): CustomersServicesDeps {
export function buildCustomerServices(
params: SetupParams,
deps: CustomersInternalDeps
): CustomerPublicServices {
const { database } = params;
// Infrastructure
const persistenceMappers = buildCustomerPersistenceMappers();
const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
const finder = buildCustomerFinder({ repository });
const creator = buildCustomerCreator({ repository });
return {
services: {
listCustomers: (filters, context) => null,
//internal.useCases.listCustomers().execute(filters, context),
findCustomerByTIN: async (tin: TINNumber, context: CustomerServicesContext) => {
const { companyId, transaction } = context;
getCustomerById: (id, context) => null,
//internal.useCases.getCustomerById().execute(id, context),
const customerResult = await finder.findCustomerByTIN(companyId, tin, transaction);
//generateCustomerReport: (id, options, context) => null,
//internal.useCases.reportCustomer().execute(id, options, context),
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
return Result.ok(customerResult.data);
},
createCustomer: async (
id: UniqueID,
props: ICustomerCreateProps,
context: CustomerServicesContext
) => {
const { companyId, transaction } = context;
const customerResult = await creator.create({
companyId,
id,
props,
transaction,
});
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
return Result.ok(customerResult.data);
},
};
}

View File

@ -6,6 +6,8 @@ import {
CreateCustomerRequestSchema,
CustomerListRequestSchema,
GetCustomerByIdRequestSchema,
UpdateCustomerByIdParamsRequestSchema,
UpdateCustomerByIdRequestSchema,
} from "../../../common/dto";
import type { CustomersInternalDeps } from "../di";
@ -13,6 +15,7 @@ import {
CreateCustomerController,
GetCustomerController,
ListCustomersController,
UpdateCustomerController,
} from "./controllers";
export const customersRouter = (params: ModuleParams, deps: CustomersInternalDeps) => {
@ -75,19 +78,19 @@ export const customersRouter = (params: ModuleParams, deps: CustomersInternalDep
}
);
/* router.put(
router.put(
"/:customer_id",
//checkTabContext,
validateRequest(UpdateCustomerByIdParamsRequestSchema, "params"),
validateRequest(UpdateCustomerByIdRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.update();
const useCase = deps.useCases.updateCustomer();
const controller = new UpdateCustomerController(useCase);
return controller.execute(req, res, next);
}
);
*/
/*router.delete(
"/:customer_id",
//checkTabContext,

View File

@ -1,3 +1,4 @@
export * from "./di";
export * from "./express";
export * from "./mappers";
export * from "./sequelize";

View File

@ -1,10 +1,10 @@
import {
CreationOptional,
type CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
type InferAttributes,
type InferCreationAttributes,
Model,
Sequelize,
type Sequelize,
} from "sequelize";
export type CustomerCreationAttributes = InferCreationAttributes<CustomerModel, {}> & {};
@ -238,6 +238,7 @@ export default (database: Sequelize) => {
fields: ["company_id", "deleted_at", "name"],
},
{ name: "idx_name", fields: ["name"] }, // <- para ordenación
{ name: "idx_tin", fields: ["tin"] }, // <- para servicios externos
{ name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get
{ name: "idx_factuges", fields: ["factuges_id"], unique: true }, // <- para el proceso python

View File

@ -5,7 +5,7 @@ import {
translateSequelizeError,
} from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize";
@ -162,6 +162,59 @@ export class CustomerRepository
}
}
/**
* Recupera un cliente por su ID y companyId.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param tin - TIN del cliente.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
*/
async getByTINInCompany(
companyId: UniqueID,
tin: TINNumber,
transaction?: Transaction,
options: FindOptions<InferAttributes<CustomerModel>> = {}
): Promise<Result<Customer, Error>> {
try {
// Normalización defensiva de order/include
const normalizedOrder = Array.isArray(options.order)
? options.order
: options.order
? [options.order]
: [];
const normalizedInclude = Array.isArray(options.include)
? options.include
: options.include
? [options.include]
: [];
const mergedOptions: FindOptions<InferAttributes<CustomerModel>> = {
...options,
where: {
...(options.where ?? {}),
tin: tin.toString(),
company_id: companyId.toString(),
},
order: normalizedOrder,
include: normalizedInclude,
transaction,
};
const row = await CustomerModel.findOne(mergedOptions);
if (!row) {
return Result.fail(new EntityNotFoundError("Customer", "tin", tin.toString()));
}
const customer = this.domainMapper.mapToDomain(row);
return customer;
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
/**
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
*

View File

@ -1,5 +1,5 @@
import {
GetCustomerByIdResponseDTO,
type GetCustomerByIdResponseDTO,
GetCustomerByIdResponseSchema,
} from "./get-customer-by-id.response.dto";

View File

@ -0,0 +1,12 @@
import type { ArrayElement } from "@repo/rdx-utils";
import type { GetCustomerByIdResponseDTO, ListCustomersResponseDTO } from "../../../common";
// Elemento de consulta paginada
export type CustomerSummary = Omit<ArrayElement<ListCustomersResponseDTO["items"]>, "metadata">;
// Consulta paginada con criteria
export type CustomerSummaryPage = Omit<ListCustomersResponseDTO, "metadata">;
// Cliente
export type Customer = Omit<GetCustomerByIdResponseDTO, "metadata">;

View File

@ -1,2 +1,3 @@
export * from "./api-types";
export * from "./get-customer-by-ip.api";
export * from "./get-customer-list.api";

View File

@ -0,0 +1,3 @@
export * from "./use-customer-get-query";
export * from "./use-customer-list-query";
export * from "./use-customer-update-mutation";

View File

@ -0,0 +1,10 @@
import type { ZodError } from "zod";
// Helpers de validación a errores de dominio
export function toValidationErrors(error: ZodError<unknown>) {
return error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
}

View File

@ -8,7 +8,7 @@ import {
useQuery,
} from "@tanstack/react-query";
import { type CustomerSummaryPage, getCustomerListApi } from "../api";
import { type CustomerSummaryPage, getCustomerListApi } from "..";
export const CUSTOMERS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
"customers",

View File

@ -0,0 +1,60 @@
import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestSchema } from "../../../common";
import type { Customer } from "../api";
import { toValidationErrors } from "./toValidationErrors";
export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const;
type UpdateCustomerContext = {};
type UpdateCustomerPayload = {
id: string;
data: Partial<CustomerFormData>;
};
export const useCustomerUpdateMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = UpdateCustomerByIdRequestSchema;
return useMutation<Customer, DefaultError, UpdateCustomerPayload, UpdateCustomerContext>({
mutationKey: CUSTOMER_UPDATE_KEY,
mutationFn: async (payload) => {
const { id: customerId, data } = payload;
if (!customerId) {
throw new Error("customerId is required");
}
const result = schema.safeParse(data);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
}
const updated = await dataSource.updateOne("customers", customerId, data);
return updated as Customer;
},
onSuccess: (updated: Customer, variables) => {
const { id: customerId } = updated;
// Invalida el listado para refrescar desde servidor
//invalidateCustomerListCache(queryClient);
// Actualiza detalle
//setCustomerDetailCache(queryClient, customerId, updated);
// Actualiza todas las páginas donde aparezca
//upsertCustomerIntoListCaches(queryClient, { ...updated });
},
onSettled: () => {
// Refresca todos los listados
//invalidateCustomerListCache(queryClient);
},
});
};

View File

@ -0,0 +1,2 @@
export * from "./api";
export * from "./hooks";

View File

@ -1,10 +1,10 @@
import { FormDebug } from "@erp/core/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from './customer-contact-fields';
import { CustomerContactFields } from "./customer-contact-fields";
type CustomerFormProps = {
formId: string;
@ -15,7 +15,7 @@ type CustomerFormProps = {
export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => {
return (
<form noValidate id={formId} onSubmit={onSubmit}>
<form id={formId} noValidate onSubmit={onSubmit}>
<FormDebug />
<section className={cn("space-y-6 p-6", className)}>
<CustomerBasicInfoFields focusRef={focusRef} />

View File

@ -1,2 +1 @@
//export * from "./customer-edit-form";
export * from "../../view/ui/components/customer-editor-skeleton";
export * from "./customer-edit-form";

View File

@ -1,5 +1,3 @@
//export * from "./client-selector-modal";
//export * from "./customer-modal-selector";
//export * from "./editor";
export * from "../../../../core/src/web/components/error-alert";
//export * from "./not-found-card";
export * from "./editor";

View File

@ -1,4 +1,4 @@
import { PropsWithChildren, createContext } from "react";
import { type PropsWithChildren, createContext } from "react";
/**
*

View File

@ -8,9 +8,9 @@ const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.Cust
const CustomerView = lazy(() => import("./view").then((m) => ({ default: m.CustomerViewPage })));
//const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage })));
/*const CustomerUpdate = lazy(() =>
import("./pages").then((m) => ({ default: m.CustomerUpdatePage }))
);*/
const CustomerUpdate = lazy(() =>
import("./update").then((m) => ({ default: m.CustomerUpdatePage }))
);
export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
return [
@ -26,7 +26,7 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
{ path: "list", element: <CustomersList /> },
//{ path: "create", element: <CustomerAdd /> },
{ path: ":id", element: <CustomerView /> },
//{ path: ":id/edit", element: <CustomerUpdate /> },
{ path: ":id/edit", element: <CustomerUpdate /> },
//
/*{ path: "create", element: <CustomersList /> },

View File

@ -1,9 +1,9 @@
import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import type { ZodError } from "zod/v4";
import { CreateCustomerRequestSchema } from "../../common";
import { toValidationErrors } from "../common/hooks/toValidationErrors";
import type { Customer, CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query";
@ -15,14 +15,6 @@ type CreateCustomerPayload = {
data: CustomerFormData;
};
// Helpers de validación a errores de dominio
export function toValidationErrors(error: ZodError<unknown>) {
return error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
}
export function useCreateCustomer() {
const queryClient = useQueryClient();
const dataSource = useDataSource();

View File

@ -3,9 +3,9 @@ import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestSchema } from "../../common";
import { toValidationErrors } from "../common/hooks/toValidationErrors";
import type { Customer, CustomerFormData } from "../schemas";
import { toValidationErrors } from "./use-create-customer-mutation";
import {
invalidateCustomerListCache,
upsertCustomerIntoListCaches,

View File

@ -1,4 +1,4 @@
import type { CustomerSummaryPage } from "../api";
import type { CustomerSummaryPage } from "../../common";
import type { CustomerSummaryPageData } from "../types";
/**

View File

@ -1,6 +0,0 @@
import type { ListCustomersResponseDTO } from "@erp/customers/common";
import type { ArrayElement } from "@repo/rdx-utils";
// Resultado de consulta con criteria (paginado, etc.)
export type CustomerSummaryPage = Omit<ListCustomersResponseDTO, "metadata">;
export type CustomerSummary = Omit<ArrayElement<CustomerSummaryPage>, "metadata">;

View File

@ -1,2 +0,0 @@
export * from "./api-types";
export * from "./get-customer-list.api";

View File

@ -2,8 +2,8 @@ import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react";
import { useCustomerListQuery } from "../../common";
import { CustomerSummaryDtoAdapter } from "../adapters";
import { useCustomerListQuery } from "../hooks";
export const useCustomerListController = () => {
const [pageIndex, setPageIndex] = useState(0);

View File

@ -1 +0,0 @@
export * from "./use-customer-list-query";

View File

@ -1,5 +1,4 @@
import type { CustomerSummary } from "../../schemas";
import type { CustomerSummaryPage } from "../api";
import type { CustomerSummary, CustomerSummaryPage } from "../../common";
export type CustomerSummaryData = CustomerSummary;

View File

@ -1,5 +1,5 @@
export * from "./address-cell";
export * from "./contact-cell";
export * from "./initials";
export * from "./kind-badge";
export * from "./soft";
export * from "./contact-cell";
export * from "./address-cell";

View File

@ -1,3 +1 @@
//export * from "./blocks";
//export * from "./components";
export * from "./pages";

View File

@ -1,4 +1,2 @@
export * from "./create";
export * from "./list";
export * from "./update";
export * from "./view";

View File

@ -9,8 +9,7 @@ import {
NotFoundCard,
} from "../../components";
import { useTranslation } from "../../i18n";
import { useCustomerUpdateController } from "./use-customer-update-controller";
import { useCustomerUpdateController } from "../../update/controllers/use-customer-update-page.controller";
export const CustomerUpdatePage = () => {
const customerId = useUrlParamId();

View File

@ -1,333 +0,0 @@
import { PageHeader } from "@erp/core/components";
import { useUrlParamId } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import {
Banknote,
EditIcon,
FileText,
Globe,
Languages,
Mail,
MapPin,
MoreVertical,
Phone,
Smartphone,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CustomerEditorSkeleton, ErrorAlert } from "../../components";
import { useCustomerQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
export const CustomerViewPage = () => {
const customerId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
// 1) Estado de carga del cliente (query)
const {
data: customer,
isLoading: isLoadingCustomer,
isError: isLoadError,
error: loadError,
} = useCustomerQuery(customerId, { enabled: !!customerId });
if (isLoadingCustomer) {
return <CustomerEditorSkeleton />;
}
if (isLoadError) {
return (
<>
<AppContent>
<ErrorAlert
message={
(loadError as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
}
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
/>
<div className="flex items-center justify-end">
<BackHistoryButton />
</div>
</AppContent>
</>
);
}
return (
<>
<AppHeader>
<PageHeader
backIcon
description={
<div className="mt-2 flex items-center gap-3">
<Badge className="font-mono" variant="secondary">
{customer?.tin}
</Badge>
<Badge variant="outline">{customer?.is_company ? "Empresa" : "Persona"}</Badge>
</div>
}
rightSlot={
<div className="flex gap-2">
<Button onClick={() => navigate("/customers/list")} size="icon" variant="outline">
<MoreVertical className="h-4 w-4" />
</Button>
<Button>
<EditIcon className="mr-2 h-4 w-4" />
Editar
</Button>
</div>
}
title={
<div className="flex flex-wrap items-center gap-2">
{customer?.name}{" "}
{customer?.trade_name && (
<span className="text-muted-foreground">({customer.trade_name})</span>
)}
</div>
}
/>
</AppHeader>
<AppContent>
{/* Main Content Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* Información Básica */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="size-5 text-primary" />
Información Básica
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Nombre</dt>
<dd className="mt-1 text-base text-foreground">{customer?.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Referencia</dt>
<dd className="mt-1 font-mono text-base text-foreground">{customer?.reference}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Registro Legal</dt>
<dd className="mt-1 text-base text-foreground">{customer?.legal_record}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Impuestos por Defecto</dt>
<dd className="mt-1">
{customer?.default_taxes.map((tax) => (
<Badge key={tax} variant={"secondary"}>
{tax}
</Badge>
))}
</dd>
</div>
</CardContent>
</Card>
{/* Dirección */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<MapPin className="size-5 text-primary" />
Dirección
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Calle</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.street}
{customer?.street2 && (
<>
<br />
{customer?.street2}
</>
)}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Ciudad</dt>
<dd className="mt-1 text-base text-foreground">{customer?.city}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Código Postal</dt>
<dd className="mt-1 text-base text-foreground">{customer?.postal_code}</dd>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Provincia</dt>
<dd className="mt-1 text-base text-foreground">{customer?.province}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">País</dt>
<dd className="mt-1 text-base text-foreground">{customer?.country}</dd>
</div>
</div>
</CardContent>
</Card>
{/* Información de Contacto */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Mail className="size-5 text-primary" />
Información de Contacto
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
{/* Contacto Principal */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">Contacto Principal</h3>
{customer?.email_primary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.email_primary}
</dd>
</div>
</div>
)}
{customer?.mobile_primary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Móvil</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.mobile_primary}
</dd>
</div>
</div>
)}
{customer?.phone_primary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.phone_primary}
</dd>
</div>
</div>
)}
</div>
{/* Contacto Secundario */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">Contacto Secundario</h3>
{customer?.email_secondary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.email_secondary}
</dd>
</div>
</div>
)}
{customer?.mobile_secondary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Móvil</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.mobile_secondary}
</dd>
</div>
</div>
)}
{customer?.phone_secondary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.phone_secondary}
</dd>
</div>
</div>
)}
</div>
{/* Otros Contactos */}
{(customer?.website || customer?.fax) && (
<div className="space-y-4 md:col-span-2">
<h3 className="font-semibold text-foreground">Otros</h3>
<div className="grid gap-4 md:grid-cols-2">
{customer?.website && (
<div className="flex items-start gap-3">
<Globe className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Sitio Web</dt>
<dd className="mt-1 text-base text-primary hover:underline">
<a href={customer?.website} rel="noopener noreferrer" target="_blank">
{customer?.website}
</a>
</dd>
</div>
</div>
)}
{customer?.fax && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Fax</dt>
<dd className="mt-1 text-base text-foreground">{customer?.fax}</dd>
</div>
</div>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Preferencias */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Languages className="size-5 text-primary" />
Preferencias
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="flex items-start gap-3">
<Languages className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Idioma Preferido</dt>
<dd className="mt-1 text-base text-foreground">{customer?.language_code}</dd>
</div>
</div>
<div className="flex items-start gap-3">
<Banknote className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Moneda Preferida</dt>
<dd className="mt-1 text-base text-foreground">{customer?.currency_code}</dd>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</AppContent>
</>
);
};

View File

@ -1 +0,0 @@
export * from "./customer-view-page";

View File

@ -0,0 +1 @@
export * from "./use-customer-update-page.controller";

View File

@ -4,7 +4,7 @@ import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui
import { useEffect, useId, useMemo } from "react";
import { type FieldErrors, FormProvider } from "react-hook-form";
import { useCustomerQuery, useUpdateCustomer } from "../../hooks";
import { useCustomerGetQuery, useCustomerUpdateMutation } from "../../common";
import { useTranslation } from "../../i18n";
import {
type Customer,
@ -34,7 +34,7 @@ export const useCustomerUpdateController = (
isLoading,
isError: isLoadError,
error: loadError,
} = useCustomerQuery(customerId, { enabled: Boolean(customerId) });
} = useCustomerGetQuery(customerId, { enabled: Boolean(customerId) });
// 2) Estado de creación (mutación)
const {
@ -42,7 +42,7 @@ export const useCustomerUpdateController = (
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomer();
} = useCustomerUpdateMutation();
const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]);

View File

@ -0,0 +1,2 @@
export * from "./use-customer-form";
export * from "./use-customer-update-mutation";

View File

@ -0,0 +1,22 @@
import { useHookForm } from "@erp/core/hooks";
import { useEffect, useMemo } from "react";
import type { Customer } from "../api";
function useCustomerForm(customerData: Customer | undefined, isDisabled: boolean) {
const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]);
const form = useHookForm()<CustomerFormData>({
resolverSchema: CustomerFormSchema,
initialValues,
disabled: isDisabled,
});
useEffect(() => {
if (customerData) form.reset(customerData);
}, [customerData, form]);
const resetForm = () => form.reset(customerData ?? defaultCustomerFormData);
return { form, resetForm };
}

View File

@ -0,0 +1,35 @@
import { useDataSource } from "@erp/core/hooks";
import {
type DefaultError,
type QueryKey,
type UseQueryResult,
useQuery,
} from "@tanstack/react-query";
import { type Customer, getCustomerById } from "../api";
export const CUSTOMER_QUERY_KEY = (customerId?: string): QueryKey => [
"customers:detail",
{
customerId,
},
];
type CustomerQueryOptions = {
enabled?: boolean;
};
export const useCustomerGetQuery = (
customerId?: string,
options?: CustomerQueryOptions
): UseQueryResult<Customer, DefaultError> => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? Boolean(customerId);
return useQuery<Customer, DefaultError>({
queryKey: CUSTOMER_QUERY_KEY(customerId),
queryFn: async ({ signal }) => getCustomerById(dataSource, signal, customerId),
enabled,
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};

View File

@ -0,0 +1,46 @@
import { useQueryClient } from "@tanstack/react-query";
import type { UseFormReturn } from "react-hook-form";
import type { Customer } from "../api";
function useCustomerUpdateMutation(
customerId?: string,
options?: UseCustomerUpdateControllerOptions
) {
const queryClient = useQueryClient();
const { mutateAsync, isPending } = useUpdateCustomer();
const updateCustomer = async (
patchData: ReturnType<typeof pickFormDirtyValues>,
previousData: Customer | undefined,
form: UseFormReturn<CustomerFormData>
) => {
if (!customerId) return;
if (options?.undoAllowed) {
queryClient.setQueryData(["customers", customerId], (old: Customer) => ({
...old,
...patchData,
}));
}
try {
const updated = await mutateAsync({ id: customerId, data: patchData });
queryClient.setQueryData(["customers", customerId], updated);
form.reset(updated);
options?.onUpdated?.(updated);
} catch (error: any) {
queryClient.setQueryData(["customers", customerId], previousData);
form.reset(previousData);
options?.onError?.(error, patchData);
}
};
return {
updateCustomer,
isUpdating: isPending,
};
}

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -0,0 +1 @@
export * from "./types";

View File

@ -0,0 +1,3 @@
import type { Customer } from "../api";
export type CustomerData = Customer;

View File

@ -0,0 +1 @@
export * from "./pages";

View File

@ -0,0 +1,117 @@
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider, UpdateCommitButtonGroup, useUrlParamId } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { useNavigate } from "react-router-dom";
import { CustomerEditForm } from "../../../components";
import { useTranslation } from "../../../i18n";
import { useCustomerUpdateController } from "../../controllers";
import { CustomerEditorSkeleton } from "../components";
export const CustomerUpdatePage = () => {
const initialCustomerId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
const {
form,
formId,
onSubmit,
resetForm,
customerData,
isLoading,
isLoadError,
loadError,
isUpdating,
isUpdateError,
updateError,
FormProvider,
} = useCustomerUpdateController(initialCustomerId, {});
if (isLoading) {
return <CustomerEditorSkeleton />;
}
if (isLoadError) {
return (
<>
<AppContent>
<ErrorAlert
message={
(loadError as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
}
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
/>
<div className="flex items-center justify-end">
<BackHistoryButton />
</div>
</AppContent>
</>
);
}
if (!customerData)
return (
<>
<AppContent>
<NotFoundCard
message={t("pages.update.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
title={t("pages.update.notFoundTitle", "Cliente no encontrado")}
/>
</AppContent>
</>
);
return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<PageHeader
backIcon
description={t("pages.update.description")}
rightSlot={
<UpdateCommitButtonGroup
cancel={{
formId,
to: "/customers/list",
disabled: isUpdating,
}}
disabled={isUpdating}
isLoading={isUpdating}
onReset={resetForm}
submit={{
formId,
disabled: isUpdating,
}}
/>
}
title={t("pages.update.title")}
/>
</AppHeader>
<AppContent>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isUpdateError && (
<ErrorAlert
message={
(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
/>
)}
<FormProvider {...form}>
<CustomerEditForm
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6" // para que el botón del header pueda hacer submit
formId={formId}
onSubmit={onSubmit}
/>
</FormProvider>
</AppContent>
</UnsavedChangesProvider>
);
};

View File

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

View File

@ -1,4 +1,4 @@
import type { Customer } from "../api";
import type { Customer } from "../../common";
import type { CustomerData } from "../types";
/**

Some files were not shown because too many files have changed in this diff Show More