- CustomerInvoiceNumber es obligatorio
- Proforma ID - Cálculo de siguiente número de factura - Paso de proforma a issue
This commit is contained in:
parent
46e6b01a97
commit
78db3318fc
@ -1,7 +1,12 @@
|
|||||||
import { Criteria } from "@repo/rdx-criteria/server";
|
import { Criteria } from "@repo/rdx-criteria/server";
|
||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Collection, Result } from "@repo/rdx-utils";
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
import { Transaction } from "sequelize";
|
import { Transaction } from "sequelize";
|
||||||
|
import {
|
||||||
|
CustomerInvoiceNumber,
|
||||||
|
CustomerInvoiceSerie,
|
||||||
|
ICustomerInvoiceNumberGenerator,
|
||||||
|
} from "../domain";
|
||||||
import {
|
import {
|
||||||
CustomerInvoice,
|
CustomerInvoice,
|
||||||
CustomerInvoicePatchProps,
|
CustomerInvoicePatchProps,
|
||||||
@ -11,17 +16,50 @@ import { ICustomerInvoiceRepository } from "../domain/repositories";
|
|||||||
import { CustomerInvoiceListDTO } from "../infrastructure";
|
import { CustomerInvoiceListDTO } from "../infrastructure";
|
||||||
|
|
||||||
export class CustomerInvoiceApplicationService {
|
export class CustomerInvoiceApplicationService {
|
||||||
constructor(private readonly repository: ICustomerInvoiceRepository) {}
|
constructor(
|
||||||
|
private readonly repository: ICustomerInvoiceRepository,
|
||||||
|
private readonly numberGenerator: ICustomerInvoiceNumberGenerator
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construye un nuevo agregado CustomerInvoice a partir de props validadas.
|
* Devuelve el siguiente nº para proformas
|
||||||
|
*
|
||||||
|
* @param companyId - Identificador de la empresa a la que pertenece la proforma.
|
||||||
|
* @param transaction - Transacción activa para la operación.
|
||||||
|
* @returns Result<CustomerInvoiceNumber, Error> - El agregado construido o un error si falla la creación.
|
||||||
|
*/
|
||||||
|
async getNextProformaNumber(
|
||||||
|
companyId: UniqueID,
|
||||||
|
transaction: Transaction
|
||||||
|
): Promise<Result<CustomerInvoiceNumber, Error>> {
|
||||||
|
return await this.numberGenerator.nextForCompany(companyId, Maybe.none(), transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve el siguiente nº para facturas (issue)
|
||||||
*
|
*
|
||||||
* @param companyId - Identificador de la empresa a la que pertenece la factura.
|
* @param companyId - Identificador de la empresa a la que pertenece la factura.
|
||||||
* @param props - Las propiedades ya validadas para crear la factura.
|
* @param series - Serie por la que buscar la última factura
|
||||||
* @param invoiceId - Identificador UUID de la factura (opcional).
|
* @param transaction - Transacción activa para la operación.
|
||||||
|
* @returns Result<CustomerInvoiceNumber, Error> - El agregado construido o un error si falla la creación.
|
||||||
|
*/
|
||||||
|
async getNextIssueInvoiceNumber(
|
||||||
|
companyId: UniqueID,
|
||||||
|
series: Maybe<CustomerInvoiceSerie>,
|
||||||
|
transaction: Transaction
|
||||||
|
): Promise<Result<CustomerInvoiceNumber, Error>> {
|
||||||
|
return await this.numberGenerator.nextForCompany(companyId, series, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye una proforma a partir de props validadas.
|
||||||
|
*
|
||||||
|
* @param companyId - Identificador de la empresa a la que pertenece la proforma.
|
||||||
|
* @param props - Las propiedades ya validadas para crear la proforma.
|
||||||
|
* @param invoiceId - Identificador UUID de la proforma (opcional).
|
||||||
* @returns Result<CustomerInvoice, Error> - El agregado construido o un error si falla la creación.
|
* @returns Result<CustomerInvoice, Error> - El agregado construido o un error si falla la creación.
|
||||||
*/
|
*/
|
||||||
buildInvoiceInCompany(
|
buildProformaInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
props: Omit<CustomerInvoiceProps, "companyId">,
|
props: Omit<CustomerInvoiceProps, "companyId">,
|
||||||
invoiceId?: UniqueID
|
invoiceId?: UniqueID
|
||||||
@ -29,6 +67,28 @@ export class CustomerInvoiceApplicationService {
|
|||||||
return CustomerInvoice.create({ ...props, companyId }, invoiceId);
|
return CustomerInvoice.create({ ...props, companyId }, invoiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye una factura issue a partir de una proforma.
|
||||||
|
*
|
||||||
|
* @param companyId - Identificador de la empresa a la que pertenece la factura.
|
||||||
|
* @param issueInvoiceId - Identificador UUID de la factura (opcional).
|
||||||
|
* @param proforma - La proforma de la cual se generará la issue
|
||||||
|
* @param pathcProps - otros props personalizados que se trasladarán a la issue
|
||||||
|
* @returns Result<CustomerInvoice, Error> - El agregado construido o un error si falla la creación.
|
||||||
|
*/
|
||||||
|
buildIssueInvoiceInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
proforma: CustomerInvoice,
|
||||||
|
patchProps: CustomerInvoicePatchProps
|
||||||
|
): Result<CustomerInvoice, Error> {
|
||||||
|
const proformaProps = proforma.getIssuedInvoiceProps();
|
||||||
|
return CustomerInvoice.create({
|
||||||
|
...proformaProps,
|
||||||
|
...patchProps,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guarda una nueva factura y devuelve la factura guardada.
|
* Guarda una nueva factura y devuelve la factura guardada.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export class CustomerInvoiceFullPresenter extends Presenter<
|
|||||||
id: invoice.id.toString(),
|
id: invoice.id.toString(),
|
||||||
company_id: invoice.companyId.toString(),
|
company_id: invoice.companyId.toString(),
|
||||||
|
|
||||||
invoice_number: toEmptyString(invoice.invoiceNumber, (value) => value.toString()),
|
invoice_number: invoice.invoiceNumber.toString(),
|
||||||
status: invoice.status.toPrimitive(),
|
status: invoice.status.toPrimitive(),
|
||||||
series: toEmptyString(invoice.series, (value) => value.toString()),
|
series: toEmptyString(invoice.series, (value) => value.toString()),
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export class ListCustomerInvoicesPresenter extends Presenter {
|
|||||||
is_proforma: invoice.isProforma,
|
is_proforma: invoice.isProforma,
|
||||||
customer_id: invoice.customerId.toString(),
|
customer_id: invoice.customerId.toString(),
|
||||||
|
|
||||||
invoice_number: toEmptyString(invoice.invoiceNumber, (value) => value.toString()),
|
invoice_number: invoice.invoiceNumber.toString(),
|
||||||
status: invoice.status.toPrimitive(),
|
status: invoice.status.toPrimitive(),
|
||||||
series: toEmptyString(invoice.series, (value) => value.toString()),
|
series: toEmptyString(invoice.series, (value) => value.toString()),
|
||||||
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export * from "./status-invoice_is_approved.spec";
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { JsonTaxCatalogProvider } from "@erp/core";
|
import { JsonTaxCatalogProvider } from "@erp/core";
|
||||||
import { DuplicateEntityError, IPresenterRegistry, ITransactionManager } from "@erp/core/api";
|
import { DuplicateEntityError, IPresenterRegistry, ITransactionManager } from "@erp/core/api";
|
||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
import { Transaction } from "sequelize";
|
import { Transaction } from "sequelize";
|
||||||
import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto";
|
import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto";
|
||||||
import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service";
|
import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service";
|
||||||
@ -21,7 +21,7 @@ export class CreateCustomerInvoiceUseCase {
|
|||||||
private readonly taxCatalog: JsonTaxCatalogProvider
|
private readonly taxCatalog: JsonTaxCatalogProvider
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public execute(params: CreateCustomerInvoiceUseCaseInput) {
|
public async execute(params: CreateCustomerInvoiceUseCaseInput) {
|
||||||
const { dto, companyId } = params;
|
const { dto, companyId } = params;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "customer-invoice",
|
||||||
@ -37,17 +37,30 @@ export class CreateCustomerInvoiceUseCase {
|
|||||||
|
|
||||||
const { props, id } = dtoResult.data;
|
const { props, id } = dtoResult.data;
|
||||||
|
|
||||||
// 2) Construir entidad de dominio
|
// 3) Ejecutar bajo transacción: verificar duplicado → persistir → ensamblar vista
|
||||||
const buildResult = this.service.buildInvoiceInCompany(companyId, props, id);
|
return this.transactionManager.complete(async (transaction: Transaction) => {
|
||||||
|
try {
|
||||||
|
// 2) Generar nuevo nº de proforma
|
||||||
|
const nextNumberResult = await this.service.getNextProformaNumber(companyId, transaction);
|
||||||
|
if (nextNumberResult.isFailure) {
|
||||||
|
return Result.fail(nextNumberResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProformaNumber = nextNumberResult.data;
|
||||||
|
|
||||||
|
// 3) Construir entidad de dominio
|
||||||
|
const proformaProps = {
|
||||||
|
...props,
|
||||||
|
invoiceNumber: Maybe.some(newProformaNumber),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildResult = this.service.buildProformaInCompany(companyId, proformaProps, id);
|
||||||
if (buildResult.isFailure) {
|
if (buildResult.isFailure) {
|
||||||
return Result.fail(buildResult.error);
|
return Result.fail(buildResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newInvoice = buildResult.data;
|
const newInvoice = buildResult.data;
|
||||||
|
|
||||||
// 3) Ejecutar bajo transacción: verificar duplicado → persistir → ensamblar vista
|
|
||||||
return this.transactionManager.complete(async (transaction: Transaction) => {
|
|
||||||
try {
|
|
||||||
const existsGuard = await this.ensureNotExists(companyId, id, transaction);
|
const existsGuard = await this.ensureNotExists(companyId, id, transaction);
|
||||||
if (existsGuard.isFailure) {
|
if (existsGuard.isFailure) {
|
||||||
return Result.fail(existsGuard.error);
|
return Result.fail(existsGuard.error);
|
||||||
|
|||||||
@ -1,15 +1,24 @@
|
|||||||
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
|
import { ITransactionManager } from "@erp/core/api";
|
||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
import { CustomerInvoiceNumber } from "../../domain";
|
import { InvalidProformaStatusError } from "../../domain";
|
||||||
|
import { StatusInvoiceIsApprovedSpecification } from "../../domain/specs";
|
||||||
import { CustomerInvoiceApplicationService } from "../customer-invoice-application.service";
|
import { CustomerInvoiceApplicationService } from "../customer-invoice-application.service";
|
||||||
import { StatusInvoiceIsApprovedSpecification } from "../specs";
|
|
||||||
|
|
||||||
type IssueCustomerInvoiceUseCaseInput = {
|
type IssueCustomerInvoiceUseCaseInput = {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
invoice_id: string;
|
proforma_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caso de uso: Conversión de una proforma a factura definitiva.
|
||||||
|
*
|
||||||
|
* - Recupera la proforma
|
||||||
|
* - Valida su estado ("approved")
|
||||||
|
* - Genera la factura definitiva (nueva entidad)
|
||||||
|
* - Marca la proforma como "issued"
|
||||||
|
* - Persiste ambas dentro de la misma transacción
|
||||||
|
*/
|
||||||
export class IssueCustomerInvoiceUseCase {
|
export class IssueCustomerInvoiceUseCase {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly service: CustomerInvoiceApplicationService,
|
private readonly service: CustomerInvoiceApplicationService,
|
||||||
@ -17,56 +26,78 @@ export class IssueCustomerInvoiceUseCase {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public execute(params: IssueCustomerInvoiceUseCaseInput) {
|
public execute(params: IssueCustomerInvoiceUseCaseInput) {
|
||||||
const { invoice_id, companyId } = params;
|
const { proforma_id, companyId } = params;
|
||||||
|
|
||||||
const idOrError = UniqueID.create(invoice_id);
|
const idOrError = UniqueID.create(proforma_id);
|
||||||
|
|
||||||
if (idOrError.isFailure) {
|
if (idOrError.isFailure) {
|
||||||
return Result.fail(idOrError.error);
|
return Result.fail(idOrError.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceId = idOrError.data;
|
const proformaId = idOrError.data;
|
||||||
|
|
||||||
return this.transactionManager.complete(async (transaction) => {
|
return this.transactionManager.complete(async (transaction) => {
|
||||||
try {
|
try {
|
||||||
const invoiceResult = await this.service.getInvoiceByIdInCompany(
|
/** 1. Recuperamos la proforma */
|
||||||
|
const proformaResult = await this.service.getInvoiceByIdInCompany(
|
||||||
companyId,
|
companyId,
|
||||||
invoiceId,
|
proformaId,
|
||||||
transaction
|
transaction
|
||||||
);
|
);
|
||||||
|
|
||||||
if (invoiceResult.isFailure) {
|
if (proformaResult.isFailure) {
|
||||||
return Result.fail(invoiceResult.error);
|
return Result.fail(proformaResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceProforma = invoiceResult.data;
|
const proforma = proformaResult.data;
|
||||||
|
|
||||||
const isOk = new StatusInvoiceIsApprovedSpecification().isSatisfiedBy(invoiceProforma);
|
/** 2. Comprobamos que la proforma origen está aprovada para generar la factura */
|
||||||
|
const isApprovedSpec = new StatusInvoiceIsApprovedSpecification();
|
||||||
|
if (!(await isApprovedSpec.isSatisfiedBy(proforma))) {
|
||||||
|
return Result.fail(new InvalidProformaStatusError(proformaId.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
if (!isOk) {
|
/** 3. Generar nueva factura */
|
||||||
return Result.fail(
|
const nextNumberResult = await this.service.getNextIssueInvoiceNumber(
|
||||||
new EntityNotFoundError("Customer invoice", "id", invoiceId.toString())
|
companyId,
|
||||||
|
proforma.series,
|
||||||
|
transaction
|
||||||
);
|
);
|
||||||
|
if (nextNumberResult.isFailure) {
|
||||||
|
return Result.fail(nextNumberResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// La factura se puede emitir.
|
const newIssueNumber = nextNumberResult.data;
|
||||||
// Pedir el número de factura
|
|
||||||
const newInvoiceNumber = CustomerInvoiceNumber.create("xxx/001").data;
|
|
||||||
|
|
||||||
// Asignamos el número de la factura
|
// props base obtenidas del agregado proforma
|
||||||
|
const issuedInvoiceOrError = this.service.buildIssueInvoiceInCompany(companyId, proforma, {
|
||||||
|
invoiceNumber: Maybe.some(newIssueNumber),
|
||||||
|
invoiceDate: UtcDate.today(),
|
||||||
|
});
|
||||||
|
|
||||||
const issuedInvoiceResult = invoiceProforma.issueInvoice(newInvoiceNumber);
|
if (issuedInvoiceOrError.isFailure) {
|
||||||
if (issuedInvoiceResult.isFailure) {
|
return Result.fail(issuedInvoiceOrError.error);
|
||||||
return Result.fail(
|
}
|
||||||
new EntityNotFoundError("Customer invoice", "id", issuedInvoiceResult.error)
|
|
||||||
|
const issuedInvoice = issuedInvoiceOrError.data;
|
||||||
|
|
||||||
|
/** 4. Persistencia */
|
||||||
|
await this.service.createInvoiceInCompany(companyId, issuedInvoice, transaction);
|
||||||
|
|
||||||
|
// actualizamos la proforma
|
||||||
|
const updatedProformaResult = proforma.asIssued();
|
||||||
|
if (updatedProformaResult.isFailure) {
|
||||||
|
return Result.fail(updatedProformaResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.service.updateInvoiceInCompany(
|
||||||
|
companyId,
|
||||||
|
updatedProformaResult.data,
|
||||||
|
transaction
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const issuedInvoice = issuedInvoiceResult.data;
|
/** 5. Resultado */
|
||||||
|
return Result.ok(issuedInvoice);
|
||||||
this.service.updateInvoiceInCompany(companyId, issuedInvoice, transaction);
|
|
||||||
|
|
||||||
//return await this.service.IssueInvoiceByIdInCompany(companyId, invoiceId, transaction);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return Result.fail(error as Error);
|
return Result.fail(error as Error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,7 +175,7 @@
|
|||||||
<td>{{description}}</td>
|
<td>{{description}}</td>
|
||||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||||
<td class="text-right">{{#if subtotal_amount}}{{subtotal_amount}}{{else}} {{/if}}</td>
|
<td class="text-right">{{#if total_amount}}{{total_amount}}{{else}} {{/if}}</td>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
@ -24,8 +24,10 @@ export interface CustomerInvoiceProps {
|
|||||||
isProforma: boolean;
|
isProforma: boolean;
|
||||||
status: CustomerInvoiceStatus;
|
status: CustomerInvoiceStatus;
|
||||||
|
|
||||||
|
proformaId: Maybe<UniqueID>;
|
||||||
|
|
||||||
series: Maybe<CustomerInvoiceSerie>;
|
series: Maybe<CustomerInvoiceSerie>;
|
||||||
invoiceNumber: Maybe<CustomerInvoiceNumber>;
|
invoiceNumber: CustomerInvoiceNumber;
|
||||||
|
|
||||||
invoiceDate: UtcDate;
|
invoiceDate: UtcDate;
|
||||||
operationDate: Maybe<UtcDate>;
|
operationDate: Maybe<UtcDate>;
|
||||||
@ -70,7 +72,9 @@ export interface ICustomerInvoice {
|
|||||||
|
|
||||||
getTaxes(): InvoiceTaxTotal[];
|
getTaxes(): InvoiceTaxTotal[];
|
||||||
|
|
||||||
issueInvoice(newInvoiceNumber: CustomerInvoiceNumber): Result<CustomerInvoice, Error>;
|
asIssued(): Result<CustomerInvoice, Error>;
|
||||||
|
|
||||||
|
getIssuedInvoiceProps(): CustomerInvoiceProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CustomerInvoice
|
export class CustomerInvoice
|
||||||
@ -143,6 +147,10 @@ export class CustomerInvoice
|
|||||||
return this.props.isProforma;
|
return this.props.isProforma;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get proformaId(): Maybe<UniqueID> {
|
||||||
|
return this.props.proformaId;
|
||||||
|
}
|
||||||
|
|
||||||
public get status(): CustomerInvoiceStatus {
|
public get status(): CustomerInvoiceStatus {
|
||||||
return this.props.status;
|
return this.props.status;
|
||||||
}
|
}
|
||||||
@ -301,15 +309,21 @@ export class CustomerInvoice
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public issueInvoice(newInvoiceNumber: CustomerInvoiceNumber) {
|
public asIssued(): Result<CustomerInvoice, Error> {
|
||||||
return CustomerInvoice.create(
|
const newProps: CustomerInvoiceProps = {
|
||||||
{
|
|
||||||
...this.props,
|
...this.props,
|
||||||
status: CustomerInvoiceStatus.createIssued(),
|
status: CustomerInvoiceStatus.createIssued(),
|
||||||
|
};
|
||||||
|
return CustomerInvoice.create(newProps, this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getIssuedInvoiceProps(): CustomerInvoiceProps {
|
||||||
|
return {
|
||||||
|
...this.props,
|
||||||
isProforma: false,
|
isProforma: false,
|
||||||
invoiceNumber: Maybe.some(newInvoiceNumber),
|
proformaId: Maybe.some(this.id),
|
||||||
},
|
status: CustomerInvoiceStatus.createIssued(),
|
||||||
this.id
|
invoiceDate: UtcDate.today(),
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
import { CurrencyCode, LanguageCode, MoneyValue, Percentage, Quantity } from "@repo/rdx-ddd";
|
|
||||||
import { CustomerInvoiceItemDescription } from "../../value-objects";
|
|
||||||
import { CustomerInvoiceItem } from "./customer-invoice-item";
|
|
||||||
|
|
||||||
describe("CustomerInvoiceItem", () => {
|
|
||||||
it("debería calcular correctamente el subtotal (unitPrice * quantity)", () => {
|
|
||||||
const props = {
|
|
||||||
description: CustomerInvoiceItemDescription.create("Producto A"),
|
|
||||||
quantity: Quantity.create({ amount: 200, scale: 2 }),
|
|
||||||
unitPrice: MoneyValue.create(50),
|
|
||||||
discount: Percentage.create(0),
|
|
||||||
languageCode: LanguageCode.create("es"),
|
|
||||||
currencyCode: CurrencyCode.create("EUR"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = CustomerInvoiceItem.create(props);
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
const customerInvoiceItem = result.unwrap();
|
|
||||||
expect(customerInvoiceItem.subtotalPrice.value).toBe(100); // 50 * 2
|
|
||||||
});
|
|
||||||
|
|
||||||
it("debería calcular correctamente el total con descuento", () => {
|
|
||||||
const props = {
|
|
||||||
description: new CustomerInvoiceItemDescription("Producto B"),
|
|
||||||
quantity: new Quantity(3),
|
|
||||||
unitPrice: new MoneyValue(30),
|
|
||||||
discount: new Percentage(10), // 10%
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = CustomerInvoiceItem.create(props);
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
const customerInvoiceItem = result.unwrap();
|
|
||||||
expect(customerInvoiceItem.totalPrice.value).toBe(81); // (30 * 3) - 10% de (30 * 3)
|
|
||||||
});
|
|
||||||
|
|
||||||
it("debería devolver los valores correctos de las propiedades", () => {
|
|
||||||
const props = {
|
|
||||||
description: new CustomerInvoiceItemDescription("Producto C"),
|
|
||||||
quantity: new Quantity(1),
|
|
||||||
unitPrice: new MoneyValue(100),
|
|
||||||
discount: new Percentage(5),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = CustomerInvoiceItem.create(props);
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
const customerInvoiceItem = result.unwrap();
|
|
||||||
expect(customerInvoiceItem.description.value).toBe("Producto C");
|
|
||||||
expect(customerInvoiceItem.quantity.value).toBe(1);
|
|
||||||
expect(customerInvoiceItem.unitPrice.value).toBe(100);
|
|
||||||
expect(customerInvoiceItem.discount.value).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("debería manejar correctamente un descuento del 0%", () => {
|
|
||||||
const props = {
|
|
||||||
description: new CustomerInvoiceItemDescription("Producto D"),
|
|
||||||
quantity: new Quantity(4),
|
|
||||||
unitPrice: new MoneyValue(25),
|
|
||||||
discount: new Percentage(0),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = CustomerInvoiceItem.create(props);
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
const customerInvoiceItem = result.unwrap();
|
|
||||||
expect(customerInvoiceItem.totalPrice.value).toBe(100); // 25 * 4
|
|
||||||
});
|
|
||||||
|
|
||||||
it("debería manejar correctamente un descuento del 100%", () => {
|
|
||||||
const props = {
|
|
||||||
description: new CustomerInvoiceItemDescription("Producto E"),
|
|
||||||
quantity: new Quantity(2),
|
|
||||||
unitPrice: new MoneyValue(50),
|
|
||||||
discount: new Percentage(100),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = CustomerInvoiceItem.create(props);
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
const customerInvoiceItem = result.unwrap();
|
|
||||||
expect(customerInvoiceItem.totalPrice.value).toBe(0); // (50 * 2) - 100% de (50 * 2)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from "./customer-invoice-id-already-exits-error";
|
export * from "./customer-invoice-id-already-exits-error";
|
||||||
|
export * from "./invalid-proforma-status-error";
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { DomainError } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
|
export class InvalidProformaStatusError extends DomainError {
|
||||||
|
constructor(id: string, options?: ErrorOptions) {
|
||||||
|
super(`Error. Proforma with id '${id}' has invalid status.`, options);
|
||||||
|
this.name = "InvalidProformaStatusError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isInvalidProformaStatusError = (e: unknown): e is InvalidProformaStatusError =>
|
||||||
|
e instanceof InvalidProformaStatusError;
|
||||||
@ -2,4 +2,6 @@ export * from "./aggregates";
|
|||||||
export * from "./entities";
|
export * from "./entities";
|
||||||
export * from "./errors";
|
export * from "./errors";
|
||||||
export * from "./repositories";
|
export * from "./repositories";
|
||||||
|
export * from "./services";
|
||||||
|
export * from "./specs";
|
||||||
export * from "./value-objects";
|
export * from "./value-objects";
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
import { CustomerInvoiceNumber, CustomerInvoiceSerie } from "../value-objects";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio de dominio que define cómo se genera el siguiente número de factura.
|
||||||
|
*/
|
||||||
|
export interface ICustomerInvoiceNumberGenerator {
|
||||||
|
/**
|
||||||
|
* Devuelve el siguiente número de factura disponible para una empresa dentro de una "serie" de factura.
|
||||||
|
*
|
||||||
|
* @param companyId - Identificador de la empresa
|
||||||
|
* @param serie - Serie por la que buscar la última factura
|
||||||
|
* @param transaction - Transacción activa
|
||||||
|
*/
|
||||||
|
nextForCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
series: Maybe<CustomerInvoiceSerie>,
|
||||||
|
transaction: any
|
||||||
|
): Promise<Result<CustomerInvoiceNumber, Error>>;
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./customer-invoice-number-generator.interface";
|
||||||
1
modules/customer-invoices/src/api/domain/specs/index.ts
Normal file
1
modules/customer-invoices/src/api/domain/specs/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./status-invoice-is-approved.specification";
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { CompositeSpecification } from "@repo/rdx-ddd";
|
import { CompositeSpecification } from "@repo/rdx-ddd";
|
||||||
import { CustomerInvoice } from "../../domain";
|
import { CustomerInvoice } from "../aggregates";
|
||||||
|
|
||||||
export class StatusInvoiceIsApprovedSpecification extends CompositeSpecification<CustomerInvoice> {
|
export class StatusInvoiceIsApprovedSpecification extends CompositeSpecification<CustomerInvoice> {
|
||||||
public async isSatisfiedBy(invoice: CustomerInvoice): Promise<boolean> {
|
public async isSatisfiedBy(invoice: CustomerInvoice): Promise<boolean> {
|
||||||
@ -7,7 +7,7 @@ interface ICustomerInvoiceNumberProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CustomerInvoiceNumber extends ValueObject<ICustomerInvoiceNumberProps> {
|
export class CustomerInvoiceNumber extends ValueObject<ICustomerInvoiceNumberProps> {
|
||||||
private static readonly MAX_LENGTH = 255;
|
private static readonly MAX_LENGTH = 12;
|
||||||
private static readonly FIELD = "invoiceNumber";
|
private static readonly FIELD = "invoiceNumber";
|
||||||
private static readonly ERROR_CODE = "INVALID_INVOICE_NUMBER";
|
private static readonly ERROR_CODE = "INVALID_INVOICE_NUMBER";
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ interface ICustomerInvoiceSerieProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CustomerInvoiceSerie extends ValueObject<ICustomerInvoiceSerieProps> {
|
export class CustomerInvoiceSerie extends ValueObject<ICustomerInvoiceSerieProps> {
|
||||||
private static readonly MAX_LENGTH = 255;
|
private static readonly MAX_LENGTH = 10;
|
||||||
private static readonly FIELD = "invoiceSeries";
|
private static readonly FIELD = "invoiceSeries";
|
||||||
private static readonly ERROR_CODE = "INVALID_INVOICE_SERIE";
|
private static readonly ERROR_CODE = "INVALID_INVOICE_SERIE";
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,9 @@ export enum INVOICE_STATUS {
|
|||||||
APPROVED = "approved", // <- Proforma
|
APPROVED = "approved", // <- Proforma
|
||||||
REJECTED = "rejected", // <- Proforma
|
REJECTED = "rejected", // <- Proforma
|
||||||
|
|
||||||
// issued <- (si is_proforma === true) => Es una proforma (histórica)
|
// status === issued <- (si is_proforma === true) => Es una proforma (histórica)
|
||||||
// issued <- (si is_proforma === false) => Factura y enviada a Veri*Factu
|
// status === issued <- (si is_proforma === false) => Factura y enviará/enviada a Veri*Factu
|
||||||
|
|
||||||
ISSUED = "issued",
|
ISSUED = "issued",
|
||||||
}
|
}
|
||||||
export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusProps> {
|
export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusProps> {
|
||||||
|
|||||||
@ -1,35 +1,32 @@
|
|||||||
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
|
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
|
||||||
|
|
||||||
|
import { JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core";
|
||||||
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
|
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InMemoryMapperRegistry,
|
InMemoryMapperRegistry,
|
||||||
InMemoryPresenterRegistry,
|
InMemoryPresenterRegistry,
|
||||||
SequelizeTransactionManager,
|
SequelizeTransactionManager,
|
||||||
} from "@erp/core/api";
|
} from "@erp/core/api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateCustomerInvoiceUseCase,
|
CreateCustomerInvoiceUseCase,
|
||||||
|
CustomerInvoiceApplicationService,
|
||||||
CustomerInvoiceFullPresenter,
|
CustomerInvoiceFullPresenter,
|
||||||
CustomerInvoiceItemsFullPresenter,
|
CustomerInvoiceItemsFullPresenter,
|
||||||
|
CustomerInvoiceItemsReportPersenter,
|
||||||
CustomerInvoiceReportHTMLPresenter,
|
CustomerInvoiceReportHTMLPresenter,
|
||||||
CustomerInvoiceReportPDFPresenter,
|
CustomerInvoiceReportPDFPresenter,
|
||||||
CustomerInvoiceReportPresenter,
|
CustomerInvoiceReportPresenter,
|
||||||
GetCustomerInvoiceUseCase,
|
GetCustomerInvoiceUseCase,
|
||||||
|
IssueCustomerInvoiceUseCase,
|
||||||
ListCustomerInvoicesPresenter,
|
ListCustomerInvoicesPresenter,
|
||||||
ListCustomerInvoicesUseCase,
|
ListCustomerInvoicesUseCase,
|
||||||
RecipientInvoiceFullPresenter,
|
RecipientInvoiceFullPresenter,
|
||||||
ReportCustomerInvoiceUseCase,
|
ReportCustomerInvoiceUseCase,
|
||||||
UpdateCustomerInvoiceUseCase,
|
UpdateCustomerInvoiceUseCase,
|
||||||
} from "../application";
|
} from "../application";
|
||||||
|
|
||||||
import { JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core";
|
|
||||||
import {
|
|
||||||
CustomerInvoiceApplicationService,
|
|
||||||
CustomerInvoiceItemsReportPersenter,
|
|
||||||
} from "../application";
|
|
||||||
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
|
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
|
||||||
import { CustomerInvoiceRepository } from "./sequelize";
|
import { CustomerInvoiceRepository } from "./sequelize";
|
||||||
|
import { SequelizeInvoiceNumberGenerator } from "./services";
|
||||||
|
|
||||||
export type CustomerInvoiceDeps = {
|
export type CustomerInvoiceDeps = {
|
||||||
transactionManager: SequelizeTransactionManager;
|
transactionManager: SequelizeTransactionManager;
|
||||||
@ -47,6 +44,7 @@ export type CustomerInvoiceDeps = {
|
|||||||
update: () => UpdateCustomerInvoiceUseCase;
|
update: () => UpdateCustomerInvoiceUseCase;
|
||||||
//delete: () => DeleteCustomerInvoiceUseCase;
|
//delete: () => DeleteCustomerInvoiceUseCase;
|
||||||
report: () => ReportCustomerInvoiceUseCase;
|
report: () => ReportCustomerInvoiceUseCase;
|
||||||
|
issue: () => IssueCustomerInvoiceUseCase;
|
||||||
};
|
};
|
||||||
getService: (name: string) => any;
|
getService: (name: string) => any;
|
||||||
listServices: () => string[];
|
listServices: () => string[];
|
||||||
@ -73,7 +71,8 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
|
|||||||
|
|
||||||
// Repository & Services
|
// Repository & Services
|
||||||
const repo = new CustomerInvoiceRepository({ mapperRegistry, database });
|
const repo = new CustomerInvoiceRepository({ mapperRegistry, database });
|
||||||
const service = new CustomerInvoiceApplicationService(repo);
|
const numberGenerator = new SequelizeInvoiceNumberGenerator();
|
||||||
|
const service = new CustomerInvoiceApplicationService(repo, numberGenerator);
|
||||||
|
|
||||||
// Presenter Registry
|
// Presenter Registry
|
||||||
const presenterRegistry = new InMemoryPresenterRegistry();
|
const presenterRegistry = new InMemoryPresenterRegistry();
|
||||||
@ -162,6 +161,7 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
|
|||||||
// delete: () => new DeleteCustomerInvoiceUseCase(service, transactionManager),
|
// delete: () => new DeleteCustomerInvoiceUseCase(service, transactionManager),
|
||||||
report: () =>
|
report: () =>
|
||||||
new ReportCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
|
new ReportCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
|
||||||
|
issue: () => new IssueCustomerInvoiceUseCase(service, transactionManager),
|
||||||
},
|
},
|
||||||
listServices,
|
listServices,
|
||||||
getService,
|
getService,
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||||
import { IssueCustomerInvoiceUseCase } from "../../../application";
|
import { IssueCustomerInvoiceUseCase } from "../../../application";
|
||||||
|
|
||||||
export class IssueCustomerInvoiceController extends ExpressController {
|
export class IssueCustomerInvoiceController extends ExpressController {
|
||||||
public constructor(
|
public constructor(private readonly useCase: IssueCustomerInvoiceUseCase) {
|
||||||
private readonly useCase: IssueCustomerInvoiceUseCase
|
|
||||||
/* private readonly presenter: any */
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeImpl() {
|
async executeImpl(): Promise<any> {
|
||||||
const tenantId = this.getTenantId()!; // garantizado por tenantGuard
|
const companyId = this.getTenantId(); // garantizado por tenantGuard
|
||||||
const { id } = this.req.params;
|
if (!companyId) {
|
||||||
|
return this.forbiddenError("Tenant ID not found");
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.useCase.execute({ id, tenantId });
|
const { proforma_id } = this.req.params;
|
||||||
|
if (!proforma_id) {
|
||||||
|
return this.invalidInputError("Proforma ID missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.useCase.execute({ proforma_id, companyId });
|
||||||
|
|
||||||
return result.match(
|
return result.match(
|
||||||
(data) => this.ok(data),
|
(data) => this.ok(data),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||||
import { UpdateCustomerInvoiceByIdRequestDTO } from "../../../../common/dto";
|
import { UpdateCustomerInvoiceByIdRequestDTO } from "../../../../common/dto";
|
||||||
import { UpdateCustomerInvoiceUseCase } from "../../../application";
|
import { UpdateCustomerInvoiceUseCase } from "../../../application";
|
||||||
|
|
||||||
@ -14,10 +14,15 @@ export class UpdateCustomerInvoiceController extends ExpressController {
|
|||||||
if (!companyId) {
|
if (!companyId) {
|
||||||
return this.forbiddenError("Tenant ID not found");
|
return this.forbiddenError("Tenant ID not found");
|
||||||
}
|
}
|
||||||
const { invoice_id } = this.req.params;
|
|
||||||
|
const { proforma_id } = this.req.params;
|
||||||
|
if (!proforma_id) {
|
||||||
|
return this.invalidInputError("Proforma ID missing");
|
||||||
|
}
|
||||||
|
|
||||||
const dto = this.req.body as UpdateCustomerInvoiceByIdRequestDTO;
|
const dto = this.req.body as UpdateCustomerInvoiceByIdRequestDTO;
|
||||||
|
|
||||||
const result = await this.useCase.execute({ invoice_id, companyId, dto });
|
const result = await this.useCase.execute({ invoice_id: proforma_id, companyId, dto });
|
||||||
|
|
||||||
return result.match(
|
return result.match(
|
||||||
(data) => this.ok(data),
|
(data) => this.ok(data),
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
ReportCustomerInvoiceController,
|
ReportCustomerInvoiceController,
|
||||||
UpdateCustomerInvoiceController,
|
UpdateCustomerInvoiceController,
|
||||||
} from "./controllers";
|
} from "./controllers";
|
||||||
|
import { IssueCustomerInvoiceController } from "./controllers/issue-customer-invoice.controller";
|
||||||
|
|
||||||
export const customerInvoicesRouter = (params: ModuleParams) => {
|
export const customerInvoicesRouter = (params: ModuleParams) => {
|
||||||
const { app, baseRoutePath, logger } = params as {
|
const { app, baseRoutePath, logger } = params as {
|
||||||
@ -84,7 +85,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
"/:invoice_id",
|
"/:proforma_id",
|
||||||
//checkTabContext,
|
//checkTabContext,
|
||||||
|
|
||||||
validateRequest(UpdateCustomerInvoiceByIdParamsRequestSchema, "params"),
|
validateRequest(UpdateCustomerInvoiceByIdParamsRequestSchema, "params"),
|
||||||
@ -117,18 +118,21 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
|
|||||||
const controller = new ReportCustomerInvoiceController(useCase);
|
const controller = new ReportCustomerInvoiceController(useCase);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
); /*router.put(
|
);
|
||||||
"/:invoice_id/issue",
|
|
||||||
|
router.put(
|
||||||
|
"/:proforma_id/issue",
|
||||||
//checkTabContext,
|
//checkTabContext,
|
||||||
validateRequest(XXX, "params"),
|
|
||||||
validateRequest(XXX, "body"),
|
/*validateRequest(XXX, "params"),
|
||||||
|
validateRequest(XXX, "body"),*/
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const useCase = deps.build.issue();
|
const useCase = deps.build.issue();
|
||||||
const controller = new IssueCustomerInvoiceController(useCase);
|
const controller = new IssueCustomerInvoiceController(useCase);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
*/
|
|
||||||
|
|
||||||
app.use(`${baseRoutePath}/customer-invoices`, router);
|
app.use(`${baseRoutePath}/customer-invoices`, router);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -70,6 +70,12 @@ export class CustomerInvoiceDomainMapper
|
|||||||
|
|
||||||
const isProforma = Boolean(source.is_proforma);
|
const isProforma = Boolean(source.is_proforma);
|
||||||
|
|
||||||
|
const proformaId = extractOrPushError(
|
||||||
|
maybeFromNullableVO(source.proforma_id, (v) => UniqueID.create(v)),
|
||||||
|
"proforma_id",
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
|
||||||
const status = extractOrPushError(
|
const status = extractOrPushError(
|
||||||
CustomerInvoiceStatus.create(source.status),
|
CustomerInvoiceStatus.create(source.status),
|
||||||
"status",
|
"status",
|
||||||
@ -83,7 +89,7 @@ export class CustomerInvoiceDomainMapper
|
|||||||
);
|
);
|
||||||
|
|
||||||
const invoiceNumber = extractOrPushError(
|
const invoiceNumber = extractOrPushError(
|
||||||
maybeFromNullableVO(source.invoice_number, (v) => CustomerInvoiceNumber.create(v)),
|
CustomerInvoiceNumber.create(source.invoice_number),
|
||||||
"invoice_number",
|
"invoice_number",
|
||||||
errors
|
errors
|
||||||
);
|
);
|
||||||
@ -172,6 +178,7 @@ export class CustomerInvoiceDomainMapper
|
|||||||
companyId,
|
companyId,
|
||||||
customerId,
|
customerId,
|
||||||
isProforma,
|
isProforma,
|
||||||
|
proformaId,
|
||||||
status,
|
status,
|
||||||
series,
|
series,
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
@ -204,13 +211,13 @@ export class CustomerInvoiceDomainMapper
|
|||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (recipientResult.isFailure) {
|
/*if (recipientResult.isFailure) {
|
||||||
errors.push({
|
errors.push({
|
||||||
path: "recipient",
|
path: "recipient",
|
||||||
|
|
||||||
message: recipientResult.error.message,
|
message: recipientResult.error.message,
|
||||||
});
|
});
|
||||||
}
|
}*/
|
||||||
|
|
||||||
// 3) Items (colección)
|
// 3) Items (colección)
|
||||||
const itemsResults = this._itemsMapper.mapToDomainCollection(
|
const itemsResults = this._itemsMapper.mapToDomainCollection(
|
||||||
@ -223,12 +230,12 @@ export class CustomerInvoiceDomainMapper
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (itemsResults.isFailure) {
|
/*if (itemsResults.isFailure) {
|
||||||
errors.push({
|
errors.push({
|
||||||
path: "items",
|
path: "items",
|
||||||
message: itemsResults.error.message,
|
message: itemsResults.error.message,
|
||||||
});
|
});
|
||||||
}
|
}*/
|
||||||
|
|
||||||
// Nota: los impuestos a nivel factura (tabla customer_invoice_taxes) se derivan de los items.
|
// Nota: los impuestos a nivel factura (tabla customer_invoice_taxes) se derivan de los items.
|
||||||
// El agregado expone un getter `taxes` (derivado). No se incluye en las props.
|
// El agregado expone un getter `taxes` (derivado). No se incluye en las props.
|
||||||
@ -254,6 +261,7 @@ export class CustomerInvoiceDomainMapper
|
|||||||
companyId: attributes.companyId!,
|
companyId: attributes.companyId!,
|
||||||
|
|
||||||
isProforma: attributes.isProforma,
|
isProforma: attributes.isProforma,
|
||||||
|
proformaId: attributes.proformaId!,
|
||||||
status: attributes.status!,
|
status: attributes.status!,
|
||||||
series: attributes.series!,
|
series: attributes.series!,
|
||||||
invoiceNumber: attributes.invoiceNumber!,
|
invoiceNumber: attributes.invoiceNumber!,
|
||||||
@ -333,7 +341,7 @@ export class CustomerInvoiceDomainMapper
|
|||||||
const allAmounts = source.getAllAmounts();
|
const allAmounts = source.getAllAmounts();
|
||||||
|
|
||||||
// 4) Cliente
|
// 4) Cliente
|
||||||
const recipient = this._mapRecipientToPersistence(source, {
|
const recipient = this._recipientMapper.mapToPersistence(source.recipient, {
|
||||||
errors,
|
errors,
|
||||||
parent: source,
|
parent: source,
|
||||||
...params,
|
...params,
|
||||||
@ -346,16 +354,17 @@ export class CustomerInvoiceDomainMapper
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceValues: CustomerInvoiceCreationAttributes = {
|
const invoiceValues: Partial<CustomerInvoiceCreationAttributes> = {
|
||||||
// Identificación
|
// Identificación
|
||||||
id: source.id.toPrimitive(),
|
id: source.id.toPrimitive(),
|
||||||
company_id: source.companyId.toPrimitive(),
|
company_id: source.companyId.toPrimitive(),
|
||||||
|
|
||||||
// Flags / estado / serie / número
|
// Flags / estado / serie / número
|
||||||
is_proforma: source.isProforma,
|
is_proforma: source.isProforma,
|
||||||
|
proforma_id: toNullable(source.proformaId, (v) => v.toPrimitive()),
|
||||||
status: source.status.toPrimitive(),
|
status: source.status.toPrimitive(),
|
||||||
series: toNullable(source.series, (v) => v.toPrimitive()),
|
series: toNullable(source.series, (v) => v.toPrimitive()),
|
||||||
invoice_number: toNullable(source.invoiceNumber, (v) => v.toPrimitive()),
|
invoice_number: source.invoiceNumber.toPrimitive(),
|
||||||
|
|
||||||
invoice_date: source.invoiceDate.toPrimitive(),
|
invoice_date: source.invoiceDate.toPrimitive(),
|
||||||
operation_date: toNullable(source.operationDate, (v) => v.toPrimitive()),
|
operation_date: toNullable(source.operationDate, (v) => v.toPrimitive()),
|
||||||
@ -397,47 +406,8 @@ export class CustomerInvoiceDomainMapper
|
|||||||
items,
|
items,
|
||||||
};
|
};
|
||||||
|
|
||||||
return Result.ok<CustomerInvoiceCreationAttributes>(invoiceValues);
|
return Result.ok<CustomerInvoiceCreationAttributes>(
|
||||||
}
|
invoiceValues as CustomerInvoiceCreationAttributes
|
||||||
|
);
|
||||||
protected _mapRecipientToPersistence(source: CustomerInvoice, params?: MapperParamsType) {
|
|
||||||
const { errors } = params as {
|
|
||||||
errors: ValidationErrorDetail[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasRecipient = source.hasRecipient;
|
|
||||||
const recipient = source.recipient?.getOrUndefined();
|
|
||||||
|
|
||||||
if (!source.isProforma && !hasRecipient) {
|
|
||||||
errors.push({
|
|
||||||
path: "recipient",
|
|
||||||
message: "[CustomerInvoiceDomainMapper] Issued customer invoice w/o recipient data",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipientValues = {
|
|
||||||
customer_tin: !source.isProforma ? recipient?.tin.toPrimitive() : null,
|
|
||||||
customer_name: !source.isProforma ? recipient?.name.toPrimitive() : null,
|
|
||||||
customer_street: !source.isProforma
|
|
||||||
? toNullable(recipient?.street, (v) => v.toPrimitive())
|
|
||||||
: null,
|
|
||||||
customer_street2: !source.isProforma
|
|
||||||
? toNullable(recipient?.street2, (v) => v.toPrimitive())
|
|
||||||
: null,
|
|
||||||
customer_city: !source.isProforma
|
|
||||||
? toNullable(recipient?.city, (v) => v.toPrimitive())
|
|
||||||
: null,
|
|
||||||
customer_province: !source.isProforma
|
|
||||||
? toNullable(recipient?.province, (v) => v.toPrimitive())
|
|
||||||
: null,
|
|
||||||
customer_postal_code: !source.isProforma
|
|
||||||
? toNullable(recipient?.postalCode, (v) => v.toPrimitive())
|
|
||||||
: null,
|
|
||||||
customer_country: !source.isProforma
|
|
||||||
? toNullable(recipient?.country, (v) => v.toPrimitive())
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return recipientValues;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,20 @@
|
|||||||
|
import { MapperParamsType } from "@erp/core/api";
|
||||||
import {
|
import {
|
||||||
City,
|
City,
|
||||||
Country,
|
Country,
|
||||||
|
extractOrPushError,
|
||||||
|
maybeFromNullableVO,
|
||||||
Name,
|
Name,
|
||||||
PostalCode,
|
PostalCode,
|
||||||
Province,
|
Province,
|
||||||
Street,
|
Street,
|
||||||
TINNumber,
|
TINNumber,
|
||||||
|
toNullable,
|
||||||
ValidationErrorCollection,
|
ValidationErrorCollection,
|
||||||
ValidationErrorDetail,
|
ValidationErrorDetail,
|
||||||
extractOrPushError,
|
|
||||||
maybeFromNullableVO,
|
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
|
|
||||||
import { MapperParamsType } from "@erp/core/api";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
import { CustomerInvoiceProps, InvoiceRecipient } from "../../../domain";
|
import { CustomerInvoice, CustomerInvoiceProps, InvoiceRecipient } from "../../../domain";
|
||||||
import { CustomerInvoiceModel } from "../../sequelize";
|
import { CustomerInvoiceModel } from "../../sequelize";
|
||||||
|
|
||||||
export class InvoiceRecipientDomainMapper {
|
export class InvoiceRecipientDomainMapper {
|
||||||
@ -114,4 +114,64 @@ export class InvoiceRecipientDomainMapper {
|
|||||||
|
|
||||||
return Result.ok(Maybe.some(createResult.data));
|
return Result.ok(Maybe.some(createResult.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea los datos del destinatario (recipient) de una factura de cliente
|
||||||
|
* al formato esperado por la capa de persistencia.
|
||||||
|
*
|
||||||
|
* Reglas:
|
||||||
|
* - Si la factura es proforma (`isProforma === true`), todos los campos de recipient son `null`.
|
||||||
|
* - Si la factura no es proforma (`isProforma === false`), debe existir `recipient`.
|
||||||
|
* En caso contrario, se agrega un error de validación.
|
||||||
|
*/
|
||||||
|
mapToPersistence(source: Maybe<InvoiceRecipient>, params?: MapperParamsType) {
|
||||||
|
const { errors, parent } = params as {
|
||||||
|
parent: CustomerInvoice;
|
||||||
|
errors: ValidationErrorDetail[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { isProforma, hasRecipient } = parent;
|
||||||
|
|
||||||
|
// Validación: facturas emitidas deben tener destinatario.
|
||||||
|
if (!isProforma && !hasRecipient) {
|
||||||
|
errors.push({
|
||||||
|
path: "recipient",
|
||||||
|
message: "[CustomerInvoiceDomainMapper] Issued customer invoice without recipient data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si hay errores previos, devolvemos fallo de validación inmediatamente.
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return Result.fail(
|
||||||
|
new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si es proforma o no hay destinatario, todos los campos deben ser null.
|
||||||
|
if (isProforma || source.isNone()) {
|
||||||
|
return {
|
||||||
|
customer_tin: null,
|
||||||
|
customer_name: null,
|
||||||
|
customer_street: null,
|
||||||
|
customer_street2: null,
|
||||||
|
customer_city: null,
|
||||||
|
customer_province: null,
|
||||||
|
customer_postal_code: null,
|
||||||
|
customer_country: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = source.unwrap();
|
||||||
|
|
||||||
|
return {
|
||||||
|
customer_tin: recipient.tin.toPrimitive(),
|
||||||
|
customer_name: recipient.name.toPrimitive(),
|
||||||
|
customer_street: toNullable(recipient.street, (v) => v.toPrimitive()),
|
||||||
|
customer_street2: toNullable(recipient.street2, (v) => v.toPrimitive()),
|
||||||
|
customer_city: toNullable(recipient.city, (v) => v.toPrimitive()),
|
||||||
|
customer_province: toNullable(recipient.province, (v) => v.toPrimitive()),
|
||||||
|
customer_postal_code: toNullable(recipient.postalCode, (v) => v.toPrimitive()),
|
||||||
|
customer_country: toNullable(recipient.country, (v) => v.toPrimitive()),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export type CustomerInvoiceListDTO = {
|
|||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
|
|
||||||
isProforma: boolean;
|
isProforma: boolean;
|
||||||
invoiceNumber: Maybe<CustomerInvoiceNumber>;
|
invoiceNumber: CustomerInvoiceNumber;
|
||||||
status: CustomerInvoiceStatus;
|
status: CustomerInvoiceStatus;
|
||||||
series: Maybe<CustomerInvoiceSerie>;
|
series: Maybe<CustomerInvoiceSerie>;
|
||||||
|
|
||||||
@ -152,7 +152,7 @@ export class CustomerInvoiceListMapper
|
|||||||
);
|
);
|
||||||
|
|
||||||
const invoiceNumber = extractOrPushError(
|
const invoiceNumber = extractOrPushError(
|
||||||
maybeFromNullableVO(raw.invoice_number, (value) => CustomerInvoiceNumber.create(value)),
|
CustomerInvoiceNumber.create(raw.invoice_number),
|
||||||
"invoice_number",
|
"invoice_number",
|
||||||
errors
|
errors
|
||||||
);
|
);
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export class CustomerInvoiceModel extends Model<
|
|||||||
declare proforma_id: CreationOptional<string | null>; // ID de la proforma origen de la factura
|
declare proforma_id: CreationOptional<string | null>; // ID de la proforma origen de la factura
|
||||||
|
|
||||||
declare series: CreationOptional<string | null>;
|
declare series: CreationOptional<string | null>;
|
||||||
declare invoice_number: CreationOptional<string | null>;
|
declare invoice_number: CreationOptional<string>;
|
||||||
declare invoice_date: CreationOptional<string>;
|
declare invoice_date: CreationOptional<string>;
|
||||||
declare operation_date: CreationOptional<string | null>;
|
declare operation_date: CreationOptional<string | null>;
|
||||||
declare language_code: CreationOptional<string>;
|
declare language_code: CreationOptional<string>;
|
||||||
@ -183,7 +183,7 @@ export default (database: Sequelize) => {
|
|||||||
proforma_id: {
|
proforma_id: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: true,
|
defaultValue: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
status: {
|
status: {
|
||||||
@ -193,13 +193,13 @@ export default (database: Sequelize) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
series: {
|
series: {
|
||||||
type: new DataTypes.STRING(),
|
type: new DataTypes.STRING(10),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
invoice_number: {
|
invoice_number: {
|
||||||
type: new DataTypes.STRING(),
|
type: new DataTypes.STRING(12),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
},
|
},
|
||||||
@ -404,10 +404,22 @@ export default (database: Sequelize) => {
|
|||||||
name: "idx_invoices_company_date",
|
name: "idx_invoices_company_date",
|
||||||
fields: ["company_id", "deleted_at", { name: "invoice_date", order: "DESC" }],
|
fields: ["company_id", "deleted_at", { name: "invoice_date", order: "DESC" }],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "idx_invoice_company_series_number",
|
||||||
|
fields: ["company_id", "series", "invoice_number"],
|
||||||
|
unique: true,
|
||||||
|
}, // <- para consulta get
|
||||||
|
|
||||||
{ name: "idx_invoice_date", fields: ["invoice_date"] }, // <- para ordenación
|
{ name: "idx_invoice_date", fields: ["invoice_date"] }, // <- para ordenación
|
||||||
{ name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get
|
|
||||||
{ name: "idx_proforma_id", fields: ["proforma_id"], unique: false },
|
{ name: "idx_invoice_company_id", fields: ["id", "company_id"], unique: true }, // <- para consulta get
|
||||||
{ name: "idx_factuges", fields: ["factuges_id"], unique: false }, // <- para el proceso python
|
|
||||||
|
{ name: "idx_invoice_proforma_id", fields: ["proforma_id"], unique: false }, // <- para localizar factura por medio de proforma
|
||||||
|
|
||||||
|
{ name: "idx_invoice_factuges", fields: ["factuges_id"], unique: false }, // <- para el proceso python
|
||||||
|
|
||||||
|
// Para búsquedas simples
|
||||||
{
|
{
|
||||||
name: "ft_customer_invoice",
|
name: "ft_customer_invoice",
|
||||||
type: "FULLTEXT",
|
type: "FULLTEXT",
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./sequelize-invoice-number-generator";
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
import { literal, Transaction, WhereOptions } from "sequelize";
|
||||||
|
import {
|
||||||
|
CustomerInvoiceNumber,
|
||||||
|
CustomerInvoiceSerie,
|
||||||
|
ICustomerInvoiceNumberGenerator,
|
||||||
|
} from "../../domain";
|
||||||
|
import { CustomerInvoiceModel } from "../sequelize";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generador de números de factura
|
||||||
|
*/
|
||||||
|
export class SequelizeInvoiceNumberGenerator implements ICustomerInvoiceNumberGenerator {
|
||||||
|
public async nextForCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
series: Maybe<CustomerInvoiceSerie>,
|
||||||
|
transaction: Transaction
|
||||||
|
): Promise<Result<CustomerInvoiceNumber, Error>> {
|
||||||
|
const where: WhereOptions = {
|
||||||
|
company_id: companyId.toString(),
|
||||||
|
is_proforma: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
series.match(
|
||||||
|
(serieVO) => {
|
||||||
|
where.series = serieVO.toString();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
where.series = null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lastInvoice = await CustomerInvoiceModel.findOne({
|
||||||
|
attributes: ["invoice_number"],
|
||||||
|
where,
|
||||||
|
// Orden numérico real: CAST(... AS UNSIGNED)
|
||||||
|
order: [literal("CAST(invoice_number AS UNSIGNED) DESC")],
|
||||||
|
transaction,
|
||||||
|
raw: true,
|
||||||
|
// Bloqueo opcional para evitar carreras si estás dentro de una TX
|
||||||
|
lock: transaction.LOCK.UPDATE, // requiere InnoDB y TX abierta
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextValue = "00001"; // valor inicial por defecto
|
||||||
|
|
||||||
|
if (lastInvoice) {
|
||||||
|
const current = Number(lastInvoice.invoice_number);
|
||||||
|
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
|
||||||
|
nextValue = String(next).padStart(5, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberResult = CustomerInvoiceNumber.create(nextValue);
|
||||||
|
if (numberResult.isFailure) {
|
||||||
|
return Result.fail(numberResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(numberResult.data);
|
||||||
|
} catch (error) {
|
||||||
|
return Result.fail(
|
||||||
|
new Error(
|
||||||
|
`Error generating invoice number for company ${companyId}: ${(error as Error).message}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,25 +4,25 @@ import {
|
|||||||
Country,
|
Country,
|
||||||
CurrencyCode,
|
CurrencyCode,
|
||||||
EmailAddress,
|
EmailAddress,
|
||||||
|
extractOrPushError,
|
||||||
LanguageCode,
|
LanguageCode,
|
||||||
|
maybeFromNullableVO,
|
||||||
Name,
|
Name,
|
||||||
PhoneNumber,
|
PhoneNumber,
|
||||||
PostalAddress,
|
PostalAddress,
|
||||||
PostalCode,
|
PostalCode,
|
||||||
Province,
|
Province,
|
||||||
Street,
|
Street,
|
||||||
TINNumber,
|
|
||||||
TaxCode,
|
TaxCode,
|
||||||
TextValue,
|
TextValue,
|
||||||
URLAddress,
|
TINNumber,
|
||||||
|
toNullable,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
|
URLAddress,
|
||||||
ValidationErrorCollection,
|
ValidationErrorCollection,
|
||||||
ValidationErrorDetail,
|
ValidationErrorDetail,
|
||||||
extractOrPushError,
|
|
||||||
maybeFromNullableVO,
|
|
||||||
toNullable,
|
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Collection, Result, isNullishOrEmpty } from "@repo/rdx-utils";
|
import { Collection, isNullishOrEmpty, Result } from "@repo/rdx-utils";
|
||||||
import { Customer, CustomerProps, CustomerStatus } from "../../../domain";
|
import { Customer, CustomerProps, CustomerStatus } from "../../../domain";
|
||||||
import { CustomerCreationAttributes, CustomerModel } from "../../sequelize";
|
import { CustomerCreationAttributes, CustomerModel } from "../../sequelize";
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user