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": { "dependencies": {
"@erp/auth": "workspace:*", "@erp/auth": "workspace:*",
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*", "@erp/customers": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/factuges": "workspace:*",
"@repo/rdx-logger": "workspace:*", "@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*", "@repo/rdx-utils": "workspace:*",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",

View File

@ -132,8 +132,11 @@ async function setupModule(name: string, params: ModuleParams, stack: string[])
// 5) services (namespaced) // 5) services (namespaced)
if (pkgApi?.services) { if (pkgApi?.services) {
await withPhase(name, "registerServices", async () => { await withPhase(name, "registerServices", async () => {
validateModuleServices(name, pkgApi.services);
for (const [serviceKey, serviceApi] of Object.entries(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); 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() { function validateModuleDependencies() {
for (const [moduleName, pkg] of registeredModules.entries()) { for (const [moduleName, pkg] of registeredModules.entries()) {
const declared = new Set(pkg.dependencies ?? []); const declared = new Set(pkg.dependencies ?? []);

View File

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

View File

@ -140,7 +140,7 @@
"noControlCharactersInRegex": "error", "noControlCharactersInRegex": "error",
"noDoubleEquals": "error", "noDoubleEquals": "error",
"noDuplicateCase": "error", "noDuplicateCase": "error",
"noEmptyBlockStatements": "error", "noEmptyBlockStatements": "off",
"noFallthroughSwitchClause": "error", "noFallthroughSwitchClause": "error",
"noFunctionAssign": "error", "noFunctionAssign": "error",
"noGlobalAssign": "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> = [ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// 1) Validación múltiple (colección) // 1) Validación múltiple (colección)
@ -165,7 +165,7 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// 5.5) Errores de FastReport inesperados // 5.5) Errores de FastReport inesperados
{ {
priority: 55, priority: 56,
matches: (e) => isDocumentGenerationError(e), matches: (e) => isDocumentGenerationError(e),
build: (e) => { build: (e) => {
const error = e as DocumentGenerationError; const error = e as DocumentGenerationError;
@ -178,6 +178,7 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
return new InternalApiError(cause.message, title); return new InternalApiError(cause.message, title);
}, },
}, },
{ {
priority: 55, priority: 55,
matches: (e) => isFastReportError(e), matches: (e) => isFastReportError(e),
@ -198,10 +199,11 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// 7) Autenticación/autorización por nombre (si no tienes clases dedicadas) // 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", matches: (e): e is Error => e instanceof Error && e.name === "UnauthorizedError",
build: (e) => new UnauthorizedApiError((e as Error).message || "Unauthorized"), build: (e) => new UnauthorizedApiError((e as Error).message || "Unauthorized"),
}, },
{ {
priority: 40, priority: 40,
matches: (e): e is Error => e instanceof Error && e.name === "ForbiddenError", 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 return e; // ya es un ApiError
} }
const message = typeof (e as any)?.message === "string" ? (e as any).message : ""; const message =
const detail = typeof (e as any)?.detail === "string" ? (e as any).detail : ""; 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>; internal?: Record<string, unknown>;
}; };
export type SetupParams = ModuleParams & { export type SetupParams = ModuleParams;
registerService: (name: string, api: unknown) => void;
};
export type StartParams = ModuleParams & { export type StartParams = ModuleParams & {
/** /**

View File

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

View File

@ -5,6 +5,7 @@ export * from "./use-pagination";
export * from "./use-percentage"; export * from "./use-percentage";
export * from "./use-quantity"; export * from "./use-quantity";
export * from "./use-query-key"; export * from "./use-query-key";
export * from "./use-rhf-error-focus";
export * from "./use-toggle"; export * from "./use-toggle";
export * from "./use-unsaved-changes-notifier"; export * from "./use-unsaved-changes-notifier";
export * from "./use-url-param-id"; 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", ".": "./src/common/index.ts",
"./common": "./src/common/index.ts", "./common": "./src/common/index.ts",
"./api": "./src/api/index.ts", "./api": "./src/api/index.ts",
"./api/domain": "./src/api/domain/index.ts",
"./client": "./src/web/manifest.ts", "./client": "./src/web/manifest.ts",
"./globals.css": "./src/web/globals.css" "./globals.css": "./src/web/globals.css"
}, },

View File

@ -10,7 +10,12 @@ import {
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { CreateCustomerInvoiceRequestDTO } from "../../../common"; 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"; 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)); return Result.fail(new ValidationErrorCollection("Invoice dto mapping failed", errors));
} }
const invoiceProps: IProformaProps = { const invoiceProps: IProformaCreateProps = {
invoiceNumber: invoiceNumber!, invoiceNumber: invoiceNumber!,
series: invoiceSeries!, series: invoiceSeries!,
invoiceDate: invoiceDate!, invoiceDate: invoiceDate!,
operationDate: operationDate!, operationDate: operationDate!,
status: InvoiceStatus.createDraft(), status: InvoiceStatus.fromDraft(),
currencyCode: currencyCode!, currencyCode: currencyCode!,
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import {
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { import {
type IProformaProps, type IProformaCreateProps,
type Proforma, type Proforma,
VerifactuRecord, VerifactuRecord,
VerifactuRecordEstado, VerifactuRecordEstado,
@ -43,7 +43,7 @@ export class CustomerInvoiceVerifactuDomainMapper
): Result<Maybe<VerifactuRecord>, Error> { ): Result<Maybe<VerifactuRecord>, Error> {
const { errors, attributes } = params as { const { errors, attributes } = params as {
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
attributes: Partial<IProformaProps>; attributes: Partial<IProformaCreateProps>;
}; };
if (!source) { 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 "./common/persistence";
export * from "./issued-invoices"; export * from "./issued-invoices";
export * from "./proformas"; export * from "./proformas";
export * from "./renderers";

View File

@ -1,26 +1,48 @@
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"; import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di";
export type IssuedInvoicesServiceslDeps = { export type IssuedInvoicePublicServices = {
services: { listIssuedInvoices: (filters: unknown, context: unknown) => null;
listIssuedInvoices: (filters: unknown, context: unknown) => null; getIssuedInvoiceById: (id: unknown, context: unknown) => null;
getIssuedInvoiceById: (id: unknown, context: unknown) => null; generateIssuedInvoiceReport: (id: unknown, options: unknown, context: unknown) => null;
generateIssuedInvoiceReport: (id: unknown, options: unknown, context: unknown) => null;
};
}; };
export function buildIssuedInvoiceServices( export function buildIssuedInvoiceServices(
params: SetupParams,
deps: IssuedInvoicesInternalDeps 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 { return {
services: { listIssuedInvoices: (filters, context) => null,
listIssuedInvoices: (filters, context) => null, //internal.useCases.listIssuedInvoices().execute(filters, context),
//internal.useCases.listIssuedInvoices().execute(filters, context),
getIssuedInvoiceById: (id, context) => null, getIssuedInvoiceById: (id, context) => null,
//internal.useCases.getIssuedInvoiceById().execute(id, context), //internal.useCases.getIssuedInvoiceById().execute(id, context),
generateIssuedInvoiceReport: (id, options, context) => null, generateIssuedInvoiceReport: (id, options, context) => null,
//internal.useCases.reportIssuedInvoice().execute(id, options, context), //internal.useCases.reportIssuedInvoice().execute(id, options, context),
},
}; };
} }

View File

@ -1,24 +1,73 @@
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"; import type { ProformasInternalDeps } from "./proformas.di";
export type ProformasServicesDeps = { type ProformaServicesContext = {
services: { transaction: Transaction;
listProformas: (filters: unknown, context: unknown) => null; companyId: UniqueID;
getProformaById: (id: unknown, context: unknown) => null;
generateProformaReport: (id: unknown, options: unknown, context: unknown) => null;
};
}; };
export function buildProformaServices(deps: ProformasInternalDeps): ProformasServicesDeps { 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(
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 { return {
services: { createProforma: async (
listProformas: (filters, context) => null, id: UniqueID,
//internal.useCases.listProformas().execute(filters, context), props: IProformaCreatorParams["props"],
context: ProformaServicesContext
) => {
const { transaction, companyId } = context;
getProformaById: (id, context) => null, const createResult = await creator.create({ companyId, id, props, transaction });
//internal.useCases.getProformaById().execute(id, context),
generateProformaReport: (id, options, context) => null, if (createResult.isFailure) {
//internal.useCases.reportProforma().execute(id, options, context), return Result.fail(createResult.error);
}
return Result.ok(createResult.data);
}, },
listProformas: (filters, context) => null,
//internal.useCases.listProformas().execute(filters, context),
getProformaById: (id, context) => null,
//internal.useCases.getProformaById().execute(id, context),
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 { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../../common";
import { import {
type IProformaCreateProps,
type IProformaItemProps, type IProformaItemProps,
type IProformaProps,
InvoiceNumber, InvoiceNumber,
InvoicePaymentMethod, InvoicePaymentMethod,
type InvoiceRecipient, type InvoiceRecipient,
@ -41,7 +41,6 @@ import {
* *
*/ */
export class CreateProformaRequestMapper { export class CreateProformaRequestMapper {
private readonly taxCatalog: JsonTaxCatalogProvider; private readonly taxCatalog: JsonTaxCatalogProvider;
private errors: ValidationErrorDetail[] = []; private errors: ValidationErrorDetail[] = [];
@ -58,7 +57,7 @@ export class CreateProformaRequestMapper {
try { try {
this.errors = []; this.errors = [];
const defaultStatus = InvoiceStatus.createDraft(); const defaultStatus = InvoiceStatus.fromDraft();
const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", this.errors); 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, companyId,
status: defaultStatus!, status: defaultStatus!,
@ -182,7 +181,7 @@ export class CreateProformaRequestMapper {
} }
} }
private mapItems(items: CreateProformaItemRequestDTO[]): IProformaItemProps[] { private mapItems(items: CreateProformaItemRequestDTO[]): IProformaItemProps[] {
const proformaItems = CustomerInvoiceItems.create({ const proformaItems = CustomerInvoiceItems.create({
currencyCode: this.currencyCode!, currencyCode: this.currencyCode!,
languageCode: this.languageCode!, languageCode: this.languageCode!,

View File

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

View File

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

View File

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

View File

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

View File

@ -28,11 +28,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": [ "include": ["src"],
"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"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

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

View File

@ -1,12 +1,17 @@
import type { ITransactionManager } from "@erp/core/api"; import type { ITransactionManager } from "@erp/core/api";
import type { ICreateCustomerInputMapper } from "../mappers"; import type { ICreateCustomerInputMapper, IUpdateCustomerInputMapper } from "../mappers";
import type { ICustomerCreator, ICustomerFinder } from "../services"; import type { ICustomerCreator, ICustomerFinder, ICustomerUpdater } from "../services";
import type { import type {
ICustomerFullSnapshotBuilder, ICustomerFullSnapshotBuilder,
ICustomerSummarySnapshotBuilder, ICustomerSummarySnapshotBuilder,
} from "../snapshot-builders"; } from "../snapshot-builders";
import { CreateCustomerUseCase, GetCustomerByIdUseCase, ListCustomersUseCase } from "../use-cases"; import {
CreateCustomerUseCase,
GetCustomerByIdUseCase,
ListCustomersUseCase,
UpdateCustomerUseCase,
} from "../use-cases";
export function buildGetCustomerByIdUseCase(deps: { export function buildGetCustomerByIdUseCase(deps: {
finder: ICustomerFinder; 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: { /*export function buildReportCustomerUseCase(deps: {
finder: ICustomerFinder; finder: ICustomerFinder;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder; 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 }) { export function buildDeleteCustomerUseCase(deps: { finder: ICustomerFinder }) {
return new DeleteCustomerUseCase(deps.finder); return new DeleteCustomerUseCase(deps.finder);

View File

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

View File

@ -1 +1,2 @@
export * from "./create-customer-input.mapper"; 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 { 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 { Collection, Result } from "@repo/rdx-utils";
import type { Customer } from "../../domain/aggregates"; import type { Customer } from "../../domain/aggregates";
@ -48,6 +48,16 @@ export interface ICustomerRepository {
transaction: unknown transaction: unknown
): Promise<Result<Customer, Error>>; ): 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 * Recupera múltiples customers dentro de una empresa
* según un criterio dinámico (búsqueda, paginación, etc.). * 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 { 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 { Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize"; import type { Transaction } from "sequelize";
@ -10,7 +10,13 @@ import type { ICustomerRepository } from "../repositories";
export interface ICustomerFinder { export interface ICustomerFinder {
findCustomerById( findCustomerById(
companyId: UniqueID, companyId: UniqueID,
invoiceId: UniqueID, customerId: UniqueID,
transaction?: Transaction
): Promise<Result<Customer, Error>>;
findCustomerByTIN(
companyId: UniqueID,
tin: TINNumber,
transaction?: Transaction transaction?: Transaction
): Promise<Result<Customer, Error>>; ): Promise<Result<Customer, Error>>;
@ -38,6 +44,14 @@ export class CustomerFinder implements ICustomerFinder {
return this.repository.getByIdInCompany(companyId, customerId, transaction); 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( async customerExists(
companyId: UniqueID, companyId: UniqueID,
customerId: UniqueID, customerId: UniqueID,

View File

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

View File

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

View File

@ -9,14 +9,13 @@ import {
type PostalAddressPatchProps, type PostalAddressPatchProps,
type PostalAddressProps, type PostalAddressProps,
type TINNumber, type TINNumber,
type TaxCode,
type TextValue, type TextValue,
type URLAddress, type URLAddress,
type UniqueID, type UniqueID,
} from "@repo/rdx-ddd"; } 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 { export interface ICustomerCreateProps {
companyId: UniqueID; companyId: UniqueID;
@ -44,14 +43,14 @@ export interface ICustomerCreateProps {
legalRecord: Maybe<TextValue>; legalRecord: Maybe<TextValue>;
defaultTaxes: TaxCode[]; defaultTaxes: CustomerTaxesProps;
languageCode: LanguageCode; languageCode: LanguageCode;
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
} }
export type CustomerPatchProps = Partial< export type CustomerPatchProps = Partial<
Omit<ICustomerCreateProps, "companyId" | "address" | "isCompany" | "status"> Omit<ICustomerCreateProps, "companyId" | "address" | "status">
> & { > & {
address?: PostalAddressPatchProps; address?: PostalAddressPatchProps;
}; };
@ -87,7 +86,7 @@ export interface ICustomer {
readonly website: Maybe<URLAddress>; readonly website: Maybe<URLAddress>;
readonly legalRecord: Maybe<TextValue>; readonly legalRecord: Maybe<TextValue>;
readonly defaultTaxes: Collection<TaxCode>; readonly defaultTaxes: CustomerTaxesProps;
readonly languageCode: LanguageCode; readonly languageCode: LanguageCode;
readonly currencyCode: CurrencyCode; readonly currencyCode: CurrencyCode;
@ -142,8 +141,6 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
if (addressResult.isFailure) { if (addressResult.isFailure) {
return Result.fail(addressResult.error); return Result.fail(addressResult.error);
} }
this.props.address = addressResult.data;
} }
return Result.ok(); return Result.ok();
@ -223,7 +220,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
return this.props.legalRecord; return this.props.legalRecord;
} }
public get defaultTaxes(): Collection<TaxCode> { public get defaultTaxes(): CustomerTaxesProps {
return this.props.defaultTaxes; 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-number.vo";
export * from "./customer-serie.vo"; export * from "./customer-serie.vo";
export * from "./customer-status.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 type { IModuleServer } from "@erp/core/api";
import { customersRouter, models } from "./infrastructure"; import { type CustomerPublicServices, customersRouter, models } from "./infrastructure";
import { buildCustomersDependencies, buildCustomerServices, CustomersInternalDeps } from "./infrastructure/di"; import {
type CustomersInternalDeps,
buildCustomerServices,
buildCustomersDependencies,
} from "./infrastructure/di";
export * from "./infrastructure/sequelize"; export * from "./infrastructure/sequelize";
export type { CustomerPublicServices };
export const customersAPIModule: IModuleServer = { export const customersAPIModule: IModuleServer = {
name: "customers", name: "customers",
version: "1.0.0", version: "1.0.0",
@ -21,10 +27,10 @@ export const customersAPIModule: IModuleServer = {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
// 1) Dominio interno // 1) Dominio interno
const customerInternalDeps = buildCustomersDependencies(params); const internal = buildCustomersDependencies(params);
// 2) Servicios públicos (Application Services) // 2) Servicios públicos (Application Services)
const customerServices = buildCustomerServices(customerInternalDeps); const customersServices: CustomerPublicServices = buildCustomerServices(params, internal);
logger.info("🚀 Customers module dependencies registered", { logger.info("🚀 Customers module dependencies registered", {
label: this.name, label: this.name,
@ -35,10 +41,12 @@ export const customersAPIModule: IModuleServer = {
models, models,
// Servicios expuestos a otros módulos // Servicios expuestos a otros módulos
services: customerServices, services: {
general: customersServices, // 'customers:general'
},
// Implementación privada del módulo // 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"; import type { CustomersInternalDeps } from "./customers.di";
export type CustomersServicesDeps = { type CustomerServicesContext = {
services: { transaction: Transaction;
listCustomers: (filters: unknown, context: unknown) => null; companyId: UniqueID;
getCustomerById: (id: unknown, context: unknown) => null;
//generateCustomerReport: (id: unknown, options: unknown, context: unknown) => null;
};
}; };
export function buildCustomerServices(deps: CustomersInternalDeps): CustomersServicesDeps { 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(
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 { return {
services: { findCustomerByTIN: async (tin: TINNumber, context: CustomerServicesContext) => {
listCustomers: (filters, context) => null, const { companyId, transaction } = context;
//internal.useCases.listCustomers().execute(filters, context),
getCustomerById: (id, context) => null, const customerResult = await finder.findCustomerByTIN(companyId, tin, transaction);
//internal.useCases.getCustomerById().execute(id, context),
//generateCustomerReport: (id, options, context) => null, if (customerResult.isFailure) {
//internal.useCases.reportCustomer().execute(id, options, context), 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, CreateCustomerRequestSchema,
CustomerListRequestSchema, CustomerListRequestSchema,
GetCustomerByIdRequestSchema, GetCustomerByIdRequestSchema,
UpdateCustomerByIdParamsRequestSchema,
UpdateCustomerByIdRequestSchema,
} from "../../../common/dto"; } from "../../../common/dto";
import type { CustomersInternalDeps } from "../di"; import type { CustomersInternalDeps } from "../di";
@ -13,6 +15,7 @@ import {
CreateCustomerController, CreateCustomerController,
GetCustomerController, GetCustomerController,
ListCustomersController, ListCustomersController,
UpdateCustomerController,
} from "./controllers"; } from "./controllers";
export const customersRouter = (params: ModuleParams, deps: CustomersInternalDeps) => { export const customersRouter = (params: ModuleParams, deps: CustomersInternalDeps) => {
@ -75,19 +78,19 @@ export const customersRouter = (params: ModuleParams, deps: CustomersInternalDep
} }
); );
/* router.put( router.put(
"/:customer_id", "/:customer_id",
//checkTabContext, //checkTabContext,
validateRequest(UpdateCustomerByIdParamsRequestSchema, "params"), validateRequest(UpdateCustomerByIdParamsRequestSchema, "params"),
validateRequest(UpdateCustomerByIdRequestSchema, "body"), validateRequest(UpdateCustomerByIdRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.update(); const useCase = deps.useCases.updateCustomer();
const controller = new UpdateCustomerController(useCase); const controller = new UpdateCustomerController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
); );
*/
/*router.delete( /*router.delete(
"/:customer_id", "/:customer_id",
//checkTabContext, //checkTabContext,

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import {
translateSequelizeError, translateSequelizeError,
} 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 { TINNumber, UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils"; import { type Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize"; 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.). * 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 { import {
GetCustomerByIdResponseDTO, type GetCustomerByIdResponseDTO,
GetCustomerByIdResponseSchema, GetCustomerByIdResponseSchema,
} from "./get-customer-by-id.response.dto"; } 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 "./api-types";
export * from "./get-customer-by-ip.api"; 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, useQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { type CustomerSummaryPage, getCustomerListApi } from "../api"; import { type CustomerSummaryPage, getCustomerListApi } from "..";
export const CUSTOMERS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [ export const CUSTOMERS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
"customers", "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 { 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 { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields"; import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields"; import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from './customer-contact-fields'; import { CustomerContactFields } from "./customer-contact-fields";
type CustomerFormProps = { type CustomerFormProps = {
formId: string; formId: string;
@ -15,7 +15,7 @@ type CustomerFormProps = {
export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => { export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => {
return ( return (
<form noValidate id={formId} onSubmit={onSubmit}> <form id={formId} noValidate onSubmit={onSubmit}>
<FormDebug /> <FormDebug />
<section className={cn("space-y-6 p-6", className)}> <section className={cn("space-y-6 p-6", className)}>
<CustomerBasicInfoFields focusRef={focusRef} /> <CustomerBasicInfoFields focusRef={focusRef} />

View File

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

View File

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

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

View File

@ -1,9 +1,9 @@
import { useDataSource } from "@erp/core/hooks"; import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd"; import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import type { ZodError } from "zod/v4";
import { CreateCustomerRequestSchema } from "../../common"; import { CreateCustomerRequestSchema } from "../../common";
import { toValidationErrors } from "../common/hooks/toValidationErrors";
import type { Customer, CustomerFormData } from "../schemas"; import type { Customer, CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query"; import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query";
@ -15,14 +15,6 @@ type CreateCustomerPayload = {
data: CustomerFormData; 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() { export function useCreateCustomer() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dataSource = useDataSource(); 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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestSchema } from "../../common"; import { UpdateCustomerByIdRequestSchema } from "../../common";
import { toValidationErrors } from "../common/hooks/toValidationErrors";
import type { Customer, CustomerFormData } from "../schemas"; import type { Customer, CustomerFormData } from "../schemas";
import { toValidationErrors } from "./use-create-customer-mutation";
import { import {
invalidateCustomerListCache, invalidateCustomerListCache,
upsertCustomerIntoListCaches, upsertCustomerIntoListCaches,

View File

@ -1,4 +1,4 @@
import type { CustomerSummaryPage } from "../api"; import type { CustomerSummaryPage } from "../../common";
import type { CustomerSummaryPageData } from "../types"; 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 { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useCustomerListQuery } from "../../common";
import { CustomerSummaryDtoAdapter } from "../adapters"; import { CustomerSummaryDtoAdapter } from "../adapters";
import { useCustomerListQuery } from "../hooks";
export const useCustomerListController = () => { export const useCustomerListController = () => {
const [pageIndex, setPageIndex] = useState(0); 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 { CustomerSummary, CustomerSummaryPage } from "../../common";
import type { CustomerSummaryPage } from "../api";
export type CustomerSummaryData = CustomerSummary; export type CustomerSummaryData = CustomerSummary;

View File

@ -1,5 +1,5 @@
export * from "./address-cell";
export * from "./contact-cell";
export * from "./initials"; export * from "./initials";
export * from "./kind-badge"; export * from "./kind-badge";
export * from "./soft"; 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"; export * from "./pages";

View File

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

View File

@ -9,8 +9,7 @@ import {
NotFoundCard, NotFoundCard,
} from "../../components"; } from "../../components";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { useCustomerUpdateController } from "../../update/controllers/use-customer-update-page.controller";
import { useCustomerUpdateController } from "./use-customer-update-controller";
export const CustomerUpdatePage = () => { export const CustomerUpdatePage = () => {
const customerId = useUrlParamId(); 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 { useEffect, useId, useMemo } from "react";
import { type FieldErrors, FormProvider } from "react-hook-form"; import { type FieldErrors, FormProvider } from "react-hook-form";
import { useCustomerQuery, useUpdateCustomer } from "../../hooks"; import { useCustomerGetQuery, useCustomerUpdateMutation } from "../../common";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { import {
type Customer, type Customer,
@ -34,7 +34,7 @@ export const useCustomerUpdateController = (
isLoading, isLoading,
isError: isLoadError, isError: isLoadError,
error: loadError, error: loadError,
} = useCustomerQuery(customerId, { enabled: Boolean(customerId) }); } = useCustomerGetQuery(customerId, { enabled: Boolean(customerId) });
// 2) Estado de creación (mutación) // 2) Estado de creación (mutación)
const { const {
@ -42,7 +42,7 @@ export const useCustomerUpdateController = (
isPending: isUpdating, isPending: isUpdating,
isError: isUpdateError, isError: isUpdateError,
error: updateError, error: updateError,
} = useUpdateCustomer(); } = useCustomerUpdateMutation();
const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]); 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"; import type { CustomerData } from "../types";
/** /**

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