Merge pull request 'feature/fraction-catalogs' (#152) from feature/fraction-catalogs into development

Reviewed-on: ADUANASOFT/anexo76#152
This commit is contained in:
2026-02-19 23:40:15 +00:00
46 changed files with 4928 additions and 1273 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, nico, 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, nico, 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

@@ -142,6 +142,10 @@ class AuditMapper:
("doda", "CREATE"): "ADD DODA",
("doda", "UPDATE"): "EDIT DODA",
("doda", "DELETE"): "DELETE DODA",
("company", "CREATE"): "ADD COMPANY",
("company", "UPDATE"): "EDIT COMPANY",
("company", "DELETE"): "DELETE COMPANY",
}
@staticmethod

View File

@@ -85,88 +85,149 @@ class CompanyCreateDTO(BaseModel):
)
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
# Sectors
sector1: Optional[str] = Field(None, max_length=150)
sector2: Optional[str] = Field(None, max_length=150)
sector3: Optional[str] = Field(None, max_length=5)
# Certification (CompanyCertification flattened)
is_certified_company: Optional[str] = Field(None, max_length=1)
certified_company_registration: Optional[str] = Field(None, max_length=40)
certified_company_start_date: Optional[int] = None
certified_company_end_date: Optional[int] = None
annex31_certification_date: Optional[int] = None
annex31_certification_number: Optional[str] = Field(None, max_length=50)
annex31_modality: Optional[str] = Field(None, max_length=50)
annex31_company_type: Optional[str] = Field(None, max_length=50)
annex31_renewal_date: Optional[int] = None
annex31_final_certification_date: Optional[int] = None
is_oea_company: Optional[int] = None
neec_company: Optional[int] = None
# Addresses (Flattened)
# Main
main_street: Optional[str] = Field(None, max_length=255)
main_exterior_number: Optional[str] = Field(None, max_length=10)
main_interior_number: Optional[str] = Field(None, max_length=10)
main_postal_code: Optional[str] = Field(None, max_length=5)
main_neighborhood: Optional[str] = Field(None, max_length=255)
main_city: Optional[str] = Field(None, max_length=255)
main_municipality: Optional[str] = Field(None, max_length=255)
main_state: Optional[str] = Field(None, max_length=255)
main_country: Optional[str] = Field(None, max_length=255)
main_phone: Optional[str] = Field(None, max_length=20)
main_fax: Optional[str] = Field(None, max_length=20)
main_email: Optional[str] = Field(None, max_length=255)
# Industrial 1
ind1_street: Optional[str] = Field(None, max_length=255)
ind1_exterior_number: Optional[str] = Field(None, max_length=10)
ind1_interior_number: Optional[str] = Field(None, max_length=10)
ind1_postal_code: Optional[str] = Field(None, max_length=5)
ind1_neighborhood: Optional[str] = Field(None, max_length=255)
ind1_city: Optional[str] = Field(None, max_length=255)
ind1_municipality: Optional[str] = Field(None, max_length=255)
ind1_state: Optional[str] = Field(None, max_length=255)
ind1_country: Optional[str] = Field(None, max_length=255)
ind1_phone: Optional[str] = Field(None, max_length=20)
ind1_fax: Optional[str] = Field(None, max_length=20)
ind1_email: Optional[str] = Field(None, max_length=255)
# Industrial 2
ind2_street: Optional[str] = Field(None, max_length=255)
ind2_exterior_number: Optional[str] = Field(None, max_length=10)
ind2_interior_number: Optional[str] = Field(None, max_length=10)
ind2_postal_code: Optional[str] = Field(None, max_length=5)
ind2_neighborhood: Optional[str] = Field(None, max_length=255)
ind2_city: Optional[str] = Field(None, max_length=255)
ind2_municipality: Optional[str] = Field(None, max_length=255)
ind2_state: Optional[str] = Field(None, max_length=255)
ind2_country: Optional[str] = Field(None, max_length=255)
ind2_phone: Optional[str] = Field(None, max_length=20)
ind2_fax: Optional[str] = Field(None, max_length=20)
ind2_email: Optional[str] = Field(None, max_length=255)
# Technical flags
active_labels: Optional[int] = None
active_fractions: Optional[int] = None
activate_caat: Optional[int] = None
trans_interface: Optional[int] = None
american_costs: Optional[int] = None
scaf_readonly: Optional[int] = None
parts_replacement: Optional[int] = None
activate_facmexame: Optional[int] = None
part_reference: Optional[int] = None
international_firm: Optional[int] = None
# Advanced Config
ftp_key: Optional[str] = Field(None, max_length=10)
sifra_path: Optional[str] = Field(None, max_length=255)
version_type: Optional[str] = Field(None, max_length=20)
sql_language: Optional[str] = Field(None, max_length=19)
balance_operation_mode: Optional[str] = Field(None, max_length=50)
# Prevalidator (detailed)
prev_customs: Optional[str] = Field(None, max_length=20)
prev_key: Optional[str] = Field(None, max_length=20)
prev_patent: Optional[str] = Field(None, max_length=4)
prev_description: Optional[str] = Field(None, max_length=100)
# Ventanilla Única (VU)
vu_webservice_user: Optional[str] = Field(None, max_length=100)
vu_webservice_password: Optional[str] = Field(None, max_length=100)
vu_email: Optional[str] = Field(None, max_length=800)
vu_figure_type: Optional[str] = Field(None, max_length=29)
vu_central_path: Optional[str] = Field(None, max_length=1499)
vu_xml_files_path: Optional[str] = Field(None, max_length=1499)
vu_query_rfc: Optional[str] = Field(None, max_length=30)
vu_validation_rfc: Optional[str] = Field(None, max_length=30)
vu_configuration_source: Optional[str] = Field(None, max_length=30)
vu_measurement_units: Optional[str] = Field(None, max_length=3)
# Electronic Agent
ea_input_folder: Optional[str] = Field(None, max_length=1000)
ea_output_folder: Optional[str] = Field(None, max_length=1000)
ea_send_mask: Optional[str] = Field(None, max_length=20)
ea_response_mask: Optional[str] = Field(None, max_length=20)
ea_response_extension: Optional[str] = Field(None, max_length=20)
ea_counter_start: Optional[int] = None
ea_counter_end: Optional[int] = None
ea_counter_next: Optional[int] = None
# CFDI
cfdi_xml_save_path: Optional[str] = Field(None, max_length=5000)
cfdi_app_path: Optional[str] = Field(None, max_length=5000)
cfdi_pac_app_path: Optional[str] = Field(None, max_length=5000)
# Digital Certificates (CompanyDigitalCertificate flattened)
# FIEL
fiel_cer: Optional[str] = Field(None, max_length=5000)
fiel_key: Optional[str] = Field(None, max_length=5000)
fiel_pass: Optional[str] = Field(None, max_length=200)
fiel_access: Optional[str] = Field(None, max_length=50)
fiel_cer_exp: Optional[int] = None
fiel_key_exp: Optional[int] = None
# CFDI (Sello)
cfdi_cert_cer: Optional[str] = Field(None, max_length=5000)
cfdi_cert_key: Optional[str] = Field(None, max_length=5000)
cfdi_cert_pass: Optional[str] = Field(None, max_length=200)
cfdi_cert_access: Optional[str] = Field(None, max_length=50)
cfdi_cert_cer_exp: Optional[int] = None
cfdi_cert_key_exp: Optional[int] = None
# Cancellation
cancel_cer: Optional[str] = Field(None, max_length=5000)
cancel_key: Optional[str] = Field(None, max_length=5000)
cancel_pass: Optional[str] = Field(None, max_length=200)
cancel_access: Optional[str] = Field(None, max_length=50)
cancel_cer_exp: Optional[int] = None
cancel_key_exp: Optional[int] = None
class Config:
from_attributes = True
class CompanyUpdateDTO(BaseModel):
class CompanyUpdateDTO(CompanyCreateDTO):
"""DTO para actualizar una empresa"""
name: Optional[str] = Field(None, max_length=255, description="Company name")
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
main_activity: Optional[str] = Field(
None, max_length=255, description="Main activity"
)
# Program information
program: Optional[str] = Field(None, max_length=10, description="Program")
program_number: Optional[str] = Field(
None, max_length=40, description="Program number"
)
prosec: Optional[int] = Field(None, description="PROSEC")
prosec_authorization: Optional[str] = Field(
None, max_length=20, description="PROSEC authorization"
)
# Identifiers
manufacturer_id: Optional[str] = Field(
None, max_length=25, description="Manufacturer ID"
)
broker_company: Optional[str] = Field(
None, max_length=10, description="Broker company"
)
# Responsible person
responsible: Optional[str] = Field(
None, max_length=80, description="Responsible person"
)
responsible_name: Optional[str] = Field(
None, max_length=20, description="Responsible first name"
)
responsible_last_name: Optional[str] = Field(
None, max_length=20, description="Responsible last name"
)
responsible_mother_last_name: Optional[str] = Field(
None, max_length=20, description="Responsible mother's last name"
)
responsible_rfc: Optional[str] = Field(
None, max_length=30, description="Responsible RFC"
)
position: Optional[str] = Field(
None, max_length=30, description="Responsible position"
)
# Configuration
logo: Optional[str] = Field(None, max_length=255, description="Company logo")
has_express_line: Optional[bool] = Field(None, description="Has express line")
order_format_type: Optional[str] = Field(
None, max_length=19, description="Order format type"
)
previous_code: Optional[int] = Field(None, description="Previous code")
is_service_company: Optional[bool] = Field(None, description="Is service company")
# Client and subassembly
client_name: Optional[str] = Field(None, max_length=300, description="Client name")
subassembly_mode: Optional[str] = Field(
None, max_length=7, description="Subassembly mode"
)
# Additional information
curp: Optional[str] = Field(None, max_length=19, description="CURP")
inter_db_name: Optional[str] = Field(
None, max_length=100, description="Inter DB name"
)
ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI")
trusted_exporter_number: Optional[str] = Field(
None, max_length=50, description="Trusted exporter number"
)
prevalidator_key: Optional[str] = Field(
None, max_length=20, description="Prevalidator key"
)
seventh_amendment: Optional[bool] = Field(None, description="Seventh amendment")
class Config:
from_attributes = True
pass
class CompanyResponseDTO(BaseModel):
@@ -219,5 +280,142 @@ class CompanyResponseDTO(BaseModel):
created_at: datetime
updated_at: Optional[datetime] = None
# --- Flattened Fields for Response ---
# Sectores
sector1: Optional[str] = None
sector2: Optional[str] = None
sector3: Optional[str] = None
# Certification
is_certified_company: Optional[str] = None
certified_company_registration: Optional[str] = None
certified_company_start_date: Optional[int] = None
certified_company_end_date: Optional[int] = None
annex31_certification_date: Optional[int] = None
annex31_certification_number: Optional[str] = None
annex31_modality: Optional[str] = None
annex31_company_type: Optional[str] = None
annex31_renewal_date: Optional[int] = None
annex31_final_certification_date: Optional[int] = None
is_oea_company: Optional[int] = None
neec_company: Optional[int] = None
# Addresses
# ... (Main, Ind1, Ind2 can be added here if needed for flattened response)
main_street: Optional[str] = None
main_exterior_number: Optional[str] = None
main_interior_number: Optional[str] = None
main_postal_code: Optional[str] = None
main_neighborhood: Optional[str] = None
main_city: Optional[str] = None
main_municipality: Optional[str] = None
main_state: Optional[str] = None
main_country: Optional[str] = None
main_phone: Optional[str] = None
main_fax: Optional[str] = None
main_email: Optional[str] = None
ind1_street: Optional[str] = None
ind1_exterior_number: Optional[str] = None
ind1_interior_number: Optional[str] = None
ind1_postal_code: Optional[str] = None
ind1_neighborhood: Optional[str] = None
ind1_city: Optional[str] = None
ind1_municipality: Optional[str] = None
ind1_state: Optional[str] = None
ind1_country: Optional[str] = None
ind1_phone: Optional[str] = None
ind1_fax: Optional[str] = None
ind1_email: Optional[str] = None
ind2_street: Optional[str] = None
ind2_exterior_number: Optional[str] = None
ind2_interior_number: Optional[str] = None
ind2_postal_code: Optional[str] = None
ind2_neighborhood: Optional[str] = None
ind2_city: Optional[str] = None
ind2_municipality: Optional[str] = None
ind2_state: Optional[str] = None
ind2_country: Optional[str] = None
ind2_phone: Optional[str] = None
ind2_fax: Optional[str] = None
ind2_email: Optional[str] = None
# Technical flags
active_labels: Optional[int] = None
active_fractions: Optional[int] = None
activate_caat: Optional[int] = None
trans_interface: Optional[int] = None
american_costs: Optional[int] = None
scaf_readonly: Optional[int] = None
parts_replacement: Optional[int] = None
activate_facmexame: Optional[int] = None
part_reference: Optional[int] = None
international_firm: Optional[int] = None
# Advanced Config
ftp_key: Optional[str] = None
sifra_path: Optional[str] = None
version_type: Optional[str] = None
sql_language: Optional[str] = None
balance_operation_mode: Optional[str] = None
# Prevalidator
prev_customs: Optional[str] = None
prev_key: Optional[str] = None
prev_patent: Optional[str] = None
prev_description: Optional[str] = None
# VU
vu_webservice_user: Optional[str] = None
vu_webservice_password: Optional[str] = None
vu_email: Optional[str] = None
vu_figure_type: Optional[str] = None
vu_central_path: Optional[str] = None
vu_xml_files_path: Optional[str] = None
vu_query_rfc: Optional[str] = None
vu_validation_rfc: Optional[str] = None
vu_configuration_source: Optional[str] = None
vu_measurement_units: Optional[str] = None
# Electronic Agent
ea_input_folder: Optional[str] = None
ea_output_folder: Optional[str] = None
ea_send_mask: Optional[str] = None
ea_response_mask: Optional[str] = None
ea_response_extension: Optional[str] = None
ea_counter_start: Optional[int] = None
ea_counter_end: Optional[int] = None
ea_counter_next: Optional[int] = None
# CFDI
cfdi_xml_save_path: Optional[str] = None
cfdi_app_path: Optional[str] = None
cfdi_pac_app_path: Optional[str] = None
# Digital Certificates (Flattened)
fiel_cer: Optional[str] = None
fiel_key: Optional[str] = None
fiel_pass: Optional[str] = None
fiel_access: Optional[str] = None
fiel_cer_exp: Optional[int] = None
fiel_key_exp: Optional[int] = None
cfdi_cert_cer: Optional[str] = None
cfdi_cert_key: Optional[str] = None
cfdi_cert_pass: Optional[str] = None
cfdi_cert_access: Optional[str] = None
cfdi_cert_cer_exp: Optional[int] = None
cfdi_cert_key_exp: Optional[int] = None
cancel_cer: Optional[str] = None
cancel_key: Optional[str] = None
cancel_pass: Optional[str] = None
cancel_access: Optional[str] = None
cancel_cer_exp: Optional[int] = None
cancel_key_exp: Optional[int] = None
class Config:
from_attributes = True

