Proceso de paso de proforma a factura

This commit is contained in:
David Arranz 2025-11-06 17:17:28 +01:00
parent 2c84dc26bd
commit c8eff4e9fc
6 changed files with 255 additions and 199 deletions

View File

@ -1,8 +1,8 @@
import { ITransactionManager } from "@erp/core/api"; import { ITransactionManager } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd"; import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { InvalidProformaStatusError } from "../../domain"; import { ProformaCannotBeConvertedToInvoiceError } from "../../domain";
import { StatusInvoiceIsApprovedSpecification } from "../../domain/specs"; import { ProformaCanTranstionToIssuedSpecification } from "../../domain/specs";
import { CustomerInvoiceApplicationService } from "../customer-invoice-application.service"; import { CustomerInvoiceApplicationService } from "../customer-invoice-application.service";
type IssueCustomerInvoiceUseCaseInput = { type IssueCustomerInvoiceUseCaseInput = {
@ -52,9 +52,9 @@ export class IssueCustomerInvoiceUseCase {
const proforma = proformaResult.data; const proforma = proformaResult.data;
/** 2. Comprobamos que la proforma origen está aprovada para generar la factura */ /** 2. Comprobamos que la proforma origen está aprovada para generar la factura */
const isApprovedSpec = new StatusInvoiceIsApprovedSpecification(); const isOk = new ProformaCanTranstionToIssuedSpecification();
if (!(await isApprovedSpec.isSatisfiedBy(proforma))) { if (!(await isOk.isSatisfiedBy(proforma))) {
return Result.fail(new InvalidProformaStatusError(proformaId.toString())); return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proformaId.toString()));
} }
/** 3. Generar nueva factura */ /** 3. Generar nueva factura */
@ -71,7 +71,7 @@ export class IssueCustomerInvoiceUseCase {
// props base obtenidas del agregado proforma // props base obtenidas del agregado proforma
const issuedInvoiceOrError = this.service.buildIssueInvoiceInCompany(companyId, proforma, { const issuedInvoiceOrError = this.service.buildIssueInvoiceInCompany(companyId, proforma, {
invoiceNumber: Maybe.some(newIssueNumber), invoiceNumber: newIssueNumber,
invoiceDate: UtcDate.today(), invoiceDate: UtcDate.today(),
}); });

View File

@ -1 +1 @@
export * from "./status-invoice-is-approved.specification"; export * from "./proforma-can-transtion-to-issued.specification";

View File

@ -0,0 +1,9 @@
import { CompositeSpecification } from "@repo/rdx-ddd";
import { CustomerInvoice } from "../aggregates";
import { INVOICE_STATUS } from "../value-objects";
export class ProformaCanTranstionToIssuedSpecification extends CompositeSpecification<CustomerInvoice> {
public async isSatisfiedBy(proforma: CustomerInvoice): Promise<boolean> {
return proforma.isProforma && proforma.canTransitionTo(INVOICE_STATUS.ISSUED);
}
}

View File

@ -1,8 +0,0 @@
import { CompositeSpecification } from "@repo/rdx-ddd";
import { CustomerInvoice } from "../aggregates";
export class StatusInvoiceIsApprovedSpecification extends CompositeSpecification<CustomerInvoice> {
public async isSatisfiedBy(invoice: CustomerInvoice): Promise<boolean> {
return invoice.status.isApproved();
}
}

View File

@ -11,9 +11,8 @@ export enum INVOICE_STATUS {
APPROVED = "approved", // <- Proforma APPROVED = "approved", // <- Proforma
REJECTED = "rejected", // <- Proforma REJECTED = "rejected", // <- Proforma
// status === issued <- (si is_proforma === true) => Es una proforma (histórica) // status === "issued" <- (si is_proforma === true) => Es una proforma (histórica)
// status === issued <- (si is_proforma === false) => Factura y enviará/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> {
@ -93,15 +92,6 @@ export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusPro
return CustomerInvoiceStatus.TRANSITIONS[this.props.value].includes(nextStatus); return CustomerInvoiceStatus.TRANSITIONS[this.props.value].includes(nextStatus);
} }
transitionTo(nextStatus: string): Result<CustomerInvoiceStatus, Error> {
if (!this.canTransitionTo(nextStatus)) {
return Result.fail(
new Error(`Transición no permitida de ${this.props.value} a ${nextStatus}`)
);
}
return CustomerInvoiceStatus.create(nextStatus);
}
toString() { toString() {
return String(this.props.value); return String(this.props.value);
} }

View File

@ -1,17 +1,16 @@
import { DataTableColumnHeader } from '@repo/rdx-ui/components'; import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import { InputGroup, InputGroupTextarea } from "@repo/shadcn-ui/components"; import { InputGroup, InputGroupTextarea } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import * as React from "react"; import * as React from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../../context'; import { useInvoiceContext } from "../../../context";
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select'; import { CustomerInvoiceTaxesMultiSelect } from "../../customer-invoice-taxes-multi-select";
import { AmountInputField } from './amount-input-field'; import { AmountInputField } from "./amount-input-field";
import { HoverCardTotalsSummary } from './hover-card-total-summary'; import { HoverCardTotalsSummary } from "./hover-card-total-summary";
import { ItemDataTableRowActions } from './items-data-table-row-actions'; import { ItemDataTableRowActions } from "./items-data-table-row-actions";
import { PercentageInputField } from './percentage-input-field'; import { PercentageInputField } from "./percentage-input-field";
import { QuantityInputField } from './quantity-input-field'; import { QuantityInputField } from "./quantity-input-field";
export interface InvoiceItemFormData { export interface InvoiceItemFormData {
id: string; // ← mapea RHF field.id aquí id: string; // ← mapea RHF field.id aquí
@ -22,16 +21,19 @@ export interface InvoiceItemFormData {
tax_codes: string[]; tax_codes: string[];
total_amount: number | ""; // readonly calculado total_amount: number | ""; // readonly calculado
} }
export interface InvoiceFormData { items: InvoiceItemFormData[] } export interface InvoiceFormData {
items: InvoiceItemFormData[];
}
export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] { export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
const { t, readOnly, currency_code, language_code } = useInvoiceContext(); const { t, readOnly, currency_code, language_code } = useInvoiceContext();
const { control } = useFormContext<InvoiceFormData>(); const { control } = useFormContext<InvoiceFormData>();
// Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla // Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla
return React.useMemo<ColumnDef<InvoiceItemFormData>[]>(() => [ return React.useMemo<ColumnDef<InvoiceItemFormData>[]>(
() => [
{ {
id: 'position', id: "position",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={"#"} className='text-center' /> <DataTableColumnHeader column={column} title={"#"} className='text-center' />
), ),
@ -42,7 +44,11 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
{ {
accessorKey: "description", accessorKey: "description",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.description.label")} className='text-left' /> <DataTableColumnHeader
column={column}
title={t("form_fields.item.description.label")}
className='text-left'
/>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<Controller <Controller
@ -50,7 +56,8 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
name={`items.${row.index}.description`} name={`items.${row.index}.description`}
render={({ field }) => ( render={({ field }) => (
<InputGroup> <InputGroup>
<InputGroupTextarea {...field} <InputGroupTextarea
{...field}
id={`desc-${row.original.id}`} // ← estable id={`desc-${row.original.id}`} // ← estable
rows={1} rows={1}
aria-label={t("form_fields.item.description.label")} aria-label={t("form_fields.item.description.label")}
@ -67,7 +74,8 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
"focus:resize-y" "focus:resize-y"
)} )}
data-cell-focus /> data-cell-focus
/>
{/*<InputGroupAddon align="block-end"> {/*<InputGroupAddon align="block-end">
<InputGroupText>Line 1, Column 1</InputGroupText> <InputGroupText>Line 1, Column 1</InputGroupText>
<InputGroupButton <InputGroupButton
@ -81,17 +89,22 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
</InputGroupButton> </InputGroupButton>
</InputGroupAddon>*/} </InputGroupAddon>*/}
</InputGroup> </InputGroup>
)} )}
/> />
), ),
enableSorting: false, enableSorting: false,
size: 480, minSize: 240, maxSize: 768, size: 480,
minSize: 240,
maxSize: 768,
}, },
{ {
accessorKey: "quantity", accessorKey: "quantity",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.quantity.label")} className='text-right' /> <DataTableColumnHeader
column={column}
title={t("form_fields.item.quantity.label")}
className='text-right'
/>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<QuantityInputField <QuantityInputField
@ -99,20 +112,26 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
name={`items.${row.index}.quantity`} name={`items.${row.index}.quantity`}
readOnly={readOnly} readOnly={readOnly}
inputId={`qty-${row.original.id}`} inputId={`qty-${row.original.id}`}
emptyMode="blank" emptyMode='blank'
data-row-index={row.index} data-row-index={row.index}
data-col-index={4} data-col-index={4}
data-cell-focus data-cell-focus
className="font-base" className='font-base'
/> />
), ),
enableSorting: false, enableSorting: false,
size: 52, minSize: 48, maxSize: 64, size: 52,
minSize: 48,
maxSize: 64,
}, },
{ {
accessorKey: "unit_amount", accessorKey: "unit_amount",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.unit_amount.label")} className='text-right' /> <DataTableColumnHeader
column={column}
title={t("form_fields.item.unit_amount.label")}
className='text-right'
/>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<AmountInputField <AmountInputField
@ -126,16 +145,22 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
data-row-index={row.index} data-row-index={row.index}
data-col-index={5} data-col-index={5}
data-cell-focus data-cell-focus
className="font-base" className='font-base'
/> />
), ),
enableSorting: false, enableSorting: false,
size: 120, minSize: 100, maxSize: 160, size: 120,
minSize: 100,
maxSize: 160,
}, },
{ {
accessorKey: "discount_percentage", accessorKey: "discount_percentage",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.discount_percentage.label")} className='text-right' /> <DataTableColumnHeader
column={column}
title={t("form_fields.item.discount_percentage.label")}
className='text-right'
/>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<PercentageInputField <PercentageInputField
@ -147,11 +172,41 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
data-row-index={row.index} data-row-index={row.index}
data-col-index={6} data-col-index={6}
data-cell-focus data-cell-focus
className="font-base" className='font-base'
/> />
), ),
enableSorting: false, enableSorting: false,
size: 40, minSize: 40 size: 40,
minSize: 40,
},
{
accessorKey: "taxable_amount",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("form_fields.item.taxable_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<AmountInputField
control={control}
name={`items.${row.index}.taxable_amount`}
readOnly={readOnly}
inputId={`unit-${row.original.id}`}
scale={4}
currencyCode={currency_code}
languageCode={language_code}
data-row-index={row.index}
data-col-index={5}
data-cell-focus
className='font-base'
/>
),
enableSorting: false,
size: 120,
minSize: 100,
maxSize: 160,
}, },
{ {
accessorKey: "tax_codes", accessorKey: "tax_codes",
@ -174,12 +229,18 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
/> />
), ),
enableSorting: false, enableSorting: false,
size: 240, minSize: 232, maxSize: 320, size: 120,
minSize: 130,
maxSize: 180,
}, },
{ {
accessorKey: "total_amount", accessorKey: "total_amount",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.total_amount.label")} className='text-right' /> <DataTableColumnHeader
column={column}
title={t("form_fields.item.total_amount.label")}
className='text-right'
/>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<HoverCardTotalsSummary rowIndex={row.index}> <HoverCardTotalsSummary rowIndex={row.index}>
@ -190,12 +251,14 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
inputId={`total-${row.original.id}`} inputId={`total-${row.original.id}`}
currencyCode={currency_code} currencyCode={currency_code}
languageCode={language_code} languageCode={language_code}
className="font-semibold" className='font-semibold'
/> />
</HoverCardTotalsSummary> </HoverCardTotalsSummary>
), ),
enableSorting: false, enableSorting: false,
size: 120, minSize: 100, maxSize: 160, size: 120,
minSize: 100,
maxSize: 160,
}, },
{ {
id: "actions", id: "actions",
@ -204,5 +267,7 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
), ),
cell: ({ row, table }) => <ItemDataTableRowActions row={row} table={table} />, cell: ({ row, table }) => <ItemDataTableRowActions row={row} table={table} />,
}, },
], [t, readOnly, control, currency_code, language_code,]); ],
[t, readOnly, control, currency_code, language_code]
);
} }