Proformas: saber la issued invoice de una proforma

This commit is contained in:
David Arranz 2026-04-03 11:36:29 +02:00
parent 5bde207188
commit 400b90f631
11 changed files with 130 additions and 73 deletions

View File

@ -35,4 +35,6 @@ export type ProformaSummary = {
taxableAmount: InvoiceAmount; taxableAmount: InvoiceAmount;
taxesAmount: InvoiceAmount; taxesAmount: InvoiceAmount;
totalAmount: InvoiceAmount; totalAmount: InvoiceAmount;
linkedInvoiceId: Maybe<UniqueID>;
}; };

View File

@ -37,6 +37,8 @@ export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotB
taxes_amount: proforma.taxesAmount.toObjectString(), taxes_amount: proforma.taxesAmount.toObjectString(),
total_amount: proforma.totalAmount.toObjectString(), total_amount: proforma.totalAmount.toObjectString(),
linked_invoice_id: maybeToEmptyString(proforma.linkedInvoiceId, (value) => value.toString()),
metadata: { metadata: {
entity: "proforma", entity: "proforma",
}, },

View File

@ -34,5 +34,7 @@ export interface IProformaSummarySnapshot {
taxes_amount: { value: string; scale: string; currency_code: string }; taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string }; total_amount: { value: string; scale: string; currency_code: string };
linked_invoice_id: string;
metadata?: Record<string, string>; metadata?: Record<string, string>;
} }

View File

@ -125,8 +125,10 @@ export class CustomerInvoiceModel extends Model<
// Relaciones // Relaciones
declare items: NonAttribute<CustomerInvoiceItemModel[]>; declare items: NonAttribute<CustomerInvoiceItemModel[]>;
declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>; declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>;
declare current_customer: NonAttribute<CustomerModel>; declare current_customer?: NonAttribute<CustomerModel | null>;
declare verifactu: NonAttribute<VerifactuRecordModel>; declare verifactu?: NonAttribute<VerifactuRecordModel | null>;
declare proforma?: NonAttribute<CustomerInvoiceModel | null>;
declare linked_invoice?: NonAttribute<CustomerInvoiceModel | null>;
static associate(database: Sequelize) { static associate(database: Sequelize) {
const models = database.models; const models = database.models;
@ -182,6 +184,26 @@ export class CustomerInvoiceModel extends Model<
onDelete: "CASCADE", onDelete: "CASCADE",
onUpdate: "CASCADE", onUpdate: "CASCADE",
}); });
// Relaciones con proforma e invoice vinculada (si esta factura es una proforma o tiene una proforma origen)
CustomerInvoiceModel.belongsTo(CustomerInvoiceModel, {
as: "proforma",
foreignKey: "proforma_id",
targetKey: "id",
constraints: true,
onDelete: "SET NULL",
onUpdate: "CASCADE",
});
CustomerInvoiceModel.hasOne(CustomerInvoiceModel, {
as: "linked_invoice",
foreignKey: "proforma_id",
sourceKey: "id",
constraints: true,
onDelete: "SET NULL",
onUpdate: "CASCADE",
});
} }
static hooks(_database: Sequelize) { static hooks(_database: Sequelize) {
@ -507,10 +529,10 @@ export default (database: Sequelize) => {
{ name: "idx_invoice_company_id", fields: ["id", "company_id"], unique: true }, // <- para consulta get { name: "idx_invoice_company_id", fields: ["id", "company_id"], unique: true }, // <- para consulta get
{ 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 { name: "idx_invoice_factuges", fields: ["factuges_id"], unique: false }, // <- para el proceso python
{ name: "uq_invoice_proforma_id", fields: ["proforma_id"], unique: true }, // <- para asegurar que una proforma solo tenga una factura vinculada
// Para búsquedas simples // Para búsquedas simples
{ {
name: "ft_customer_invoice", name: "ft_customer_invoice",

View File

@ -87,6 +87,8 @@ export class SequelizeProformaSummaryMapper extends SequelizeQueryMapper<
taxableAmount: attributes.taxableAmount!, taxableAmount: attributes.taxableAmount!,
taxesAmount: attributes.taxesAmount!, taxesAmount: attributes.taxesAmount!,
totalAmount: attributes.totalAmount!, totalAmount: attributes.totalAmount!,
linkedInvoiceId: attributes.linkedInvoiceId!,
}); });
} }
@ -197,6 +199,12 @@ export class SequelizeProformaSummaryMapper extends SequelizeQueryMapper<
errors errors
); );
const linkedInvoiceId = extractOrPushError(
maybeFromNullableResult(raw.linked_invoice?.id, (value) => UniqueID.create(value)),
"linked_invoice_id",
errors
);
return { return {
invoiceId, invoiceId,
companyId, companyId,
@ -217,6 +225,8 @@ export class SequelizeProformaSummaryMapper extends SequelizeQueryMapper<
taxableAmount, taxableAmount,
taxesAmount, taxesAmount,
totalAmount, totalAmount,
linkedInvoiceId,
}; };
} }
} }