View File

@@ -341,3 +341,101 @@ async def upload_company_logo(
"logo_path": file_path,
"company_id": company_id,
}
@router.post(
"/{company_id}/upload-certificate",
response_model=dict,
summary="Upload company certificate",
)
async def upload_company_certificate(
company_id: int,
certificate_type: str,
file: UploadFile = File(...),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Upload a certificate for a company
certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key
"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
# Validar que la empresa existe
service = CompanyService(db)
company = service.get_by_id(db, company_id, tenant_id, 0)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
# Validar tipo de certificado
valid_types = [
"fiel_cer", "fiel_key",
"cfdi_cert_cer", "cfdi_cert_key",
"cancel_cer", "cancel_key"
]
if certificate_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid certificate type. Allowed: {', '.join(valid_types)}",
)
# Validar extensión
file_ext = os.path.splitext(file.filename)[1].lower()
allowed_exts = {".cer", ".key"}
if file_ext not in allowed_exts:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type not allowed. Allowed: {', '.join(allowed_exts)}",
)
# Validar correspondencia extensión vs tipo (simple check)
if "cer" in certificate_type and file_ext != ".cer":
raise HTTPException(status_code=400, detail="For this certificate type, file must be .cer")
if "key" in certificate_type and file_ext != ".key":
raise HTTPException(status_code=400, detail="For this certificate type, file must be .key")
# Validar tamaño
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB",
)
# Crear directorio si no existe
certs_dir = os.path.join(UPLOAD_DIR, str(company_id), "certificates")
os.makedirs(certs_dir, exist_ok=True)
# Generar nombre único
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{certificate_type}_{timestamp}{file_ext}"
file_path = os.path.join(certs_dir, filename)
# Guardar archivo
try:
await file.seek(0)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving file: {str(e)}",
)
# Actualizar la base de datos
service.upload_certificate(company_id, certificate_type, file_path, tenant_id)
return {
"message": "Certificate uploaded successfully",
"file_path": file_path,
"certificate_type": certificate_type,
"company_id": company_id,
}

View File

