diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/models/recurring-proforma-read.models.ts b/modules/customer-invoices/src/api/application/recurring-proformas/models/recurring-proforma-read.models.ts index a49e20d4..fc68b27c 100644 --- a/modules/customer-invoices/src/api/application/recurring-proformas/models/recurring-proforma-read.models.ts +++ b/modules/customer-invoices/src/api/application/recurring-proformas/models/recurring-proforma-read.models.ts @@ -1,4 +1,5 @@ import type { DiscountPercentage } from "@erp/core/api"; +import type { CustomerPublicSummary } from "@erp/customers/api"; import type { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd"; import type { @@ -21,10 +22,13 @@ export type RecurringProformaScheduleReadModel = { export type RecurringProformaListItemReadModel = { id: UniqueID; name: string; + description: string | null; customerId: UniqueID | null; + customer: CustomerPublicSummary | null; status: RecurringProformaStatus; schedule: RecurringProformaScheduleReadModel | null; nextOccurrenceDate: Date | null; + hasIssue: boolean; updatedAt: Date; }; @@ -88,8 +92,11 @@ export type ListRecurringProformasQuery = { companyId: UniqueID; page: number; limit: number; - status?: RecurringProformaStatus; + status?: Exclude; search?: string; + scope?: "default" | "archived"; + orderBy?: "name" | "updated_at"; + order?: "asc" | "desc"; }; export type ListRecurringProformaOccurrencesQuery = { diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.test.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.test.ts index 93cf0c31..45c15f2a 100644 --- a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.test.ts +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.test.ts @@ -15,7 +15,10 @@ import { RecurringProformaOccurrence, } from "../../../domain"; import { toRecurringProformaAuditTarget } from "../audit"; -import { RecurringProformaCommands, RecurringProformaReader } from "./recurring-proforma-capabilities"; +import { + RecurringProformaCommands, + RecurringProformaReader, +} from "./recurring-proforma-capabilities"; const required = (result: { data: T }): T => result.data; @@ -36,30 +39,49 @@ const buildRecurring = () => { const currency = required(CurrencyCode.create("EUR")); const language = required(LanguageCode.create("es")); const discount = required(DiscountPercentage.create({ value: 500 })); - const schedule = required(RecurrenceSchedule.create({ startDate: new Date("2027-01-15T00:00:00.000Z"), endDate: null, unit: "month", interval: 1, monthlyRule: "same_day" })); - return required(RecurringProforma.create({ - companyId: companyA, - name: "Cuota mensual", - customerId: Maybe.none(), - description: Maybe.none(), - paymentTermId: Maybe.none(), - paymentMethodId: Maybe.none(), - language: Maybe.none(), - currency: Maybe.none(), - proformaSeries: Maybe.none(), - issuedInvoiceSeries: Maybe.none(), - globalDiscountPercentage: Maybe.some(discount), - schedule: Maybe.some(schedule), - items: [{ - description: Maybe.some(required(ItemDescription.create("Servicio"))), - quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))), - unitAmount: Maybe.some(required(ItemAmount.create({ value: 1000, currency_code: currency.code }))), - itemDiscountPercentage: Maybe.some(discount), - ivaCode: Maybe.some("general"), recCode: Maybe.some("rec"), retentionCode: Maybe.some("ret"), - }], - createdAt: new Date("2027-01-01T00:00:00.000Z"), - updatedAt: new Date("2027-01-02T00:00:00.000Z"), - }, recurringId)); + const schedule = required( + RecurrenceSchedule.create({ + startDate: new Date("2027-01-15T00:00:00.000Z"), + endDate: null, + unit: "month", + interval: 1, + monthlyRule: "same_day", + }) + ); + return required( + RecurringProforma.create( + { + companyId: companyA, + name: "Cuota mensual", + customerId: Maybe.none(), + description: Maybe.none(), + paymentTermId: Maybe.none(), + paymentMethodId: Maybe.none(), + language: Maybe.none(), + currency: Maybe.none(), + proformaSeries: Maybe.none(), + issuedInvoiceSeries: Maybe.none(), + globalDiscountPercentage: Maybe.some(discount), + schedule: Maybe.some(schedule), + items: [ + { + description: Maybe.some(required(ItemDescription.create("Servicio"))), + quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))), + unitAmount: Maybe.some( + required(ItemAmount.create({ value: 1000, currency_code: currency.code })) + ), + itemDiscountPercentage: Maybe.some(discount), + ivaCode: Maybe.some("general"), + recCode: Maybe.some("rec"), + retentionCode: Maybe.some("ret"), + }, + ], + createdAt: new Date("2027-01-01T00:00:00.000Z"), + updatedAt: new Date("2027-01-02T00:00:00.000Z"), + }, + recurringId + ) + ); }; test("toRecurringProformaAuditTarget incluye el nombre como label", () => { @@ -72,31 +94,72 @@ test("toRecurringProformaAuditTarget incluye el nombre como label", () => { test("RecurringProformaReader propaga company, filtros, paginación y conserva el listado", async () => { let receivedQuery: unknown; let receivedTransaction: unknown; - const item = { id: recurringId, name: "Cuota mensual", customerId: null, status: "draft" as const, schedule: null, nextOccurrenceDate: null, updatedAt: new Date("2027-01-02T00:00:00.000Z") }; - const reader = new RecurringProformaReader({ - listInCompany: async (query, currentTransaction) => { - receivedQuery = query; receivedTransaction = currentTransaction; - return Result.ok({ page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] }); - }, - } as never, {} as never, transactionManager()); - const result = await reader.list({ companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" }); + const item = { + id: recurringId, + name: "Cuota mensual", + description: null, + customerId: null, + customer: null, + status: "draft" as const, + schedule: null, + nextOccurrenceDate: null, + hasIssue: false, + updatedAt: new Date("2027-01-02T00:00:00.000Z"), + }; + const reader = new RecurringProformaReader( + { + listInCompany: async (query, currentTransaction) => { + receivedQuery = query; + receivedTransaction = currentTransaction; + return Result.ok({ page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] }); + }, + } as never, + {} as never, + {} as never, + transactionManager() + ); + const result = await reader.list({ + companyId: companyA, + page: 2, + limit: 5, + status: "draft", + search: "Cuota", + }); assert.equal(result.isSuccess, true); - assert.deepEqual(receivedQuery, { companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" }); + assert.deepEqual(receivedQuery, { + companyId: companyA, + page: 2, + limit: 5, + status: "draft", + search: "Cuota", + }); assert.equal(receivedTransaction, transaction); - assert.deepEqual(result.data, { page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] }); + assert.deepEqual(result.data, { + page: 2, + perPage: 5, + totalItems: 6, + totalPages: 2, + items: [item], + }); assert.equal((receivedQuery as { companyId: UniqueID }).companyId.equals(companyB), false); }); test("RecurringProformaReader conserva overrides nulos, schedule e items ordenados en detail", async () => { let receivedCompany: UniqueID | undefined; const recurring = buildRecurring(); - const reader = new RecurringProformaReader({ - findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => { - receivedCompany = companyId; - assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction); - return Result.ok(recurring); - }, - } as never, {} as never, transactionManager()); + const reader = new RecurringProformaReader( + { + findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => { + receivedCompany = companyId; + assert.equal(id.equals(recurringId), true); + assert.equal(currentTransaction, transaction); + return Result.ok(recurring); + }, + } as never, + {} as never, + {} as never, + transactionManager() + ); const result = await reader.detail({ companyId: companyA, id: recurringId }); assert.equal(result.isSuccess, true); assert.equal(receivedCompany?.equals(companyA), true); @@ -114,15 +177,92 @@ test("RecurringProformaReader conserva overrides nulos, schedule e items ordenad test("RecurringProformaReader propaga parent, empresa y paginación de occurrences", async () => { let receivedQuery: unknown; const proformaId = UniqueID.generateNewID(); - const generated = required(RecurringProformaOccurrence.create({ companyId: companyA, recurringProformaId: recurringId, scheduledDate: new Date("2027-02-15T00:00:00.000Z"), status: "generated", attemptCount: 1, proformaId: Maybe.some(proformaId), generatedAt: Maybe.some(new Date("2027-02-16T00:00:00.000Z")), createdAt: new Date(), updatedAt: new Date() })); - const failed = required(RecurringProformaOccurrence.create({ companyId: companyA, recurringProformaId: recurringId, scheduledDate: new Date("2027-01-15T00:00:00.000Z"), status: "failed", attemptCount: 2, lastAttemptAt: Maybe.some(new Date("2027-01-16T00:00:00.000Z")), lastErrorCode: Maybe.some("failed"), lastErrorMessage: Maybe.some("Error"), createdAt: new Date(), updatedAt: new Date() })); - const toRead = (source: typeof generated) => ({ id: source.id, scheduledDate: source.scheduledDate, status: source.status, attemptCount: source.attemptCount, lastAttemptAt: source.lastAttemptAt.match((value) => value, () => null), processingStartedAt: null, lastErrorCode: source.lastErrorCode.match((value) => value, () => null), lastErrorMessage: source.lastErrorMessage.match((value) => value, () => null), proformaId: source.proformaId.match((value) => value, () => null), generatedAt: source.generatedAt.match((value) => value, () => null), createdAt: source.createdAt, updatedAt: source.updatedAt }); - const reader = new RecurringProformaReader({} as never, { - listByRecurringProformaInCompany: async (query) => { receivedQuery = query; return Result.ok({ page: 1, perPage: 10, totalItems: 2, totalPages: 1, items: [toRead(generated), toRead(failed)] }); }, - } as never, transactionManager()); - const result = await reader.occurrences({ companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 }); + const generated = required( + RecurringProformaOccurrence.create({ + companyId: companyA, + recurringProformaId: recurringId, + scheduledDate: new Date("2027-02-15T00:00:00.000Z"), + status: "generated", + attemptCount: 1, + proformaId: Maybe.some(proformaId), + generatedAt: Maybe.some(new Date("2027-02-16T00:00:00.000Z")), + createdAt: new Date(), + updatedAt: new Date(), + }) + ); + const failed = required( + RecurringProformaOccurrence.create({ + companyId: companyA, + recurringProformaId: recurringId, + scheduledDate: new Date("2027-01-15T00:00:00.000Z"), + status: "failed", + attemptCount: 2, + lastAttemptAt: Maybe.some(new Date("2027-01-16T00:00:00.000Z")), + lastErrorCode: Maybe.some("failed"), + lastErrorMessage: Maybe.some("Error"), + createdAt: new Date(), + updatedAt: new Date(), + }) + ); + const toRead = (source: typeof generated) => ({ + id: source.id, + scheduledDate: source.scheduledDate, + status: source.status, + attemptCount: source.attemptCount, + lastAttemptAt: source.lastAttemptAt.match( + (value) => value, + () => null + ), + processingStartedAt: null, + lastErrorCode: source.lastErrorCode.match( + (value) => value, + () => null + ), + lastErrorMessage: source.lastErrorMessage.match( + (value) => value, + () => null + ), + proformaId: source.proformaId.match( + (value) => value, + () => null + ), + generatedAt: source.generatedAt.match( + (value) => value, + () => null + ), + createdAt: source.createdAt, + updatedAt: source.updatedAt, + }); + const reader = new RecurringProformaReader( + {} as never, + { + listByRecurringProformaInCompany: async (query) => { + receivedQuery = query; + return Result.ok({ + page: 1, + perPage: 10, + totalItems: 2, + totalPages: 1, + items: [toRead(generated), toRead(failed)], + }); + }, + } as never, + {} as never, + transactionManager() + ); + const result = await reader.occurrences({ + companyId: companyA, + recurringProformaId: recurringId, + page: 1, + limit: 10, + }); assert.equal(result.isSuccess, true); - assert.deepEqual(receivedQuery, { companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 }); + assert.deepEqual(receivedQuery, { + companyId: companyA, + recurringProformaId: recurringId, + page: 1, + limit: 10, + }); assert.equal(result.data.items[0]?.proformaId?.equals(proformaId), true); assert.equal(result.data.items[1]?.lastErrorCode, "failed"); assert.equal(result.data.items[1]?.attemptCount, 2); @@ -131,29 +271,73 @@ test("RecurringProformaReader propaga parent, empresa y paginación de occurrenc test("RecurringProformaCommands posee la transacción y propaga las transiciones", async () => { const calls: Array<{ action: string; transaction: unknown }> = []; const commands = new RecurringProformaCommands({ - statusChanger: { change: async (params: { action: string; transaction: unknown; companyId: UniqueID; id: UniqueID }) => { - assert.equal(params.companyId.equals(companyA), true); assert.equal(params.id.equals(recurringId), true); - calls.push({ action: params.action, transaction: params.transaction }); return Result.ok(buildRecurring()); - } }, - creator: {}, updater: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {}, + statusChanger: { + change: async (params: { + action: string; + transaction: unknown; + companyId: UniqueID; + id: UniqueID; + }) => { + assert.equal(params.companyId.equals(companyA), true); + assert.equal(params.id.equals(recurringId), true); + calls.push({ action: params.action, transaction: params.transaction }); + return Result.ok(buildRecurring()); + }, + }, + creator: {}, + updater: {}, + deleter: {}, + duplicator: {}, + fromProformaCreator: {}, + readinessChecker: {}, + repository: {}, transactionManager: transactionManager(), } as never); for (const action of ["activate", "pause", "resume", "complete", "archive"] as const) { const result = await commands[action]({ companyId: companyA, id: recurringId }); assert.equal(result.isSuccess, true); } - assert.deepEqual(calls.map((call) => call.action), ["activate", "pause", "resume", "complete", "archive"]); - assert.equal(calls.every((call) => call.transaction === transaction), true); + assert.deepEqual( + calls.map((call) => call.action), + ["activate", "pause", "resume", "complete", "archive"] + ); + assert.equal( + calls.every((call) => call.transaction === transaction), + true + ); }); test("RecurringProformaCommands propaga name y schedule al facade fromProforma", async () => { let received: { name: string; schedule: unknown; transaction: unknown } | undefined; - const schedule = { startDate: new Date("2027-01-15T00:00:00.000Z"), endDate: null, unit: "month" as const, interval: 1, monthlyRule: "same_day" as const }; + const schedule = { + startDate: new Date("2027-01-15T00:00:00.000Z"), + endDate: null, + unit: "month" as const, + interval: 1, + monthlyRule: "same_day" as const, + }; const commands = new RecurringProformaCommands({ - fromProformaCreator: { create: async (params: { name: string; schedule: typeof schedule; transaction: unknown }) => { received = params; return Result.ok(buildRecurring()); } }, - creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(), + fromProformaCreator: { + create: async (params: { name: string; schedule: typeof schedule; transaction: unknown }) => { + received = params; + return Result.ok(buildRecurring()); + }, + }, + creator: {}, + updater: {}, + statusChanger: {}, + deleter: {}, + duplicator: {}, + readinessChecker: {}, + repository: {}, + transactionManager: transactionManager(), } as never); - const result = await commands.fromProforma({ companyId: companyA, proformaId: recurringId, name: "Desde proforma", schedule }); + const result = await commands.fromProforma({ + companyId: companyA, + proformaId: recurringId, + name: "Desde proforma", + schedule, + }); assert.equal(result.isSuccess, true); assert.equal(received?.name, "Desde proforma"); assert.deepEqual(received?.schedule, schedule); @@ -164,24 +348,96 @@ test("RecurringProformaCommands propaga create, update, duplicate y delete sin n const calls: Record = {}; const patch = { paymentTermId: null }; const commands = new RecurringProformaCommands({ - creator: { create: async (params: { companyId: UniqueID; name: string; description?: Maybe; transaction: unknown }) => { calls.create = params; return Result.ok(buildRecurring()); } }, - updater: { update: async (params: { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown }) => { calls.update = params; return Result.ok(buildRecurring()); } }, - duplicator: { duplicate: async (params: { companyId: UniqueID; sourceId: UniqueID; transaction: unknown }) => { calls.duplicate = params; return Result.ok(buildRecurring()); } }, - deleter: { delete: async (params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) => { calls.delete = params; return Result.ok(true); } }, - statusChanger: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(), + creator: { + create: async (params: { + companyId: UniqueID; + name: string; + description?: Maybe; + transaction: unknown; + }) => { + calls.create = params; + return Result.ok(buildRecurring()); + }, + }, + updater: { + update: async (params: { + companyId: UniqueID; + id: UniqueID; + patch: typeof patch; + transaction: unknown; + }) => { + calls.update = params; + return Result.ok(buildRecurring()); + }, + }, + duplicator: { + duplicate: async (params: { + companyId: UniqueID; + sourceId: UniqueID; + transaction: unknown; + }) => { + calls.duplicate = params; + return Result.ok(buildRecurring()); + }, + }, + deleter: { + delete: async (params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) => { + calls.delete = params; + return Result.ok(true); + }, + }, + statusChanger: {}, + fromProformaCreator: {}, + readinessChecker: {}, + repository: {}, + transactionManager: transactionManager(), } as never); - assert.equal((await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" })).isSuccess, true); - assert.equal((await commands.update({ companyId: companyA, id: recurringId, patch })).isSuccess, true); - assert.equal((await commands.duplicate({ companyId: companyA, sourceId: recurringId })).isSuccess, true); + assert.equal( + (await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" })) + .isSuccess, + true + ); + assert.equal( + (await commands.update({ companyId: companyA, id: recurringId, patch })).isSuccess, + true + ); + assert.equal( + (await commands.duplicate({ companyId: companyA, sourceId: recurringId })).isSuccess, + true + ); assert.equal((await commands.delete({ companyId: companyA, id: recurringId })).data, true); - const create = calls.create as { companyId: UniqueID; name: string; description?: Maybe; transaction: unknown }; - const update = calls.update as { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown }; - const duplicate = calls.duplicate as { companyId: UniqueID; sourceId: UniqueID; transaction: unknown }; + const create = calls.create as { + companyId: UniqueID; + name: string; + description?: Maybe; + transaction: unknown; + }; + const update = calls.update as { + companyId: UniqueID; + id: UniqueID; + patch: typeof patch; + transaction: unknown; + }; + const duplicate = calls.duplicate as { + companyId: UniqueID; + sourceId: UniqueID; + transaction: unknown; + }; const deleted = calls.delete as { companyId: UniqueID; id: UniqueID; transaction: unknown }; - assert.equal(create.companyId.equals(companyA), true); assert.equal(create.name, "Nueva"); assert.equal(create.description?.unwrap(), "Descripción"); assert.equal(create.transaction, transaction); - assert.equal(update.companyId.equals(companyA), true); assert.equal(update.id.equals(recurringId), true); assert.equal(update.patch.paymentTermId, null); assert.equal(update.transaction, transaction); - assert.equal(duplicate.companyId.equals(companyA), true); assert.equal(duplicate.sourceId.equals(recurringId), true); assert.equal(duplicate.transaction, transaction); - assert.equal(deleted.companyId.equals(companyA), true); assert.equal(deleted.id.equals(recurringId), true); assert.equal(deleted.transaction, transaction); + assert.equal(create.companyId.equals(companyA), true); + assert.equal(create.name, "Nueva"); + assert.equal(create.description?.unwrap(), "Descripción"); + assert.equal(create.transaction, transaction); + assert.equal(update.companyId.equals(companyA), true); + assert.equal(update.id.equals(recurringId), true); + assert.equal(update.patch.paymentTermId, null); + assert.equal(update.transaction, transaction); + assert.equal(duplicate.companyId.equals(companyA), true); + assert.equal(duplicate.sourceId.equals(recurringId), true); + assert.equal(duplicate.transaction, transaction); + assert.equal(deleted.companyId.equals(companyA), true); + assert.equal(deleted.id.equals(recurringId), true); + assert.equal(deleted.transaction, transaction); }); test("RecurringProformaCommands propaga readiness con blockers sin interpretarlos", async () => { @@ -190,9 +446,35 @@ test("RecurringProformaCommands propaga readiness con blockers sin interpretarlo const readiness = { canActivate: false, blockers: [{ code: "missing_customer" as const }] }; const recurring = buildRecurring(); const commands = new RecurringProformaCommands({ - repository: { findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => { repositoryCalls += 1; assert.equal(companyId.equals(companyA), true); assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction); return Result.ok(recurring); } }, - readinessChecker: { check: async (params: { companyId: UniqueID; recurringProforma: RecurringProforma; transaction: unknown }) => { checkerCalls += 1; assert.equal(params.companyId.equals(companyA), true); assert.equal(params.recurringProforma, recurring); assert.equal(params.transaction, transaction); return Result.ok(readiness); } }, - creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, transactionManager: transactionManager(), + repository: { + findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => { + repositoryCalls += 1; + assert.equal(companyId.equals(companyA), true); + assert.equal(id.equals(recurringId), true); + assert.equal(currentTransaction, transaction); + return Result.ok(recurring); + }, + }, + readinessChecker: { + check: async (params: { + companyId: UniqueID; + recurringProforma: RecurringProforma; + transaction: unknown; + }) => { + checkerCalls += 1; + assert.equal(params.companyId.equals(companyA), true); + assert.equal(params.recurringProforma, recurring); + assert.equal(params.transaction, transaction); + return Result.ok(readiness); + }, + }, + creator: {}, + updater: {}, + statusChanger: {}, + deleter: {}, + duplicator: {}, + fromProformaCreator: {}, + transactionManager: transactionManager(), } as never); const result = await commands.activationReadiness({ companyId: companyA, id: recurringId }); assert.equal(result.isSuccess, true); diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.ts index 92267167..3085ceb2 100644 --- a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.ts +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.ts @@ -1,4 +1,5 @@ import type { ITransactionManager } from "@erp/core/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; import { UniqueID } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; @@ -34,13 +35,43 @@ export class RecurringProformaReader { public constructor( private readonly repository: IRecurringProformaRepository, private readonly occurrenceRepository: IRecurringProformaOccurrenceRepository, + private readonly customerServices: ICustomerPublicServices, private readonly transactionManager: ITransactionManager ) {} public list(query: ListRecurringProformasQuery) { - return this.transactionManager.complete((transaction) => - this.repository.listInCompany(query, transaction) - ); + return this.transactionManager.complete(async (transaction) => { + const page = await this.repository.listInCompany(query, transaction); + if (page.isFailure) return Result.fail(page.error); + + const customerIds = [ + ...new Map( + page.data.items + .flatMap((item) => (item.customerId ? [item.customerId] : [])) + .map((customerId) => [customerId.toPrimitive(), customerId]) + ).values(), + ]; + if (customerIds.length === 0) return page; + + const customers = await this.customerServices.findSummariesByIds(customerIds, { + companyId: query.companyId, + transaction, + }); + if (customers.isFailure) return Result.fail(customers.error); + + const customersById = new Map( + customers.data.map((customer) => [customer.id.toPrimitive(), customer]) + ); + return Result.ok({ + ...page.data, + items: page.data.items.map((item) => ({ + ...item, + customer: item.customerId + ? (customersById.get(item.customerId.toPrimitive()) ?? null) + : null, + })), + }); + }); } public detail(params: { companyId: UniqueID; id: UniqueID }) { diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index ffc15574..14e0b4de 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -16,6 +16,18 @@ import { type ListProformasUseCase, type PreviewProformaReportUseCase, ProformaIssuanceReadinessChecker, + ProformaTaxConfigResolver, + RecurringProformaActivationReadinessChecker, + RecurringProformaCommands, + RecurringProformaCreator, + RecurringProformaDeleter, + RecurringProformaDuplicator, + RecurringProformaFromProformaCreator, + RecurringProformaMaterializer, + type RecurringProformaOccurrenceProcessor, + RecurringProformaReader, + RecurringProformaStatusChanger, + RecurringProformaUpdater, type ReportProformaPdfUseCase, type UnarchiveProformaByIdUseCase, type UpdateProformaByIdUseCase, @@ -47,23 +59,15 @@ import { buildUnarchiveProformaByIdUseCase, buildUpdateProformaUseCase, createRecurringProformaOccurrenceProcessor, - ProformaTaxConfigResolver, - RecurringProformaMaterializer, - RecurringProformaActivationReadinessChecker, - RecurringProformaCommands, - RecurringProformaCreator, - RecurringProformaDeleter, - RecurringProformaDuplicator, - RecurringProformaFromProformaCreator, - type RecurringProformaOccurrenceProcessor, - RecurringProformaReader, - RecurringProformaStatusChanger, - RecurringProformaUpdater, } from "../../../application"; import { CompanyVerifactuProfileDomainMapper, SequelizeCompanyVerifactuProfileRepository, } from "../../company-verifactu-profile/persistence"; +import { + SequelizeRecurringProformaOccurrenceRepository, + SequelizeRecurringProformaRepository, +} from "../../recurring-proformas/persistence/sequelize/repositories"; import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder"; import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator"; import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator"; @@ -71,10 +75,6 @@ import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/ import { buildProformaDocumentServices } from "./proforma-documents.di"; import { buildV2ProformaPersistence } from "./proforma-persistence.di"; import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di"; -import { - SequelizeRecurringProformaOccurrenceRepository, - SequelizeRecurringProformaRepository, -} from "../../recurring-proformas/persistence/sequelize/repositories"; export type ProformasInternalDeps = { recurringProformas: { @@ -218,6 +218,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter const recurringReader = new RecurringProformaReader( recurringProformaRepository, occurrenceRepository, + customerServices, transactionManager ); const recurringCommands = new RecurringProformaCommands({ @@ -425,7 +426,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter transactionManager, }), - issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) => + issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) => buildIssueProformaUseCase({ publicServices, finder, diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts index fa61c119..25b49665 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts @@ -69,14 +69,15 @@ abstract class RecurringProformaController extends ExpressController { export class ListRecurringProformasController extends RecurringProformaController { public constructor( private readonly list: any, - private readonly presenter = new RecurringProformaListPresenter() + private readonly presenter = new RecurringProformaListPresenter(), + private readonly scope: "default" | "archived" = "default" ) { super(); } protected async executeImpl() { const companyId = this.company(); if (!companyId) return this.forbiddenError("Tenant ID not found"); - const result = await this.list({ companyId, ...this.req.query }); + const result = await this.list({ companyId, ...this.req.query, scope: this.scope }); return result.match( (data: any) => this.ok(this.presenter.present(data)), (error: Error) => this.handleError(error) diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts index 0b1dafbc..3f923267 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts @@ -12,7 +12,14 @@ export class RecurringProformaListPresenter { items: page.items.map((item: any) => ({ id: id(item.id), name: item.name, - customer_id: id(item.customerId), + description: item.description, + customer: item.customer + ? { + id: id(item.customer.id), + name: item.customer.name, + tin: item.customer.tin, + } + : null, status: item.status, schedule: item.schedule ? { @@ -24,6 +31,7 @@ export class RecurringProformaListPresenter { } : null, next_occurrence_date: dateOnly(item.nextOccurrenceDate), + has_issue: item.hasIssue, updated_at: item.updatedAt.toISOString(), })), }; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.http.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.http.test.ts index 3c25cc58..7ad707e1 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.http.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.http.test.ts @@ -25,7 +25,13 @@ test("HTTP list presenta paginación vacía y fila con schedule date-only", () = { id: UniqueID.generateNewID(), name: "Mantenimiento", + description: "Servicio preventivo", customerId: UniqueID.generateNewID(), + customer: { + id: UniqueID.generateNewID(), + name: "ACME", + tin: "B12345678", + }, status: "active", schedule: { startDate: new Date("2027-02-03T00:00:00.000Z"), @@ -35,12 +41,16 @@ test("HTTP list presenta paginación vacía y fila con schedule date-only", () = monthlyRule: "same_day", }, nextOccurrenceDate: new Date("2027-03-03T00:00:00.000Z"), + hasIssue: true, updatedAt: new Date("2027-02-01T10:00:00.000Z"), }, ], }); assert.equal(dto.items[0]?.schedule.start_date, "2027-02-03"); assert.equal(dto.items[0]?.next_occurrence_date, "2027-03-03"); + assert.equal(dto.items[0]?.description, "Servicio preventivo"); + assert.equal(dto.items[0]?.customer?.name, "ACME"); + assert.equal(dto.items[0]?.has_issue, true); }); test("HTTP detail conserva null inheritance, overrides, orden y date-only", () => { diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts index 5b344095..8b39ea64 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts @@ -40,6 +40,12 @@ export const recurringProformasRouter = (params: StartParams) => { (req: Request, res: Response, next: NextFunction) => new ListRecurringProformasController(deps.list).execute(req, res, next) ); + router.get( + "/archived", + validateRequest(ListRecurringProformasRequestSchema, "query"), + (req: Request, res: Response, next: NextFunction) => + new ListRecurringProformasController(deps.list, undefined, "archived").execute(req, res, next) + ); router.post( "/", validateRequest(CreateRecurringProformaRequestSchema, "body"), diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts index 53697d20..d47c5f21 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts @@ -52,7 +52,10 @@ test("SequelizeRecurringProformaRepository reemplaza items en la transacción su ); const originalUpsert = RecurringProformaModel.upsert; const originalDestroy = RecurringProformaItemModel.destroy; - RecurringProformaModel.upsert = (async () => [{} as never, true]) as typeof RecurringProformaModel.upsert; + RecurringProformaModel.upsert = (async () => [ + {} as never, + true, + ]) as typeof RecurringProformaModel.upsert; RecurringProformaItemModel.destroy = (async () => 0) as typeof RecurringProformaItemModel.destroy; try { assert.equal((await repository.save(recurringProforma, {})).isSuccess, true); @@ -76,10 +79,17 @@ test("findByIdInCompanyForUpdate exige transacción y limita por company", async return null; }) as typeof RecurringProformaModel.findOne; try { - assert.equal((await repository.findByIdInCompanyForUpdate(companyId, recurringProformaId, transaction)).isFailure, true); + assert.equal( + (await repository.findByIdInCompanyForUpdate(companyId, recurringProformaId, transaction)) + .isFailure, + true + ); assert.equal(options?.lock, true); assert.equal(options?.transaction, transaction); - assert.deepEqual(options?.where, { id: recurringProformaId.toPrimitive(), company_id: companyId.toPrimitive() }); + assert.deepEqual(options?.where, { + id: recurringProformaId.toPrimitive(), + company_id: companyId.toPrimitive(), + }); } finally { RecurringProformaModel.findOne = originalFindOne; } @@ -97,13 +107,58 @@ test("listInCompany limita tenant, filtros, paginación y orden determinista", a return { count: 0, rows: [] } as never; }) as typeof RecurringProformaModel.findAndCountAll; try { - const result = await repository.listInCompany({ companyId, status: "draft", search: "Cuota", page: 2, limit: 10 }); + const result = await repository.listInCompany({ + companyId, + status: "draft", + search: "Cuota", + page: 2, + limit: 10, + }); assert.equal(result.isSuccess, true); - assert.deepEqual(options?.where, { company_id: companyId.toPrimitive(), status: "draft", name: { [Op.like]: "%Cuota%" } }); - assert.deepEqual(options?.order, [["updated_at", "DESC"], ["id", "DESC"]]); + assert.deepEqual(options?.where, { + company_id: companyId.toPrimitive(), + status: "draft", + name: { [Op.like]: "%Cuota%" }, + }); + assert.deepEqual(options?.order, [ + ["updated_at", "DESC"], + ["id", "DESC"], + ]); assert.equal(options?.limit, 10); assert.equal(options?.offset, 10); } finally { RecurringProformaModel.findAndCountAll = originalFindAndCountAll; } }); + +test("listInCompany excluye archivadas y permite ordenar por nombre", async () => { + const repository = new SequelizeRecurringProformaRepository( + new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }) + ); + const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501")); + const originalFindAndCountAll = RecurringProformaModel.findAndCountAll; + let options: { where?: Record; order?: unknown } | undefined; + RecurringProformaModel.findAndCountAll = (async (value) => { + options = value; + return { count: 0, rows: [] } as never; + }) as typeof RecurringProformaModel.findAndCountAll; + try { + await repository.listInCompany({ + companyId, + page: 1, + limit: 20, + orderBy: "name", + order: "asc", + }); + assert.deepEqual(options?.where, { + company_id: companyId.toPrimitive(), + status: { [Op.ne]: "archived" }, + }); + assert.deepEqual(options?.order, [ + ["name", "ASC"], + ["id", "DESC"], + ]); + } finally { + RecurringProformaModel.findAndCountAll = originalFindAndCountAll; + } +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts index 1af9bb04..f6ae97fc 100644 --- a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts @@ -1,7 +1,7 @@ import { EntityNotFoundError, translateSequelizeError } from "@erp/core/api"; import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { Op, type Transaction } from "sequelize"; +import { literal, Op, type Transaction } from "sequelize"; import type { DueRecurringProforma, @@ -166,7 +166,11 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR try { const where = { company_id: query.companyId.toPrimitive(), - ...(query.status ? { status: query.status } : {}), + ...(query.scope === "archived" + ? { status: "archived" } + : query.status + ? { status: query.status } + : { status: { [Op.ne]: "archived" } }), ...(query.search ? { name: { [Op.like]: `%${query.search}%` } } : {}), }; const limit = Math.max(1, query.limit); @@ -175,6 +179,7 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR attributes: [ "id", "name", + "description", "customer_id", "status", "schedule_start_date", @@ -184,10 +189,20 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR "schedule_monthly_rule", "next_occurrence_date", "updated_at", + [ + literal(`EXISTS ( + SELECT 1 + FROM recurring_proforma_occurrences AS occurrence + WHERE occurrence.recurring_proforma_id = RecurringProformaModel.id + AND occurrence.company_id = RecurringProformaModel.company_id + AND occurrence.status = 'failed' + )`), + "has_issue", + ], ], where, order: [ - ["updated_at", "DESC"], + [query.orderBy ?? "updated_at", (query.order ?? "desc").toUpperCase()], ["id", "DESC"], ], limit, @@ -204,7 +219,9 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR items.push({ id: id.data, name: row.name, + description: row.description ?? null, customerId: customerId?.data ?? null, + customer: null, status: row.status as RecurringProformaListItemReadModel["status"], schedule: hasSchedule ? { @@ -222,6 +239,7 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR nextOccurrenceDate: row.next_occurrence_date ? this.toDateOnly(row.next_occurrence_date) : null, + hasIssue: Boolean(row.get("has_issue")), updatedAt: row.updated_at, }); } diff --git a/modules/customer-invoices/src/common/dto/request/recurring-proformas/recurring-proformas-read.request.dto.ts b/modules/customer-invoices/src/common/dto/request/recurring-proformas/recurring-proformas-read.request.dto.ts index 4f6218a1..242f465e 100644 --- a/modules/customer-invoices/src/common/dto/request/recurring-proformas/recurring-proformas-read.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/recurring-proformas/recurring-proformas-read.request.dto.ts @@ -3,9 +3,11 @@ import { z } from "zod/v4"; export const RecurringProformaIdParamsSchema = z.object({ recurring_proforma_id: z.uuid() }); export const ListRecurringProformasRequestSchema = z.object({ search: z.string().min(1).optional(), - status: z.enum(["draft", "active", "paused", "completed", "archived"]).optional(), + status: z.enum(["draft", "active", "paused", "completed"]).optional(), page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), + orderBy: z.enum(["name", "updated_at"]).optional(), + order: z.enum(["asc", "desc"]).optional(), }); export const ListRecurringProformaOccurrencesRequestSchema = z.object({ page: z.coerce.number().int().positive().default(1), diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index c033d12f..3a2f1230 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -31,9 +31,18 @@ "empty": "There are no recurring proformas.", "columns": { "name": "Name", + "customer": "Customer", "status": "Status", "schedule": "Recurrence", - "updated_at": "Updated" + "frequency": "Frequency", + "next_occurrence": "Next generation", + "issues": "Issues", + "updated_at": "Last updated", + "actions": "Actions" + }, + "issues": { + "none": "No issues", + "issue": "Issue" }, "schedule": { "none": "No recurrence", @@ -52,6 +61,15 @@ "retry": "Retry" } }, + "actions": { + "activate": "Activate", + "pause": "Pause", + "resume": "Resume", + "complete": "Complete", + "archive": "Archive", + "duplicate": "Duplicate", + "delete": "Delete" + }, "status": { "all": "All", "draft": "Draft", diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 04cc827f..94dbf7aa 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -31,9 +31,18 @@ "empty": "No hay proformas recurrentes.", "columns": { "name": "Nombre", + "customer": "Cliente", "status": "Estado", "schedule": "Recurrencia", - "updated_at": "Actualizada" + "frequency": "Frecuencia", + "next_occurrence": "Próxima generación", + "issues": "Incidencias", + "updated_at": "Última actualización", + "actions": "Acciones" + }, + "issues": { + "none": "Sin incidencias", + "issue": "Incidencia" }, "schedule": { "none": "Sin recurrencia", @@ -52,6 +61,15 @@ "retry": "Reintentar" } }, + "actions": { + "activate": "Activar", + "pause": "Pausar", + "resume": "Reanudar", + "complete": "Completar", + "archive": "Archivar", + "duplicate": "Duplicar", + "delete": "Eliminar" + }, "status": { "all": "Todos", "draft": "Borrador", diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 69e4410b..a2cdfc98 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -145,6 +145,18 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] ), }, + { + path: "recurring-proformas/archived", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: ( + + + + ), + }, { path: "recurring-proformas/create", handle: { diff --git a/modules/customer-invoices/src/web/recurring-proformas/list/controllers/use-list-recurring-proformas-page-controller.ts b/modules/customer-invoices/src/web/recurring-proformas/list/controllers/use-list-recurring-proformas-page-controller.ts index 3af1e082..e32625d3 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/list/controllers/use-list-recurring-proformas-page-controller.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/list/controllers/use-list-recurring-proformas-page-controller.ts @@ -1,6 +1,7 @@ import { useDebounce } from "@erp/core/hooks"; +import type { DataTableSort, DataTableSortDirection } from "@repo/rdx-ui/components"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "../../../i18n"; import { useRecurringProformas } from "../../shared/hooks/use-recurring-proformas"; @@ -11,8 +12,10 @@ import type { } from "../../shared/recurring-proforma-list"; import { buildRecurringProformaDetailPath } from "../../shared/recurring-proforma-navigation"; -const STATUSES = ["draft", "active", "paused", "completed", "archived"] as const; +const STATUSES = ["draft", "active", "paused", "completed"] as const; const PAGE_SIZE = 20; +const DEFAULT_SORT = { field: "updatedAt", direction: "desc" } satisfies DataTableSort; +const SORT_FIELDS = { name: "name", updatedAt: "updated_at" } as const; type RecurringProformaStatusFilter = "all" | RecurringProformaStatus; @@ -23,9 +26,9 @@ interface RecurringProformaPageErrorViewModel { const EMPTY_RECURRING_PROFORMAS_LIST: RecurringProformaList = { page: 1, - per_page: PAGE_SIZE, - total_items: 0, - total_pages: 0, + perPage: PAGE_SIZE, + totalItems: 0, + totalPages: 0, items: [], }; @@ -38,6 +41,7 @@ const resolveStatusFilter = (value: string | null): RecurringProformaStatusFilte export const useListRecurringProformasPageController = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(searchParams.get("search") ?? ""); const [statusFilter, setStatusFilter] = useState( @@ -47,6 +51,15 @@ export const useListRecurringProformasPageController = () => { const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); const limit = Math.max(1, Number(searchParams.get("limit") ?? String(PAGE_SIZE)) || PAGE_SIZE); const debouncedSearch = useDebounce(search, 300); + const sortField = searchParams.get("sortField"); + const sortDirection = searchParams.get("sortDirection"); + const sort = + sortField !== null && + sortField in SORT_FIELDS && + (sortDirection === "asc" || sortDirection === "desc") + ? { field: sortField, direction: sortDirection as DataTableSortDirection } + : DEFAULT_SORT; + const scope = location.pathname.endsWith("/archived") ? "archived" : "default"; useEffect(() => { setSearch(searchParams.get("search") ?? ""); @@ -59,8 +72,11 @@ export const useListRecurringProformasPageController = () => { limit, ...(debouncedSearch ? { search: debouncedSearch } : {}), ...(statusFilter === "all" ? {} : { status: statusFilter }), + orderBy: SORT_FIELDS[sort.field as keyof typeof SORT_FIELDS] ?? SORT_FIELDS.updatedAt, + order: sort.direction, + scope, }), - [debouncedSearch, limit, page, statusFilter] + [debouncedSearch, limit, page, scope, sort.direction, sort.field, statusFilter] ); const query = useRecurringProformas(filters); @@ -134,6 +150,15 @@ export const useListRecurringProformasPageController = () => { [limit, updateSearchParams] ); + const setSort = useCallback( + (nextSort: DataTableSort) => { + const field = nextSort.field in SORT_FIELDS ? nextSort.field : DEFAULT_SORT.field; + const direction = nextSort.direction === "asc" ? "asc" : "desc"; + updateSearchParams({ sortField: field, sortDirection: direction }); + }, + [updateSearchParams] + ); + const retryList = useCallback(async () => { await query.refetch(); }, [query]); @@ -192,6 +217,9 @@ export const useListRecurringProformasPageController = () => { statusFilter, setStatusFilter: setStatusFilterValue, status: statusFilter === "all" ? undefined : statusFilter, + sort, + setSort, + scope, }, pageError, refreshError, diff --git a/modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.test.tsx b/modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.test.tsx index 642338fa..113b0d3c 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.test.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.test.tsx @@ -14,9 +14,9 @@ interface ControllerState { listCtrl: { data: { page: number; - per_page: number; - total_items: number; - total_pages: number; + perPage: number; + totalItems: number; + totalPages: number; items: Array>; }; isLoading: boolean; @@ -29,6 +29,8 @@ interface ControllerState { setPageSize: (value: number) => void; statusFilter: string; setStatusFilter: (value: string) => void; + sort: { field: string; direction: "asc" | "desc" }; + setSort: (value: { field: string; direction: "asc" | "desc" }) => void; }; pageError: { title: string; description: string } | null; refreshError: { title: string; message: string } | null; @@ -72,9 +74,9 @@ const createControllerState = (): ControllerState => ({ listCtrl: { data: { page: 1, - per_page: 20, - total_items: 0, - total_pages: 0, + perPage: 20, + totalItems: 0, + totalPages: 0, items: [], }, isLoading: false, @@ -95,6 +97,8 @@ const createControllerState = (): ControllerState => ({ setStatusFilter: (value) => { events.status = value; }, + sort: { field: "updatedAt", direction: "desc" }, + setSort: () => undefined, }, pageError: null, refreshError: null, @@ -167,6 +171,12 @@ mock.module("@repo/shadcn-ui/components", { return ; }, Badge: ({ children }: { children: ReactNode }) => {children}, + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: ReactNode }) => ( + + ), + DropdownMenuTrigger: ({ render }: { render: ReactNode }) => <>{render}, ToggleGroup: ({ children, onValueChange }: ToggleGroupProps & { children: ReactNode }) => { capturedToggleGroupProps = { onValueChange }; return
{children}
; @@ -177,6 +187,12 @@ mock.module("@repo/shadcn-ui/components", { }, }); +mock.module("../shared/hooks/use-recurring-proforma-action-mutation", { + namedExports: { + useRecurringProformaActionMutation: () => ({ isPending: false, mutate: () => undefined }), + }, +}); + const renderPage = async () => { const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page"); @@ -230,24 +246,26 @@ test("conecta CTA, detalle, búsqueda, filtro y paginación con el controller", controllerState = createControllerState(); controllerState.listCtrl.data = { page: 1, - per_page: 20, - total_items: 1, - total_pages: 1, + perPage: 20, + totalItems: 1, + totalPages: 1, items: [ { id: "rec-1", name: "Cuota mensual", - customer_id: null, + description: null, + customer: null, status: "active", schedule: { - start_date: "2026-09-01", - end_date: null, + startDate: "2026-09-01", + endDate: null, unit: "month", interval: 1, - monthly_rule: "same_day", + monthlyRule: "same_day", }, - next_occurrence_date: null, - updated_at: "2026-09-01", + nextOccurrenceDate: null, + hasIssue: false, + updatedAt: "2026-09-01", }, ], }; diff --git a/modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.tsx b/modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.tsx index b437ec9a..17eea487 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.tsx @@ -1,24 +1,53 @@ import { PageErrorState, SimpleSearchInput } from "@erp/core/components"; import { DataTable, DataTableColumnHeader, RdxPageContainer } from "@repo/rdx-ui/components"; -import { Badge, Button } from "@repo/shadcn-ui/components"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@repo/shadcn-ui/components"; import type { ColumnDef } from "@tanstack/react-table"; +import { CircleIcon, MoreHorizontalIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "../../i18n"; -import type { RecurringProformaListItem } from "../shared/recurring-proforma-list"; +import type { RecurringProformaListRow } from "../shared/recurring-proforma-list"; import { buildRecurringProformaDetailPath } from "../shared/recurring-proforma-navigation"; +import { + type RecurringProformaAction, + useRecurringProformaActionMutation, +} from "../shared/hooks/use-recurring-proforma-action-mutation"; import { useListRecurringProformasPageController } from "./controllers/use-list-recurring-proformas-page-controller"; import { formatRecurringProformaSchedule, + formatRecurringProformaDateOnly, + formatRecurringProformaDateTime, getRecurringProformaStatusKey, } from "./recurring-proforma-list-utils"; import { RecurringProformaStatusSegmentedFilter } from "./ui/components/recurring-proforma-status-segmented-filter"; import { ScopedListRecurringProformasPageView } from "./ui/pages/scoped-list-recurring-proformas-page-view"; +const getActions = (status: RecurringProformaListRow["status"]): RecurringProformaAction[] => { + switch (status) { + case "draft": + return ["activate", "complete", "duplicate", "delete"]; + case "active": + return ["pause", "complete", "duplicate"]; + case "paused": + return ["resume", "complete", "duplicate"]; + case "completed": + return ["archive", "duplicate"]; + case "archived": + return ["duplicate"]; + } +}; + export const ListRecurringProformasPage = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [isSearchPending, setIsSearchPending] = useState(false); const { listCtrl, @@ -30,12 +59,14 @@ export const ListRecurringProformasPage = () => { openCreatePage, openDetailPage, } = useListRecurringProformasPageController(); + const actionMutation = useRecurringProformaActionMutation(); + const dateLocale = i18n.language === "en" ? "en-US" : "es-ES"; - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { accessorKey: "name", - enableSorting: false, + enableSorting: true, header: ({ column }) => ( { className="font-medium text-primary underline-offset-4 hover:underline" to={buildRecurringProformaDetailPath(row.original.id)} > - {row.original.name} + {row.original.name} + {row.original.description ? ( + + {row.original.description} + + ) : null} ), }, + { + id: "customer", + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.original.customer ? ( +
+ {row.original.customer.name} + + {row.original.customer.tin ?? "—"} + +
+ ) : ( + "—" + ), + }, { accessorKey: "status", enableSorting: false, @@ -65,12 +121,12 @@ export const ListRecurringProformasPage = () => { ), }, { - id: "schedule", + id: "frequency", enableSorting: false, header: ({ column }) => ( ), cell: ({ row }) => @@ -79,18 +135,98 @@ export const ListRecurringProformasPage = () => { ), }, { - accessorKey: "updated_at", - enableSorting: false, + id: "nextOccurrenceDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => + formatRecurringProformaDateOnly(row.original.nextOccurrenceDate, dateLocale), + }, + { + id: "hasIssue", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + + ), + }, + { + accessorKey: "updatedAt", + enableSorting: true, header: ({ column }) => ( ), - cell: ({ row }) => {row.original.updated_at}, + cell: ({ row }) => ( + + {formatRecurringProformaDateTime(row.original.updatedAt, dateLocale)} + + ), + }, + { + id: "actions", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + + + + } + /> + + {getActions(row.original.status).map((action) => ( + { + event.stopPropagation(); + actionMutation.mutate({ id: row.original.id, action }); + }} + > + {t(`recurring_proformas.actions.${action}`)} + + ))} + + + ), }, ], - [t] + [actionMutation, dateLocale, t] ); if (hasBlockingError && pageError) { @@ -123,12 +259,15 @@ export const ListRecurringProformasPage = () => { isFetching={listCtrl.isFetching} loading={listCtrl.isLoading} manualPagination + manualSorting onPageChange={listCtrl.setPageIndex} onPageSizeChange={listCtrl.setPageSize} onRowClick={(row) => openDetailPage(row.id)} + onSortChange={listCtrl.setSort} pageIndex={listCtrl.pageIndex} pageSize={listCtrl.pageSize} searching={isSearchPending} + sort={listCtrl.sort} topContent={
@@ -150,10 +289,10 @@ export const ListRecurringProformasPage = () => { />
} - totalItems={listCtrl.data.total_items} + totalItems={listCtrl.data.totalItems} /> - {listCtrl.data.total_items === 0 ? ( + {listCtrl.data.totalItems === 0 ? (
{t("recurring_proformas.list.empty")}
diff --git a/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.test.ts b/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.test.ts index 69bce56e..18004cd1 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.test.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.test.ts @@ -2,34 +2,53 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + formatRecurringProformaDateOnly, formatRecurringProformaSchedule, getRecurringProformaStatusKey, getRecurringProformaListFeedbackState, } from "./recurring-proforma-list-utils"; test("resuelve loading, error, empty y rows para el listado", () => { - assert.equal(getRecurringProformaListFeedbackState({ isLoading: true, isError: false, totalItems: 0 }), "loading"); - assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: true, totalItems: 0 }), "error"); - assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 0 }), "empty"); - assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 1 }), "ready"); + assert.equal( + getRecurringProformaListFeedbackState({ isLoading: true, isError: false, totalItems: 0 }), + "loading" + ); + assert.equal( + getRecurringProformaListFeedbackState({ isLoading: false, isError: true, totalItems: 0 }), + "error" + ); + assert.equal( + getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 0 }), + "empty" + ); + assert.equal( + getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 1 }), + "ready" + ); }); test("presenta la recurrencia mensual de una fila sin calcular ocurrencias", () => { const result = formatRecurringProformaSchedule( { - start_date: "2026-09-01", - end_date: null, + startDate: "2026-09-01", + endDate: null, unit: "month", interval: 1, - monthly_rule: "same_day", + monthlyRule: "same_day", }, - (key) => ({ - "recurring_proformas.list.schedule.month": "Cada mes", - "recurring_proformas.list.schedule.same_day": "Mismo dia", - })[key] ?? key + (key) => + ({ + "recurring_proformas.list.schedule.month": "Cada mes", + "recurring_proformas.list.schedule.same_day": "Mismo dia", + })[key] ?? key ); - assert.equal(result, "Cada mes, Mismo dia"); + assert.equal(result, "Cada mes · Mismo dia"); +}); + +test("presenta fechas civiles sin desplazar el día y usa un valor vacío consistente", () => { + assert.equal(formatRecurringProformaDateOnly("2026-09-01", "es-ES"), "1/9/26"); + assert.equal(formatRecurringProformaDateOnly(null, "es-ES"), "—"); }); test("resuelve la clave i18n del estado que presenta cada fila", () => { diff --git a/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.ts b/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.ts index 08536088..4f8c454d 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.ts @@ -1,5 +1,6 @@ import type { - RecurringProformaListItem, + RecurringProformaDetail, + RecurringProformaListRow, RecurringProformaStatus, } from "../shared/recurring-proforma-list"; @@ -9,7 +10,7 @@ export const getRecurringProformaStatusKey = (status: RecurringProformaStatus) = `recurring_proformas.status.${status}`; export const formatRecurringProformaSchedule = ( - schedule: RecurringProformaListItem["schedule"], + schedule: RecurringProformaListRow["schedule"] | RecurringProformaDetail["schedule"], t: Translate ) => { if (!schedule) return t("recurring_proformas.list.schedule.none"); @@ -17,14 +18,27 @@ export const formatRecurringProformaSchedule = ( const cadence = t(`recurring_proformas.list.schedule.${schedule.unit}`, { count: schedule.interval, }); + const monthlyRule = "monthlyRule" in schedule ? schedule.monthlyRule : schedule.monthly_rule; const rule = - schedule.monthly_rule === null - ? "" - : `, ${t(`recurring_proformas.list.schedule.${schedule.monthly_rule}`)}`; + monthlyRule === null ? "" : ` · ${t(`recurring_proformas.list.schedule.${monthlyRule}`)}`; return `${cadence}${rule}`; }; +export const formatRecurringProformaDateTime = (value: string, locale: string) => + new Intl.DateTimeFormat(locale, { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(value)); + +export const formatRecurringProformaDateOnly = (value: string | null, locale: string) => { + if (!value) return "—"; + const [year, month, day] = value.split("-").map(Number); + return new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format( + new Date(year, month - 1, day) + ); +}; + export const getRecurringProformaListFeedbackState = ({ isLoading, isError, diff --git a/modules/customer-invoices/src/web/recurring-proformas/list/ui/components/recurring-proforma-status-segmented-filter.tsx b/modules/customer-invoices/src/web/recurring-proformas/list/ui/components/recurring-proforma-status-segmented-filter.tsx index fe4105ad..014d4b57 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/list/ui/components/recurring-proforma-status-segmented-filter.tsx +++ b/modules/customer-invoices/src/web/recurring-proformas/list/ui/components/recurring-proforma-status-segmented-filter.tsx @@ -1,12 +1,6 @@ import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; -import { - ArchiveIcon, - CheckCircle2Icon, - CirclePauseIcon, - FileTextIcon, - PlayCircleIcon, -} from "lucide-react"; +import { CheckCircle2Icon, CirclePauseIcon, FileTextIcon, PlayCircleIcon } from "lucide-react"; import type { ElementType } from "react"; import { useTranslation } from "../../../../i18n"; @@ -56,13 +50,6 @@ const RECURRING_PROFORMA_STATUS_FILTER_OPTIONS = [ activeClassName: "data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700", }, - { - value: "archived", - labelKey: "recurring_proformas.status.archived", - icon: ArchiveIcon, - activeClassName: - "data-[state=on]:border-muted-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground", - }, ] as const satisfies readonly { value: RecurringProformaStatusFilter; labelKey: string; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/adapters/list-recurring-proformas.adapter.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/adapters/list-recurring-proformas.adapter.ts new file mode 100644 index 00000000..84677341 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/adapters/list-recurring-proformas.adapter.ts @@ -0,0 +1,35 @@ +import type { + RecurringProformaList, + RecurringProformaListDTO, + RecurringProformaListRow, +} from "../recurring-proforma-list"; + +const toRow = (dto: RecurringProformaListDTO["items"][number]): RecurringProformaListRow => ({ + id: dto.id, + name: dto.name, + description: dto.description, + customer: dto.customer, + status: dto.status, + schedule: dto.schedule + ? { + startDate: dto.schedule.start_date, + endDate: dto.schedule.end_date, + unit: dto.schedule.unit, + interval: dto.schedule.interval, + monthlyRule: dto.schedule.monthly_rule, + } + : null, + nextOccurrenceDate: dto.next_occurrence_date, + hasIssue: dto.has_issue, + updatedAt: dto.updated_at, +}); + +export const ListRecurringProformasAdapter = { + fromDto: (dto: RecurringProformaListDTO): RecurringProformaList => ({ + page: dto.page, + perPage: dto.per_page, + totalItems: dto.total_items, + totalPages: dto.total_pages, + items: dto.items.map(toRow), + }), +}; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/api/list-recurring-proformas.api.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/api/list-recurring-proformas.api.ts index 0165f645..5778df5e 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/api/list-recurring-proformas.api.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/api/list-recurring-proformas.api.ts @@ -1,13 +1,21 @@ import type { IDataSource } from "@erp/core/client"; -import type { RecurringProformaList, RecurringProformaListFilters } from "../recurring-proforma-list"; +import type { + RecurringProformaListDTO, + RecurringProformaListFilters, +} from "../recurring-proforma-list"; export const listRecurringProformas = ( dataSource: IDataSource, filters: RecurringProformaListFilters, signal?: AbortSignal -): Promise => - dataSource.getList("recurring-proformas", { - ...filters, - signal, - }); +): Promise => { + const { scope = "default", ...query } = filters; + return dataSource.getList( + scope === "archived" ? "recurring-proformas/archived" : "recurring-proformas", + { + ...query, + signal, + } + ); +}; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-action-mutation.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-action-mutation.ts new file mode 100644 index 00000000..f177b584 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-action-mutation.ts @@ -0,0 +1,31 @@ +import { useDataSource } from "@erp/core/hooks"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys"; + +export type RecurringProformaAction = + | "activate" + | "pause" + | "resume" + | "complete" + | "archive" + | "duplicate" + | "delete"; + +export const useRecurringProformaActionMutation = () => { + const dataSource = useDataSource(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, action }: { id: string; action: RecurringProformaAction }) => + dataSource.custom({ + path: + action === "delete" ? `recurring-proformas/${id}` : `recurring-proformas/${id}/${action}`, + method: action === "delete" ? "delete" : "post", + data: undefined, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.lists() }); + }, + }); +}; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts index 2f0e60dc..c6d5552c 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts @@ -2,6 +2,7 @@ import { useDataSource } from "@erp/core/hooks"; import { useQuery } from "@tanstack/react-query"; import { listRecurringProformas } from "../api/list-recurring-proformas.api"; +import { ListRecurringProformasAdapter } from "../adapters/list-recurring-proformas.adapter"; import type { RecurringProformaListFilters } from "../recurring-proforma-list"; import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys"; @@ -11,7 +12,10 @@ export const useRecurringProformas = (filters: RecurringProformaListFilters) => return useQuery({ queryKey: recurringProformaQueryKeys.list(filters), - queryFn: ({ signal }) => listRecurringProformas(dataSource, filters, signal), + queryFn: async ({ signal }) => + ListRecurringProformasAdapter.fromDto( + await listRecurringProformas(dataSource, filters, signal) + ), placeholderData: (previousData) => previousData, }); }; diff --git a/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-list.ts b/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-list.ts index 8c626c35..3d3f225f 100644 --- a/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-list.ts +++ b/modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-list.ts @@ -1,21 +1,24 @@ -export type RecurringProformaStatus = - | "draft" - | "active" - | "paused" - | "completed" - | "archived"; +export type RecurringProformaStatus = "draft" | "active" | "paused" | "completed" | "archived"; export type RecurringProformaListFilters = { page: number; limit: number; search?: string; status?: RecurringProformaStatus; + orderBy?: "name" | "updated_at"; + order?: "asc" | "desc"; + scope?: "default" | "archived"; }; -export type RecurringProformaListItem = { +export type RecurringProformaListItemDTO = { id: string; name: string; - customer_id: string | null; + description: string | null; + customer: { + id: string; + name: string; + tin: string | null; + } | null; status: RecurringProformaStatus; schedule: { start_date: string; @@ -25,15 +28,46 @@ export type RecurringProformaListItem = { monthly_rule: "same_day" | "last_day_of_month" | null; } | null; next_occurrence_date: string | null; + has_issue: boolean; updated_at: string; }; -export type RecurringProformaList = { +export type RecurringProformaListDTO = { page: number; per_page: number; total_items: number; total_pages: number; - items: RecurringProformaListItem[]; + items: RecurringProformaListItemDTO[]; +}; + +export type RecurringProformaListRow = { + id: string; + name: string; + description: string | null; + customer: { + id: string; + name: string; + tin: string | null; + } | null; + status: RecurringProformaStatus; + schedule: { + startDate: string; + endDate: string | null; + unit: "week" | "month" | "year"; + interval: number; + monthlyRule: "same_day" | "last_day_of_month" | null; + } | null; + nextOccurrenceDate: string | null; + hasIssue: boolean; + updatedAt: string; +}; + +export type RecurringProformaList = { + page: number; + perPage: number; + totalItems: number; + totalPages: number; + items: RecurringProformaListRow[]; }; export type RecurringProformaPercentage = { @@ -60,18 +94,14 @@ export type RecurringProformaDetail = { proforma_series: string | null; issued_invoice_series: string | null; global_discount_percentage: RecurringProformaPercentage | null; - schedule: RecurringProformaListItem["schedule"]; + schedule: RecurringProformaListItemDTO["schedule"]; next_occurrence_date: string | null; items: RecurringProformaDetailItem[]; created_at: string; updated_at: string; }; -export type RecurringProformaOccurrenceStatus = - | "pending" - | "processing" - | "failed" - | "generated"; +export type RecurringProformaOccurrenceStatus = "pending" | "processing" | "failed" | "generated"; export type RecurringProformaOccurrencesFilters = { page: number; diff --git a/modules/customers/src/api/application/models/customer-public-summary.ts b/modules/customers/src/api/application/models/customer-public-summary.ts new file mode 100644 index 00000000..14929c45 --- /dev/null +++ b/modules/customers/src/api/application/models/customer-public-summary.ts @@ -0,0 +1,8 @@ +import type { UniqueID } from "@repo/rdx-ddd"; + +/** Proyección mínima de cliente para consumidores de otros módulos. */ +export type CustomerPublicSummary = { + id: UniqueID; + name: string; + tin: string | null; +}; diff --git a/modules/customers/src/api/application/models/index.ts b/modules/customers/src/api/application/models/index.ts index 439bf5e6..0725b456 100644 --- a/modules/customers/src/api/application/models/index.ts +++ b/modules/customers/src/api/application/models/index.ts @@ -1,5 +1,6 @@ export * from "./customer-full-read-model"; export * from "./customer-payment-method-read.model"; export * from "./customer-payment-term-read.model"; +export * from "./customer-public-summary"; export * from "./customer-summary"; export * from "./customer-tax-regime-full-read.model"; diff --git a/modules/customers/src/api/application/repositories/customer-repository.interface.ts b/modules/customers/src/api/application/repositories/customer-repository.interface.ts index 6dc565a0..9af9cb6a 100644 --- a/modules/customers/src/api/application/repositories/customer-repository.interface.ts +++ b/modules/customers/src/api/application/repositories/customer-repository.interface.ts @@ -3,13 +3,19 @@ import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; import type { Collection, Result } from "@repo/rdx-utils"; import type { Customer } from "../../domain/aggregates"; -import type { CustomerSummary } from "../models"; +import type { CustomerPublicSummary, CustomerSummary } from "../models"; /** * Interfaz del repositorio para el agregado `Customer`. * El escopado multitenant está representado por `companyId`. */ export interface ICustomerRepository { + findPublicSummariesByIdsInCompany( + companyId: UniqueID, + ids: readonly UniqueID[], + transaction?: unknown + ): Promise>; + /** * * Crea un nuevo cliente diff --git a/modules/customers/src/api/application/services/customer-public-services.interface.ts b/modules/customers/src/api/application/services/customer-public-services.interface.ts index 3d59ff1c..7e74bcdf 100644 --- a/modules/customers/src/api/application/services/customer-public-services.interface.ts +++ b/modules/customers/src/api/application/services/customer-public-services.interface.ts @@ -2,6 +2,7 @@ import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; import type { Result } from "@repo/rdx-utils"; import type { Customer, ICustomerCreateProps } from "../../domain"; +import type { CustomerPublicSummary } from "../models"; export interface ICustomerServicesContext { transaction: unknown; @@ -9,6 +10,11 @@ export interface ICustomerServicesContext { } export interface ICustomerPublicServices { + findSummariesByIds: ( + customerIds: readonly UniqueID[], + context: ICustomerServicesContext + ) => Promise>; + findCustomerByTIN: ( tin: TINNumber, context: ICustomerServicesContext diff --git a/modules/customers/src/api/infrastructure/di/customer-public-services.ts b/modules/customers/src/api/infrastructure/di/customer-public-services.ts index 26966f74..0d301236 100644 --- a/modules/customers/src/api/infrastructure/di/customer-public-services.ts +++ b/modules/customers/src/api/infrastructure/di/customer-public-services.ts @@ -30,6 +30,14 @@ export function buildCustomerPublicServices( const creator = buildCustomerCreator({ repository, audit: params.audit }); return { + findSummariesByIds: async (customerIds, context) => { + return repository.findPublicSummariesByIdsInCompany( + context.companyId, + customerIds, + context.transaction + ); + }, + findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => { const { companyId, transaction } = context; diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.test.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.test.ts new file mode 100644 index 00000000..b17e9d89 --- /dev/null +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Op, Sequelize } from "sequelize"; + +import { CustomerModel } from "../models/sequelize-customer.model"; + +import { CustomerRepository } from "./customer.repository"; + +const required = (result: { data: T }): T => result.data; + +test("findPublicSummariesByIdsInCompany proyecta clientes únicos y mantiene el scope", async () => { + const repository = new CustomerRepository( + {} as never, + {} as never, + new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }) + ); + const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501")); + const customerId = required(UniqueID.create("01900000-0000-7000-8000-000000000502")); + const originalFindAll = CustomerModel.findAll; + let received: { attributes?: unknown; where?: Record } | undefined; + CustomerModel.findAll = (async (options) => { + received = options; + return [{ id: customerId.toPrimitive(), name: "ACME", tin: "B12345678" }] as never; + }) as typeof CustomerModel.findAll; + + try { + const result = await repository.findPublicSummariesByIdsInCompany(companyId, [ + customerId, + customerId, + ]); + assert.ok(result.isSuccess, result.isFailure ? result.error.message : ""); + assert.deepEqual(received?.attributes, ["id", "name", "tin"]); + assert.deepEqual(received?.where, { + company_id: companyId.toPrimitive(), + id: { [Op.in]: [customerId.toPrimitive()] }, + }); + assert.deepEqual( + result.data.map((customer) => ({ ...customer, id: customer.id.toPrimitive() })), + [{ id: customerId.toPrimitive(), name: "ACME", tin: "B12345678" }] + ); + } finally { + CustomerModel.findAll = originalFindAll; + } +}); + +test("findPublicSummariesByIdsInCompany no consulta cuando no hay IDs", async () => { + const repository = new CustomerRepository( + {} as never, + {} as never, + new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }) + ); + const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501")); + const originalFindAll = CustomerModel.findAll; + let calls = 0; + CustomerModel.findAll = (async () => { + calls += 1; + return []; + }) as typeof CustomerModel.findAll; + + try { + const result = await repository.findPublicSummariesByIdsInCompany(companyId, []); + assert.equal(result.isSuccess, true); + assert.deepEqual(result.data, []); + assert.equal(calls, 0); + } finally { + CustomerModel.findAll = originalFindAll; + } +}); diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts index d8461e6a..9151354d 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts @@ -1,10 +1,22 @@ import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api"; import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; -import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; +import { UniqueID } from "@repo/rdx-ddd"; +import type { TINNumber } from "@repo/rdx-ddd"; import { type Collection, Result } from "@repo/rdx-utils"; -import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize"; +import { + Op, + type FindOptions, + type InferAttributes, + type OrderItem, + type Sequelize, + type Transaction, +} from "sequelize"; -import type { CustomerSummary, ICustomerRepository } from "../../../../application"; +import type { + CustomerPublicSummary, + CustomerSummary, + ICustomerRepository, +} from "../../../../application"; import type { Customer } from "../../../../domain"; import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers"; import { CustomerModel } from "../models/sequelize-customer.model"; @@ -21,6 +33,35 @@ export class CustomerRepository super({ database }); } + public async findPublicSummariesByIdsInCompany( + companyId: UniqueID, + ids: readonly UniqueID[], + transaction?: unknown + ): Promise> { + try { + const uniqueIds = [...new Set(ids.map((id) => id.toPrimitive()))]; + if (uniqueIds.length === 0) return Result.ok([]); + + const rows = await CustomerModel.findAll({ + attributes: ["id", "name", "tin"], + where: { + company_id: companyId.toPrimitive(), + id: { [Op.in]: uniqueIds }, + }, + transaction: transaction as Transaction | undefined, + }); + const summaries: CustomerPublicSummary[] = []; + for (const row of rows) { + const id = UniqueID.create(row.id); + if (id.isFailure) return Result.fail(id.error); + summaries.push({ id: id.data, name: row.name, tin: row.tin ?? null }); + } + return Result.ok(summaries); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + /** * * Crea un nuevo cliente diff --git a/modules/customers/src/api/module.ts b/modules/customers/src/api/module.ts index c27c581c..48fe9aab 100644 --- a/modules/customers/src/api/module.ts +++ b/modules/customers/src/api/module.ts @@ -4,7 +4,7 @@ import type { ICustomerPublicServices } from "./application"; import { buildCustomerPublicServices, buildCustomersDependencies } from "./infrastructure/di"; import { models } from "./infrastructure/persistence/sequelize"; -export type { ICustomerPublicServices } from "./application"; +export type { CustomerPublicSummary, ICustomerPublicServices } from "./application"; export * from "./infrastructure/persistence/sequelize"; /** Manifest neutral de clientes: composición y servicio público sin HTTP. */