From fbb24f2cd3737fa728b9f9f064984d62ccbefb55 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Mon, 23 Mar 2026 09:48:33 -0500 Subject: [PATCH] Refactor invoice financials to change `iva_factor` type from string to numeric - Updated the `iva_factor` field in the `InvoiceFinancials` model to use a numeric type with precision and scale. - Adjusted related frontend components and API interfaces to reflect the new numeric type for `iva_factor`. - Enhanced Alembic migration scripts to accommodate the schema changes, ensuring proper index management and data type conversions. These changes improve data integrity and consistency for financial calculations in invoices. --- backend/alembic/env.py | 133 +++++++++++++++++- .../versions/bccb7f8986c7_iva_factor.py | 45 ++++++ backend/api/v1/modules/a76/invoices/models.py | 6 +- .../src/lib/api/dashboard/a76/invoices.ts | 2 +- .../invoices/create-edit-dialog.svelte | 2 +- .../dashboard/invoices/details-dialog.svelte | 86 ++++++----- .../dashboard/invoices/edit/save-invoice.ts | 2 +- 7 files changed, 225 insertions(+), 51 deletions(-) create mode 100644 backend/alembic/versions/bccb7f8986c7_iva_factor.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 348bcb40..fbe67248 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -6,6 +6,7 @@ from logging.config import fileConfig from urllib.parse import quote_plus from alembic import context +from alembic.operations import ops from core.config import settings from core.database import Base from sqlalchemy import engine_from_config, pool @@ -78,6 +79,125 @@ fileConfig(config.config_file_name) target_metadata = Base.metadata +def include_object(object_, name, type_, reflected, compare_to): + """ + Keep all objects in autogenerate. + FK noise is cleaned in process_revision_directives. + """ + return True + + +def _fk_drop_signature(op_): + if not isinstance(op_, ops.DropConstraintOp): + return None + if getattr(op_, "constraint_type", None) != "foreignkey": + return None + return ( + getattr(op_, "schema", None), + getattr(op_, "table_name", None), + getattr(op_, "constraint_name", None), + ) + + +def _fk_create_signature(op_): + if not isinstance(op_, ops.CreateForeignKeyOp): + return None + local_cols = tuple(getattr(op_, "local_cols", ()) or ()) + remote_cols = tuple(getattr(op_, "remote_cols", ()) or ()) + return ( + getattr(op_, "source_schema", None), + getattr(op_, "source_table", None), + getattr(op_, "referent_schema", None), + getattr(op_, "referent_table", None), + local_cols, + remote_cols, + ) + + +def _drop_to_create_match(drop_op, create_op): + if not isinstance(drop_op, ops.DropConstraintOp): + return False + if not isinstance(create_op, ops.CreateForeignKeyOp): + return False + if getattr(drop_op, "constraint_type", None) != "foreignkey": + return False + + def _normalize_schema(value): + # PostgreSQL reports default schema inconsistently as None/public. + return "public" if value in (None, "") else value + + # Prefer structural comparison using Alembic's reverse op when available. + reverse_create = getattr(drop_op, "_reverse", None) + if isinstance(reverse_create, ops.CreateForeignKeyOp): + return ( + _normalize_schema(getattr(reverse_create, "source_schema", None)) + == _normalize_schema(getattr(create_op, "source_schema", None)) + and getattr(reverse_create, "source_table", None) == getattr(create_op, "source_table", None) + and _normalize_schema(getattr(reverse_create, "referent_schema", None)) + == _normalize_schema(getattr(create_op, "referent_schema", None)) + and getattr(reverse_create, "referent_table", None) == getattr(create_op, "referent_table", None) + and tuple(getattr(reverse_create, "local_cols", ()) or ()) + == tuple(getattr(create_op, "local_cols", ()) or ()) + and tuple(getattr(reverse_create, "remote_cols", ()) or ()) + == tuple(getattr(create_op, "remote_cols", ()) or ()) + ) + + # Fallback for older op payloads: compare source table/schema and name. + return ( + _normalize_schema(getattr(drop_op, "schema", None)) == _normalize_schema(getattr(create_op, "source_schema", None)) + and getattr(drop_op, "table_name", None) == getattr(create_op, "source_table", None) + and getattr(drop_op, "constraint_name", None) == getattr(create_op, "constraint_name", None) + ) + + +def _prune_fk_churn(container): + if not hasattr(container, "ops"): + return + + # First recurse into nested containers. + for op_ in list(container.ops): + _prune_fk_churn(op_) + + table_ops = container.ops + kept_ops = [] + consumed_indexes = set() + + for i, op_i in enumerate(table_ops): + if i in consumed_indexes: + continue + + if isinstance(op_i, ops.DropConstraintOp) and getattr(op_i, "constraint_type", None) == "foreignkey": + matched_j = None + for j in range(i + 1, len(table_ops)): + if j in consumed_indexes: + continue + op_j = table_ops[j] + if _drop_to_create_match(op_i, op_j): + matched_j = j + break + if matched_j is not None: + # Drop + recreate same FK detected; remove both. + consumed_indexes.add(i) + consumed_indexes.add(matched_j) + continue + + kept_ops.append(op_i) + + container.ops = kept_ops + + +def process_revision_directives(context_, revision, directives): + """ + Remove autogenerate noise where Alembic emits drop/create for equivalent FKs. + Real FK changes are preserved. + """ + if not directives: + return + script = directives[0] + _prune_fk_churn(script.upgrade_ops) + _prune_fk_churn(script.downgrade_ops) + + def import_models_from_dir(dir_path: str): """Importa recursivamente cualquier archivo models.py desde dir_path y archivos en directorios models/""" import sys @@ -132,6 +252,10 @@ def run_migrations_offline() -> None: context.configure( url=url, target_metadata=target_metadata, + compare_type=True, + include_schemas=True, + include_object=include_object, + process_revision_directives=process_revision_directives, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) @@ -153,7 +277,14 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + include_schemas=True, + include_object=include_object, + process_revision_directives=process_revision_directives, + ) with context.begin_transaction(): context.run_migrations() diff --git a/backend/alembic/versions/bccb7f8986c7_iva_factor.py b/backend/alembic/versions/bccb7f8986c7_iva_factor.py new file mode 100644 index 00000000..2c31ffb5 --- /dev/null +++ b/backend/alembic/versions/bccb7f8986c7_iva_factor.py @@ -0,0 +1,45 @@ +"""iva_factor + +Revision ID: bccb7f8986c7 +Revises: 9f3c2d1b7a11 +Create Date: 2026-03-23 09:44:02.275257 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bccb7f8986c7' +down_revision: Union[str, Sequence[str], None] = '9f3c2d1b7a11' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_public_carta_porte_code'), table_name='carta_porte_codes') + op.create_index(op.f('ix_public_carta_porte_codes_code'), 'carta_porte_codes', ['code'], unique=False, schema='public') + op.alter_column('invoice_financials', 'iva_factor', + existing_type=sa.VARCHAR(length=10), + type_=sa.Numeric(precision=23, scale=8), + postgresql_using='iva_factor::numeric(23,8)', + existing_nullable=True, + schema='a76') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('invoice_financials', 'iva_factor', + existing_type=sa.Numeric(precision=23, scale=8), + type_=sa.VARCHAR(length=10), + existing_nullable=True, + schema='a76') + op.drop_index(op.f('ix_public_carta_porte_codes_code'), table_name='carta_porte_codes', schema='public') + op.create_index(op.f('ix_public_carta_porte_code'), 'carta_porte_codes', ['code'], unique=False) + # ### end Alembic commands ### diff --git a/backend/api/v1/modules/a76/invoices/models.py b/backend/api/v1/modules/a76/invoices/models.py index 2bda119c..a10be8c7 100644 --- a/backend/api/v1/modules/a76/invoices/models.py +++ b/backend/api/v1/modules/a76/invoices/models.py @@ -517,9 +517,9 @@ class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin): iva_mc: Mapped[Optional[float]] = mapped_column( Numeric(23, 8), default=0, server_default="0" ) # IVAEXPOMC / IVA en MC - iva_factor: Mapped[Optional[str]] = mapped_column( - String(10) - ) # FACTORIVA / Factor IVA (puede ser varchar en imports) + iva_factor: Mapped[Optional[float]] = mapped_column( + Numeric(23, 8), default=0, server_default="0" + ) # FACTORIVA / Factor IVA tax_value_me: Mapped[Optional[float]] = mapped_column( Numeric(23, 8), default=0, server_default="0" ) # VALORIMPUESTOME / Valor impuesto ME diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index d3b7d812..d0849433 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -110,7 +110,7 @@ export interface InvoiceFinancials { iva_mn?: number | null; iva_me?: number | null; iva_mc?: number | null; - iva_factor?: string | null; + iva_factor?: number | null; tax_value_me?: number | null; seal_value_2500?: boolean | null; total_quantity?: number | null; diff --git a/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte index 4237c5e9..8652b8de 100644 --- a/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte @@ -67,7 +67,7 @@ freight: null as number | null, insurance: null as number | null, iva_mn: null as number | null, - iva_factor: null as string | null, + iva_factor: null as number | null, total_quantity: null as number | null, gross_weight: null as number | null, net_weight: null as number | null, diff --git a/frontend/src/lib/components/dashboard/invoices/details-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/details-dialog.svelte index a78dfe99..722571ea 100644 --- a/frontend/src/lib/components/dashboard/invoices/details-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/details-dialog.svelte @@ -266,7 +266,7 @@