View File

@ -399,6 +399,12 @@ export class ProformaRepository
required: false, required: false,
separate: true, // => query aparte, devuelve siempre array separate: true, // => query aparte, devuelve siempre array
}, },
{
model: CustomerInvoiceModel,
as: "linked_invoice",
required: false,
attributes: ["id"],
},
]; ];
// Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento) // Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento)

View File

@ -45,6 +45,8 @@ export const ListProformasResponseSchema = createPaginatedListSchema(
taxes_amount: MoneySchema, taxes_amount: MoneySchema,
total_amount: MoneySchema, total_amount: MoneySchema,
linked_invoice_id: z.string(),
metadata: MetadataSchema.optional(), metadata: MetadataSchema.optional(),
}) })
); );

View File

@ -3,13 +3,13 @@ import type { ColumnDef } from "@tanstack/react-table";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../../i18n"; import { useTranslation } from "../../../../../i18n";
import type { ProformaSummaryData, ProformaSummaryPageData } from "../../../../types"; import type { ProformaList, ProformaListRow } from "../../../../shared";
interface ProformasGridProps { interface ProformasGridProps {
data: ProformaSummaryPageData; data?: ProformaList;
loading: boolean; loading: boolean;
columns: ColumnDef<ProformaSummaryData, unknown>[]; columns: ColumnDef<ProformaListRow, unknown>[];
pageIndex: number; pageIndex: number;
pageSize: number; pageSize: number;
@ -31,7 +31,7 @@ export const ProformasGrid = ({
}: ProformasGridProps) => { }: ProformasGridProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { items, total_items } = data; const { items, total_items } = data || { items: [], total_items: 0 };
if (loading) if (loading)
return ( return (

View File

@ -41,7 +41,7 @@ export const ProformaListPage = () => {
onChangeStatusClick: handleChangeStatusProforma, onChangeStatusClick: handleChangeStatusProforma,
}); });
if (listCtrl.isError || !listCtrl.data) { if (listCtrl.isError) {
return ( return (
<AppContent> <AppContent>
<ErrorAlert <ErrorAlert
@ -70,74 +70,81 @@ export const ProformaListPage = () => {
title={t("pages.proformas.list.title")} title={t("pages.proformas.list.title")}
/> />
</AppHeader> </AppHeader>
<AppContent> <AppContent>
{/* Search and filters */} <div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex items-center justify-between gap-16"> {/* Search and filters */}
<SimpleSearchInput <div className="h-full min-w-0 overflow-auto w-full">
loading={listCtrl.isLoading} <div className="flex items-center justify-between gap-16">
onSearchChange={listCtrl.setSearchValue} <SimpleSearchInput
value={listCtrl.search} loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all.label")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft.label")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent.label")}</SelectItem>
<SelectItem value="approved">
{t("catalog.proformas.status.approved.label")}
</SelectItem>
<SelectItem value="rejected">
{t("catalog.proformas.status.rejected.label")}
</SelectItem>
<SelectItem value="issued">
{t("catalog.proformas.status.issued.label")}
</SelectItem>
</SelectContent>
</Select>
</div>
<ProformasGrid
columns={columns}
data={listCtrl.data}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
// acciones rápidas del grid → page controller
//onRowClick={(id) => navigate(`/proformas/${id}`)}
/>
</div>
{/* Emitir factura */}
<ProformaIssueDialog
isSubmitting={issueDialogCtrl.isSubmitting}
onConfirm={issueDialogCtrl.confirmIssue}
onOpenChange={(open) => !open && issueDialogCtrl.closeDialog()}
open={issueDialogCtrl.open}
proforma={issueDialogCtrl.proforma}
/> />
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}> {/* Cambiar estado */}
<SelectTrigger className="w-full sm:w-48"> <ChangeStatusDialog
<FilterIcon aria-hidden className="mr-2 size-4" /> isSubmitting={changeStatusDialogCtrl.isSubmitting}
<SelectValue placeholder={t("filters.status")} /> onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
</SelectTrigger> onOpenChange={(open) => !open && changeStatusDialogCtrl.closeDialog()}
<SelectContent> open={changeStatusDialogCtrl.open}
<SelectItem value="all">{t("catalog.proformas.status.all.label")}</SelectItem> proformas={changeStatusDialogCtrl.proformas} // ← recibe el status seleccionado
<SelectItem value="draft">{t("catalog.proformas.status.draft.label")}</SelectItem> />
<SelectItem value="sent">{t("catalog.proformas.status.sent.label")}</SelectItem>
<SelectItem value="approved"> {/* Eliminar */}
{t("catalog.proformas.status.approved.label")} <DeleteProformaDialog
</SelectItem> isSubmitting={deleteDialogCtrl.isSubmitting}
<SelectItem value="rejected"> onConfirm={deleteDialogCtrl.confirmDelete}
{t("catalog.proformas.status.rejected.label")} onOpenChange={(open) => !open && deleteDialogCtrl.closeDialog()}
</SelectItem> open={deleteDialogCtrl.open}
<SelectItem value="issued">{t("catalog.proformas.status.issued.label")}</SelectItem> proformas={deleteDialogCtrl.proformas}
</SelectContent> requireSecondConfirm={true}
</Select> />
</div> </div>
<ProformasGrid
columns={columns}
data={listCtrl.data}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
// acciones rápidas del grid → page controller
//onRowClick={(id) => navigate(`/proformas/${id}`)}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
{/* Emitir factura */}
<ProformaIssueDialog
isSubmitting={issueDialogCtrl.isSubmitting}
onConfirm={issueDialogCtrl.confirmIssue}
onOpenChange={(open) => !open && issueDialogCtrl.closeDialog()}
open={issueDialogCtrl.open}
proforma={issueDialogCtrl.proforma}
/>
{/* Cambiar estado */}
<ChangeStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => !open && changeStatusDialogCtrl.closeDialog()}
open={changeStatusDialogCtrl.open}
proformas={changeStatusDialogCtrl.proformas} // ← recibe el status seleccionado
/>
{/* Eliminar */}
<DeleteProformaDialog
isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(open) => !open && deleteDialogCtrl.closeDialog()}
open={deleteDialogCtrl.open}
proformas={deleteDialogCtrl.proformas}
requireSecondConfirm={true}
/>
</AppContent> </AppContent>
</> </>
); );

View File

@ -94,6 +94,8 @@ export const ListProformasRowAdapter = {
rowDto.currency_code, rowDto.currency_code,
rowDto.language_code rowDto.language_code
), ),
linked_invoice_id: rowDto.linked_invoice_id,
}; };
}, },
}; };

View File

@ -46,4 +46,6 @@ export interface ProformaListRow {
total_amount: number; total_amount: number;
total_amount_fmt: string; total_amount_fmt: string;
linked_invoice_id: string;
} }