@@ -11,6 +11,8 @@ from sqlalchemy.orm import Session
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
from ...audit_log.services.service import AuditService
from core.context import get_user_context
logger = logging.getLogger(__name__)
@@ -111,7 +113,8 @@ class CompanyService:
"active_labels", "active_fractions", "activate_caat", "trans_interface",
"american_costs", "scaf_readonly", "parts_replacement", "activate_facmexame",
"part_reference", "international_firm", "ftp_key", "sifra_path",
"version_type", "sql_language", "balance_operation_mode", "inter_db_name"
"version_type", "sql_language", "balance_operation_mode", "inter_db_name",
"seventh_amendment"
]
return {k: v for k, v in data.items() if k in company_fields}
@@ -127,15 +130,100 @@ class CompanyService:
]
return {k: v for k, v in data.items() if k in cert_fields}
def _extract_address_fields(self, data: Dict[str, Any], type_prefix: str) -> Dict[str, Any]:
"""Extrae campos de dirección con base en un prefijo (main_, ind1_, ind2_)"""
fields = ["street", "exterior_number", "interior_number", "postal_code",
"neighborhood", "city", "municipality", "state", "country",
"phone", "fax", "email"]
extracted = {}
for f in fields:
key = f"{type_prefix}_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_prevalidator_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyPrevalidator"""
# Note: 'prevalidator_key' in DTO maps to 'key' in model
fields = {}
if "prevalidator_key" in data:
fields["key"] = data["prevalidator_key"]
# Se mapean campos 'prev_*' a los nombres del modelo
mapping = {
"prev_customs": "customs",
"prev_key": "key",
"prev_patent": "patent",
"prev_description": "description"
}
extracted = {}
for dto_key, model_key in mapping.items():
if dto_key in data:
extracted[model_key] = data[dto_key]
# Add other fields if present in DTO in the future
return fields
# Retrocompatibilidad con el campo prevalidator_key que ya estaba en el DTO
if "prevalidator_key" in data and "key" not in extracted:
extracted["key"] = data["prevalidator_key"]
return extracted
def _extract_vu_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyVU (prefijo vu_)"""
vu_fields = [
"webservice_user", "webservice_password", "email", "figure_type",
"central_path", "xml_files_path", "query_rfc", "validation_rfc",
"configuration_source", "measurement_units"
]
extracted = {}
for f in vu_fields:
key = f"vu_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_electronic_agent_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyElectronicAgent (prefijo ea_)"""
ea_fields = [
"input_folder", "output_folder", "send_mask", "response_mask",
"response_extension", "counter_start", "counter_end", "counter_next"
]
extracted = {}
for f in ea_fields:
key = f"ea_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_cfdi_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Extrae campos que pertenecen a CompanyCFDI (prefijo cfdi_)"""
cfdi_fields = ["xml_save_path", "cfdi_app_path", "pac_app_path"]
extracted = {}
for f in cfdi_fields:
key = f"cfdi_{f}"
if key in data:
extracted[f] = data[key]
return extracted
def _extract_digital_certificate_fields(self, data: Dict[str, Any], cert_prefix: str) -> Dict[str, Any]:
"""Extrae campos para un tipo específico de certificado (fiel, cfdi_cert, cancel)"""
# Mapeo de prefijos DTO a nombres de modelo
fields_map = {
f"{cert_prefix}_cer": "cer_file_path",
f"{cert_prefix}_key": "key_file_path",
f"{cert_prefix}_pass": "password",
f"{cert_prefix}_access": "access_key",
f"{cert_prefix}_cer_exp": "cer_expiration_date",
f"{cert_prefix}_key_exp": "key_expiration_date"
}
extracted = {}
for dto_key, model_key in fields_map.items():
if dto_key in data:
extracted[model_key] = data[dto_key]
if extracted:
# Mapear prefijo al tipo real en base de datos
model_type_map = {'fiel': 'fiel', 'cfdi_cert': 'cfdi', 'cancel': 'cancellation'}
extracted['certificate_type'] = model_type_map.get(cert_prefix, cert_prefix)
return extracted
def flatten_company_dto(self, company: Company) -> Dict[str, Any]:
"""Flattens Company and its submodels into a single dict for DTO validation"""
@@ -144,10 +232,7 @@ class CompanyService:
k: getattr(company, k)
for k in company.__mapper__.c.keys()
}
# Explicitly ensure logo is present (defensive programming)
if hasattr(company, 'logo'):
result['logo'] = company.logo
# 2. Certification fields
if company.certification:
cert_fields = [
@@ -163,86 +248,214 @@ class CompanyService:
if val is not None:
result[field] = val
# 3. Prevalidator fields
# 3. Addresses
for addr in company.addresses:
prefix = ""
if addr.address_type == 'main': prefix = "main_"
elif addr.address_type == 'industrial': prefix = "ind1_"
elif addr.address_type == 'industrial2': prefix = "ind2_"
if prefix:
addr_fields = ["street", "exterior_number", "interior_number", "postal_code",
"neighborhood", "city", "municipality", "state", "country",
"phone", "fax", "email"]
for f in addr_fields:
val = getattr(addr, f, None)
if val is not None:
result[f"{prefix}{f}"] = val
# 4. Prevalidator fields
if company.prevalidator:
mapping = {"customs": "prev_customs", "key": "prev_key",
"patent": "prev_patent", "description": "prev_description"}
for model_f, dto_f in mapping.items():
val = getattr(company.prevalidator, model_f, None)
if val is not None:
result[dto_f] = val
# Retrocompatibilidad
if company.prevalidator.key:
result["prevalidator_key"] = company.prevalidator.key
# 5. VU fields
if company.ventanilla_unica:
f_list = ["webservice_user", "webservice_password", "email", "figure_type",
"central_path", "xml_files_path", "query_rfc", "validation_rfc",
"configuration_source", "measurement_units"]
for f in f_list:
val = getattr(company.ventanilla_unica, f, None)
if val is not None:
result[f"vu_{f}"] = val
# 6. Electronic Agent fields
if company.electronic_agent:
f_list = ["input_folder", "output_folder", "send_mask", "response_mask",
"response_extension", "counter_start", "counter_end", "counter_next"]
for f in f_list:
val = getattr(company.electronic_agent, f, None)
if val is not None:
result[f"ea_{f}"] = val
# 7. CFDI fields
if company.cfdi:
f_list = ["xml_save_path", "cfdi_app_path", "pac_app_path"]
for f in f_list:
val = getattr(company.cfdi, f, None)
if val is not None:
result[f"cfdi_{f}"] = val
# 8. Digital Certificates
cert_type_map = {'fiel': 'fiel', 'cfdi': 'cfdi_cert', 'cancellation': 'cancel'}
for dc in company.digital_certificates:
prefix = cert_type_map.get(dc.certificate_type)
if prefix:
result[f"{prefix}_cer"] = dc.cer_file_path
result[f"{prefix}_key"] = dc.key_file_path
result[f"{prefix}_pass"] = dc.password
result[f"{prefix}_access"] = dc.access_key
result[f"{prefix}_cer_exp"] = dc.cer_expiration_date
result[f"{prefix}_key_exp"] = dc.key_expiration_date
return result
# ==================== CRUD METHODS ====================
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int) -> Company:
def create_company_manually(self, data: CompanyCreateDTO, tenant_id: int, username: str = "System") -> Company:
from .submodels.certification import CompanyCertification
from .submodels.prevalidator import CompanyPrevalidator
from .submodels.address import CompanyAddress
from .submodels.vu import CompanyVU
from .submodels.electronic_agent import CompanyElectronicAgent
from .submodels.cfdi import CompanyCFDI
try:
# 1. Preparar datos
obj_data = data.model_dump(exclude_unset=True)
# 2. Extract fields for each model
# 2. Extract fields
company_data = self._extract_company_fields(obj_data)
cert_data = self._extract_certification_fields(obj_data)
preval_data = self._extract_prevalidator_fields(obj_data)
vu_data = self._extract_vu_fields(obj_data)
ea_data = self._extract_electronic_agent_fields(obj_data)
cfdi_data = self._extract_cfdi_fields(obj_data)
addr_main = self._extract_address_fields(obj_data, "main")
addr_ind1 = self._extract_address_fields(obj_data, "ind1")
addr_ind2 = self._extract_address_fields(obj_data, "ind2")
fiel_data = self._extract_digital_certificate_fields(obj_data, "fiel")
cfdi_cert_data = self._extract_digital_certificate_fields(obj_data, "cfdi_cert")
cancel_cert_data = self._extract_digital_certificate_fields(obj_data, "cancel")
# 3. Create Company
db_company = Company(**company_data, tenant_id=tenant_id)
self.db.add(db_company)
self.db.flush() # Generate ID
# 4. Create Certification if data exists
# 4. Create submodels
if cert_data:
cert = CompanyCertification(**cert_data, company_id=db_company.id)
self.db.add(cert)
# 5. Create Prevalidator if data exists
self.db.add(CompanyCertification(**cert_data, company_id=db_company.id))
if preval_data:
preval = CompanyPrevalidator(**preval_data, company_id=db_company.id)
self.db.add(preval)
self.db.add(CompanyPrevalidator(**preval_data, company_id=db_company.id))
if vu_data:
self.db.add(CompanyVU(**vu_data, company_id=db_company.id))
if ea_data:
self.db.add(CompanyElectronicAgent(**ea_data, company_id=db_company.id))
if cfdi_data:
self.db.add(CompanyCFDI(**cfdi_data, company_id=db_company.id))
# 5. Create Digital Certificates
from .submodels.digital_certificate import CompanyDigitalCertificate
for dc_data in [fiel_data, cfdi_cert_data, cancel_cert_data]:
if dc_data:
self.db.add(CompanyDigitalCertificate(**dc_data, company_id=db_company.id))
# 6. Create Addresses
# 6. Commit
if addr_main:
self.db.add(CompanyAddress(**addr_main, address_type='main', company_id=db_company.id))
if addr_ind1:
self.db.add(CompanyAddress(**addr_ind1, address_type='industrial', company_id=db_company.id))
if addr_ind2:
self.db.add(CompanyAddress(**addr_ind2, address_type='industrial2', company_id=db_company.id))
# 7. Commit
self.db.commit()
self.db.refresh(db_company)
# --- Audit Log ---
try:
# Si no se pasó un username explícito, intentar obtenerlo del contexto
if username == "System":
ctx = get_user_context()
if ctx:
username = ctx.get("preferred_username") or ctx.get("email") or "System"
# Preparamos la data para el log (aplanada)
log_data = self.flatten_company_dto(db_company)
AuditService.log_crud_operation(
db=self.db,
table_name="company",
operation_type="CREATE",
record_data=log_data,
username=username,
record_id=str(db_company.id),
company_id=db_company.id
)
except Exception as e:
logger.error(f"Error creating audit log for company creation: {e}")
# -----------------
return db_company
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating company manually: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error de integridad: Es posible que esta empresa ya exista.",
)
raise HTTPException(status_code=400, detail="Error de integridad: Es posible que esta empresa ya exista.")
except Exception as e:
self.db.rollback()
logger.error(f"Error creating company manually: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creando empresa: {str(e)}")
def update(
self, # Changed to instance method to use self helper methods
self,
db: Session,
company_id: int,
tenant_id: int,
company_id_unused: int,
company_data: CompanyUpdateDTO,
username: str = "System",
) -> Optional[Company]:
"""Update a company"""
from .submodels.certification import CompanyCertification
from .submodels.prevalidator import CompanyPrevalidator
from .submodels.address import CompanyAddress
from .submodels.vu import CompanyVU
from .submodels.electronic_agent import CompanyElectronicAgent
from .submodels.cfdi import CompanyCFDI
# Use self.db if db is passed as None, or use passed db (legacy support)
session = db if db else self.db
company = self.get_by_id(session, company_id, tenant_id, company_id_unused)
if not company:
return None
if not company: return None
# --- Audit Log Prep ---
old_values = {}
try:
# Capturamos estado actual para comparar
# Usamos flatten_company_dto para tener una representación completa
old_values = self.flatten_company_dto(company)
except Exception as e:
logger.error(f"Error prepping audit log (old values): {e}")
# ----------------------
# Update only provided fields
update_data = company_data.model_dump(exclude_unset=True)
# 1. Update Company fields
company_fields = self._extract_company_fields(update_data)
for field, value in company_fields.items():
setattr(company, field, value)
@@ -250,26 +463,97 @@ class CompanyService:
cert_fields = self._extract_certification_fields(update_data)
if cert_fields:
if company.certification:
for field, value in cert_fields.items():
setattr(company.certification, field, value)
for field, value in cert_fields.items(): setattr(company.certification, field, value)
else:
new_cert = CompanyCertification(**cert_fields, company_id=company.id)
session.add(new_cert)
session.add(CompanyCertification(**cert_fields, company_id=company.id))
# 3. Update Prevalidator
preval_fields = self._extract_prevalidator_fields(update_data)
if preval_fields:
if company.prevalidator:
for field, value in preval_fields.items():
setattr(company.prevalidator, field, value)
for field, value in preval_fields.items(): setattr(company.prevalidator, field, value)
else:
new_preval = CompanyPrevalidator(**preval_fields, company_id=company.id)
session.add(new_preval)
session.add(CompanyPrevalidator(**preval_fields, company_id=company.id))
# 4. Update VU
vu_fields = self._extract_vu_fields(update_data)
if vu_fields:
if company.ventanilla_unica:
for field, value in vu_fields.items(): setattr(company.ventanilla_unica, field, value)
else:
session.add(CompanyVU(**vu_fields, company_id=company.id))
# 5. Update Electronic Agent
ea_fields = self._extract_electronic_agent_fields(update_data)
if ea_fields:
if company.electronic_agent:
for field, value in ea_fields.items(): setattr(company.electronic_agent, field, value)
else:
session.add(CompanyElectronicAgent(**ea_fields, company_id=company.id))
# 6. Update CFDI
cfdi_fields = self._extract_cfdi_fields(update_data)
if cfdi_fields:
if company.cfdi:
for field, value in cfdi_fields.items(): setattr(company.cfdi, field, value)
else:
session.add(CompanyCFDI(**cfdi_fields, company_id=company.id))
# 7. Update Digital Certificates
from .submodels.digital_certificate import CompanyDigitalCertificate
for p in ["fiel", "cfdi_cert", "cancel"]:
dc_data = self._extract_digital_certificate_fields(update_data, p)
if dc_data:
m_type = dc_data['certificate_type']
target = next((c for c in company.digital_certificates if c.certificate_type == m_type), None)
if target:
for field, value in dc_data.items(): setattr(target, field, value)
else:
session.add(CompanyDigitalCertificate(**dc_data, company_id=company.id))
# 8. Update Addresses
for prefix, addr_type in [("main", "main"), ("ind1", "industrial"), ("ind2", "industrial2")]:
addr_data = self._extract_address_fields(update_data, prefix)
if addr_data:
# Buscar dirección existente de ese tipo
target_addr = next((a for a in company.addresses if a.address_type == addr_type), None)
if target_addr:
for field, value in addr_data.items(): setattr(target_addr, field, value)
else:
session.add(CompanyAddress(**addr_data, address_type=addr_type, company_id=company.id))
try:
session.commit()
session.refresh(company)
# --- Audit Log ---
try:
# Si no se pasó un username explícito, intentar obtenerlo del contexto
if username == "System":
ctx = get_user_context()
if ctx:
username = ctx.get("preferred_username") or ctx.get("email") or "System"
new_values = self.flatten_company_dto(company)
AuditService.log_crud_operation(
db=session,
table_name="company",
operation_type="UPDATE",
record_data=new_values, # Data más reciente
username=username,
record_id=str(company.id),
old_values=old_values,
new_values=new_values,
company_id=company.id
)
except Exception as e:
logger.error(f"Error creating audit log for company update: {e}")
# -----------------
return company
except Exception as e:
session.rollback()
logger.error(f"Error updating company {company_id}: {str(e)}")
@@ -277,69 +561,139 @@ class CompanyService:
@staticmethod
def delete(
db: Session, company_id: int, tenant_id: int, company_id_unused: int
db: Session, company_id: int, tenant_id: int, company_id_unused: int, username: str = "System"
) -> bool:
"""Delete a company"""
company = CompanyService.get_by_id(db, company_id, tenant_id, company_id_unused)
if not company:
return False
# --- Audit Log Prep ---
record_data = {}
try:
# Manual cascade delete for submodels to ensure order and avoid FK issues
# (Even though cascade="all, delete-orphan" is set, manual deletion is safer for strict DBs)
# 1. Delete Certification
if company.certification:
db.delete(company.certification)
# 2. Delete Prevalidator
if company.prevalidator:
db.delete(company.prevalidator)
# 3. Delete Electronic Agent
if company.electronic_agent:
db.delete(company.electronic_agent)
# 4. Delete VU
if company.ventanilla_unica:
db.delete(company.ventanilla_unica)
# 5. Delete CFDI
if company.cfdi:
db.delete(company.cfdi)
# 6. Delete Digital Certificates
for cert in company.digital_certificates:
db.delete(cert)
# 7. Delete Addresses
for addr in company.addresses:
db.delete(addr)
service = CompanyService(db) # Instancia para usar métodos de instancia si fuera necesario, o usar estático si flatten lo fuera
# flatten_company_dto es método de instancia en la definición actual, pero se está llamando aquí
# Deberíamos instanciar el servicio o mover flatten a estático.
# Como flatten usa self solo para acceder a nada realmente del estado, podría ser estático,
# pero para no romper, instanciamos.
record_data = service.flatten_company_dto(company)
except Exception:
pass
# ----------------------
try:
# Cascading deletes are handled by relationship settings, but manual is safer here
if company.certification: db.delete(company.certification)
if company.prevalidator: db.delete(company.prevalidator)
if company.electronic_agent: db.delete(company.electronic_agent)
if company.ventanilla_unica: db.delete(company.ventanilla_unica)
if company.cfdi: db.delete(company.cfdi)
for cert in company.digital_certificates: db.delete(cert)
for addr in company.addresses: db.delete(addr)
# Flush to execute submodel deletions first
db.flush()
db.delete(company)
db.commit()
# --- Audit Log ---
try:
# Context check
if username == "System":
ctx = get_user_context()
if ctx:
username = ctx.get("preferred_username") or ctx.get("email") or "System"
AuditService.log_crud_operation(
db=db,
table_name="company",
operation_type="DELETE",
record_data=record_data,
username=username,
record_id=str(company_id),
company_id=company_id
)
except Exception as e:
logger.error(f"Error creating audit log for company delete: {e}")
# -----------------
return True
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError deleting company {company_id}: {str(e)}")
# Try to get detailed error from psycopg2
detail = "No se puede eliminar la empresa porque tiene registros relacionados."
if hasattr(e, 'orig') and hasattr(e.orig, 'diag'):
if e.orig.diag.message_detail:
detail += f" Detalles: {e.orig.diag.message_detail}"
raise HTTPException(
status_code=400,
detail=detail
)
raise HTTPException(status_code=400, detail="No se puede eliminar la empresa porque tiene registros relacionados.")
except Exception as e:
db.rollback()
logger.error(f"Error deleting company {company_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error al eliminar la empresa")
def upload_certificate(
self,
company_id: int,
certificate_type: str,
file_path: str,
tenant_id: int
) -> Company:
"""
Update a certificate path for a company
certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key
"""
from .submodels.digital_certificate import CompanyDigitalCertificate
company = self.get_by_id(self.db, company_id, tenant_id, 0)
if not company:
return None
# Determinar el tipo de certificado (fiel, cfdi, cancellation) y el campo a actualizar (cer_file_path, key_file_path)
cert_model_type = ""
field_to_update = ""
if certificate_type == "fiel_cer":
cert_model_type = "fiel"
field_to_update = "cer_file_path"
elif certificate_type == "fiel_key":
cert_model_type = "fiel"
field_to_update = "key_file_path"
elif certificate_type == "cfdi_cert_cer":
cert_model_type = "cfdi"
field_to_update = "cer_file_path"
elif certificate_type == "cfdi_cert_key":
cert_model_type = "cfdi"
field_to_update = "key_file_path"
elif certificate_type == "cancel_cer":
cert_model_type = "cancellation"
field_to_update = "cer_file_path"
elif certificate_type == "cancel_key":
cert_model_type = "cancellation"
field_to_update = "key_file_path"
else:
raise ValueError(f"Invalid certificate type: {certificate_type}")
# Buscar el registro de certificado existente
target_cert = next((c for c in company.digital_certificates if c.certificate_type == cert_model_type), None)
try:
if target_cert:
# Si existe, actualizamos
setattr(target_cert, field_to_update, file_path)
else:
# Si no existe, creamos uno nuevo
new_cert_data = {
"certificate_type": cert_model_type,
"company_id": company.id,
field_to_update: file_path
}
new_cert = CompanyDigitalCertificate(**new_cert_data)
self.db.add(new_cert)
self.db.commit()
self.db.refresh(company)
return company
except Exception as e:
self.db.rollback()
logger.error(f"Error uploading certificate: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error al guardar la referencia del certificado: {str(e)}")
# Custom methods
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
"""Get all companies for a tenant"""

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,73 @@
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()
# Add deterministic sort order
query = query.order_by(CanadianTariffFraction.fraction)
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

