Completar columnas en listado de proformas recurrentes
This commit is contained in:
parent
f6002d7367
commit
727543f14f
@ -1,4 +1,5 @@
|
|||||||
import type { DiscountPercentage } from "@erp/core/api";
|
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 { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -21,10 +22,13 @@ export type RecurringProformaScheduleReadModel = {
|
|||||||
export type RecurringProformaListItemReadModel = {
|
export type RecurringProformaListItemReadModel = {
|
||||||
id: UniqueID;
|
id: UniqueID;
|
||||||
name: string;
|
name: string;
|
||||||
|
description: string | null;
|
||||||
customerId: UniqueID | null;
|
customerId: UniqueID | null;
|
||||||
|
customer: CustomerPublicSummary | null;
|
||||||
status: RecurringProformaStatus;
|
status: RecurringProformaStatus;
|
||||||
schedule: RecurringProformaScheduleReadModel | null;
|
schedule: RecurringProformaScheduleReadModel | null;
|
||||||
nextOccurrenceDate: Date | null;
|
nextOccurrenceDate: Date | null;
|
||||||
|
hasIssue: boolean;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -88,8 +92,11 @@ export type ListRecurringProformasQuery = {
|
|||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
status?: RecurringProformaStatus;
|
status?: Exclude<RecurringProformaStatus, "archived">;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
scope?: "default" | "archived";
|
||||||
|
orderBy?: "name" | "updated_at";
|
||||||
|
order?: "asc" | "desc";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListRecurringProformaOccurrencesQuery = {
|
export type ListRecurringProformaOccurrencesQuery = {
|
||||||
|
|||||||
@ -15,7 +15,10 @@ import {
|
|||||||
RecurringProformaOccurrence,
|
RecurringProformaOccurrence,
|
||||||
} from "../../../domain";
|
} from "../../../domain";
|
||||||
import { toRecurringProformaAuditTarget } from "../audit";
|
import { toRecurringProformaAuditTarget } from "../audit";
|
||||||
import { RecurringProformaCommands, RecurringProformaReader } from "./recurring-proforma-capabilities";
|
import {
|
||||||
|
RecurringProformaCommands,
|
||||||
|
RecurringProformaReader,
|
||||||
|
} from "./recurring-proforma-capabilities";
|
||||||
|
|
||||||
const required = <T>(result: { data: T }): T => result.data;
|
const required = <T>(result: { data: T }): T => result.data;
|
||||||
|
|
||||||
@ -36,30 +39,49 @@ const buildRecurring = () => {
|
|||||||
const currency = required(CurrencyCode.create("EUR"));
|
const currency = required(CurrencyCode.create("EUR"));
|
||||||
const language = required(LanguageCode.create("es"));
|
const language = required(LanguageCode.create("es"));
|
||||||
const discount = required(DiscountPercentage.create({ value: 500 }));
|
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" }));
|
const schedule = required(
|
||||||
return required(RecurringProforma.create({
|
RecurrenceSchedule.create({
|
||||||
companyId: companyA,
|
startDate: new Date("2027-01-15T00:00:00.000Z"),
|
||||||
name: "Cuota mensual",
|
endDate: null,
|
||||||
customerId: Maybe.none(),
|
unit: "month",
|
||||||
description: Maybe.none(),
|
interval: 1,
|
||||||
paymentTermId: Maybe.none(),
|
monthlyRule: "same_day",
|
||||||
paymentMethodId: Maybe.none(),
|
})
|
||||||
language: Maybe.none(),
|
);
|
||||||
currency: Maybe.none(),
|
return required(
|
||||||
proformaSeries: Maybe.none(),
|
RecurringProforma.create(
|
||||||
issuedInvoiceSeries: Maybe.none(),
|
{
|
||||||
globalDiscountPercentage: Maybe.some(discount),
|
companyId: companyA,
|
||||||
schedule: Maybe.some(schedule),
|
name: "Cuota mensual",
|
||||||
items: [{
|
customerId: Maybe.none(),
|
||||||
description: Maybe.some(required(ItemDescription.create("Servicio"))),
|
description: Maybe.none(),
|
||||||
quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))),
|
paymentTermId: Maybe.none(),
|
||||||
unitAmount: Maybe.some(required(ItemAmount.create({ value: 1000, currency_code: currency.code }))),
|
paymentMethodId: Maybe.none(),
|
||||||
itemDiscountPercentage: Maybe.some(discount),
|
language: Maybe.none(),
|
||||||
ivaCode: Maybe.some("general"), recCode: Maybe.some("rec"), retentionCode: Maybe.some("ret"),
|
currency: Maybe.none(),
|
||||||
}],
|
proformaSeries: Maybe.none(),
|
||||||
createdAt: new Date("2027-01-01T00:00:00.000Z"),
|
issuedInvoiceSeries: Maybe.none(),
|
||||||
updatedAt: new Date("2027-01-02T00:00:00.000Z"),
|
globalDiscountPercentage: Maybe.some(discount),
|
||||||
}, recurringId));
|
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", () => {
|
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 () => {
|
test("RecurringProformaReader propaga company, filtros, paginación y conserva el listado", async () => {
|
||||||
let receivedQuery: unknown;
|
let receivedQuery: unknown;
|
||||||
let receivedTransaction: 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 item = {
|
||||||
const reader = new RecurringProformaReader({
|
id: recurringId,
|
||||||
listInCompany: async (query, currentTransaction) => {
|
name: "Cuota mensual",
|
||||||
receivedQuery = query; receivedTransaction = currentTransaction;
|
description: null,
|
||||||
return Result.ok({ page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] });
|
customerId: null,
|
||||||
},
|
customer: null,
|
||||||
} as never, {} as never, transactionManager());
|
status: "draft" as const,
|
||||||
const result = await reader.list({ companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" });
|
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.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.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);
|
assert.equal((receivedQuery as { companyId: UniqueID }).companyId.equals(companyB), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("RecurringProformaReader conserva overrides nulos, schedule e items ordenados en detail", async () => {
|
test("RecurringProformaReader conserva overrides nulos, schedule e items ordenados en detail", async () => {
|
||||||
let receivedCompany: UniqueID | undefined;
|
let receivedCompany: UniqueID | undefined;
|
||||||
const recurring = buildRecurring();
|
const recurring = buildRecurring();
|
||||||
const reader = new RecurringProformaReader({
|
const reader = new RecurringProformaReader(
|
||||||
findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
|
{
|
||||||
receivedCompany = companyId;
|
findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
|
||||||
assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction);
|
receivedCompany = companyId;
|
||||||
return Result.ok(recurring);
|
assert.equal(id.equals(recurringId), true);
|
||||||
},
|
assert.equal(currentTransaction, transaction);
|
||||||
} as never, {} as never, transactionManager());
|
return Result.ok(recurring);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
transactionManager()
|
||||||
|
);
|
||||||
const result = await reader.detail({ companyId: companyA, id: recurringId });
|
const result = await reader.detail({ companyId: companyA, id: recurringId });
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
assert.equal(receivedCompany?.equals(companyA), 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 () => {
|
test("RecurringProformaReader propaga parent, empresa y paginación de occurrences", async () => {
|
||||||
let receivedQuery: unknown;
|
let receivedQuery: unknown;
|
||||||
const proformaId = UniqueID.generateNewID();
|
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 generated = required(
|
||||||
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() }));
|
RecurringProformaOccurrence.create({
|
||||||
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 });
|
companyId: companyA,
|
||||||
const reader = new RecurringProformaReader({} as never, {
|
recurringProformaId: recurringId,
|
||||||
listByRecurringProformaInCompany: async (query) => { receivedQuery = query; return Result.ok({ page: 1, perPage: 10, totalItems: 2, totalPages: 1, items: [toRead(generated), toRead(failed)] }); },
|
scheduledDate: new Date("2027-02-15T00:00:00.000Z"),
|
||||||
} as never, transactionManager());
|
status: "generated",
|
||||||
const result = await reader.occurrences({ companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 });
|
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.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[0]?.proformaId?.equals(proformaId), true);
|
||||||
assert.equal(result.data.items[1]?.lastErrorCode, "failed");
|
assert.equal(result.data.items[1]?.lastErrorCode, "failed");
|
||||||
assert.equal(result.data.items[1]?.attemptCount, 2);
|
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 () => {
|
test("RecurringProformaCommands posee la transacción y propaga las transiciones", async () => {
|
||||||
const calls: Array<{ action: string; transaction: unknown }> = [];
|
const calls: Array<{ action: string; transaction: unknown }> = [];
|
||||||
const commands = new RecurringProformaCommands({
|
const commands = new RecurringProformaCommands({
|
||||||
statusChanger: { change: async (params: { action: string; transaction: unknown; companyId: UniqueID; id: UniqueID }) => {
|
statusChanger: {
|
||||||
assert.equal(params.companyId.equals(companyA), true); assert.equal(params.id.equals(recurringId), true);
|
change: async (params: {
|
||||||
calls.push({ action: params.action, transaction: params.transaction }); return Result.ok(buildRecurring());
|
action: string;
|
||||||
} },
|
transaction: unknown;
|
||||||
creator: {}, updater: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {},
|
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(),
|
transactionManager: transactionManager(),
|
||||||
} as never);
|
} as never);
|
||||||
for (const action of ["activate", "pause", "resume", "complete", "archive"] as const) {
|
for (const action of ["activate", "pause", "resume", "complete", "archive"] as const) {
|
||||||
const result = await commands[action]({ companyId: companyA, id: recurringId });
|
const result = await commands[action]({ companyId: companyA, id: recurringId });
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
}
|
}
|
||||||
assert.deepEqual(calls.map((call) => call.action), ["activate", "pause", "resume", "complete", "archive"]);
|
assert.deepEqual(
|
||||||
assert.equal(calls.every((call) => call.transaction === transaction), true);
|
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 () => {
|
test("RecurringProformaCommands propaga name y schedule al facade fromProforma", async () => {
|
||||||
let received: { name: string; schedule: unknown; transaction: unknown } | undefined;
|
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({
|
const commands = new RecurringProformaCommands({
|
||||||
fromProformaCreator: { create: async (params: { name: string; schedule: typeof schedule; transaction: unknown }) => { received = params; return Result.ok(buildRecurring()); } },
|
fromProformaCreator: {
|
||||||
creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(),
|
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);
|
} 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(result.isSuccess, true);
|
||||||
assert.equal(received?.name, "Desde proforma");
|
assert.equal(received?.name, "Desde proforma");
|
||||||
assert.deepEqual(received?.schedule, schedule);
|
assert.deepEqual(received?.schedule, schedule);
|
||||||
@ -164,24 +348,96 @@ test("RecurringProformaCommands propaga create, update, duplicate y delete sin n
|
|||||||
const calls: Record<string, unknown> = {};
|
const calls: Record<string, unknown> = {};
|
||||||
const patch = { paymentTermId: null };
|
const patch = { paymentTermId: null };
|
||||||
const commands = new RecurringProformaCommands({
|
const commands = new RecurringProformaCommands({
|
||||||
creator: { create: async (params: { companyId: UniqueID; name: string; description?: Maybe<string>; transaction: unknown }) => { calls.create = params; return Result.ok(buildRecurring()); } },
|
creator: {
|
||||||
updater: { update: async (params: { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown }) => { calls.update = params; return Result.ok(buildRecurring()); } },
|
create: async (params: {
|
||||||
duplicator: { duplicate: async (params: { companyId: UniqueID; sourceId: UniqueID; transaction: unknown }) => { calls.duplicate = params; return Result.ok(buildRecurring()); } },
|
companyId: UniqueID;
|
||||||
deleter: { delete: async (params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) => { calls.delete = params; return Result.ok(true); } },
|
name: string;
|
||||||
statusChanger: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(),
|
description?: Maybe<string>;
|
||||||
|
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);
|
} as never);
|
||||||
assert.equal((await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" })).isSuccess, true);
|
assert.equal(
|
||||||
assert.equal((await commands.update({ companyId: companyA, id: recurringId, patch })).isSuccess, true);
|
(await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" }))
|
||||||
assert.equal((await commands.duplicate({ companyId: companyA, sourceId: recurringId })).isSuccess, true);
|
.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);
|
assert.equal((await commands.delete({ companyId: companyA, id: recurringId })).data, true);
|
||||||
const create = calls.create as { companyId: UniqueID; name: string; description?: Maybe<string>; transaction: unknown };
|
const create = calls.create as {
|
||||||
const update = calls.update as { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown };
|
companyId: UniqueID;
|
||||||
const duplicate = calls.duplicate as { companyId: UniqueID; sourceId: UniqueID; transaction: unknown };
|
name: string;
|
||||||
|
description?: Maybe<string>;
|
||||||
|
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 };
|
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(create.companyId.equals(companyA), true);
|
||||||
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(create.name, "Nueva");
|
||||||
assert.equal(duplicate.companyId.equals(companyA), true); assert.equal(duplicate.sourceId.equals(recurringId), true); assert.equal(duplicate.transaction, transaction);
|
assert.equal(create.description?.unwrap(), "Descripción");
|
||||||
assert.equal(deleted.companyId.equals(companyA), true); assert.equal(deleted.id.equals(recurringId), true); assert.equal(deleted.transaction, transaction);
|
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 () => {
|
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 readiness = { canActivate: false, blockers: [{ code: "missing_customer" as const }] };
|
||||||
const recurring = buildRecurring();
|
const recurring = buildRecurring();
|
||||||
const commands = new RecurringProformaCommands({
|
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); } },
|
repository: {
|
||||||
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); } },
|
findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
|
||||||
creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, transactionManager: transactionManager(),
|
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);
|
} as never);
|
||||||
const result = await commands.activationReadiness({ companyId: companyA, id: recurringId });
|
const result = await commands.activationReadiness({ companyId: companyA, id: recurringId });
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
|
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -34,13 +35,43 @@ export class RecurringProformaReader {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly repository: IRecurringProformaRepository,
|
private readonly repository: IRecurringProformaRepository,
|
||||||
private readonly occurrenceRepository: IRecurringProformaOccurrenceRepository,
|
private readonly occurrenceRepository: IRecurringProformaOccurrenceRepository,
|
||||||
|
private readonly customerServices: ICustomerPublicServices,
|
||||||
private readonly transactionManager: ITransactionManager
|
private readonly transactionManager: ITransactionManager
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public list(query: ListRecurringProformasQuery) {
|
public list(query: ListRecurringProformasQuery) {
|
||||||
return this.transactionManager.complete((transaction) =>
|
return this.transactionManager.complete(async (transaction) => {
|
||||||
this.repository.listInCompany(query, 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 }) {
|
public detail(params: { companyId: UniqueID; id: UniqueID }) {
|
||||||
|
|||||||
@ -16,6 +16,18 @@ import {
|
|||||||
type ListProformasUseCase,
|
type ListProformasUseCase,
|
||||||
type PreviewProformaReportUseCase,
|
type PreviewProformaReportUseCase,
|
||||||
ProformaIssuanceReadinessChecker,
|
ProformaIssuanceReadinessChecker,
|
||||||
|
ProformaTaxConfigResolver,
|
||||||
|
RecurringProformaActivationReadinessChecker,
|
||||||
|
RecurringProformaCommands,
|
||||||
|
RecurringProformaCreator,
|
||||||
|
RecurringProformaDeleter,
|
||||||
|
RecurringProformaDuplicator,
|
||||||
|
RecurringProformaFromProformaCreator,
|
||||||
|
RecurringProformaMaterializer,
|
||||||
|
type RecurringProformaOccurrenceProcessor,
|
||||||
|
RecurringProformaReader,
|
||||||
|
RecurringProformaStatusChanger,
|
||||||
|
RecurringProformaUpdater,
|
||||||
type ReportProformaPdfUseCase,
|
type ReportProformaPdfUseCase,
|
||||||
type UnarchiveProformaByIdUseCase,
|
type UnarchiveProformaByIdUseCase,
|
||||||
type UpdateProformaByIdUseCase,
|
type UpdateProformaByIdUseCase,
|
||||||
@ -47,23 +59,15 @@ import {
|
|||||||
buildUnarchiveProformaByIdUseCase,
|
buildUnarchiveProformaByIdUseCase,
|
||||||
buildUpdateProformaUseCase,
|
buildUpdateProformaUseCase,
|
||||||
createRecurringProformaOccurrenceProcessor,
|
createRecurringProformaOccurrenceProcessor,
|
||||||
ProformaTaxConfigResolver,
|
|
||||||
RecurringProformaMaterializer,
|
|
||||||
RecurringProformaActivationReadinessChecker,
|
|
||||||
RecurringProformaCommands,
|
|
||||||
RecurringProformaCreator,
|
|
||||||
RecurringProformaDeleter,
|
|
||||||
RecurringProformaDuplicator,
|
|
||||||
RecurringProformaFromProformaCreator,
|
|
||||||
type RecurringProformaOccurrenceProcessor,
|
|
||||||
RecurringProformaReader,
|
|
||||||
RecurringProformaStatusChanger,
|
|
||||||
RecurringProformaUpdater,
|
|
||||||
} from "../../../application";
|
} from "../../../application";
|
||||||
import {
|
import {
|
||||||
CompanyVerifactuProfileDomainMapper,
|
CompanyVerifactuProfileDomainMapper,
|
||||||
SequelizeCompanyVerifactuProfileRepository,
|
SequelizeCompanyVerifactuProfileRepository,
|
||||||
} from "../../company-verifactu-profile/persistence";
|
} from "../../company-verifactu-profile/persistence";
|
||||||
|
import {
|
||||||
|
SequelizeRecurringProformaOccurrenceRepository,
|
||||||
|
SequelizeRecurringProformaRepository,
|
||||||
|
} from "../../recurring-proformas/persistence/sequelize/repositories";
|
||||||
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
|
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
|
||||||
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
|
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
|
||||||
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-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 { buildProformaDocumentServices } from "./proforma-documents.di";
|
||||||
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
|
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
|
||||||
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
|
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
|
||||||
import {
|
|
||||||
SequelizeRecurringProformaOccurrenceRepository,
|
|
||||||
SequelizeRecurringProformaRepository,
|
|
||||||
} from "../../recurring-proformas/persistence/sequelize/repositories";
|
|
||||||
|
|
||||||
export type ProformasInternalDeps = {
|
export type ProformasInternalDeps = {
|
||||||
recurringProformas: {
|
recurringProformas: {
|
||||||
@ -218,6 +218,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
|||||||
const recurringReader = new RecurringProformaReader(
|
const recurringReader = new RecurringProformaReader(
|
||||||
recurringProformaRepository,
|
recurringProformaRepository,
|
||||||
occurrenceRepository,
|
occurrenceRepository,
|
||||||
|
customerServices,
|
||||||
transactionManager
|
transactionManager
|
||||||
);
|
);
|
||||||
const recurringCommands = new RecurringProformaCommands({
|
const recurringCommands = new RecurringProformaCommands({
|
||||||
@ -425,7 +426,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
|||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) =>
|
issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) =>
|
||||||
buildIssueProformaUseCase({
|
buildIssueProformaUseCase({
|
||||||
publicServices,
|
publicServices,
|
||||||
finder,
|
finder,
|
||||||
|
|||||||
@ -69,14 +69,15 @@ abstract class RecurringProformaController extends ExpressController {
|
|||||||
export class ListRecurringProformasController extends RecurringProformaController {
|
export class ListRecurringProformasController extends RecurringProformaController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly list: any,
|
private readonly list: any,
|
||||||
private readonly presenter = new RecurringProformaListPresenter()
|
private readonly presenter = new RecurringProformaListPresenter(),
|
||||||
|
private readonly scope: "default" | "archived" = "default"
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
protected async executeImpl() {
|
protected async executeImpl() {
|
||||||
const companyId = this.company();
|
const companyId = this.company();
|
||||||
if (!companyId) return this.forbiddenError("Tenant ID not found");
|
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(
|
return result.match(
|
||||||
(data: any) => this.ok(this.presenter.present(data)),
|
(data: any) => this.ok(this.presenter.present(data)),
|
||||||
(error: Error) => this.handleError(error)
|
(error: Error) => this.handleError(error)
|
||||||
|
|||||||
@ -12,7 +12,14 @@ export class RecurringProformaListPresenter {
|
|||||||
items: page.items.map((item: any) => ({
|
items: page.items.map((item: any) => ({
|
||||||
id: id(item.id),
|
id: id(item.id),
|
||||||
name: item.name,
|
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,
|
status: item.status,
|
||||||
schedule: item.schedule
|
schedule: item.schedule
|
||||||
? {
|
? {
|
||||||
@ -24,6 +31,7 @@ export class RecurringProformaListPresenter {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
next_occurrence_date: dateOnly(item.nextOccurrenceDate),
|
next_occurrence_date: dateOnly(item.nextOccurrenceDate),
|
||||||
|
has_issue: item.hasIssue,
|
||||||
updated_at: item.updatedAt.toISOString(),
|
updated_at: item.updatedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -25,7 +25,13 @@ test("HTTP list presenta paginación vacía y fila con schedule date-only", () =
|
|||||||
{
|
{
|
||||||
id: UniqueID.generateNewID(),
|
id: UniqueID.generateNewID(),
|
||||||
name: "Mantenimiento",
|
name: "Mantenimiento",
|
||||||
|
description: "Servicio preventivo",
|
||||||
customerId: UniqueID.generateNewID(),
|
customerId: UniqueID.generateNewID(),
|
||||||
|
customer: {
|
||||||
|
id: UniqueID.generateNewID(),
|
||||||
|
name: "ACME",
|
||||||
|
tin: "B12345678",
|
||||||
|
},
|
||||||
status: "active",
|
status: "active",
|
||||||
schedule: {
|
schedule: {
|
||||||
startDate: new Date("2027-02-03T00:00:00.000Z"),
|
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",
|
monthlyRule: "same_day",
|
||||||
},
|
},
|
||||||
nextOccurrenceDate: new Date("2027-03-03T00:00:00.000Z"),
|
nextOccurrenceDate: new Date("2027-03-03T00:00:00.000Z"),
|
||||||
|
hasIssue: true,
|
||||||
updatedAt: new Date("2027-02-01T10:00:00.000Z"),
|
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]?.schedule.start_date, "2027-02-03");
|
||||||
assert.equal(dto.items[0]?.next_occurrence_date, "2027-03-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", () => {
|
test("HTTP detail conserva null inheritance, overrides, orden y date-only", () => {
|
||||||
|
|||||||
@ -40,6 +40,12 @@ export const recurringProformasRouter = (params: StartParams) => {
|
|||||||
(req: Request, res: Response, next: NextFunction) =>
|
(req: Request, res: Response, next: NextFunction) =>
|
||||||
new ListRecurringProformasController(deps.list).execute(req, res, next)
|
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(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
validateRequest(CreateRecurringProformaRequestSchema, "body"),
|
validateRequest(CreateRecurringProformaRequestSchema, "body"),
|
||||||
|
|||||||
@ -52,7 +52,10 @@ test("SequelizeRecurringProformaRepository reemplaza items en la transacción su
|
|||||||
);
|
);
|
||||||
const originalUpsert = RecurringProformaModel.upsert;
|
const originalUpsert = RecurringProformaModel.upsert;
|
||||||
const originalDestroy = RecurringProformaItemModel.destroy;
|
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;
|
RecurringProformaItemModel.destroy = (async () => 0) as typeof RecurringProformaItemModel.destroy;
|
||||||
try {
|
try {
|
||||||
assert.equal((await repository.save(recurringProforma, {})).isSuccess, true);
|
assert.equal((await repository.save(recurringProforma, {})).isSuccess, true);
|
||||||
@ -76,10 +79,17 @@ test("findByIdInCompanyForUpdate exige transacción y limita por company", async
|
|||||||
return null;
|
return null;
|
||||||
}) as typeof RecurringProformaModel.findOne;
|
}) as typeof RecurringProformaModel.findOne;
|
||||||
try {
|
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?.lock, true);
|
||||||
assert.equal(options?.transaction, transaction);
|
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 {
|
} finally {
|
||||||
RecurringProformaModel.findOne = originalFindOne;
|
RecurringProformaModel.findOne = originalFindOne;
|
||||||
}
|
}
|
||||||
@ -97,13 +107,58 @@ test("listInCompany limita tenant, filtros, paginación y orden determinista", a
|
|||||||
return { count: 0, rows: [] } as never;
|
return { count: 0, rows: [] } as never;
|
||||||
}) as typeof RecurringProformaModel.findAndCountAll;
|
}) as typeof RecurringProformaModel.findAndCountAll;
|
||||||
try {
|
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.equal(result.isSuccess, true);
|
||||||
assert.deepEqual(options?.where, { company_id: companyId.toPrimitive(), status: "draft", name: { [Op.like]: "%Cuota%" } });
|
assert.deepEqual(options?.where, {
|
||||||
assert.deepEqual(options?.order, [["updated_at", "DESC"], ["id", "DESC"]]);
|
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?.limit, 10);
|
||||||
assert.equal(options?.offset, 10);
|
assert.equal(options?.offset, 10);
|
||||||
} finally {
|
} finally {
|
||||||
RecurringProformaModel.findAndCountAll = originalFindAndCountAll;
|
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<string, unknown>; 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { EntityNotFoundError, translateSequelizeError } from "@erp/core/api";
|
import { EntityNotFoundError, translateSequelizeError } 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 { Op, type Transaction } from "sequelize";
|
import { literal, Op, type Transaction } from "sequelize";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DueRecurringProforma,
|
DueRecurringProforma,
|
||||||
@ -166,7 +166,11 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
|
|||||||
try {
|
try {
|
||||||
const where = {
|
const where = {
|
||||||
company_id: query.companyId.toPrimitive(),
|
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}%` } } : {}),
|
...(query.search ? { name: { [Op.like]: `%${query.search}%` } } : {}),
|
||||||
};
|
};
|
||||||
const limit = Math.max(1, query.limit);
|
const limit = Math.max(1, query.limit);
|
||||||
@ -175,6 +179,7 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
|
|||||||
attributes: [
|
attributes: [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
|
"description",
|
||||||
"customer_id",
|
"customer_id",
|
||||||
"status",
|
"status",
|
||||||
"schedule_start_date",
|
"schedule_start_date",
|
||||||
@ -184,10 +189,20 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
|
|||||||
"schedule_monthly_rule",
|
"schedule_monthly_rule",
|
||||||
"next_occurrence_date",
|
"next_occurrence_date",
|
||||||
"updated_at",
|
"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,
|
where,
|
||||||
order: [
|
order: [
|
||||||
["updated_at", "DESC"],
|
[query.orderBy ?? "updated_at", (query.order ?? "desc").toUpperCase()],
|
||||||
["id", "DESC"],
|
["id", "DESC"],
|
||||||
],
|
],
|
||||||
limit,
|
limit,
|
||||||
@ -204,7 +219,9 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
|
|||||||
items.push({
|
items.push({
|
||||||
id: id.data,
|
id: id.data,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
description: row.description ?? null,
|
||||||
customerId: customerId?.data ?? null,
|
customerId: customerId?.data ?? null,
|
||||||
|
customer: null,
|
||||||
status: row.status as RecurringProformaListItemReadModel["status"],
|
status: row.status as RecurringProformaListItemReadModel["status"],
|
||||||
schedule: hasSchedule
|
schedule: hasSchedule
|
||||||
? {
|
? {
|
||||||
@ -222,6 +239,7 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
|
|||||||
nextOccurrenceDate: row.next_occurrence_date
|
nextOccurrenceDate: row.next_occurrence_date
|
||||||
? this.toDateOnly(row.next_occurrence_date)
|
? this.toDateOnly(row.next_occurrence_date)
|
||||||
: null,
|
: null,
|
||||||
|
hasIssue: Boolean(row.get("has_issue")),
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,11 @@ import { z } from "zod/v4";
|
|||||||
export const RecurringProformaIdParamsSchema = z.object({ recurring_proforma_id: z.uuid() });
|
export const RecurringProformaIdParamsSchema = z.object({ recurring_proforma_id: z.uuid() });
|
||||||
export const ListRecurringProformasRequestSchema = z.object({
|
export const ListRecurringProformasRequestSchema = z.object({
|
||||||
search: z.string().min(1).optional(),
|
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),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
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({
|
export const ListRecurringProformaOccurrencesRequestSchema = z.object({
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
|||||||
@ -31,9 +31,18 @@
|
|||||||
"empty": "There are no recurring proformas.",
|
"empty": "There are no recurring proformas.",
|
||||||
"columns": {
|
"columns": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"customer": "Customer",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"schedule": "Recurrence",
|
"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": {
|
"schedule": {
|
||||||
"none": "No recurrence",
|
"none": "No recurrence",
|
||||||
@ -52,6 +61,15 @@
|
|||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"actions": {
|
||||||
|
"activate": "Activate",
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Resume",
|
||||||
|
"complete": "Complete",
|
||||||
|
"archive": "Archive",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
|
|||||||
@ -31,9 +31,18 @@
|
|||||||
"empty": "No hay proformas recurrentes.",
|
"empty": "No hay proformas recurrentes.",
|
||||||
"columns": {
|
"columns": {
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
|
"customer": "Cliente",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"schedule": "Recurrencia",
|
"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": {
|
"schedule": {
|
||||||
"none": "Sin recurrencia",
|
"none": "Sin recurrencia",
|
||||||
@ -52,6 +61,15 @@
|
|||||||
"retry": "Reintentar"
|
"retry": "Reintentar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"actions": {
|
||||||
|
"activate": "Activar",
|
||||||
|
"pause": "Pausar",
|
||||||
|
"resume": "Reanudar",
|
||||||
|
"complete": "Completar",
|
||||||
|
"archive": "Archivar",
|
||||||
|
"duplicate": "Duplicar",
|
||||||
|
"delete": "Eliminar"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"all": "Todos",
|
"all": "Todos",
|
||||||
"draft": "Borrador",
|
"draft": "Borrador",
|
||||||
|
|||||||
@ -145,6 +145,18 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
|
|||||||
</RecurringProformaLayout>
|
</RecurringProformaLayout>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "recurring-proformas/archived",
|
||||||
|
handle: {
|
||||||
|
layout: "app-sidebar",
|
||||||
|
protected: true,
|
||||||
|
},
|
||||||
|
element: (
|
||||||
|
<RecurringProformaLayout>
|
||||||
|
<RecurringProformasListPage />
|
||||||
|
</RecurringProformaLayout>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "recurring-proformas/create",
|
path: "recurring-proformas/create",
|
||||||
handle: {
|
handle: {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useDebounce } from "@erp/core/hooks";
|
import { useDebounce } from "@erp/core/hooks";
|
||||||
|
import type { DataTableSort, DataTableSortDirection } from "@repo/rdx-ui/components";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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 { useTranslation } from "../../../i18n";
|
||||||
import { useRecurringProformas } from "../../shared/hooks/use-recurring-proformas";
|
import { useRecurringProformas } from "../../shared/hooks/use-recurring-proformas";
|
||||||
@ -11,8 +12,10 @@ import type {
|
|||||||
} from "../../shared/recurring-proforma-list";
|
} from "../../shared/recurring-proforma-list";
|
||||||
import { buildRecurringProformaDetailPath } from "../../shared/recurring-proforma-navigation";
|
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 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;
|
type RecurringProformaStatusFilter = "all" | RecurringProformaStatus;
|
||||||
|
|
||||||
@ -23,9 +26,9 @@ interface RecurringProformaPageErrorViewModel {
|
|||||||
|
|
||||||
const EMPTY_RECURRING_PROFORMAS_LIST: RecurringProformaList = {
|
const EMPTY_RECURRING_PROFORMAS_LIST: RecurringProformaList = {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: PAGE_SIZE,
|
perPage: PAGE_SIZE,
|
||||||
total_items: 0,
|
totalItems: 0,
|
||||||
total_pages: 0,
|
totalPages: 0,
|
||||||
items: [],
|
items: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -38,6 +41,7 @@ const resolveStatusFilter = (value: string | null): RecurringProformaStatusFilte
|
|||||||
export const useListRecurringProformasPageController = () => {
|
export const useListRecurringProformasPageController = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [search, setSearch] = useState(searchParams.get("search") ?? "");
|
const [search, setSearch] = useState(searchParams.get("search") ?? "");
|
||||||
const [statusFilter, setStatusFilter] = useState<RecurringProformaStatusFilter>(
|
const [statusFilter, setStatusFilter] = useState<RecurringProformaStatusFilter>(
|
||||||
@ -47,6 +51,15 @@ export const useListRecurringProformasPageController = () => {
|
|||||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
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 limit = Math.max(1, Number(searchParams.get("limit") ?? String(PAGE_SIZE)) || PAGE_SIZE);
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
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(() => {
|
useEffect(() => {
|
||||||
setSearch(searchParams.get("search") ?? "");
|
setSearch(searchParams.get("search") ?? "");
|
||||||
@ -59,8 +72,11 @@ export const useListRecurringProformasPageController = () => {
|
|||||||
limit,
|
limit,
|
||||||
...(debouncedSearch ? { search: debouncedSearch } : {}),
|
...(debouncedSearch ? { search: debouncedSearch } : {}),
|
||||||
...(statusFilter === "all" ? {} : { status: statusFilter }),
|
...(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);
|
const query = useRecurringProformas(filters);
|
||||||
@ -134,6 +150,15 @@ export const useListRecurringProformasPageController = () => {
|
|||||||
[limit, updateSearchParams]
|
[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 () => {
|
const retryList = useCallback(async () => {
|
||||||
await query.refetch();
|
await query.refetch();
|
||||||
}, [query]);
|
}, [query]);
|
||||||
@ -192,6 +217,9 @@ export const useListRecurringProformasPageController = () => {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter: setStatusFilterValue,
|
setStatusFilter: setStatusFilterValue,
|
||||||
status: statusFilter === "all" ? undefined : statusFilter,
|
status: statusFilter === "all" ? undefined : statusFilter,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
|
scope,
|
||||||
},
|
},
|
||||||
pageError,
|
pageError,
|
||||||
refreshError,
|
refreshError,
|
||||||
|
|||||||
@ -14,9 +14,9 @@ interface ControllerState {
|
|||||||
listCtrl: {
|
listCtrl: {
|
||||||
data: {
|
data: {
|
||||||
page: number;
|
page: number;
|
||||||
per_page: number;
|
perPage: number;
|
||||||
total_items: number;
|
totalItems: number;
|
||||||
total_pages: number;
|
totalPages: number;
|
||||||
items: Array<Record<string, unknown>>;
|
items: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@ -29,6 +29,8 @@ interface ControllerState {
|
|||||||
setPageSize: (value: number) => void;
|
setPageSize: (value: number) => void;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
setStatusFilter: (value: string) => void;
|
setStatusFilter: (value: string) => void;
|
||||||
|
sort: { field: string; direction: "asc" | "desc" };
|
||||||
|
setSort: (value: { field: string; direction: "asc" | "desc" }) => void;
|
||||||
};
|
};
|
||||||
pageError: { title: string; description: string } | null;
|
pageError: { title: string; description: string } | null;
|
||||||
refreshError: { title: string; message: string } | null;
|
refreshError: { title: string; message: string } | null;
|
||||||
@ -72,9 +74,9 @@ const createControllerState = (): ControllerState => ({
|
|||||||
listCtrl: {
|
listCtrl: {
|
||||||
data: {
|
data: {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 20,
|
perPage: 20,
|
||||||
total_items: 0,
|
totalItems: 0,
|
||||||
total_pages: 0,
|
totalPages: 0,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -95,6 +97,8 @@ const createControllerState = (): ControllerState => ({
|
|||||||
setStatusFilter: (value) => {
|
setStatusFilter: (value) => {
|
||||||
events.status = value;
|
events.status = value;
|
||||||
},
|
},
|
||||||
|
sort: { field: "updatedAt", direction: "desc" },
|
||||||
|
setSort: () => undefined,
|
||||||
},
|
},
|
||||||
pageError: null,
|
pageError: null,
|
||||||
refreshError: null,
|
refreshError: null,
|
||||||
@ -167,6 +171,12 @@ mock.module("@repo/shadcn-ui/components", {
|
|||||||
return <button type="button">{children}</button>;
|
return <button type="button">{children}</button>;
|
||||||
},
|
},
|
||||||
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||||
|
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
|
||||||
|
<button type="button">{children}</button>
|
||||||
|
),
|
||||||
|
DropdownMenuTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
|
||||||
ToggleGroup: ({ children, onValueChange }: ToggleGroupProps & { children: ReactNode }) => {
|
ToggleGroup: ({ children, onValueChange }: ToggleGroupProps & { children: ReactNode }) => {
|
||||||
capturedToggleGroupProps = { onValueChange };
|
capturedToggleGroupProps = { onValueChange };
|
||||||
return <div>{children}</div>;
|
return <div>{children}</div>;
|
||||||
@ -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 renderPage = async () => {
|
||||||
const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page");
|
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 = createControllerState();
|
||||||
controllerState.listCtrl.data = {
|
controllerState.listCtrl.data = {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 20,
|
perPage: 20,
|
||||||
total_items: 1,
|
totalItems: 1,
|
||||||
total_pages: 1,
|
totalPages: 1,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "rec-1",
|
id: "rec-1",
|
||||||
name: "Cuota mensual",
|
name: "Cuota mensual",
|
||||||
customer_id: null,
|
description: null,
|
||||||
|
customer: null,
|
||||||
status: "active",
|
status: "active",
|
||||||
schedule: {
|
schedule: {
|
||||||
start_date: "2026-09-01",
|
startDate: "2026-09-01",
|
||||||
end_date: null,
|
endDate: null,
|
||||||
unit: "month",
|
unit: "month",
|
||||||
interval: 1,
|
interval: 1,
|
||||||
monthly_rule: "same_day",
|
monthlyRule: "same_day",
|
||||||
},
|
},
|
||||||
next_occurrence_date: null,
|
nextOccurrenceDate: null,
|
||||||
updated_at: "2026-09-01",
|
hasIssue: false,
|
||||||
|
updatedAt: "2026-09-01",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,24 +1,53 @@
|
|||||||
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
|
||||||
import { DataTable, DataTableColumnHeader, RdxPageContainer } from "@repo/rdx-ui/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 type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { CircleIcon, MoreHorizontalIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../i18n";
|
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 { 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 { useListRecurringProformasPageController } from "./controllers/use-list-recurring-proformas-page-controller";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatRecurringProformaSchedule,
|
formatRecurringProformaSchedule,
|
||||||
|
formatRecurringProformaDateOnly,
|
||||||
|
formatRecurringProformaDateTime,
|
||||||
getRecurringProformaStatusKey,
|
getRecurringProformaStatusKey,
|
||||||
} from "./recurring-proforma-list-utils";
|
} from "./recurring-proforma-list-utils";
|
||||||
import { RecurringProformaStatusSegmentedFilter } from "./ui/components/recurring-proforma-status-segmented-filter";
|
import { RecurringProformaStatusSegmentedFilter } from "./ui/components/recurring-proforma-status-segmented-filter";
|
||||||
import { ScopedListRecurringProformasPageView } from "./ui/pages/scoped-list-recurring-proformas-page-view";
|
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 = () => {
|
export const ListRecurringProformasPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [isSearchPending, setIsSearchPending] = useState(false);
|
const [isSearchPending, setIsSearchPending] = useState(false);
|
||||||
const {
|
const {
|
||||||
listCtrl,
|
listCtrl,
|
||||||
@ -30,12 +59,14 @@ export const ListRecurringProformasPage = () => {
|
|||||||
openCreatePage,
|
openCreatePage,
|
||||||
openDetailPage,
|
openDetailPage,
|
||||||
} = useListRecurringProformasPageController();
|
} = useListRecurringProformasPageController();
|
||||||
|
const actionMutation = useRecurringProformaActionMutation();
|
||||||
|
const dateLocale = i18n.language === "en" ? "en-US" : "es-ES";
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<RecurringProformaListItem>[]>(
|
const columns = useMemo<ColumnDef<RecurringProformaListRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableSorting: false,
|
enableSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
column={column}
|
column={column}
|
||||||
@ -47,10 +78,35 @@ export const ListRecurringProformasPage = () => {
|
|||||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||||
to={buildRecurringProformaDetailPath(row.original.id)}
|
to={buildRecurringProformaDetailPath(row.original.id)}
|
||||||
>
|
>
|
||||||
{row.original.name}
|
<span className="block font-semibold">{row.original.name}</span>
|
||||||
|
{row.original.description ? (
|
||||||
|
<span className="mt-0.5 block text-sm font-normal text-muted-foreground">
|
||||||
|
{row.original.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "customer",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
column={column}
|
||||||
|
title={t("recurring_proformas.list.columns.customer")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.customer ? (
|
||||||
|
<div>
|
||||||
|
<span className="block font-medium">{row.original.customer.name}</span>
|
||||||
|
<span className="block text-sm text-muted-foreground">
|
||||||
|
{row.original.customer.tin ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@ -65,12 +121,12 @@ export const ListRecurringProformasPage = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "schedule",
|
id: "frequency",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
column={column}
|
column={column}
|
||||||
title={t("recurring_proformas.list.columns.schedule")}
|
title={t("recurring_proformas.list.columns.frequency")}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
@ -79,18 +135,98 @@ export const ListRecurringProformasPage = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "updated_at",
|
id: "nextOccurrenceDate",
|
||||||
enableSorting: false,
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
column={column}
|
||||||
|
title={t("recurring_proformas.list.columns.next_occurrence")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
formatRecurringProformaDateOnly(row.original.nextOccurrenceDate, dateLocale),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hasIssue",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
column={column}
|
||||||
|
title={t("recurring_proformas.list.columns.issues")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CircleIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
row.original.hasIssue
|
||||||
|
? "size-2 fill-red-500 text-red-500"
|
||||||
|
: "size-2 fill-emerald-500 text-emerald-500"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
|
row.original.hasIssue
|
||||||
|
? "recurring_proformas.list.issues.issue"
|
||||||
|
: "recurring_proformas.list.issues.none"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "updatedAt",
|
||||||
|
enableSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader
|
<DataTableColumnHeader
|
||||||
column={column}
|
column={column}
|
||||||
title={t("recurring_proformas.list.columns.updated_at")}
|
title={t("recurring_proformas.list.columns.updated_at")}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span className="tabular-nums">{row.original.updated_at}</span>,
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatRecurringProformaDateTime(row.original.updatedAt, dateLocale)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
column={column}
|
||||||
|
title={t("recurring_proformas.list.columns.actions")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
aria-label={t("recurring_proformas.list.columns.actions")}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{getActions(row.original.status).map((action) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
key={action}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
actionMutation.mutate({ id: row.original.id, action });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(`recurring_proformas.actions.${action}`)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t]
|
[actionMutation, dateLocale, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasBlockingError && pageError) {
|
if (hasBlockingError && pageError) {
|
||||||
@ -123,12 +259,15 @@ export const ListRecurringProformasPage = () => {
|
|||||||
isFetching={listCtrl.isFetching}
|
isFetching={listCtrl.isFetching}
|
||||||
loading={listCtrl.isLoading}
|
loading={listCtrl.isLoading}
|
||||||
manualPagination
|
manualPagination
|
||||||
|
manualSorting
|
||||||
onPageChange={listCtrl.setPageIndex}
|
onPageChange={listCtrl.setPageIndex}
|
||||||
onPageSizeChange={listCtrl.setPageSize}
|
onPageSizeChange={listCtrl.setPageSize}
|
||||||
onRowClick={(row) => openDetailPage(row.id)}
|
onRowClick={(row) => openDetailPage(row.id)}
|
||||||
|
onSortChange={listCtrl.setSort}
|
||||||
pageIndex={listCtrl.pageIndex}
|
pageIndex={listCtrl.pageIndex}
|
||||||
pageSize={listCtrl.pageSize}
|
pageSize={listCtrl.pageSize}
|
||||||
searching={isSearchPending}
|
searching={isSearchPending}
|
||||||
|
sort={listCtrl.sort}
|
||||||
topContent={
|
topContent={
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
@ -150,10 +289,10 @@ export const ListRecurringProformasPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
totalItems={listCtrl.data.total_items}
|
totalItems={listCtrl.data.totalItems}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{listCtrl.data.total_items === 0 ? (
|
{listCtrl.data.totalItems === 0 ? (
|
||||||
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||||
{t("recurring_proformas.list.empty")}
|
{t("recurring_proformas.list.empty")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,34 +2,53 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
formatRecurringProformaDateOnly,
|
||||||
formatRecurringProformaSchedule,
|
formatRecurringProformaSchedule,
|
||||||
getRecurringProformaStatusKey,
|
getRecurringProformaStatusKey,
|
||||||
getRecurringProformaListFeedbackState,
|
getRecurringProformaListFeedbackState,
|
||||||
} from "./recurring-proforma-list-utils";
|
} from "./recurring-proforma-list-utils";
|
||||||
|
|
||||||
test("resuelve loading, error, empty y rows para el listado", () => {
|
test("resuelve loading, error, empty y rows para el listado", () => {
|
||||||
assert.equal(getRecurringProformaListFeedbackState({ isLoading: true, isError: false, totalItems: 0 }), "loading");
|
assert.equal(
|
||||||
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: true, totalItems: 0 }), "error");
|
getRecurringProformaListFeedbackState({ isLoading: true, isError: false, totalItems: 0 }),
|
||||||
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 0 }), "empty");
|
"loading"
|
||||||
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 1 }), "ready");
|
);
|
||||||
|
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", () => {
|
test("presenta la recurrencia mensual de una fila sin calcular ocurrencias", () => {
|
||||||
const result = formatRecurringProformaSchedule(
|
const result = formatRecurringProformaSchedule(
|
||||||
{
|
{
|
||||||
start_date: "2026-09-01",
|
startDate: "2026-09-01",
|
||||||
end_date: null,
|
endDate: null,
|
||||||
unit: "month",
|
unit: "month",
|
||||||
interval: 1,
|
interval: 1,
|
||||||
monthly_rule: "same_day",
|
monthlyRule: "same_day",
|
||||||
},
|
},
|
||||||
(key) => ({
|
(key) =>
|
||||||
"recurring_proformas.list.schedule.month": "Cada mes",
|
({
|
||||||
"recurring_proformas.list.schedule.same_day": "Mismo dia",
|
"recurring_proformas.list.schedule.month": "Cada mes",
|
||||||
})[key] ?? key
|
"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", () => {
|
test("resuelve la clave i18n del estado que presenta cada fila", () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
RecurringProformaListItem,
|
RecurringProformaDetail,
|
||||||
|
RecurringProformaListRow,
|
||||||
RecurringProformaStatus,
|
RecurringProformaStatus,
|
||||||
} from "../shared/recurring-proforma-list";
|
} from "../shared/recurring-proforma-list";
|
||||||
|
|
||||||
@ -9,7 +10,7 @@ export const getRecurringProformaStatusKey = (status: RecurringProformaStatus) =
|
|||||||
`recurring_proformas.status.${status}`;
|
`recurring_proformas.status.${status}`;
|
||||||
|
|
||||||
export const formatRecurringProformaSchedule = (
|
export const formatRecurringProformaSchedule = (
|
||||||
schedule: RecurringProformaListItem["schedule"],
|
schedule: RecurringProformaListRow["schedule"] | RecurringProformaDetail["schedule"],
|
||||||
t: Translate
|
t: Translate
|
||||||
) => {
|
) => {
|
||||||
if (!schedule) return t("recurring_proformas.list.schedule.none");
|
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}`, {
|
const cadence = t(`recurring_proformas.list.schedule.${schedule.unit}`, {
|
||||||
count: schedule.interval,
|
count: schedule.interval,
|
||||||
});
|
});
|
||||||
|
const monthlyRule = "monthlyRule" in schedule ? schedule.monthlyRule : schedule.monthly_rule;
|
||||||
const rule =
|
const rule =
|
||||||
schedule.monthly_rule === null
|
monthlyRule === null ? "" : ` · ${t(`recurring_proformas.list.schedule.${monthlyRule}`)}`;
|
||||||
? ""
|
|
||||||
: `, ${t(`recurring_proformas.list.schedule.${schedule.monthly_rule}`)}`;
|
|
||||||
|
|
||||||
return `${cadence}${rule}`;
|
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 = ({
|
export const getRecurringProformaListFeedbackState = ({
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
|||||||
@ -1,12 +1,6 @@
|
|||||||
import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
|
import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import {
|
import { CheckCircle2Icon, CirclePauseIcon, FileTextIcon, PlayCircleIcon } from "lucide-react";
|
||||||
ArchiveIcon,
|
|
||||||
CheckCircle2Icon,
|
|
||||||
CirclePauseIcon,
|
|
||||||
FileTextIcon,
|
|
||||||
PlayCircleIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ElementType } from "react";
|
import type { ElementType } from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
@ -56,13 +50,6 @@ const RECURRING_PROFORMA_STATUS_FILTER_OPTIONS = [
|
|||||||
activeClassName:
|
activeClassName:
|
||||||
"data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700",
|
"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 {
|
] as const satisfies readonly {
|
||||||
value: RecurringProformaStatusFilter;
|
value: RecurringProformaStatusFilter;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
|
|||||||
@ -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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@ -1,13 +1,21 @@
|
|||||||
import type { IDataSource } from "@erp/core/client";
|
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 = (
|
export const listRecurringProformas = (
|
||||||
dataSource: IDataSource,
|
dataSource: IDataSource,
|
||||||
filters: RecurringProformaListFilters,
|
filters: RecurringProformaListFilters,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<RecurringProformaList> =>
|
): Promise<RecurringProformaListDTO> => {
|
||||||
dataSource.getList<RecurringProformaList>("recurring-proformas", {
|
const { scope = "default", ...query } = filters;
|
||||||
...filters,
|
return dataSource.getList<RecurringProformaListDTO>(
|
||||||
signal,
|
scope === "archived" ? "recurring-proformas/archived" : "recurring-proformas",
|
||||||
});
|
{
|
||||||
|
...query,
|
||||||
|
signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -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() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -2,6 +2,7 @@ import { useDataSource } from "@erp/core/hooks";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { listRecurringProformas } from "../api/list-recurring-proformas.api";
|
import { listRecurringProformas } from "../api/list-recurring-proformas.api";
|
||||||
|
import { ListRecurringProformasAdapter } from "../adapters/list-recurring-proformas.adapter";
|
||||||
import type { RecurringProformaListFilters } from "../recurring-proforma-list";
|
import type { RecurringProformaListFilters } from "../recurring-proforma-list";
|
||||||
|
|
||||||
import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys";
|
import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys";
|
||||||
@ -11,7 +12,10 @@ export const useRecurringProformas = (filters: RecurringProformaListFilters) =>
|
|||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: recurringProformaQueryKeys.list(filters),
|
queryKey: recurringProformaQueryKeys.list(filters),
|
||||||
queryFn: ({ signal }) => listRecurringProformas(dataSource, filters, signal),
|
queryFn: async ({ signal }) =>
|
||||||
|
ListRecurringProformasAdapter.fromDto(
|
||||||
|
await listRecurringProformas(dataSource, filters, signal)
|
||||||
|
),
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,21 +1,24 @@
|
|||||||
export type RecurringProformaStatus =
|
export type RecurringProformaStatus = "draft" | "active" | "paused" | "completed" | "archived";
|
||||||
| "draft"
|
|
||||||
| "active"
|
|
||||||
| "paused"
|
|
||||||
| "completed"
|
|
||||||
| "archived";
|
|
||||||
|
|
||||||
export type RecurringProformaListFilters = {
|
export type RecurringProformaListFilters = {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: RecurringProformaStatus;
|
status?: RecurringProformaStatus;
|
||||||
|
orderBy?: "name" | "updated_at";
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
scope?: "default" | "archived";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecurringProformaListItem = {
|
export type RecurringProformaListItemDTO = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
customer_id: string | null;
|
description: string | null;
|
||||||
|
customer: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tin: string | null;
|
||||||
|
} | null;
|
||||||
status: RecurringProformaStatus;
|
status: RecurringProformaStatus;
|
||||||
schedule: {
|
schedule: {
|
||||||
start_date: string;
|
start_date: string;
|
||||||
@ -25,15 +28,46 @@ export type RecurringProformaListItem = {
|
|||||||
monthly_rule: "same_day" | "last_day_of_month" | null;
|
monthly_rule: "same_day" | "last_day_of_month" | null;
|
||||||
} | null;
|
} | null;
|
||||||
next_occurrence_date: string | null;
|
next_occurrence_date: string | null;
|
||||||
|
has_issue: boolean;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecurringProformaList = {
|
export type RecurringProformaListDTO = {
|
||||||
page: number;
|
page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
total_items: number;
|
total_items: number;
|
||||||
total_pages: 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 = {
|
export type RecurringProformaPercentage = {
|
||||||
@ -60,18 +94,14 @@ export type RecurringProformaDetail = {
|
|||||||
proforma_series: string | null;
|
proforma_series: string | null;
|
||||||
issued_invoice_series: string | null;
|
issued_invoice_series: string | null;
|
||||||
global_discount_percentage: RecurringProformaPercentage | null;
|
global_discount_percentage: RecurringProformaPercentage | null;
|
||||||
schedule: RecurringProformaListItem["schedule"];
|
schedule: RecurringProformaListItemDTO["schedule"];
|
||||||
next_occurrence_date: string | null;
|
next_occurrence_date: string | null;
|
||||||
items: RecurringProformaDetailItem[];
|
items: RecurringProformaDetailItem[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecurringProformaOccurrenceStatus =
|
export type RecurringProformaOccurrenceStatus = "pending" | "processing" | "failed" | "generated";
|
||||||
| "pending"
|
|
||||||
| "processing"
|
|
||||||
| "failed"
|
|
||||||
| "generated";
|
|
||||||
|
|
||||||
export type RecurringProformaOccurrencesFilters = {
|
export type RecurringProformaOccurrencesFilters = {
|
||||||
page: number;
|
page: number;
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
export * from "./customer-full-read-model";
|
export * from "./customer-full-read-model";
|
||||||
export * from "./customer-payment-method-read.model";
|
export * from "./customer-payment-method-read.model";
|
||||||
export * from "./customer-payment-term-read.model";
|
export * from "./customer-payment-term-read.model";
|
||||||
|
export * from "./customer-public-summary";
|
||||||
export * from "./customer-summary";
|
export * from "./customer-summary";
|
||||||
export * from "./customer-tax-regime-full-read.model";
|
export * from "./customer-tax-regime-full-read.model";
|
||||||
|
|||||||
@ -3,13 +3,19 @@ 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";
|
||||||
import type { CustomerSummary } from "../models";
|
import type { CustomerPublicSummary, CustomerSummary } from "../models";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interfaz del repositorio para el agregado `Customer`.
|
* Interfaz del repositorio para el agregado `Customer`.
|
||||||
* El escopado multitenant está representado por `companyId`.
|
* El escopado multitenant está representado por `companyId`.
|
||||||
*/
|
*/
|
||||||
export interface ICustomerRepository {
|
export interface ICustomerRepository {
|
||||||
|
findPublicSummariesByIdsInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
ids: readonly UniqueID[],
|
||||||
|
transaction?: unknown
|
||||||
|
): Promise<Result<readonly CustomerPublicSummary[], Error>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Crea un nuevo cliente
|
* Crea un nuevo cliente
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
|
|||||||
import type { Result } from "@repo/rdx-utils";
|
import type { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { Customer, ICustomerCreateProps } from "../../domain";
|
import type { Customer, ICustomerCreateProps } from "../../domain";
|
||||||
|
import type { CustomerPublicSummary } from "../models";
|
||||||
|
|
||||||
export interface ICustomerServicesContext {
|
export interface ICustomerServicesContext {
|
||||||
transaction: unknown;
|
transaction: unknown;
|
||||||
@ -9,6 +10,11 @@ export interface ICustomerServicesContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ICustomerPublicServices {
|
export interface ICustomerPublicServices {
|
||||||
|
findSummariesByIds: (
|
||||||
|
customerIds: readonly UniqueID[],
|
||||||
|
context: ICustomerServicesContext
|
||||||
|
) => Promise<Result<readonly CustomerPublicSummary[], Error>>;
|
||||||
|
|
||||||
findCustomerByTIN: (
|
findCustomerByTIN: (
|
||||||
tin: TINNumber,
|
tin: TINNumber,
|
||||||
context: ICustomerServicesContext
|
context: ICustomerServicesContext
|
||||||
|
|||||||
@ -30,6 +30,14 @@ export function buildCustomerPublicServices(
|
|||||||
const creator = buildCustomerCreator({ repository, audit: params.audit });
|
const creator = buildCustomerCreator({ repository, audit: params.audit });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
findSummariesByIds: async (customerIds, context) => {
|
||||||
|
return repository.findPublicSummariesByIdsInCompany(
|
||||||
|
context.companyId,
|
||||||
|
customerIds,
|
||||||
|
context.transaction
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => {
|
findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => {
|
||||||
const { companyId, transaction } = context;
|
const { companyId, transaction } = context;
|
||||||
|
|
||||||
|
|||||||
@ -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 = <T>(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<string, unknown> } | 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,10 +1,22 @@
|
|||||||
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
||||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
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 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 { Customer } from "../../../../domain";
|
||||||
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers";
|
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers";
|
||||||
import { CustomerModel } from "../models/sequelize-customer.model";
|
import { CustomerModel } from "../models/sequelize-customer.model";
|
||||||
@ -21,6 +33,35 @@ export class CustomerRepository
|
|||||||
super({ database });
|
super({ database });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async findPublicSummariesByIdsInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
ids: readonly UniqueID[],
|
||||||
|
transaction?: unknown
|
||||||
|
): Promise<Result<readonly CustomerPublicSummary[], Error>> {
|
||||||
|
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
|
* Crea un nuevo cliente
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { ICustomerPublicServices } from "./application";
|
|||||||
import { buildCustomerPublicServices, buildCustomersDependencies } from "./infrastructure/di";
|
import { buildCustomerPublicServices, buildCustomersDependencies } from "./infrastructure/di";
|
||||||
import { models } from "./infrastructure/persistence/sequelize";
|
import { models } from "./infrastructure/persistence/sequelize";
|
||||||
|
|
||||||
export type { ICustomerPublicServices } from "./application";
|
export type { CustomerPublicSummary, ICustomerPublicServices } from "./application";
|
||||||
export * from "./infrastructure/persistence/sequelize";
|
export * from "./infrastructure/persistence/sequelize";
|
||||||
|
|
||||||
/** Manifest neutral de clientes: composición y servicio público sin HTTP. */
|
/** Manifest neutral de clientes: composición y servicio público sin HTTP. */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user