Merge pull request 'feature/fraction-catalogs' (#152) from feature/fraction-catalogs into development
Reviewed-on: ADUANASOFT/anexo76#152
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
77
frontend/src/lib/api/dashboard/general_catalogs/canadian.ts
Normal file
77
frontend/src/lib/api/dashboard/general_catalogs/canadian.ts
Normal 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()}`);
|
||||
}
|
||||
35
frontend/src/lib/api/dashboard/general_catalogs/sectors.ts
Normal file
35
frontend/src/lib/api/dashboard/general_catalogs/sectors.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SectorsList from '$lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte';
|
||||
</script>
|
||||
|
||||
<SectorsList />
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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']()} />
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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} />
|
||||
Reference in New Issue
Block a user