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