feat: Add general catalogs for historical, Canadian, US, and SITAR tariff fractions and sectors with full stack implementation.

This commit is contained in:
Galindo97
2026-02-17 10:22:54 -06:00
parent 307bc5d3ce
commit 2a0480a7f3
36 changed files with 2089 additions and 96 deletions

View File

@@ -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")

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)")

View File

@@ -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
)

View File

@@ -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"<USTariffFraction {self.code}>"

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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<HistoricalFractionList> {
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<HistoricalFractionList>(`/v1/a76/general_catalogs/fractions/historical-tariff-fractions/?${params.toString()}`);
if (!response.data) throw new Error('Error fetching historical fractions');
return response.data;
}

View File

@@ -69,22 +69,33 @@ export async function getTariffFractionById(
export async function createTariffFraction(
data: TariffFractionCreate,
companyId: number
companyId: number,
catalog = 'mex'
): Promise<ApiResponse<TariffFraction>> {
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<ApiResponse<TariffFraction>> {
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<ApiResponse<void>> {
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}`
);
}

View File

@@ -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<CanadianFractionList> {
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<CanadianFractionList>(`${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<CanadianFraction> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<CanadianFraction>(`${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<CanadianFraction> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.put<CanadianFraction>(`${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<void> {
const params = new URLSearchParams({ company_id: companyId.toString() });
await api.delete(`${BASE_URL}${id}?${params.toString()}`);
}

View File

@@ -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<SectorListResponse> {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString()
});
if (search) {
params.append('search', search);
}
const response = await api.get<SectorListResponse>(`/v1/public/reference_data/sectors/?${params.toString()}`);
if (!response.data) throw new Error('Error fetching sectors');
return response.data;
}

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table';
import * as Card from '$lib/components/ui/card';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { getSectors, type Sector } from '$lib/api/dashboard/general_catalogs/sectors';
import { Loader2, Search } from 'lucide-svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
export let title = 'Sectores';
let sectors: Sector[] = [];
let loading = false;
let searchTerm = '';
let page = 1;
let pageSize = 50;
let hasMore = true;
let total = 0;
async function loadSectors(reset = false) {
if (loading || (!hasMore && !reset)) return;
loading = true;
if (reset) {
page = 1;
sectors = [];
hasMore = true;
}
try {
const response = await getSectors(page, pageSize, searchTerm || undefined);
if (response.items.length === 0) {
hasMore = false;
} else {
sectors = reset ? response.items : [...sectors, ...response.items];
total = response.total;
hasMore = sectors.length < total;
if (sectors.length >= total) hasMore = false;
}
} catch (error) {
console.error('Error loading sectors:', error);
toast.error('Error al cargar sectores');
} finally {
loading = false;
}
}
function handleSearch() {
loadSectors(true);
}
function handleLoadMore() {
if (!loading && hasMore) {
page++;
loadSectors();
}
}
onMount(() => {
loadSectors(true);
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-3xl font-bold tracking-tight">{title}</h2>
</div>
<Card.Root>
<Card.Header>
<div class="flex w-full items-center gap-4">
<div class="relative flex-1">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar por clave o descripción..."
class="pl-8"
bind:value={searchTerm}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Button onclick={handleSearch} disabled={loading}>
{#if loading && page === 1}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Buscar
</Button>
</div>
</Card.Header>
<Card.Content>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Clave</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head class="text-right">Estatus</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading && page === 1}
<Table.Row>
<Table.Cell colspan={3} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{:else if sectors.length === 0}
<Table.Row>
<Table.Cell colspan={3} class="h-24 text-center">
No se encontraron sectores.
</Table.Cell>
</Table.Row>
{:else}
{#each sectors as sector}
<Table.Row>
<Table.Cell class="font-medium">{sector.key}</Table.Cell>
<Table.Cell>{sector.description}</Table.Cell>
<Table.Cell class="text-right">
{#if sector.authorized}
<Badge variant="default">Autorizado</Badge>
{:else}
<Badge variant="secondary">No Autorizado</Badge>
{/if}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<div class="mt-4 flex flex-col items-center gap-2">
<div class="text-xs text-muted-foreground">
Mostrando {sectors.length} de {total} registros
</div>
{#if hasMore}
<Button variant="outline" onclick={handleLoadMore} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Cargando...
{:else}
Cargar más
{/if}
</Button>
{/if}
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
getCanadianFractions,
type CanadianFraction
} from '$lib/api/dashboard/general_catalogs/canadian';
import * as Table from '$lib/components/ui/table';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Search, Loader2 } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
let fractions: CanadianFraction[] = [];
let loading = false;
let searchQuery = '';
let page = 1;
let totalItems = 0;
let totalPages = 0;
let pageSize = 50;
async function loadFractions(targetPage = 1) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
loading = true;
page = targetPage;
try {
const response = await getCanadianFractions(
companyId,
page,
pageSize,
searchQuery || undefined
);
fractions = response.items;
totalItems = response.total;
totalPages = response.pages;
} catch (error) {
console.error('Error loading Canadian fractions:', error);
toast.error('Error al cargar fracciones canadienses');
} finally {
loading = false;
}
}
function handleSearch() {
loadFractions(1);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSearch();
}
}
onMount(() => {
if (companyStore.activeCompany) {
loadFractions(1);
}
});
$effect(() => {
if (companyStore.activeCompany?.id) {
loadFractions(1);
}
});
</script>
<div class="space-y-4">
<div class="flex flex-col gap-4 md:flex-row">
<div class="flex-1">
<label for="search-fraction" class="mb-2 block text-sm font-medium">Buscar</label>
<div class="relative">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search-fraction"
type="text"
placeholder="Buscar por fracción o descripción..."
class="pl-9"
bind:value={searchQuery}
onkeydown={handleKeyDown}
/>
</div>
</div>
<div class="flex items-end">
<Button onclick={handleSearch} disabled={loading}>Buscar</Button>
</div>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Fracción</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head>País</Table.Head>
<Table.Head>Unidad</Table.Head>
<Table.Head class="text-right">ADV</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={5} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{:else if fractions.length === 0}
<Table.Row>
<Table.Cell colspan={5} class="h-24 text-center"
>No se encontraron resultados</Table.Cell
>
</Table.Row>
{:else}
{#each fractions as fraction}
<Table.Row>
<Table.Cell class="font-medium">{fraction.fraction}</Table.Cell>
<Table.Cell>{fraction.description || '-'}</Table.Cell>
<Table.Cell>{fraction.country_code}</Table.Cell>
<Table.Cell>{fraction.unit_of_measure || '-'}</Table.Cell>
<Table.Cell class="text-right">{fraction.ad_valorem ?? '-'}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Pagination -->
<div class="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onclick={() => loadFractions(page - 1)}
disabled={page === 1 || loading}
>
Anterior
</Button>
<div class="text-sm text-muted-foreground">
Página {page} de {totalPages || 1}
</div>
<Button
variant="outline"
size="sm"
onclick={() => loadFractions(page + 1)}
disabled={page >= totalPages || loading}
>
Siguiente
</Button>
</div>
</div>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
getHistoricalFractions,
type HistoricalFraction
} from '$lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions';
import * as Table from '$lib/components/ui/table';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Search, Loader2 } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
let fractions = $state<HistoricalFraction[]>([]);
let loading = $state(false);
let historicalFraction = $state('');
let page = $state(1);
let totalItems = $state(0);
let totalPages = $state(0);
let pageSize = 50;
async function loadFractions(targetPage = 1) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
loading = true;
page = targetPage;
try {
const response = await getHistoricalFractions(
companyId,
historicalFraction || undefined,
page,
pageSize
);
fractions = response.items;
totalItems = response.total;
totalPages = response.pages;
} catch (error) {
console.error('Error loading historical fractions:', error);
toast.error('Error al cargar fracciones históricas');
} finally {
loading = false;
}
}
function handleSearch() {
loadFractions(1);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSearch();
}
}
onMount(() => {
if (companyStore.activeCompany?.id) {
loadFractions(1);
}
});
$effect(() => {
if (companyStore.activeCompany?.id) {
loadFractions(1);
}
});
</script>
<div class="space-y-4">
<div class="flex flex-col gap-4 md:flex-row">
<div class="flex-1">
<label for="search-fraction" class="mb-2 block text-sm font-medium">Fracción Histórica</label>
<div class="relative">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search-fraction"
type="text"
placeholder="Buscar fracción..."
class="pl-9"
bind:value={historicalFraction}
onkeydown={handleKeyDown}
/>
</div>
</div>
<div class="flex items-end">
<Button onclick={handleSearch} disabled={loading}>Buscar</Button>
</div>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Fracción</Table.Head>
<Table.Head>Tipo</Table.Head>
<Table.Head>UM</Table.Head>
<Table.Head>País</Table.Head>
<Table.Head>Fecha Pub.</Table.Head>
<Table.Head>Fecha Fin</Table.Head>
<Table.Head class="text-right">IGI</Table.Head>
<Table.Head class="text-right">IGE</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={8} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{:else if fractions.length === 0}
<Table.Row>
<Table.Cell colspan={8} class="h-24 text-center"
>No se encontraron resultados</Table.Cell
>
</Table.Row>
{:else}
{#each fractions as fraction}
<Table.Row>
<Table.Cell class="font-medium">{fraction.historical_fraction}</Table.Cell>
<Table.Cell>{fraction.fraction_type || '-'}</Table.Cell>
<Table.Cell>{fraction.unit_of_measure_code || '-'}</Table.Cell>
<Table.Cell>{fraction.country || '-'}</Table.Cell>
<Table.Cell
>{fraction.publication_date
? new Date(fraction.publication_date).toLocaleDateString()
: '-'}</Table.Cell
>
<Table.Cell
>{fraction.end_date
? new Date(fraction.end_date).toLocaleDateString()
: '-'}</Table.Cell
>
<Table.Cell class="text-right">{fraction.import_tax_rate ?? '-'}</Table.Cell>
<Table.Cell class="text-right">{fraction.export_tax_rate ?? '-'}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Pagination -->
<div class="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onclick={() => loadFractions(page - 1)}
disabled={page === 1 || loading}
>
Anterior
</Button>
<div class="text-sm text-muted-foreground">
Página {page} de {totalPages || 1}
</div>
<Button
variant="outline"
size="sm"
onclick={() => loadFractions(page + 1)}
disabled={page >= totalPages || loading}
>
Siguiente
</Button>
</div>
</div>

View File

@@ -0,0 +1,198 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea';
import {
createTariffFraction,
updateTariffFraction,
type TariffFraction,
type TariffFractionCreate,
type TariffFractionUpdate
} from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions';
import { companyStore } from '$lib/stores/company.svelte';
import { toast } from 'svelte-sonner';
import { Loader2 } from 'lucide-svelte';
let {
open = $bindable(false),
fraction = null, // If null, create mode. If set, edit mode.
catalog = 'mex',
onSuccess
}: {
open: boolean;
fraction?: TariffFraction | null;
catalog?: string;
onSuccess: () => void;
} = $props();
let isLoading = $state(false);
// Form fields
let code = $state('');
let fractionFormatted = $state('');
let description = $state('');
let nico = $state('');
let umt = $state('');
let adv_impo = $state('');
let adv_expo = $state('');
let um_code = $state(''); // New field for unit code
// Load data on open/fraction change
$effect(() => {
if (open) {
if (fraction) {
// Edit mode
code = fraction.code;
fractionFormatted = fraction.fraction;
description = fraction.description || '';
nico = fraction.nico || '';
umt = fraction.umt || '';
adv_impo = fraction.adv_impo || '';
adv_expo = fraction.adv_expo || '';
um_code = fraction.um_code || '';
} else {
// Create mode - reset
code = '';
fractionFormatted = '';
description = '';
nico = '';
umt = '';
adv_impo = '';
adv_expo = '';
um_code = '';
}
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
isLoading = true;
try {
if (fraction) {
// Update
const updateData: TariffFractionUpdate = {
fraction: fractionFormatted,
description,
nico: catalog === 'mex' ? nico : null,
umt,
adv_impo,
adv_expo
};
await updateTariffFraction(fraction.id, updateData, companyId, catalog);
toast.success('Fracción actualizada correctamente');
} else {
// Create
const createData: TariffFractionCreate = {
code,
fraction: fractionFormatted,
description,
nico: catalog === 'mex' ? nico : null,
umt,
adv_impo,
adv_expo
};
await createTariffFraction(createData, companyId, catalog);
toast.success('Fracción creada correctamente');
}
onSuccess();
} catch (error) {
console.error('Error saving fraction:', error);
toast.error('Error al guardar la fracción');
} finally {
isLoading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title
>{fraction ? 'Editar' : 'Crear'} Fracción Arancelaria {catalog === 'usa'
? '(USA)'
: ''}</Dialog.Title
>
<Dialog.Description>
{fraction
? 'Modifica los detalles de la fracción seleccionada.'
: 'Ingresa los datos para la nueva fracción.'}
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="code">Clave / Código {catalog === 'mex' ? '(Sin puntos)' : ''}</Label>
<Input id="code" bind:value={code} disabled={!!fraction} placeholder="Ej. 01012101" />
{#if fraction}
<p class="text-xs text-muted-foreground">
El código no se puede modificar una vez creado.
</p>
{/if}
</div>
<div class="space-y-2">
<Label for="fraction">Fracción {catalog === 'mex' ? '(Con puntos)' : ''}</Label>
<Input id="fraction" bind:value={fractionFormatted} placeholder="Ej. 0101.21.01" />
</div>
</div>
<div class="space-y-2">
<Label for="description">Descripción</Label>
<Textarea
id="description"
bind:value={description}
placeholder="Descripción de la mercancía..."
/>
</div>
{#if catalog === 'mex'}
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label for="nico">NICO</Label>
<Input id="nico" bind:value={nico} placeholder="Ej. 00" />
</div>
<div class="space-y-2">
<Label for="um_code">Clave U.M.</Label>
<Input id="um_code" bind:value={um_code} placeholder="Ej. 06" />
</div>
<div class="space-y-2">
<Label for="umt">U.M.T</Label>
<Input id="umt" bind:value={umt} placeholder="Ej. Kg" />
</div>
</div>
{:else}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="umt">Unidad de Medida</Label>
<Input id="umt" bind:value={umt} placeholder="Unit" />
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="adv_impo">Adv. Impo</Label>
<Input id="adv_impo" bind:value={adv_impo} placeholder="Ej. Ex." />
</div>
<div class="space-y-2">
<Label for="adv_expo">Adv. Expo</Label>
<Input id="adv_expo" bind:value={adv_expo} placeholder="Ej. Ex." />
</div>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
<Button onclick={handleSubmit} disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Guardar
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,293 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table';
import { Loader2, Plus, Search, Trash2, Edit } from 'lucide-svelte';
import {
getTariffFractions,
deleteTariffFraction,
type TariffFraction
} from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions';
import { companyStore } from '$lib/stores/company.svelte';
import { onMount } from 'svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import TariffFractionFormDialog from './TariffFractionFormDialog.svelte';
import { toast } from 'svelte-sonner';
let {
title = 'Fracciones Arancelarias',
catalog = 'mex', // 'mex' or 'usa'
levelFilter = null, // null or number
readOnly = false
}: {
title?: string;
catalog?: string;
levelFilter?: number | null;
readOnly?: boolean;
} = $props();
let fractions = $state<TariffFraction[]>([]);
let totalFractions = $state(0);
let currentPage = $state(1);
let pageSize = 50;
let isLoading = $state(false);
let search = $state('');
let searchTimeout: ReturnType<typeof setTimeout>;
let isFormDialogOpen = $state(false);
let selectedFraction = $state<TariffFraction | null>(null);
let isManageMode = $state(false); // If true, opens form in edit mode
// Delete confirmation
let showDeleteConfirm = $state(false);
let fractionToDelete = $state<TariffFraction | null>(null);
async function loadFractions() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
isLoading = true;
try {
const filters: Record<string, any> = {};
if (search) filters.search = search;
if (levelFilter !== null) filters.level = levelFilter;
filters.catalog = catalog;
const response = await getTariffFractions(currentPage, pageSize, companyId, filters);
if (response.data) {
fractions = response.data.items;
totalFractions = response.data.total;
}
} catch (error) {
console.error('Error loading fractions:', error);
toast.error('Error al cargar las fracciones');
} finally {
isLoading = false;
}
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage = 1;
loadFractions();
}, 500);
}
function handlePageChange(newPage: number) {
currentPage = newPage;
loadFractions();
}
function openCreateDialog() {
selectedFraction = null;
isManageMode = false; // Create mode
isFormDialogOpen = true;
}
function openEditDialog(fraction: TariffFraction) {
selectedFraction = fraction;
isManageMode = true; // Edit mode
isFormDialogOpen = true;
}
function confirmDelete(fraction: TariffFraction) {
fractionToDelete = fraction;
showDeleteConfirm = true;
}
async function handleDelete() {
if (!fractionToDelete || !companyStore.activeCompany?.id) return;
try {
// Note: Delete might allow deleting items from source API if allowed,
// or just local overrides. Assuming Service handles logic.
await deleteTariffFraction(fractionToDelete.id, companyStore.activeCompany.id, catalog);
toast.success('Fracción eliminada correctamente');
loadFractions();
} catch (error) {
console.error('Error deleting fraction:', error);
toast.error('Error al eliminar la fracción. Puede que esté en uso.');
} finally {
showDeleteConfirm = false;
fractionToDelete = null;
}
}
onMount(() => {
if (companyStore.activeCompany) {
loadFractions();
}
});
// Reload when company changes
$effect(() => {
if (companyStore.activeCompany?.id) {
loadFractions();
}
});
</script>
<div class="space-y-4 p-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold tracking-tight">{title}</h2>
{#if !readOnly}
<Button onclick={openCreateDialog}>
<Plus class="mr-2 h-4 w-4" />
Nueva Fracción
</Button>
{/if}
</div>
<div class="flex items-center space-x-2">
<div class="relative max-w-sm flex-1">
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Buscar..." class="pl-8" bind:value={search} oninput={handleSearchInput} />
</div>
</div>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Clave</TableHead>
<TableHead>Fracción</TableHead>
<TableHead>Descripción</TableHead>
{#if catalog === 'mex'}
<TableHead>NICO</TableHead>
<TableHead>U.M.T</TableHead>
{:else}
<TableHead>Unidad</TableHead>
{/if}
<TableHead>Adv. Impo</TableHead>
<TableHead>Adv. Expo</TableHead>
{#if !readOnly}
<TableHead class="w-[100px]">Acciones</TableHead>
{/if}
</TableRow>
</TableHeader>
<TableBody>
{#if isLoading}
<TableRow>
<TableCell
colspan={catalog === 'mex' ? (readOnly ? 7 : 8) : readOnly ? 6 : 7}
class="h-24 text-center"
>
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</TableCell>
</TableRow>
{:else if fractions.length === 0}
<TableRow>
<TableCell
colspan={catalog === 'mex' ? (readOnly ? 7 : 8) : readOnly ? 6 : 7}
class="h-24 text-center"
>
No se encontraron resultados
</TableCell>
</TableRow>
{:else}
{#each fractions as fraction}
<TableRow>
<TableCell class="font-mono">{fraction.um_code || fraction.code}</TableCell>
<TableCell class="font-medium">{fraction.fraction}</TableCell>
<TableCell class="max-w-md truncate" title={fraction.description}>
{fraction.description}
</TableCell>
{#if catalog === 'mex'}
<TableCell>{fraction.nico || '-'}</TableCell>
<TableCell>{fraction.umt || '-'}</TableCell>
{:else}
<TableCell>{fraction.umt || '-'}</TableCell>
{/if}
<TableCell>{fraction.adv_impo || '-'}</TableCell>
<TableCell>{fraction.adv_expo || '-'}</TableCell>
{#if !readOnly}
<TableCell>
<div class="flex items-center gap-2">
<Button variant="ghost" size="icon" onclick={() => openEditDialog(fraction)}>
<Edit class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive"
onclick={() => confirmDelete(fraction)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
{/if}
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
Anterior
</Button>
<div class="text-sm text-muted-foreground">
Página {currentPage} de {Math.ceil(totalFractions / pageSize) || 1}
</div>
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(currentPage + 1)}
disabled={!fractions.length || fractions.length < pageSize || isLoading}
>
Siguiente
</Button>
</div>
</div>
<AlertDialog.Root bind:open={showDeleteConfirm}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Description>
Esta acción no se puede deshacer. Se eliminará la fracción arancelaria permanentemente.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
onclick={handleDelete}
>
Eliminar
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{#if isFormDialogOpen}
<TariffFractionFormDialog
bind:open={isFormDialogOpen}
fraction={selectedFraction}
{catalog}
onSuccess={() => {
loadFractions();
isFormDialogOpen = false;
}}
/>
{/if}

View File

@@ -304,31 +304,31 @@ export function getSidebarData(): SidebarData {
items: [
{
title: m["sidebar.fractions.sitar"](),
url: "#",
url: "/dashboard/general_catalogs/tariff-fractions/sitar",
},
{
title: m["sidebar.fractions.sitar_seventh_amendment"](),
url: "#",
url: "/dashboard/general_catalogs/tariff-fractions/seventh-amendment",
},
{
title: m["sidebar.fractions.sitar_us"](),
url: "#",
url: "/dashboard/general_catalogs/tariff-fractions/us",
},
{
title: m["sidebar.fractions.american"](),
url: "#",
url: "/dashboard/general_catalogs/tariff-fractions/american",
},
{
title: m["sidebar.fractions.canadian"](),
url: "#",
url: "/dashboard/general_catalogs/tariff-fractions/canadian",
},
{
title: m["sidebar.fractions.historical"](),
url: "#",
url: "/dashboard/general_catalogs/tariff-fractions/historical",
},
{
title: m["sidebar.fractions.sectors"](),
url: "#",
url: "/dashboard/general_catalogs/sectors",
},
],
},

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import SectorsList from '$lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte';
</script>
<SectorsList />

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import TariffFractionList from '$lib/components/dashboard/goods/fractions/TariffFractionList.svelte';
import * as m from '$lib/paraglide/messages.js';
</script>
<TariffFractionList title={m['sidebar.fractions.american']()} catalog="american" readOnly={false} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import CanadianFractionList from '$lib/components/dashboard/goods/fractions/CanadianFractionList.svelte';
import * as m from '$lib/paraglide/messages.js';
</script>
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold tracking-tight">{m['sidebar.fractions.canadian']()}</h1>
</div>
<CanadianFractionList />
</div>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import HistoricalFractionList from '$lib/components/dashboard/goods/fractions/HistoricalFractionList.svelte';
import * as m from '$lib/paraglide/messages.js';
</script>
<HistoricalFractionList title={m['sidebar.fractions.historical']()} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import TariffFractionList from '$lib/components/dashboard/goods/fractions/TariffFractionList.svelte';
import * as m from '$lib/paraglide/messages.js';
</script>
<TariffFractionList
title={m['sidebar.fractions.sitar_seventh_amendment']()}
catalog="mex"
levelFilter={5}
readOnly={true}
/>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import TariffFractionList from '$lib/components/dashboard/goods/fractions/TariffFractionList.svelte';
import * as m from '$lib/paraglide/messages.js';
</script>
<TariffFractionList
title={m['sidebar.fractions.sitar']()}
catalog="mex"
levelFilter={-1}
readOnly={true}
/>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import TariffFractionList from '$lib/components/dashboard/goods/fractions/TariffFractionList.svelte';
import * as m from '$lib/paraglide/messages.js';
</script>
<TariffFractionList title={m['sidebar.fractions.sitar_us']()} catalog="usa" readOnly={true} />