Factor IVA

-

{invoice.financials.iva_factor || '-'}

+

{formatCurrency(invoice.financials.iva_factor)}

@@ -296,59 +296,57 @@ - {#if invoice.logistics && invoice.logistics.length > 0} -
- {#each invoice.logistics as logistics, index} -
-

Logística #{index + 1}

-
-
-

Transportista

-

{logistics.carrier_id || '-'}

-
+ {#if invoice.logistics} +
+
+

Logística

+
+
+

Transportista

+

{invoice.logistics.carrier_id || '-'}

+
-
-

Tipo de Transporte

-

{logistics.transport_type || '-'}

-
+
+

Tipo de Transporte

+

{invoice.logistics.transport_type || '-'}

+
-
-

Modo de Transporte

-

{logistics.transport_mode || '-'}

-
+
+

Modo de Transporte

+

{invoice.logistics.transport_mode || '-'}

+
-
-

Conductor

-

{logistics.driver_name || '-'}

-
+
+

Conductor

+

{invoice.logistics.driver_name || '-'}

+
-
-

Número de Vehículo

-

{logistics.vehicle_num || '-'}

-
+
+

Número de Vehículo

+

{invoice.logistics.vehicle_num || '-'}

+
-
-

Placa

-

{logistics.license_plate || '-'}

-
+
+

Placa

+

{invoice.logistics.license_plate || '-'}

+
-
-

Número de Sello

-

{logistics.seal_number || '-'}

-
+
+

Número de Sello

+

{invoice.logistics.seal_number || '-'}

+
-
-

Guía

-

{logistics.guide_number || '-'}

-
+
+

Guía

+

{invoice.logistics.guide_number || '-'}

+
-
-

Fecha Entrada/Salida

-

{formatDate(logistics.entry_exit_date)}

-
+
+

Fecha Entrada/Salida

+

{formatDate(invoice.logistics.entry_exit_date)}

- {/each} +
{:else}

No hay información de logística disponible.

diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index 141a1fbd..8cd4db12 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -238,7 +238,7 @@ function buildFinancialsData(generalFormData: any, observationFormData: any, oth currency_type: generalFormData?.currency_type || (generalFormData?.currency === 'foreign' ? 'USD' : null), currency: generalFormData?.currency || null, exchange_rate: generalFormData?.exchange_rate ? Number(generalFormData.exchange_rate) : null, - iva_factor: (InvoiceTopFieldsFormData?.iva_factor || generalFormData?.iva_factor) ? String(InvoiceTopFieldsFormData?.iva_factor || generalFormData.iva_factor) : null, + iva_factor: (InvoiceTopFieldsFormData?.iva_factor || generalFormData?.iva_factor) ? Number(InvoiceTopFieldsFormData?.iva_factor || generalFormData.iva_factor) : null, // Costs & increments from observationFormData freight: observationFormData?.freight || null, insurance: observationFormData?.insurance || null,