@@ -1,128 +1,101 @@
"""
Endpoints for historical tariff fractions.
"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
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 .dto import HistoricalTariffFractionResponseDTO
from .service import HistoricalTariffFractionService
from .schemas import HistoricalTariffFractionResponse, HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate, HistoricalTariffFractionListResponse
router = APIRouter(
prefix="/historical-tariff-fractions",
tags=["a76 / general catalogs / historical tariff fractions"],
)
from api.v1.common.tenant_crud_routes import validate_access_to_resource
router = APIRouter()
@router.get(
"/",
response_model=Dict[str, Any],
summary="List Historical Tariff Fractions",
description=(
"Get paginated list of historical tariff fractions with optional filters"
),
)
async def list_historical_tariff_fractions(
@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=10000, description="Page size"),
historical_fraction: Optional[str] = Query(
None, description="Filter by historical fraction"
),
nico: Optional[str] = Query(None, description="Filter by NICO"),
country: Optional[str] = Query(None, description="Filter by country"),
publication_date: Optional[str] = Query(
None, description="Filter by publication date (YYYY-MM-DD)"
),
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: Dict[str, Any] = Depends(get_current_user),
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
filters: Dict[str, Any] = {}
if historical_fraction:
filters["historical_fraction"] = historical_fraction
if nico:
filters["nico"] = nico
if country:
filters["country"] = country
if publication_date:
filters["publication_date"] = publication_date
items, total = HistoricalTariffFractionService.get_all(db, skip, page_size, filters)
service = HistoricalTariffFractionService(db)
items, total = service.get_multi(tenant_id, company_id, skip=skip, limit=page_size, historical_fraction=historical_fraction)
return {
"items": [
HistoricalTariffFractionResponseDTO.model_validate(item) for item in items
],
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
"pages": (total + page_size - 1) // page_size if page_size > 0 else 1
}
@router.get(
"/rate",
response_model=Dict[str, Any],
summary="Get Historical Tariff Rate for Invoice",
description=(
"Get the import tax rate for a historical tariff fraction based on invoice date. "
"Replicates Clarion ASIGNA_FRACCION_HISTORICA logic: returns the most recent "
"fraction published before or on the invoice date."
),
)
async def get_historical_tariff_rate(
historical_fraction: str = Query(
...,
min_length=8,
max_length=8,
description="Historical fraction (8 characters)",
),
nico: str = Query(
..., min_length=2, max_length=2, description="nico code (2 characters)"
),
fraction_type: str = Query(..., description="Fraction type (GENERAL, TLCS, etc.)"),
invoice_date: str = Query(..., description="Invoice date in YYYY-MM-DD format"),
sector: Optional[str] = Query(None, description="Sector (optional, for PROSEC)"),
@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: Dict[str, Any] = Depends(get_current_user),
current_user = Depends(get_current_user)
):
item = HistoricalTariffFractionService.get_rate_for_invoice(
db, historical_fraction, nico, fraction_type, invoice_date, sector
)
if not item:
return {"rate": None, "found": False}
return {
"rate": float(item.import_tax_rate) if item.import_tax_rate else 0.0,
"found": True,
"publication_date": (
item.publication_date.isoformat() if item.publication_date else None
),
}
"""
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.get(
"/{historical_tariff_fraction_id}",
response_model=HistoricalTariffFractionResponseDTO,
summary="Get Historical Tariff Fraction by ID",
description="Get a specific historical tariff fraction by ID",
)
async def get_historical_tariff_fraction(
historical_tariff_fraction_id: int,
@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: Dict[str, Any] = Depends(get_current_user),
current_user = Depends(get_current_user)
):
item = HistoricalTariffFractionService.get_by_id(db, historical_tariff_fraction_id)
if not item:
from fastapi import HTTPException
"""
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)
raise HTTPException(
status_code=404, detail="Historical tariff fraction not found"
)
return HistoricalTariffFractionResponseDTO.model_validate(item)
@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

@@ -1,111 +1,69 @@
"""
Service for historical tariff fractions (global catalog).
"""
from datetime import datetime, time
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy import cast, Date
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:
@staticmethod
def get_all(
db: Session,
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,
filters: Optional[Dict[str, Any]] = None,
historical_fraction: Optional[str] = None
) -> Tuple[List[HistoricalTariffFraction], int]:
query = db.query(HistoricalTariffFraction)
if filters:
if filters.get("historical_fraction"):
term = filters["historical_fraction"]
query = query.filter(
HistoricalTariffFraction.historical_fraction.ilike(f"%{term}%")
)
if filters.get("nico"):
term = filters["nico"]
query = query.filter(
HistoricalTariffFraction.historical_fraction.ilike(f"%{term}%")
)
if filters.get("country"):
term = filters["country"]
query = query.filter(
HistoricalTariffFraction.country.ilike(f"%{term}%")
)
if filters.get("publication_date"):
try:
date_str = filters["publication_date"]
date_val = datetime.strptime(date_str, "%Y-%m-%d").date()
start_date = datetime.combine(date_val, time.min)
end_date = datetime.combine(date_val, time.max)
query = query.filter(
HistoricalTariffFraction.publication_date >= start_date,
HistoricalTariffFraction.publication_date <= end_date,
)
except (ValueError, TypeError):
query = query.filter(
cast(HistoricalTariffFraction.publication_date, Date)
== filters["publication_date"]
)
total = query.count()
items = (
query.order_by(HistoricalTariffFraction.publication_date.desc())
.offset(skip)
.limit(limit)
.all()
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()
# Add deterministic sort order
query = query.order_by(HistoricalTariffFraction.historical_fraction)
items = self.db.scalars(query.offset(skip).limit(limit)).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
historical_tariff_fraction_id: int,
) -> Optional[HistoricalTariffFraction]:
return (
db.query(HistoricalTariffFraction)
.filter(HistoricalTariffFraction.id == historical_tariff_fraction_id)
.first()
)
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
@staticmethod
def get_rate_for_invoice(
db: Session,
historical_fraction: str,
nico: str,
fraction_type: str,
invoice_date: str,
sector: Optional[str] = None,
) -> Optional[HistoricalTariffFraction]:
"""
Replicates Clarion ASIGNA_FRACCION_HISTORICA logic:
SELECT TOP 1 WHERE FraccionHistorica = {fraction_8chars}
AND Pais = {nico_2chars}
AND FechaPublicacion <= {invoice_date}
ORDER BY FechaPublicacion ASC
"""
try:
date_val = datetime.strptime(invoice_date, "%Y-%m-%d").date()
invoice_datetime = datetime.combine(date_val, time.max)
except (ValueError, TypeError):
return None
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
print(f"Searching for historical tariff fraction with historical_fraction={historical_fraction}, nico={nico}, fraction_type={fraction_type}, invoice_date={invoice_datetime}, sector={sector}")
query = db.query(HistoricalTariffFraction).filter(
HistoricalTariffFraction.historical_fraction == historical_fraction,
HistoricalTariffFraction.nico == nico,
HistoricalTariffFraction.fraction_type == fraction_type,
HistoricalTariffFraction.publication_date <= invoice_datetime,
)
if sector:
query = query.filter(HistoricalTariffFraction.sector == sector)
return query.order_by(HistoricalTariffFraction.publication_date.asc()).first()
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,130 @@ 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
search_description = None
if filters and filters.get("search"):
term = filters["search"]
# Simple heuristic: if it looks like a code, use code search, else description
# FIX: Short numeric codes (e.g. "01") often fail strict 'fraccion' search.
# Treat them as description search for partial matching.
clean_term = term.replace(".", "")
if clean_term.isdigit() and len(clean_term) >= 4:
search_term = term
else:
search_description = term
# USA Service search signature: fraccion, descripcion, skip, limit
usa_items = await usa_service.search(
fraccion=search_term,
descripcion=search_description,
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:
import traceback
logger.error(f"Error fetching USA fractions (API): {e}")
logger.error(traceback.format_exc())
# 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 +203,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 +210,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 +222,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 +230,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
)
@@ -194,6 +291,8 @@ class TariffFractionService:
query = query.filter(TariffFraction.umt.ilike(f"%{filters['umt']}%"))
total = query.count()
# Add deterministic sort order
query = query.order_by(TariffFraction.fraction)
items = query.offset(skip).limit(limit).all()
return items, total

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,9 +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.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
@@ -34,7 +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)
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,25 @@ 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()
# Add deterministic sort order
query = query.order_by(Sector.key)
items = query.offset(skip).limit(page_size).all()
return {
"items": [SectorDTO.model_validate(obj) for obj in items],
"total": total,
@@ -40,47 +54,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

@@ -86,6 +86,8 @@ class SitarAPIBaseService:
httpx.HTTPError: If request fails
"""
token = await self._get_token()
# Ensure no double slash between fractures and endpoint
endpoint = endpoint.lstrip("/")
url = f"{self.base_url}/fractions/{endpoint}"
headers = {
"Authorization": f"Bearer {token}",

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"""
@@ -20,6 +21,7 @@ class FraccionesUSAService(SitarAPIBaseService):
async def search(
self,
fraccion: Optional[str] = None,
descripcion: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesUSAResponse]:
@@ -27,11 +29,13 @@ class FraccionesUSAService(SitarAPIBaseService):
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if descripcion:
params["descripcion"] = descripcion
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

@@ -104,7 +104,7 @@ export interface Company {
responsible_last_name: string | null;
responsible_mother_last_name: string | null;
responsible_rfc?: string | null;
position?: string | null;
position?: string | null;
has_express_line?: boolean;
is_service_company?: boolean;
order_format_type?: string | null;
@@ -219,7 +219,27 @@ export interface Company {
cfdi_xml_save_path?: string | null;
cfdi_app_path?: string | null;
cfdi_pac_app_path?: string | null;
// Digital Certificates (Flattened)
fiel_cer?: string | null;
fiel_key?: string | null;
fiel_pass?: string | null;
fiel_access?: string | null;
fiel_cer_exp?: number | null;
fiel_key_exp?: number | null;
cfdi_cert_cer?: string | null;
cfdi_cert_key?: string | null;
cfdi_cert_pass?: string | null;
cfdi_cert_access?: string | null;
cfdi_cert_cer_exp?: number | null;
cfdi_cert_key_exp?: number | null;
cancel_cer?: string | null;
cancel_key?: string | null;
cancel_pass?: string | null;
cancel_access?: string | null;
cancel_cer_exp?: number | null;
cancel_key_exp?: number | null;
// Submodelos
addresses?: CompanyAddress[];
certification?: CompanyCertification | null;
digital_certificates?: CompanyDigitalCertificate[];
@@ -253,31 +273,139 @@ export interface CompanyCreate {
order_format_type?: string | null;
ctpat_svi?: string | null;
trusted_exporter_number?: string | null;
logo?: string | null;
previous_code?: number | null;
client_name?: string | null;
subassembly_mode?: string | null;
inter_db_name?: string | null;
prevalidator_key?: string | null;
seventh_amendment?: boolean;
// Digital Certificates
fiel_cer?: string | null;
fiel_key?: string | null;
fiel_pass?: string | null;
fiel_access?: string | null;
fiel_cer_exp?: number | null;
fiel_key_exp?: number | null;
cfdi_cert_cer?: string | null;
cfdi_cert_key?: string | null;
cfdi_cert_pass?: string | null;
cfdi_cert_access?: string | null;
cfdi_cert_cer_exp?: number | null;
cfdi_cert_key_exp?: number | null;
cancel_cer?: string | null;
cancel_key?: string | null;
cancel_pass?: string | null;
cancel_access?: string | null;
cancel_cer_exp?: number | null;
cancel_key_exp?: number | null;
// Sectores
sector1?: string | null;
sector2?: string | null;
sector3?: string | null;
// Flags técnicos
active_labels?: number | null;
active_fractions?: number | null;
activate_caat?: number | null;
trans_interface?: number | null;
american_costs?: number | null;
scaf_readonly?: number | null;
parts_replacement?: number | null;
activate_facmexame?: number | null;
part_reference?: number | null;
international_firm?: number | null;
// Configuraciones
ftp_key?: string | null;
sifra_path?: string | null;
version_type?: string | null;
sql_language?: string | null;
balance_operation_mode?: string | null;
// Campos de certificación
is_certified_company?: string | null;
certified_company_registration?: string | null;
certified_company_start_date?: number | null;
certified_company_end_date?: number | null;
annex31_certification_date?: number | null;
annex31_certification_number?: string | null;
annex31_modality?: string | null;
annex31_company_type?: string | null;
annex31_renewal_date?: number | null;
annex31_final_certification_date?: number | null;
is_oea_company?: number | null;
neec_company?: number | null;
// Campos de dirección principal
main_street?: string | null;
main_exterior_number?: string | null;
main_interior_number?: string | null;
main_postal_code?: string | null;
main_neighborhood?: string | null;
main_city?: string | null;
main_municipality?: string | null;
main_state?: string | null;
main_country?: string | null;
main_phone?: string | null;
main_fax?: string | null;
main_email?: string | null;
// Campos de dirección industrial 1
ind1_street?: string | null;
ind1_exterior_number?: string | null;
ind1_interior_number?: string | null;
ind1_postal_code?: string | null;
ind1_neighborhood?: string | null;
ind1_city?: string | null;
ind1_municipality?: string | null;
ind1_state?: string | null;
ind1_country?: string | null;
ind1_phone?: string | null;
ind1_fax?: string | null;
ind1_email?: string | null;
// Campos de dirección industrial 2
ind2_street?: string | null;
ind2_exterior_number?: string | null;
ind2_interior_number?: string | null;
ind2_postal_code?: string | null;
ind2_neighborhood?: string | null;
ind2_city?: string | null;
ind2_municipality?: string | null;
ind2_state?: string | null;
ind2_country?: string | null;
ind2_phone?: string | null;
ind2_fax?: string | null;
ind2_email?: string | null;
// Campos de prevalidador
prev_customs?: string | null;
prev_key?: string | null;
prev_patent?: string | null;
prev_description?: string | null;
// Campos de ventanilla única
vu_webservice_user?: string | null;
vu_webservice_password?: string | null;
vu_email?: string | null;
vu_figure_type?: string | null;
vu_central_path?: string | null;
vu_xml_files_path?: string | null;
vu_query_rfc?: string | null;
vu_validation_rfc?: string | null;
vu_configuration_source?: string | null;
vu_measurement_units?: string | null;
// Campos de agente aduanal electrónico
ea_input_folder?: string | null;
ea_output_folder?: string | null;
ea_send_mask?: string | null;
ea_response_mask?: string | null;
ea_response_extension?: string | null;
ea_counter_start?: number | null;
ea_counter_end?: number | null;
ea_counter_next?: number | null;
// Campos de CFDI
cfdi_xml_save_path?: string | null;
cfdi_app_path?: string | null;
cfdi_pac_app_path?: string | null;
}
export interface CompanyUpdate {
name?: string | null;
rfc?: string | null;
curp?: string | null;
main_activity?: string | null;
program?: string | null;
program_number?: string | null;
prosec?: number | null;
prosec_authorization?: string | null;
manufacturer_id?: string | null;
broker_company?: string | null;
responsible?: string | null;
responsible_name?: string | null;
responsible_last_name?: string | null;
responsible_mother_last_name?: string | null;
responsible_rfc?: string | null;
position?: string | null;
has_express_line?: boolean;
is_service_company?: boolean;
order_format_type?: string | null;
ctpat_svi?: string | null;
trusted_exporter_number?: string | null;
}
export interface CompanyUpdate extends CompanyCreate { }
export interface CompanyListResponse {
items: Company[];
@@ -319,12 +447,12 @@ export async function deleteCompany(id: number): Promise<ApiResponse<void>> {
export async function uploadCompanyLogo(id: number, file: File): Promise<ApiResponse<{ message: string; logo_path: string; company_id: number }>> {
const formData = new FormData();
formData.append('file', file);
// Para FormData, usamos fetch directamente ya que necesitamos omitir Content-Type
// para que el navegador establezca el boundary automáticamente
const token = localStorage.getItem('access_token');
const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
const response = await fetch(`${API_BASE_URL}/v1/a76/company/${id}/upload-logo`, {
method: 'POST',
headers: {
@@ -333,16 +461,48 @@ export async function uploadCompanyLogo(id: number, file: File): Promise<ApiResp
body: formData,
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
return {
error: data.detail || data.message || 'Error al subir el logo',
status: response.status
};
}
return {
data,
status: response.status
};
}
export async function uploadCompanyCertificate(id: number, file: File, type: string): Promise<ApiResponse<{ message: string; file_path: string; certificate_type: string; company_id: number }>> {
const formData = new FormData();
formData.append('file', file);
// Add certificate type query param
const token = localStorage.getItem('access_token');
const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
const response = await fetch(`${API_BASE_URL}/v1/a76/company/${id}/upload-certificate?certificate_type=${type}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
return {
error: data.detail || data.message || 'Error al subir el certificado',
status: response.status
};
}
return {
data,
status: response.status

View File

@@ -0,0 +1,103 @@
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 HistoricalFractionCreate {
historical_fraction: string;
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 HistoricalFractionUpdate {
historical_fraction?: string;
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/fractions/historical-tariff-fractions/?${params.toString()}`);
if (!response.data) throw new Error('Error fetching historical fractions');
return response.data;
}
export async function createHistoricalFraction(
companyId: number,
data: HistoricalFractionCreate
): Promise<ApiResponse<HistoricalFraction>> {
return await api.post(`/v1/a76/fractions/historical-tariff-fractions/?company_id=${companyId}`, data);
}
export async function updateHistoricalFraction(
companyId: number,
id: number,
data: HistoricalFractionUpdate
): Promise<ApiResponse<HistoricalFraction>> {
return await api.put(`/v1/a76/fractions/historical-tariff-fractions/${id}/?company_id=${companyId}`, data);
}
export async function deleteHistoricalFraction(
companyId: number,
id: number
): Promise<ApiResponse<void>> {
return await api.delete(`/v1/a76/fractions/historical-tariff-fractions/${id}/?company_id=${companyId}`);
}

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,188 @@
<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, untrack } 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';
let { title = 'Sectores' }: { title?: string } = $props();
let sectors = $state<Sector[]>([]);
let loading = $state(false);
let searchTerm = $state('');
let page = $state(1);
let pageSize = 50;
let hasMore = $state(true);
let total = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
let observer: IntersectionObserver;
let sentinel: HTMLDivElement;
async function loadSectors(reset = false) {
if (loading || (!hasMore && !reset)) return;
loading = true;
if (reset) {
page = 1;
sectors = [];
hasMore = true;
} else {
page++;
}
try {
const response = await getSectors(page, pageSize, searchTerm || undefined);
const newItems = response.items || [];
if (reset) {
sectors = newItems;
} else {
sectors = [...sectors, ...newItems];
}
total = response.total;
// Safer end-of-data detection
hasMore = newItems.length === pageSize && sectors.length < total;
} catch (error) {
console.error('Error loading sectors:', error);
toast.error('Error al cargar sectores');
hasMore = false;
} finally {
loading = false;
}
}
function handleSearch() {
loadSectors(true);
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadSectors(true);
}, 500);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
clearTimeout(searchTimeout);
handleSearch();
}
}
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && sectors.length > 0) {
loadSectors(false);
}
},
{ rootMargin: '100px' }
);
if (sentinel) observer.observe(sentinel);
}
// Initial load
$effect(() => {
untrack(() => loadSectors(true));
});
// Setup observer only when sentinel is available
$effect(() => {
if (sentinel) {
setupObserver();
return () => observer?.disconnect();
}
});
</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}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
/>
</div>
</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}
{#if loading && page > 1}
<Table.Row>
<Table.Cell colspan={3} class="h-12 text-center">
<div class="flex justify-center">
<Loader2 class="h-4 w-4 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{/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>
<!-- Infinite Scroll Sentinel -->
<div bind:this={sentinel} class="h-4 w-full"></div>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,180 @@
<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 {
createCanadianFraction,
updateCanadianFraction,
type CanadianFraction,
type CanadianFractionCreate,
type CanadianFractionUpdate
} from '$lib/api/dashboard/general_catalogs/canadian';
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.
onSuccess
}: {
open: boolean;
fraction?: CanadianFraction | null;
onSuccess: () => void;
} = $props();
let isLoading = $state(false);
// Form fields
let fractionCode = $state('');
let countryCode = $state('');
let description = $state('');
let unitOfMeasure = $state('');
let adValorem = $state('');
// Load data on open/fraction change
$effect(() => {
if (open) {
if (fraction) {
// Edit mode
fractionCode = fraction.fraction;
countryCode = fraction.country_code;
description = fraction.description || '';
unitOfMeasure = fraction.unit_of_measure || '';
adValorem = fraction.ad_valorem?.toString() || '';
} else {
// Create mode - reset
fractionCode = '';
countryCode = '';
description = '';
unitOfMeasure = '';
adValorem = '';
}
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
// Validation
if (!fractionCode) {
toast.error('La fracción es requerida');
return;
}
if (!countryCode) {
toast.error('El código de país es requerido');
return;
}
isLoading = true;
try {
const adValoremNum = adValorem ? parseFloat(adValorem) : undefined;
if (adValorem && isNaN(adValoremNum!)) {
toast.error('El Ad Valorem debe ser un número válido');
isLoading = false;
return;
}
if (fraction) {
// Update
const updateData: CanadianFractionUpdate = {
fraction: fractionCode,
country_code: countryCode,
description,
unit_of_measure: unitOfMeasure || undefined,
ad_valorem: adValoremNum
};
await updateCanadianFraction(companyId, fraction.id, updateData);
toast.success('Fracción actualizada correctamente');
} else {
// Create
const createData: CanadianFractionCreate = {
fraction: fractionCode,
country_code: countryCode,
description,
unit_of_measure: unitOfMeasure || undefined,
ad_valorem: adValoremNum
};
await createCanadianFraction(companyId, createData);
toast.success('Fracción creada correctamente');
}
onSuccess();
open = false;
} catch (error) {
console.error('Error saving Canadian 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 Canadiense</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="fraction">Fracción</Label>
<Input
id="fraction"
bind:value={fractionCode}
placeholder="Ej. 9999999999"
maxlength={13}
/>
</div>
<div class="space-y-2">
<Label for="country_code">País (Código ISO)</Label>
<Input id="country_code" bind:value={countryCode} placeholder="Ej. CAN" maxlength={3} />
</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>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="unit">Unidad de Medida</Label>
<Input id="unit" bind:value={unitOfMeasure} placeholder="Ej. Kg" maxlength={5} />
</div>
<div class="space-y-2">
<Label for="ad_valorem">Ad Valorem (%)</Label>
<Input
id="ad_valorem"
type="number"
step="0.01"
bind:value={adValorem}
placeholder="0.00"
/>
</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,266 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import {
getCanadianFractions,
deleteCanadianFraction,
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, Plus, Pencil, Trash2 } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import CanadianFractionDialog from './CanadianFractionDialog.svelte';
import { companyStore } from '$lib/stores/company.svelte';
let fractions = $state<CanadianFraction[]>([]);
let loading = $state(false);
let searchQuery = $state('');
let page = $state(1);
let totalItems = $state(0);
let totalPages = $state(0);
let pageSize = 50;
let searchTimeout: ReturnType<typeof setTimeout>;
let observer: IntersectionObserver;
let sentinel: HTMLDivElement;
// Infinite scroll state
let hasMore = $state(true);
// Dialog state
let dialogOpen = $state(false);
let editingFraction = $state<CanadianFraction | null>(null);
let deletingFractionId = $state<number | null>(null);
async function loadFractions(reset = false) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (loading) return;
loading = true;
if (reset) {
page = 1;
fractions = [];
hasMore = true;
} else {
page++;
}
try {
const response = await getCanadianFractions(
companyId,
page,
pageSize,
searchQuery || undefined
);
const newItems = response.items || [];
if (reset) {
fractions = newItems;
} else {
fractions = [...fractions, ...newItems];
}
totalItems = response.total;
totalPages = response.pages;
// Safer end-of-data detection
hasMore = newItems.length === pageSize && fractions.length < totalItems;
} catch (error) {
console.error('Error loading Canadian fractions:', error);
toast.error('Error al cargar fracciones canadienses');
hasMore = false;
} finally {
loading = false;
}
}
function handleSearch() {
loadFractions(true);
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadFractions(true);
}, 500);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
clearTimeout(searchTimeout);
handleSearch();
}
}
function handleCreate() {
editingFraction = null;
dialogOpen = true;
}
function handleEdit(fraction: CanadianFraction) {
editingFraction = fraction;
dialogOpen = true;
}
async function handleDelete(fraction: CanadianFraction) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (!confirm(`¿Estás seguro de eliminar la fracción ${fraction.fraction}?`)) return;
try {
deletingFractionId = fraction.id;
await deleteCanadianFraction(companyId, fraction.id);
toast.success('Fracción eliminada correctamente');
loadFractions(true);
} catch (error) {
console.error('Error deleting Canadian fraction:', error);
toast.error('Error al eliminar la fracción');
} finally {
deletingFractionId = null;
}
}
function handleSuccess() {
loadFractions(true);
}
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && fractions.length > 0) {
loadFractions(false);
}
},
{ rootMargin: '100px' }
);
if (sentinel) observer.observe(sentinel);
}
// Removed onMount as we use $effect for company changes which covers initial load
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (companyId) {
untrack(() => loadFractions(true));
}
});
// Setup observer only when sentinel is available
$effect(() => {
if (sentinel) {
setupObserver();
return () => observer?.disconnect();
}
});
</script>
<div class="space-y-4">
<div class="flex flex-col items-end justify-between gap-4 md:flex-row">
<div class="flex max-w-2xl flex-1 items-end gap-4">
<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}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
/>
</div>
</div>
</div>
<Button onclick={handleCreate}>
<Plus class="mr-2 h-4 w-4" />
Nueva Fracción
</Button>
</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.Head class="w-[100px]">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if fractions.length === 0 && !loading}
<Table.Row>
<Table.Cell colspan={6} 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.Cell>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => handleEdit(fraction)}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
onclick={() => handleDelete(fraction)}
disabled={deletingFractionId === fraction.id}
>
{#if deletingFractionId === fraction.id}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Trash2 class="h-4 w-4" />
{/if}
</Button>
</div>
</Table.Cell>
</Table.Row>
{/each}
{/if}
{#if loading}
<Table.Row>
<Table.Cell colspan={6} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Infinite Scroll Sentinel -->
<div bind:this={sentinel} class="h-4 w-full"></div>
<CanadianFractionDialog
bind:open={dialogOpen}
fraction={editingFraction}
onSuccess={handleSuccess}
/>
</div>

View File

@@ -0,0 +1,262 @@
<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 { Switch } from '$lib/components/ui/switch';
import {
createHistoricalFraction,
updateHistoricalFraction,
type HistoricalFraction,
type HistoricalFractionCreate,
type HistoricalFractionUpdate
} from '$lib/api/dashboard/a76/general_catalogs/historical-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.
onSuccess
}: {
open: boolean;
fraction?: HistoricalFraction | null;
onSuccess: () => void;
} = $props();
let isLoading = $state(false);
// Form fields
let historicalFractionCode = $state('');
let unitOfMeasureCode = $state('');
let country = $state('');
let fractionType = $state('');
let sector = $state('');
let importTaxRate = $state('');
let exportTaxRate = $state('');
let publicationDate = $state('');
let endDate = $state('');
let isImmex = $state(false);
let normalTemporality = $state(false);
let servicesTemporality = $state(false);
let certifiedTemporality = $state(false);
let byLog = $state(false);
// Load data on open/fraction change
$effect(() => {
if (open) {
if (fraction) {
// Edit mode
historicalFractionCode = fraction.historical_fraction || '';
unitOfMeasureCode = fraction.unit_of_measure_code || '';
country = fraction.country || '';
fractionType = fraction.fraction_type || '';
sector = fraction.sector || '';
importTaxRate = fraction.import_tax_rate?.toString() || '';
exportTaxRate = fraction.export_tax_rate?.toString() || '';
publicationDate = fraction.publication_date ? fraction.publication_date.split('T')[0] : '';
endDate = fraction.end_date ? fraction.end_date.split('T')[0] : '';
isImmex = fraction.is_immex || false;
normalTemporality = fraction.normal_temporality || false;
servicesTemporality = fraction.services_temporality || false;
certifiedTemporality = fraction.certified_temporality || false;
byLog = fraction.by_log || false;
} else {
// Create mode - reset
historicalFractionCode = '';
unitOfMeasureCode = '';
country = '';
fractionType = '';
sector = '';
importTaxRate = '';
exportTaxRate = '';
publicationDate = '';
endDate = '';
isImmex = false;
normalTemporality = false;
servicesTemporality = false;
certifiedTemporality = false;
byLog = false;
}
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
// Validation
if (!historicalFractionCode) {
toast.error('La fracción es requerida');
return;
}
isLoading = true;
try {
const importRateNum = importTaxRate ? parseFloat(importTaxRate) : undefined;
const exportRateNum = exportTaxRate ? parseFloat(exportTaxRate) : undefined;
const baseData = {
historical_fraction: historicalFractionCode,
unit_of_measure_code: unitOfMeasureCode || null,
country: country || null,
fraction_type: fractionType || null,
sector: sector || null,
import_tax_rate: importRateNum,
export_tax_rate: exportRateNum,
publication_date: publicationDate || null,
end_date: endDate || null,
is_immex: isImmex,
normal_temporality: normalTemporality,
services_temporality: servicesTemporality,
certified_temporality: certifiedTemporality,
by_log: byLog
};
if (fraction) {
// Update
const updateData: HistoricalFractionUpdate = baseData;
await updateHistoricalFraction(companyId, fraction.id, updateData);
toast.success('Fracción actualizada correctamente');
} else {
// Create
const createData: HistoricalFractionCreate = {
...baseData,
historical_fraction: historicalFractionCode // Required in create
};
await createHistoricalFraction(companyId, createData);
toast.success('Fracción creada correctamente');
}
onSuccess();
open = false;
} catch (error) {
console.error('Error saving historical fraction:', error);
toast.error('Error al guardar la fracción');
} finally {
isLoading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[700px]">
<Dialog.Header>
<Dialog.Title>{fraction ? 'Editar' : 'Crear'} Fracción Histórica</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 max-h-[70vh] gap-4 overflow-y-auto py-4 pr-2">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="fraction">Fracción</Label>
<Input
id="fraction"
bind:value={historicalFractionCode}
placeholder="Ej. 01010101"
maxlength={8}
/>
</div>
<div class="space-y-2">
<Label for="unit">Unidad de Medida</Label>
<Input id="unit" bind:value={unitOfMeasureCode} placeholder="Ej. 01" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="country">País</Label>
<Input id="country" bind:value={country} placeholder="Ej. MEX" />
</div>
<div class="space-y-2">
<Label for="type">Tipo</Label>
<Input id="type" bind:value={fractionType} placeholder="Ej. General" maxlength={7} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sector">Sector</Label>
<Input id="sector" bind:value={sector} placeholder="Sectores..." maxlength={5} />
</div>
<div class="space-y-2">
<Label for="by-log">Por Bitácora</Label>
<div class="flex items-center space-x-2 pt-2">
<Switch id="by-log" bind:checked={byLog} />
<span class="text-sm text-muted-foreground">{byLog ? 'Sí' : 'No'}</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="import-tax">Tasa IGI (%)</Label>
<Input
id="import-tax"
type="number"
step="0.01"
bind:value={importTaxRate}
placeholder="0.00"
/>
</div>
<div class="space-y-2">
<Label for="export-tax">Tasa IGE (%)</Label>
<Input
id="export-tax"
type="number"
step="0.01"
bind:value={exportTaxRate}
placeholder="0.00"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="pub-date">Fecha Publicación</Label>
<Input id="pub-date" type="date" bind:value={publicationDate} />
</div>
<div class="space-y-2">
<Label for="end-date">Fecha Fin</Label>
<Input id="end-date" type="date" bind:value={endDate} />
</div>
</div>
<div class="grid grid-cols-2 gap-4 border-t pt-4">
<div class="flex items-center justify-between space-x-2">
<Label for="is-immex">IMMEX</Label>
<Switch id="is-immex" bind:checked={isImmex} />
</div>
<div class="flex items-center justify-between space-x-2">
<Label for="normal-temp">Temp. Normal</Label>
<Switch id="normal-temp" bind:checked={normalTemporality} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center justify-between space-x-2">
<Label for="services-temp">Temp. Servicios</Label>
<Switch id="services-temp" bind:checked={servicesTemporality} />
</div>
<div class="flex items-center justify-between space-x-2">
<Label for="certified-temp">Temp. Certificada</Label>
<Switch id="certified-temp" bind:checked={certifiedTemporality} />
</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,283 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import {
getHistoricalFractions,
deleteHistoricalFraction,
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, Plus, Pencil, Trash2 } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import HistoricalFractionDialog from './HistoricalFractionDialog.svelte';
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;
let searchTimeout: ReturnType<typeof setTimeout>;
let observer: IntersectionObserver;
let sentinel: HTMLDivElement;
// Infinite scroll state
let hasMore = $state(true);
// Dialog state
let dialogOpen = $state(false);
let editingFraction = $state<HistoricalFraction | null>(null);
let deletingFractionId = $state<number | null>(null);
async function loadFractions(reset = false) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (loading) return;
loading = true;
if (reset) {
page = 1;
fractions = [];
hasMore = true;
} else {
page++;
}
try {
const response = await getHistoricalFractions(
companyId,
historicalFraction || undefined,
page,
pageSize
);
const newItems = response.items || [];
if (reset) {
fractions = newItems;
} else {
fractions = [...fractions, ...newItems];
}
totalItems = response.total;
totalPages = response.pages;
// Safer end-of-data detection
hasMore = newItems.length === pageSize && fractions.length < totalItems;
} catch (error) {
console.error('Error loading historical fractions:', error);
toast.error('Error al cargar fracciones históricas');
hasMore = false;
} finally {
loading = false;
}
}
function handleSearch() {
loadFractions(true);
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadFractions(true);
}, 500);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
clearTimeout(searchTimeout);
handleSearch();
}
}
function handleCreate() {
editingFraction = null;
dialogOpen = true;
}
function handleEdit(fraction: HistoricalFraction) {
editingFraction = fraction;
dialogOpen = true;
}
async function handleDelete(fraction: HistoricalFraction) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (!confirm(`¿Estás seguro de eliminar la fracción ${fraction.historical_fraction}?`)) return;
try {
deletingFractionId = fraction.id;
await deleteHistoricalFraction(companyId, fraction.id);
toast.success('Fracción eliminada correctamente');
loadFractions(true);
} catch (error) {
console.error('Error deleting historical fraction:', error);
toast.error('Error al eliminar la fracción');
} finally {
deletingFractionId = null;
}
}
function handleSuccess() {
loadFractions(true);
}
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && fractions.length > 0) {
loadFractions(false);
}
},
{ rootMargin: '100px' }
);
if (sentinel) observer.observe(sentinel);
}
// Removed onMount as we use $effect for company changes which covers initial load
// Reload when company changes
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (companyId) {
untrack(() => loadFractions(true));
}
});
// Setup observer only when sentinel is available
$effect(() => {
if (sentinel) {
setupObserver();
return () => observer?.disconnect();
}
});
</script>
<div class="space-y-4">
<div class="flex flex-col items-end justify-between gap-4 md:flex-row">
<div class="flex max-w-2xl flex-1 items-end gap-4">
<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}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
/>
</div>
</div>
</div>
<Button onclick={handleCreate}>
<Plus class="mr-2 h-4 w-4" />
Nueva Fracción
</Button>
</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.Head class="w-[100px]">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if fractions.length === 0 && !loading}
<Table.Row>
<Table.Cell colspan={9} 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.Cell>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => handleEdit(fraction)}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
onclick={() => handleDelete(fraction)}
disabled={deletingFractionId === fraction.id}
>
{#if deletingFractionId === fraction.id}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Trash2 class="h-4 w-4" />
{/if}
</Button>
</div>
</Table.Cell>
</Table.Row>
{/each}
{/if}
{#if loading}
<Table.Row>
<Table.Cell colspan={9} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Infinite Scroll Sentinel -->
<div bind:this={sentinel} class="h-4 w-full"></div>
<HistoricalFractionDialog
bind:open={dialogOpen}
fraction={editingFraction}
onSuccess={handleSuccess}
/>
</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,317 @@
<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, untrack } 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 observer: IntersectionObserver;
let sentinel: HTMLDivElement;
// Infinite scroll state
let hasMore = $state(true);
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(reset = false) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (isLoading) return;
isLoading = true;
if (reset) {
currentPage = 1;
fractions = [];
hasMore = 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) {
const newItems = response.data.items || [];
if (reset) {
fractions = newItems;
} else {
fractions = [...fractions, ...newItems];
}
totalFractions = response.data.total;
// Safer end-of-data detection
hasMore = newItems.length === pageSize && fractions.length < totalFractions;
} else {
if (reset) fractions = [];
hasMore = false;
}
} catch (error) {
console.error('Error loading fractions:', error);
toast.error('Error al cargar las fracciones');
hasMore = false;
} finally {
isLoading = false;
}
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadFractions(true);
}, 500);
}
function handlePageChange(newPage: number) {
currentPage = newPage;
loadFractions();
}
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoading && fractions.length > 0) {
handlePageChange(currentPage + 1);
}
},
{ rootMargin: '100px' }
);
if (sentinel) observer.observe(sentinel);
}
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(true);
} 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;
}
}
// Removed onMount as we use $effect for company changes which covers initial load
// Reload when company changes
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (companyId) {
untrack(() => loadFractions(true));
}
});
// Setup observer only when sentinel is available
$effect(() => {
if (sentinel) {
setupObserver();
return () => observer?.disconnect();
}
});
</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 fractions.length === 0 && !isLoading}
<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}
{#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>
{/if}
</TableBody>
</Table>
</div>
<!-- Infinite Scroll Sentinel -->
<div bind:this={sentinel} class="h-4 w-full"></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

@@ -305,31 +305,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

@@ -1,9 +1,9 @@
<script lang="ts">
import { page } from '$app/stores';
import DataTable from '$lib/components/dashboard/general_catalogs/company/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/general_catalogs/company/columns.js';
//import CreateEditDialog from '$lib/components/dashboard/general_catalogs/company/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/general_catalogs/company/columns';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
import { goto } from '$app/navigation';
@@ -56,17 +56,15 @@
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Información de Empresas</h1>
<!-- {m["sidebar.general_catalogs.company_information"]()} -->
<p class="text-muted-foreground">Gestión de información de empresas</p>
</div>
<!-- <Button onclick={() => dialogOpen = true}> -->
<Button href="/dashboard/general_catalogs/company_information/edit">
<Plus class="mr-2 h-4 w-4" />
Nueva Empresa
</Button>
</div>
<div class="flex gap-4 items-end">
<div class="flex items-end gap-4">
<div class="grid w-full max-w-sm items-center gap-1.5">
<Input placeholder="Buscar por nombre..." bind:value={searchName} oninput={handleSearch} />
</div>
@@ -83,9 +81,4 @@
totalItems={data.companies?.total || 0}
/>
</div>
<!-- <CreateEditDialog
bind:open={dialogOpen}
onSuccess={handleSuccess}
/> -->
</div>

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} />