diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index 586a1da6..a572f0c2 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -1,7 +1,8 @@ """seed_initial_data Revision ID: 7937209f9718 -Revises: 531bf8cdae06 +Revises: + Create Date: 2025-10-19 18:23:55.258800 """ @@ -95,6 +96,174 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" + # --- CREAR TABLA fraccion canadienses --- + op.create_table('canadian_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fraction', sa.String(length=13), nullable=False), + sa.Column('ad_valorem', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('country_code', sa.String(length=3), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + sa.UniqueConstraint('fraction', 'country_code', 'company_id', name='uq_canadian_fraction_country_company'), + schema='a76' + ) + op.create_index(op.f('ix_a76_canadian_tariff_fractions_country_code'), 'canadian_tariff_fractions', ['country_code'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_fraction'), 'canadian_tariff_fractions', ['fraction'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_id'), 'canadian_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_company_id'), 'canadian_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_tenant_id'), 'canadian_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + + # --- CREAR TABLA fraccion americana --- + op.create_table('us_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=16), nullable=False, comment='Código de fracción americana'), + sa.Column('prefix', sa.String(length=10), nullable=True, comment='Prefijo de clasificación'), + sa.Column('type_code', sa.String(length=10), nullable=True, comment='Código de tipo'), + sa.Column('ad_valorem', sa.Numeric(precision=10, scale=2), nullable=True, comment='Porcentaje ad valorem'), + sa.Column('fixed_cost', sa.Numeric(precision=15, scale=8), nullable=True, comment='Tasa fija'), + sa.Column('unit_of_measure', sa.String(length=10), nullable=True, comment='Unidad de medida'), + sa.Column('description', sa.String(), nullable=True, comment='Descripción de la fracción'), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + schema='a76' + ) + op.create_index(op.f('ix_a76_us_tariff_fractions_id'), 'us_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_company_id'), 'us_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_tenant_id'), 'us_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + + # --- CREAR TABLA fraccion historico --- + op.create_table('historical_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('historical_fraction', sa.String(length=8), nullable=True), + sa.Column('unit_of_measure_code', sa.String(), nullable=True), + sa.Column('country', sa.String(), nullable=True), + sa.Column('fraction_type', sa.String(length=7), nullable=True), + sa.Column('sector', sa.String(length=5), nullable=True), + sa.Column('import_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('export_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('publication_date', sa.DateTime(), nullable=True), + sa.Column('is_immex', sa.Boolean(), nullable=True), + sa.Column('normal_temporality', sa.Boolean(), nullable=True), + sa.Column('services_temporality', sa.Boolean(), nullable=True), + sa.Column('certified_temporality', sa.Boolean(), nullable=True), + sa.Column('by_log', sa.Boolean(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + # Foreign Keys to other tables (assuming they exist or are created before/simultaneously, referencing explicit schemas) + sa.ForeignKeyConstraint(['unit_of_measure_code'], ['a76.unit_of_measure_customs.code'], source_schema='a76', referent_schema='a76'), + sa.ForeignKeyConstraint(['country'], ['public.countries.m3_key'], source_schema='a76', referent_schema='public'), + + schema='a76' + ) + op.create_index(op.f('ix_a76_historical_tariff_fractions_id'), 'historical_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_company_id'), 'historical_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_tenant_id'), 'historical_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + + # --- CREAR TABLA fraccion americana --- + op.create_table('us_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=16), nullable=False, comment='Código de fracción americana'), + sa.Column('prefix', sa.String(length=10), nullable=True, comment='Prefijo de clasificación'), + sa.Column('type_code', sa.String(length=10), nullable=True, comment='Código de tipo'), + sa.Column('ad_valorem', sa.Numeric(precision=10, scale=2), nullable=True, comment='Porcentaje ad valorem'), + sa.Column('fixed_cost', sa.Numeric(precision=15, scale=8), nullable=True, comment='Tasa fija'), + sa.Column('unit_of_measure', sa.String(length=10), nullable=True, comment='Unidad de medida'), + sa.Column('description', sa.String(), nullable=True, comment='Descripción de la fracción'), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + schema='a76' + ) + op.create_index(op.f('ix_a76_us_tariff_fractions_id'), 'us_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_company_id'), 'us_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_tenant_id'), 'us_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + + # --- CREAR TABLA fraccion historico --- + op.create_table('historical_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('historical_fraction', sa.String(length=8), nullable=True), + sa.Column('unit_of_measure_code', sa.String(), nullable=True), + sa.Column('country', sa.String(), nullable=True), + sa.Column('fraction_type', sa.String(length=7), nullable=True), + sa.Column('sector', sa.String(length=5), nullable=True), + sa.Column('import_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('export_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('publication_date', sa.DateTime(), nullable=True), + sa.Column('is_immex', sa.Boolean(), nullable=True), + sa.Column('normal_temporality', sa.Boolean(), nullable=True), + sa.Column('services_temporality', sa.Boolean(), nullable=True), + sa.Column('certified_temporality', sa.Boolean(), nullable=True), + sa.Column('by_log', sa.Boolean(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + # Foreign Keys to other tables (assuming they exist or are created before/simultaneously, referencing explicit schemas) + sa.ForeignKeyConstraint(['unit_of_measure_code'], ['a76.unit_of_measure_customs.code'], source_schema='a76', referent_schema='a76'), + sa.ForeignKeyConstraint(['country'], ['public.countries.m3_key'], source_schema='a76', referent_schema='public'), + + schema='a76' + ) + op.create_index(op.f('ix_a76_historical_tariff_fractions_id'), 'historical_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_company_id'), 'historical_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_tenant_id'), 'historical_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + # --- UTILIDAD DE FORMATEO --- def format_value(val): if val is None or str(val).strip() == "" or str(val).upper() == "NONE": @@ -520,7 +689,8 @@ def upgrade() -> None: f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, " f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, " f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, " - f"{format_bool(by_log)}, {format_timestamp(end_date)})" + f"{format_bool(by_log)}, {format_timestamp(end_date)}, " + f"1, 1, 'NOW()', 'NOW()')" # tenant_id=1, company_id=1, created_at=NOW(), updated_at=NOW() for historical_fraction, unit_measure, country, fraction_type, sector, import_tax, export_tax, pub_date, is_immex, normal_temp, services_temp, certified_temp, by_log, end_date in historical_tariff_fractions_seed ] ) @@ -531,7 +701,8 @@ def upgrade() -> None: INSERT INTO a76.historical_tariff_fractions (historical_fraction, unit_of_measure_code, country, fraction_type, sector, import_tax_rate, export_tax_rate, publication_date, is_immex, - normal_temporality, services_temporality, certified_temporality, by_log, end_date) + normal_temporality, services_temporality, certified_temporality, by_log, end_date, + tenant_id, company_id, created_at, updated_at) VALUES {values_historical_fractions} ON CONFLICT DO NOTHING; """ @@ -541,6 +712,9 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" + op.drop_table("us_tariff_fractions", schema="a76") + op.drop_table("historical_tariff_fractions", schema="a76") + op.drop_table("canadian_tariff_fractions", schema="a76") op.drop_table("valuation_methods", schema="public") op.drop_table("transport_types", schema="public") op.drop_table("trailer_types", schema="public") diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/__init__.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/models.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/models.py new file mode 100644 index 00000000..f67d68bd --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/models.py @@ -0,0 +1,29 @@ + +from datetime import datetime +from typing import Optional +from sqlalchemy import String, Numeric, TIMESTAMP, func, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from core.database import Base + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin + +class CanadianTariffFraction(Base, TenantScopedMixin, TimestampMixin): + """Model for Canadian Tariff Fractions (GFracEUACan)""" + __tablename__ = "canadian_tariff_fractions" + __table_args__ = ( + UniqueConstraint('fraction', 'country_code', 'company_id', name='uq_canadian_fraction_country_company'), + {"schema": "a76"} + ) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + + # FRACCION + fraction: Mapped[str] = mapped_column(String(13), nullable=False, index=True) + # ADV + ad_valorem: Mapped[Optional[float]] = mapped_column(Numeric(5, 2)) + # UNIDAD + unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5)) + # CLAVEM3 (Part of original PK) + country_code: Mapped[str] = mapped_column(String(3), nullable=False, index=True) + # DESCRIPCION + description: Mapped[Optional[str]] = mapped_column(String(1000)) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py new file mode 100644 index 00000000..55daf80d --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py @@ -0,0 +1,98 @@ + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from core.database import get_core_db +from core.security import get_current_user +from .service import CanadianTariffFractionService +from .schemas import ( + CanadianTariffFractionResponse, + CanadianTariffFractionCreate, + CanadianTariffFractionUpdate, + CanadianTariffFractionListResponse +) + +from api.v1.common.tenant_crud_routes import validate_access_to_resource + +router = APIRouter() + +@router.get("/", response_model=CanadianTariffFractionListResponse) +def list_canadian_fractions( + company_id: int = Query(..., description="Company ID"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=1000), + search: Optional[str] = None, + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + skip = (page - 1) * page_size + service = CanadianTariffFractionService(db) + items, total = service.get_multi( + tenant_id=tenant_id, + company_id=company_id, + skip=skip, + limit=page_size, + search=search + ) + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "pages": (total + page_size - 1) // page_size if page_size > 0 else 1 + } + +@router.get("/{id}", response_model=CanadianTariffFractionResponse) +def get_canadian_fraction( + id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = CanadianTariffFractionService(db) + item = service.get(id, tenant_id, company_id) + if not item: + raise HTTPException(status_code=404, detail="Not found") + return item + +@router.post("/", response_model=CanadianTariffFractionResponse) +def create_canadian_fraction( + item_in: CanadianTariffFractionCreate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = CanadianTariffFractionService(db) + return service.create(item_in, tenant_id, company_id) + +@router.put("/{id}", response_model=CanadianTariffFractionResponse) +def update_canadian_fraction( + id: int, + item_in: CanadianTariffFractionUpdate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = CanadianTariffFractionService(db) + item = service.get(id, tenant_id, company_id) + if not item: + raise HTTPException(status_code=404, detail="Not found") + return service.update(item, item_in) + +@router.delete("/{id}", response_model=CanadianTariffFractionResponse) +def delete_canadian_fraction( + id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = CanadianTariffFractionService(db) + item = service.get(id, tenant_id, company_id) + if not item: + raise HTTPException(status_code=404, detail="Not found") + return service.delete(id, tenant_id, company_id) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/schemas.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/schemas.py new file mode 100644 index 00000000..4748e4ca --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/schemas.py @@ -0,0 +1,32 @@ + +from typing import Optional, List +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from decimal import Decimal + +class CanadianTariffFractionBase(BaseModel): + fraction: str = Field(..., max_length=13) + ad_valorem: Optional[Decimal] = Field(None, max_digits=5, decimal_places=2) + unit_of_measure: Optional[str] = Field(None, max_length=5) + country_code: str = Field(..., max_length=3) + description: Optional[str] = Field(None, max_length=1000) + +class CanadianTariffFractionCreate(CanadianTariffFractionBase): + pass + +class CanadianTariffFractionUpdate(CanadianTariffFractionBase): + pass + +class CanadianTariffFractionResponse(CanadianTariffFractionBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + +class CanadianTariffFractionListResponse(BaseModel): + items: List[CanadianTariffFractionResponse] + total: int + page: int + page_size: int + pages: int diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py new file mode 100644 index 00000000..82b5a648 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py @@ -0,0 +1,71 @@ + +from typing import Optional, List, Tuple +from sqlalchemy import select, func +from sqlalchemy.orm import Session +from .models import CanadianTariffFraction +from .schemas import CanadianTariffFractionCreate, CanadianTariffFractionUpdate + +class CanadianTariffFractionService: + def __init__(self, db: Session): + self.db = db + + def get(self, id: int, tenant_id: int, company_id: int) -> Optional[CanadianTariffFraction]: + return self.db.query(CanadianTariffFraction).filter( + CanadianTariffFraction.id == id, + CanadianTariffFraction.tenant_id == tenant_id, + CanadianTariffFraction.company_id == company_id + ).first() + + def get_multi( + self, + tenant_id: int, + company_id: int, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None + ) -> Tuple[List[CanadianTariffFraction], int]: + query = select(CanadianTariffFraction).where( + CanadianTariffFraction.tenant_id == tenant_id, + CanadianTariffFraction.company_id == company_id + ) + + if search: + query = query.where( + (CanadianTariffFraction.fraction.ilike(f"%{search}%")) | + (CanadianTariffFraction.description.ilike(f"%{search}%")) + ) + + total = self.db.execute(select(func.count()).select_from(query.subquery())).scalar_one() + items = self.db.scalars(query.offset(skip).limit(limit)).all() + return items, total + + def create(self, obj_in: CanadianTariffFractionCreate, tenant_id: int, company_id: int) -> CanadianTariffFraction: + db_obj = CanadianTariffFraction( + **obj_in.model_dump(), + tenant_id=tenant_id, + company_id=company_id + ) + self.db.add(db_obj) + self.db.commit() + self.db.refresh(db_obj) + return db_obj + + def update( + self, + db_obj: CanadianTariffFraction, + obj_in: CanadianTariffFractionUpdate + ) -> CanadianTariffFraction: + update_data = obj_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_obj, field, value) + self.db.add(db_obj) + self.db.commit() + self.db.refresh(db_obj) + return db_obj + + def delete(self, id: int, tenant_id: int, company_id: int) -> Optional[CanadianTariffFraction]: + obj = self.get(id, tenant_id, company_id) + if obj: + self.db.delete(obj) + self.db.commit() + return obj diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/models.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/models.py index 13a7068e..ceb0c8f9 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/models.py @@ -4,9 +4,10 @@ from decimal import Decimal from sqlalchemy import DateTime, ForeignKey, String, Integer, Numeric, Boolean from sqlalchemy.orm import Mapped, mapped_column from core.database import Base +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin -class HistoricalTariffFraction(Base): +class HistoricalTariffFraction(Base, TenantScopedMixin, TimestampMixin): """ Historical tariff fractions catalog. Maps to SQL Server table: GFraccionesHistorico diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py new file mode 100644 index 00000000..7a020605 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py @@ -0,0 +1,101 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from core.database import get_core_db +from core.security import get_current_user +from .service import HistoricalTariffFractionService +from .schemas import HistoricalTariffFractionResponse, HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate, HistoricalTariffFractionListResponse + +from api.v1.common.tenant_crud_routes import validate_access_to_resource + +router = APIRouter() + +@router.get("/", response_model=HistoricalTariffFractionListResponse) +def get_historical_fractions( + company_id: int = Query(..., description="Company ID"), + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(50, ge=1, le=1000, description="Page size"), + historical_fraction: Optional[str] = Query(None, description="Search by historical fraction code"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + """ + Get all historical tariff fractions (paginated). + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + skip = (page - 1) * page_size + service = HistoricalTariffFractionService(db) + items, total = service.get_multi(tenant_id, company_id, skip=skip, limit=page_size, historical_fraction=historical_fraction) + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "pages": (total + page_size - 1) // page_size if page_size > 0 else 1 + } + +@router.get("/{id}", response_model=HistoricalTariffFractionResponse) +def get_historical_fraction( + id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + """ + Get a historical tariff fraction by ID. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = HistoricalTariffFractionService(db) + fraction = service.get(id, tenant_id, company_id) + if not fraction: + raise HTTPException(status_code=404, detail="Historical tariff fraction not found") + return fraction + +@router.post("/", response_model=HistoricalTariffFractionResponse) +def create_historical_fraction( + fraction_in: HistoricalTariffFractionCreate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + """ + Create a new historical tariff fraction. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = HistoricalTariffFractionService(db) + return service.create(fraction_in, tenant_id, company_id) + +@router.put("/{id}", response_model=HistoricalTariffFractionResponse) +def update_historical_fraction( + id: int, + fraction_in: HistoricalTariffFractionUpdate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + """ + Update a historical tariff fraction. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = HistoricalTariffFractionService(db) + fraction = service.get(id, tenant_id, company_id) + if not fraction: + raise HTTPException(status_code=404, detail="Historical tariff fraction not found") + return service.update(fraction, fraction_in) + +@router.delete("/{id}", response_model=HistoricalTariffFractionResponse) +def delete_historical_fraction( + id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + """ + Delete a historical tariff fraction. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = HistoricalTariffFractionService(db) + fraction = service.get(id, tenant_id, company_id) + if not fraction: + raise HTTPException(status_code=404, detail="Historical tariff fraction not found") + return service.delete(id, tenant_id, company_id) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/schemas.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/schemas.py new file mode 100644 index 00000000..e6cc1c53 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/schemas.py @@ -0,0 +1,45 @@ + +from typing import Optional, List +from datetime import datetime +from decimal import Decimal +from pydantic import BaseModel, Field + +class HistoricalTariffFractionBase(BaseModel): + """Base schema for Historical Tariff Fraction""" + historical_fraction: Optional[str] = Field(None, max_length=8) + unit_of_measure_code: Optional[str] = None + country: Optional[str] = None + fraction_type: Optional[str] = Field(None, max_length=7) + sector: Optional[str] = Field(None, max_length=5) + import_tax_rate: Optional[Decimal] = None + export_tax_rate: Optional[Decimal] = None + publication_date: Optional[datetime] = None + is_immex: Optional[bool] = None + normal_temporality: Optional[bool] = None + services_temporality: Optional[bool] = None + certified_temporality: Optional[bool] = None + by_log: Optional[bool] = None + end_date: Optional[datetime] = None + +class HistoricalTariffFractionCreate(HistoricalTariffFractionBase): + """Schema for creating a Historical Tariff Fraction""" + pass + +class HistoricalTariffFractionUpdate(HistoricalTariffFractionBase): + """Schema for updating a Historical Tariff Fraction""" + pass + +class HistoricalTariffFractionResponse(HistoricalTariffFractionBase): + """Schema for reading a Historical Tariff Fraction""" + id: int + + class Config: + from_attributes = True + +class HistoricalTariffFractionListResponse(BaseModel): + """Schema for paginated list of Historical Tariff Fractions""" + items: List[HistoricalTariffFractionResponse] + total: int + page: int + page_size: int + pages: int diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py new file mode 100644 index 00000000..f585a015 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py @@ -0,0 +1,67 @@ + +from typing import Optional, List, Tuple +from sqlalchemy import select, or_, func +from sqlalchemy.orm import Session +from .models import HistoricalTariffFraction +from .schemas import HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate + +class HistoricalTariffFractionService: + def __init__(self, db: Session): + self.db = db + + def get(self, id: int, tenant_id: int, company_id: int) -> Optional[HistoricalTariffFraction]: + return self.db.query(HistoricalTariffFraction).filter( + HistoricalTariffFraction.id == id, + HistoricalTariffFraction.tenant_id == tenant_id, + HistoricalTariffFraction.company_id == company_id + ).first() + + def get_multi( + self, + tenant_id: int, + company_id: int, + skip: int = 0, + limit: int = 100, + historical_fraction: Optional[str] = None + ) -> Tuple[List[HistoricalTariffFraction], int]: + query = select(HistoricalTariffFraction).where( + HistoricalTariffFraction.tenant_id == tenant_id, + HistoricalTariffFraction.company_id == company_id + ) + + if historical_fraction: + query = query.where(HistoricalTariffFraction.historical_fraction.ilike(f"%{historical_fraction}%")) + + total = self.db.execute(select(func.count()).select_from(query.subquery())).scalar_one() + items = self.db.scalars(query.offset(skip).limit(limit)).all() + return items, total + + def create(self, obj_in: HistoricalTariffFractionCreate, tenant_id: int, company_id: int) -> HistoricalTariffFraction: + db_obj = HistoricalTariffFraction(**obj_in.model_dump()) + db_obj.tenant_id = tenant_id + db_obj.company_id = company_id + self.db.add(db_obj) + self.db.commit() + self.db.refresh(db_obj) + return db_obj + + def update( + self, + db_obj: HistoricalTariffFraction, + obj_in: HistoricalTariffFractionUpdate + ) -> HistoricalTariffFraction: + # db_obj already validated for tenant/company in get() + update_data = obj_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_obj, field, value) + self.db.add(db_obj) + self.db.commit() + self.db.refresh(db_obj) + return db_obj + + def delete(self, id: int, tenant_id: int, company_id: int) -> Optional[HistoricalTariffFraction]: + obj = self.get(id, tenant_id, company_id) + if obj: + self.db.delete(obj) + self.db.commit() + return obj diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py index da1c2881..5f0f1600 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py @@ -30,6 +30,8 @@ async def list_tariff_fractions( page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(50, ge=1, le=10000, description="Page size"), search: Optional[str] = Query(None, description="Search in code, fraction, description, nico, or umt"), + level: Optional[int] = Query(None, description="Filter by hierarchy level (e.g. 5)"), + catalog: Optional[str] = Query("mex", description="Catalog source: 'mex' (default) or 'usa'"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -37,13 +39,20 @@ async def list_tariff_fractions( filters = {} if search: filters["search"] = search + if level is not None: + filters["level"] = level # Updated to async call with Sitar integration # WARNING: Using async def with blocking DB dependency (Session) run in threadpool by FastAPI. # Service.get_all calls Sitar (async) or DB (sync). # This should be fine. + + tenant_id = current_user.get("tenant_id") + company_id = current_user.get("company_id") # Assuming user is context-aware or we use a default? + # If using headers for selected company, it might be in current_user context if middleware sets it. + items, total = await TariffFractionService.get_all( - db, skip, page_size, filters + db, skip, page_size, filters, catalog, tenant_id, company_id ) return { @@ -72,3 +81,132 @@ async def get_tariff_fraction( raise HTTPException(status_code=404, detail="Tariff fraction not found") return TariffFractionResponseDTO.model_validate(item) + +@router.post( + "/", + response_model=TariffFractionResponseDTO, + summary="Create Tariff Fraction", + description="Create a new tariff fraction (Only supported for 'american' catalog)", +) +async def create_tariff_fraction( + fraction_data: TariffFractionCreateDTO, + catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Crea una nueva fracción. + - MEX/USA: No permitido (Read-Only) + - AMERICAN: Permitido (Local DB) + """ + from fastapi import HTTPException + + tenant_id = current_user.get("tenant_id") + + if catalog == "american": + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionCreateDTO + import re + + # Map generic DTO to US DTO + ad_valorem = None + if fraction_data.adv_impo: + try: + # remove non-numeric chars except dot + clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo) + if clean: + ad_valorem = float(clean) + except: + pass + + us_dto = USTariffFractionCreateDTO( + code=fraction_data.code, + description=fraction_data.description, + unit_of_measure=fraction_data.umt, + ad_valorem=ad_valorem, + # Defaults for others + prefix=None, + type_code=None, + fixed_cost=None + ) + + created = USTariffFractionService.create(db, tenant_id, company_id, us_dto) + return TariffFractionService.to_domain_usa_local(created) + + else: + raise HTTPException(status_code=400, detail=f"Creation not allowed for '{catalog}' catalog (Read-Only)") + + +@router.put( + "/{tariff_fraction_id}", + response_model=TariffFractionResponseDTO, + summary="Update Tariff Fraction", + description="Update a tariff fraction (Only supported for 'american' catalog)", +) +async def update_tariff_fraction( + tariff_fraction_id: int, + fraction_data: TariffFractionUpdateDTO, + catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + from fastapi import HTTPException + + tenant_id = current_user.get("tenant_id") + + if catalog == "american": + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionUpdateDTO + import re + + ad_valorem = None + if fraction_data.adv_impo: + try: + clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo) + if clean: + ad_valorem = float(clean) + except: + pass + + us_dto = USTariffFractionUpdateDTO( + description=fraction_data.description, + unit_of_measure=fraction_data.umt, + ad_valorem=ad_valorem + ) + + updated = USTariffFractionService.update(db, tenant_id, company_id, tariff_fraction_id, us_dto) + if not updated: + raise HTTPException(status_code=404, detail="US Tariff fraction not found") + return TariffFractionService.to_domain_usa_local(updated) + + else: + raise HTTPException(status_code=400, detail=f"Update not allowed for '{catalog}' catalog (Read-Only)") + + +@router.delete( + "/{tariff_fraction_id}", + summary="Delete Tariff Fraction", + description="Delete a tariff fraction (Only supported for 'american' catalog)", +) +async def delete_tariff_fraction( + tariff_fraction_id: int, + catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + from fastapi import HTTPException + tenant_id = current_user.get("tenant_id") + + if catalog == "american": + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService + success = USTariffFractionService.delete(db, tenant_id, company_id, tariff_fraction_id) + if not success: + raise HTTPException(status_code=404, detail="US Tariff fraction not found") + return {"ok": True} + + else: + raise HTTPException(status_code=400, detail=f"Delete not allowed for '{catalog}' catalog (Read-Only)") + diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py index fd719085..09ea40bb 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py @@ -14,6 +14,8 @@ from .models import TariffFraction from .dto import TariffFractionCreateDTO, TariffFractionUpdateDTO from api.v1.modules.sitar.fracciones.service import FraccionesService from api.v1.modules.sitar.fracciones.schemas import FraccionesResponse +from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService +from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse logger = logging.getLogger(__name__) @@ -68,30 +70,117 @@ class TariffFractionMapper: return tf + @staticmethod + def to_domain_usa(item: FraccionesUSAResponse) -> TariffFraction: + """Map US Fraction to Domain""" + return TariffFraction( + id=item.CONSECUTIVO, + code=item.FRACCION_SIN_PUNTO or "", + fraction=item.FRACCION_CON_PUNTO or "", + description=item.DESCRIPCION or "(Sin descripción)", + nico=None, # Not applicable + umt=item.UNIDADCANTIDAD, + adv_impo=item.TARIFA1, + adv_expo=item.TARIFA2 + ) + class TariffFractionService: """Service para gestionar fracciones arancelarias (catálogo global)""" + + @staticmethod + def to_domain_usa_local(item: Any) -> TariffFraction: + """Map Local US Fraction (ORM) to Domain""" + # Formatter helper (simple logic: add dots every 2/4 chars? or just return as is?) + # US format: 1234.56.78.90. For now return as is or use helper if available. + # item is USTariffFraction (imported inside method to avoid circular import if needed, or assumed available) + + return TariffFraction( + id=item.id, + code=item.code, + fraction=item.code, # TODO: Format if needed + description=item.description or "(Sin descripción)", + nico=None, + umt=item.unit_of_measure, + adv_impo=str(item.ad_valorem) if item.ad_valorem is not None else None, + adv_expo=None + ) + @staticmethod async def get_all( db: Session, skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, + catalog: str = "mex", + tenant_id: Optional[int] = None, + company_id: Optional[int] = None, ) -> Tuple[List[TariffFraction], int]: """ Obtiene fracciones arancelarias. - Estrategia: Sitar API -> Fallback Local DB + Estrategia: + - MEX: Sitar API -> Fallback Local DB + - USA: Local DB (Defined by user requirement) """ - # 1. Try Sitar API + # AMERICAN CATALOG HANDLING (LOCAL - 'Fracciones Americanas') + if catalog == "american": + if tenant_id is None or company_id is None: + logger.warning("Solicitud de fracciones Americanas sin tenant/company ID") + return [], 0 + + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService + + # Use local service directly + usa_items, total = USTariffFractionService._get_all_local( + db, tenant_id, company_id, skip, limit, filters + ) + + items = [TariffFractionMapper.to_domain_usa_local(item) for item in usa_items] + return items, total + + # USA CATALOG HANDLING (API - 'Fracciones US') + if catalog == "usa": + try: + usa_service = FraccionesUSAService.get_instance() + search_term = None + if filters and filters.get("search"): + search_term = filters["search"] + + # USA Service search signature: fraccion, skip, limit + usa_items = await usa_service.search( + fraccion=search_term, + skip=skip, + limit=limit + ) + + items = [TariffFractionMapper.to_domain_usa(item) for item in usa_items] + total = len(items) + skip + if len(items) == limit: + total += 1 + return items, total + except Exception as e: + logger.error(f"Error fetching USA fractions (API): {e}") + # Return empty list on error as per requirement (since API is broken) + return [], 0 + + # MEX (SITAR) CATALOG HANDLING try: sitar_service = FraccionesService.get_instance() # Map filters sitar_fraccion = None sitar_nico = None - has_filters = False + + # Default level logic + level_filter = 5 # Default legacy + if filters and filters.get("level") is not None: + level_filter = filters["level"] + + # Allow disabling level filter explicitly + if level_filter == -1: + level_filter = None if filters: if filters.get("search"): @@ -101,7 +190,6 @@ class TariffFractionService: clean_term = term.replace(".", "") if clean_term and clean_term[0].isdigit(): sitar_fraccion = clean_term - has_filters = True else: # Attempt description search via API first logger.info(f"Search term '{term}' identified as text. Attempting API description search.") @@ -109,13 +197,10 @@ class TariffFractionService: if filters.get("code"): sitar_fraccion = filters["code"] - has_filters = True if filters.get("fraction"): sitar_fraccion = filters["fraction"] - has_filters = True if filters.get("nico"): sitar_nico = filters["nico"] - has_filters = True # Determine description filter sitar_description = None @@ -124,7 +209,6 @@ class TariffFractionService: clean_term = filters["search"].replace(".", "") if not (clean_term and clean_term[0].isdigit()): sitar_description = filters["search"] - has_filters = True # Note: Sitar search might not return total count. # We fetch page items. Pagination might be tricky if Sitar doesn't return total. @@ -133,7 +217,7 @@ class TariffFractionService: fraccion=sitar_fraccion, nico=sitar_nico, description=sitar_description, - nivel=5, # User requested filtering by level 5 + nivel=level_filter, # Dynamic level skip=skip, limit=limit ) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/models.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/models.py index cb08b05e..cc9097c4 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/models.py @@ -9,9 +9,10 @@ from sqlalchemy import String, Numeric, TIMESTAMP, func from sqlalchemy.orm import Mapped, mapped_column from core.database import Base +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin -class USTariffFraction(Base): +class USTariffFraction(Base, TenantScopedMixin, TimestampMixin): """Modelo para fracciones arancelarias americanas (US HTS codes)""" __tablename__ = "us_tariff_fractions" @@ -20,10 +21,6 @@ class USTariffFraction(Base): # Primary Key id: Mapped[int] = mapped_column(primary_key=True, index=True) - # Tenant/Company - tenant_id: Mapped[int] = mapped_column(index=True, nullable=False) - company_id: Mapped[int] = mapped_column(index=True, nullable=False) - # Datos principales code: Mapped[str] = mapped_column( String(16), nullable=False, comment="Código de fracción americana" @@ -47,16 +44,5 @@ class USTariffFraction(Base): String, comment="Descripción de la fracción" ) - # Timestamps - created_at: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=True), server_default=func.now(), nullable=False - ) - updated_at: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) - def __repr__(self): return f"" diff --git a/backend/api/v1/modules/a76/general_catalogs/router.py b/backend/api/v1/modules/a76/general_catalogs/router.py index c6beaf5a..3170d808 100644 --- a/backend/api/v1/modules/a76/general_catalogs/router.py +++ b/backend/api/v1/modules/a76/general_catalogs/router.py @@ -6,6 +6,8 @@ from .packages.routes import router as package_router from .ports.routes import router as ports_router from .fractions.tariff_fractions.routes import router as tariff_fractions_router from .fractions.us_tariff_fractions.routes import router as us_tariff_fractions_router +from .fractions.historical_tariff_fractions.routes import router as historical_tariff_fractions_router +from .fractions.canadian_tariff_fractions.routes import router as canadian_tariff_fractions_router from .depreciation_catalog.routes import router as depreciation_catalog_router from .fda_catalog.routes import router as fda_catalog_router from .seal.routes import router as seal_router @@ -31,6 +33,8 @@ router.include_router(package_router) router.include_router(ports_router) router.include_router(tariff_fractions_router) router.include_router(us_tariff_fractions_router) +router.include_router(historical_tariff_fractions_router, prefix="/fractions/historical-tariff-fractions", tags=["a76 / historical_tariff_fractions"]) +router.include_router(canadian_tariff_fractions_router, prefix="/fractions/canadian-tariff-fractions", tags=["a76 / canadian_tariff_fractions"]) router.include_router(depreciation_catalog_router) router.include_router(fda_catalog_router) router.include_router(seal_router, tags=["a76 / seal"]) diff --git a/backend/api/v1/modules/public/reference_data/sectors/dto.py b/backend/api/v1/modules/public/reference_data/sectors/dto.py index f25ca5dd..e97e8a1d 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/dto.py +++ b/backend/api/v1/modules/public/reference_data/sectors/dto.py @@ -4,6 +4,6 @@ from pydantic import BaseModel, ConfigDict, Field class SectorDTO(BaseModel): key: str = Field(..., min_length=1, max_length=8) description: str - authorized: int + authorized: bool model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/sectors/routes.py b/backend/api/v1/modules/public/reference_data/sectors/routes.py index 05aa0f72..46d44779 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/routes.py +++ b/backend/api/v1/modules/public/reference_data/sectors/routes.py @@ -1,8 +1,10 @@ -from typing import Any, Dict + +from typing import Any, Dict, Optional from core.database import get_core_db -from core.security import get_current_user, has_role +from core.security import get_current_user from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import or_ from sqlalchemy.orm import Session from .dto import SectorDTO @@ -15,13 +17,23 @@ router = APIRouter(prefix="/sectors") def list_sectors( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + search: Optional[str] = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): skip = (page - 1) * page_size query = db.query(Sector) - items = query.offset(skip).limit(page_size).all() + + if search: + search_filter = or_( + Sector.key.ilike(f"%{search}%"), + Sector.description.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + total = query.count() + items = query.offset(skip).limit(page_size).all() + return { "items": [SectorDTO.model_validate(obj) for obj in items], "total": total, @@ -40,47 +52,3 @@ def get_sector( if not obj: raise HTTPException(status_code=404, detail="Not found") return obj - - -@router.post("/", response_model=SectorDTO, status_code=201) -def create_sector( - data: SectorDTO, - db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")), -): - obj = Sector(**data.dict()) - db.add(obj) - db.commit() - db.refresh(obj) - return obj - - -@router.put("/{key}", response_model=SectorDTO) -def update_sector( - key: str, - data: SectorDTO, - db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")), -): - obj = db.query(Sector).filter(Sector.key == key).first() - if not obj: - raise HTTPException(status_code=404, detail="Not found") - for field, value in data.dict().items(): - setattr(obj, field, value) - db.commit() - db.refresh(obj) - return obj - - -@router.delete("/{key}", status_code=204) -def delete_sector( - key: str, - db: Session = Depends(get_core_db), - current_user: dict = Depends(has_role("admin")), -): - obj = db.query(Sector).filter(Sector.key == key).first() - if not obj: - raise HTTPException(status_code=404, detail="Not found") - db.delete(obj) - db.commit() - return None diff --git a/backend/api/v1/modules/sitar/fracciones_usa/schemas.py b/backend/api/v1/modules/sitar/fracciones_usa/schemas.py index 0022069e..1c5a8ba7 100644 --- a/backend/api/v1/modules/sitar/fracciones_usa/schemas.py +++ b/backend/api/v1/modules/sitar/fracciones_usa/schemas.py @@ -11,7 +11,7 @@ class FraccionesUSAResponse(BaseModel): FRACCION_CON_PUNTO: Optional[str] = None FRACCION_MOSTRAR: Optional[str] = None ESPECIFICO: Optional[str] = None - NIVEL: Optional[str] = None + NIVEL: Optional[int] = None DESCRIPCION: Optional[str] = None UNIDADCANTIDAD: Optional[str] = None TARIFA1: Optional[str] = None diff --git a/backend/api/v1/modules/sitar/fracciones_usa/service.py b/backend/api/v1/modules/sitar/fracciones_usa/service.py index 5109671c..00820835 100644 --- a/backend/api/v1/modules/sitar/fracciones_usa/service.py +++ b/backend/api/v1/modules/sitar/fracciones_usa/service.py @@ -1,10 +1,11 @@ """Fracciones USA Service""" -from typing import Optional, List +from typing import Optional, List, Dict, Any from ..common import SitarAPIBaseService from .schemas import FraccionesUSAResponse + class FraccionesUSAService(SitarAPIBaseService): """Service for USA Fracciones operations""" @@ -28,10 +29,10 @@ class FraccionesUSAService(SitarAPIBaseService): if fraccion: params["fraccion"] = fraccion - data = await self._make_request("GET", "/api/v1/fracciones-usa/", params=params) + data = await self._make_request("GET", "api/v1/fracciones-usa/", params=params) return [FraccionesUSAResponse(**item) for item in data] async def get_by_id(self, consecutivo: int) -> FraccionesUSAResponse: """Get single USA Fraccion record by CONSECUTIVO""" - data = await self._make_request("GET", f"/api/v1/fracciones-usa/{consecutivo}") + data = await self._make_request("GET", f"api/v1/fracciones-usa/{consecutivo}") return FraccionesUSAResponse(**data) diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index b95e612d..474bd601 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -21,7 +21,7 @@ router.include_router(core_router) router.include_router(a76_router) router.include_router(a24_router) router.include_router(public_router) -router.include_router(sitar_router) +router.include_router(sitar_router, prefix="/sitar") # Health check diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts new file mode 100644 index 00000000..427d0a90 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts @@ -0,0 +1,47 @@ +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface HistoricalFraction { + id: number; + historical_fraction: string | null; + unit_of_measure_code: string | null; + country: string | null; + fraction_type: string | null; + sector: string | null; + import_tax_rate: number | null; + export_tax_rate: number | null; + publication_date: string | null; + is_immex: boolean | null; + normal_temporality: boolean | null; + services_temporality: boolean | null; + certified_temporality: boolean | null; + by_log: boolean | null; + end_date: string | null; +} + +export interface HistoricalFractionList { + items: HistoricalFraction[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export async function getHistoricalFractions( + companyId: number, + historicalFraction?: string, + page = 1, + pageSize = 50 +): Promise { + const params = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString() + }); + + if (historicalFraction) params.append('historical_fraction', historicalFraction); + + const response = await api.get(`/v1/a76/general_catalogs/fractions/historical-tariff-fractions/?${params.toString()}`); + if (!response.data) throw new Error('Error fetching historical fractions'); + return response.data; +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts index 556dd6c7..5da92ab0 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts @@ -69,22 +69,33 @@ export async function getTariffFractionById( export async function createTariffFraction( data: TariffFractionCreate, - companyId: number + companyId: number, + catalog = 'mex' ): Promise> { - return await api.post(`/v1/a76/tariff-fractions/?company_id=${companyId}`, data); + return await api.post( + `/v1/a76/tariff-fractions/?company_id=${companyId}&catalog=${catalog}`, + data + ); } export async function updateTariffFraction( id: number, data: TariffFractionUpdate, - companyId: number + companyId: number, + catalog = 'mex' ): Promise> { - return await api.put(`/v1/a76/tariff-fractions/${id}/?company_id=${companyId}`, data); + return await api.put( + `/v1/a76/tariff-fractions/${id}/?company_id=${companyId}&catalog=${catalog}`, + data + ); } export async function deleteTariffFraction( id: number, - companyId: number + companyId: number, + catalog = 'mex' ): Promise> { - return await api.delete(`/v1/a76/tariff-fractions/${id}/?company_id=${companyId}`); + return await api.delete( + `/v1/a76/tariff-fractions/${id}/?company_id=${companyId}&catalog=${catalog}` + ); } diff --git a/frontend/src/lib/api/dashboard/general_catalogs/canadian.ts b/frontend/src/lib/api/dashboard/general_catalogs/canadian.ts new file mode 100644 index 00000000..0b5b73f2 --- /dev/null +++ b/frontend/src/lib/api/dashboard/general_catalogs/canadian.ts @@ -0,0 +1,77 @@ + +import { api } from '$lib/api'; + +export interface CanadianFraction { + id: number; + fraction: string; + ad_valorem: number | null; + unit_of_measure: string | null; + country_code: string; + description: string | null; + created_at: string; + updated_at: string; +} + +export interface CanadianFractionList { + items: CanadianFraction[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export interface CanadianFractionCreate { + fraction: string; + ad_valorem?: number; + unit_of_measure?: string; + country_code: string; + description?: string; +} + +export interface CanadianFractionUpdate { + fraction?: string; + ad_valorem?: number; + unit_of_measure?: string; + country_code?: string; + description?: string; +} + +const BASE_URL = '/v1/a76/fractions/canadian-tariff-fractions/'; + +export async function getCanadianFractions( + companyId: number, + page = 1, + pageSize = 50, + search?: string +): Promise { + const params = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString() + }); + + if (search) params.append('search', search); + + const response = await api.get(`${BASE_URL}?${params.toString()}`); + if (!response.data) throw new Error('Error fetching Canadian fractions'); + return response.data; +} + +export async function createCanadianFraction(companyId: number, data: CanadianFractionCreate): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.post(`${BASE_URL}?${params.toString()}`, data); + if (!response.data) throw new Error('Error creating Canadian fraction'); + return response.data; +} + +export async function updateCanadianFraction(companyId: number, id: number, data: CanadianFractionUpdate): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.put(`${BASE_URL}${id}?${params.toString()}`, data); + if (!response.data) throw new Error('Error updating Canadian fraction'); + return response.data; +} + +export async function deleteCanadianFraction(companyId: number, id: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + await api.delete(`${BASE_URL}${id}?${params.toString()}`); +} diff --git a/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts b/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts new file mode 100644 index 00000000..b92acd2f --- /dev/null +++ b/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts @@ -0,0 +1,35 @@ + +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Sector { + key: string; + description: string; + authorized: boolean; +} + +export interface SectorListResponse { + items: Sector[]; + total: number; + page: number; + page_size: number; +} + +export async function getSectors( + page = 1, + pageSize = 50, + search?: string +): Promise { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString() + }); + + if (search) { + params.append('search', search); + } + + const response = await api.get(`/v1/public/reference_data/sectors/?${params.toString()}`); + if (!response.data) throw new Error('Error fetching sectors'); + return response.data; +} diff --git a/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte b/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte new file mode 100644 index 00000000..9527302c --- /dev/null +++ b/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte @@ -0,0 +1,153 @@ + + +
+
+

