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;
taxesAmount: InvoiceAmount;
totalAmount: InvoiceAmount;
linkedInvoiceId: Maybe<UniqueID>;
};

View File

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

View File

@ -34,5 +34,7 @@ export interface IProformaSummarySnapshot {
taxes_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>;
}

View File

@ -125,8 +125,10 @@ export class CustomerInvoiceModel extends Model<
// Relaciones
declare items: NonAttribute<CustomerInvoiceItemModel[]>;
declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>;
declare current_customer: NonAttribute<CustomerModel>;
declare verifactu: NonAttribute<VerifactuRecordModel>;
declare current_customer?: NonAttribute<CustomerModel | null>;
declare verifactu?: NonAttribute<VerifactuRecordModel | null>;
declare proforma?: NonAttribute<CustomerInvoiceModel | null>;
declare linked_invoice?: NonAttribute<CustomerInvoiceModel | null>;
static associate(database: Sequelize) {
const models = database.models;
@ -182,6 +184,26 @@ export class CustomerInvoiceModel extends Model<
onDelete: "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) {
@ -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_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: "uq_invoice_proforma_id", fields: ["proforma_id"], unique: true }, // <- para asegurar que una proforma solo tenga una factura vinculada
// Para búsquedas simples
{
name: "ft_customer_invoice",

View File

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

View File

@ -399,6 +399,12 @@ export class ProformaRepository
required: false,
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)

View File

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

View File

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

View File

@ -41,7 +41,7 @@ export const ProformaListPage = () => {
onChangeStatusClick: handleChangeStatusProforma,
});
if (listCtrl.isError || !listCtrl.data) {
if (listCtrl.isError) {
return (
<AppContent>
<ErrorAlert
@ -70,74 +70,81 @@ export const ProformaListPage = () => {
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
{/* Search and filters */}
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput
loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* Search and filters */}
<div className="h-full min-w-0 overflow-auto w-full">
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput
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}>
<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>
{/* 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}
/>
</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>
</>
);

View File

@ -94,6 +94,8 @@ export const ListProformasRowAdapter = {
rowDto.currency_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_fmt: string;
linked_invoice_id: string;
}