{title}

+
+ + + +
+
+ + e.key === 'Enter' && handleSearch()} + /> +
+ +
+
+ +
+ + + + Clave + Descripción + Estatus + + + + {#if loading && page === 1} + + +
+ +
+
+
+ {:else if sectors.length === 0} + + + No se encontraron sectores. + + + {:else} + {#each sectors as sector} + + {sector.key} + {sector.description} + + {#if sector.authorized} + Autorizado + {:else} + No Autorizado + {/if} + + + {/each} + {/if} +
+
+
+ +
+
+ Mostrando {sectors.length} de {total} registros +
+ {#if hasMore} + + {/if} +
+
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionList.svelte new file mode 100644 index 00000000..422120b0 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionList.svelte @@ -0,0 +1,155 @@ + + +
+
+
+ +
+ + +
+
+
+ +
+
+ +
+ + + + Fracción + Descripción + País + Unidad + ADV + + + + {#if loading} + + +
+ +
+
+
+ {:else if fractions.length === 0} + + No se encontraron resultados + + {:else} + {#each fractions as fraction} + + {fraction.fraction} + {fraction.description || '-'} + {fraction.country_code} + {fraction.unit_of_measure || '-'} + {fraction.ad_valorem ?? '-'} + + {/each} + {/if} +
+
+
+ + +
+ +
+ Página {page} de {totalPages || 1} +
+ +
+
diff --git a/frontend/src/lib/components/dashboard/goods/fractions/HistoricalFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/HistoricalFractionList.svelte new file mode 100644 index 00000000..e55ccd25 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/fractions/HistoricalFractionList.svelte @@ -0,0 +1,169 @@ + + +
+
+
+ +
+ + +
+
+
+ +
+
+ +
+ + + + Fracción + Tipo + UM + País + Fecha Pub. + Fecha Fin + IGI + IGE + + + + {#if loading} + + +
+ +
+
+
+ {:else if fractions.length === 0} + + No se encontraron resultados + + {:else} + {#each fractions as fraction} + + {fraction.historical_fraction} + {fraction.fraction_type || '-'} + {fraction.unit_of_measure_code || '-'} + {fraction.country || '-'} + {fraction.publication_date + ? new Date(fraction.publication_date).toLocaleDateString() + : '-'} + {fraction.end_date + ? new Date(fraction.end_date).toLocaleDateString() + : '-'} + {fraction.import_tax_rate ?? '-'} + {fraction.export_tax_rate ?? '-'} + + {/each} + {/if} +
+
+
+ + +
+ +
+ Página {page} de {totalPages || 1} +
+ +
+
diff --git a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionFormDialog.svelte b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionFormDialog.svelte new file mode 100644 index 00000000..93eea293 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionFormDialog.svelte @@ -0,0 +1,198 @@ + + + + + + {fraction ? 'Editar' : 'Crear'} Fracción Arancelaria {catalog === 'usa' + ? '(USA)' + : ''} + + {fraction + ? 'Modifica los detalles de la fracción seleccionada.' + : 'Ingresa los datos para la nueva fracción.'} + + + +
+
+
+ + + {#if fraction} +

+ El código no se puede modificar una vez creado. +

+ {/if} +
+
+ + +
+
+ +
+ +