diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index d33dafe6..4ab685d1 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -1,7 +1,8 @@ """seed_initial_data Revision ID: 7937209f9718 -Revises: 531bf8cdae06 +Revises: + Create Date: 2025-10-19 18:23:55.258800 """ @@ -95,6 +96,174 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" + # --- CREAR TABLA fraccion canadienses --- + op.create_table('canadian_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('fraction', sa.String(length=13), nullable=False), + sa.Column('ad_valorem', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('country_code', sa.String(length=3), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + sa.UniqueConstraint('fraction', 'country_code', 'company_id', name='uq_canadian_fraction_country_company'), + schema='a76' + ) + op.create_index(op.f('ix_a76_canadian_tariff_fractions_country_code'), 'canadian_tariff_fractions', ['country_code'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_fraction'), 'canadian_tariff_fractions', ['fraction'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_id'), 'canadian_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_company_id'), 'canadian_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_canadian_tariff_fractions_tenant_id'), 'canadian_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + + # --- CREAR TABLA fraccion americana --- + op.create_table('us_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=16), nullable=False, comment='Código de fracción americana'), + sa.Column('prefix', sa.String(length=10), nullable=True, comment='Prefijo de clasificación'), + sa.Column('type_code', sa.String(length=10), nullable=True, comment='Código de tipo'), + sa.Column('ad_valorem', sa.Numeric(precision=10, scale=2), nullable=True, comment='Porcentaje ad valorem'), + sa.Column('fixed_cost', sa.Numeric(precision=15, scale=8), nullable=True, comment='Tasa fija'), + sa.Column('unit_of_measure', sa.String(length=10), nullable=True, comment='Unidad de medida'), + sa.Column('description', sa.String(), nullable=True, comment='Descripción de la fracción'), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + schema='a76' + ) + op.create_index(op.f('ix_a76_us_tariff_fractions_id'), 'us_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_company_id'), 'us_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_tenant_id'), 'us_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + + # --- CREAR TABLA fraccion historico --- + op.create_table('historical_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('historical_fraction', sa.String(length=8), nullable=True), + sa.Column('unit_of_measure_code', sa.String(), nullable=True), + sa.Column('country', sa.String(), nullable=True), + sa.Column('fraction_type', sa.String(length=7), nullable=True), + sa.Column('sector', sa.String(length=5), nullable=True), + sa.Column('import_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('export_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('publication_date', sa.DateTime(), nullable=True), + sa.Column('is_immex', sa.Boolean(), nullable=True), + sa.Column('normal_temporality', sa.Boolean(), nullable=True), + sa.Column('services_temporality', sa.Boolean(), nullable=True), + sa.Column('certified_temporality', sa.Boolean(), nullable=True), + sa.Column('by_log', sa.Boolean(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + # Foreign Keys to other tables (assuming they exist or are created before/simultaneously, referencing explicit schemas) + sa.ForeignKeyConstraint(['unit_of_measure_code'], ['a76.unit_of_measure_customs.code'], source_schema='a76', referent_schema='a76'), + sa.ForeignKeyConstraint(['country'], ['public.countries.m3_key'], source_schema='a76', referent_schema='public'), + + schema='a76' + ) + op.create_index(op.f('ix_a76_historical_tariff_fractions_id'), 'historical_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_company_id'), 'historical_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_tenant_id'), 'historical_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + + # --- CREAR TABLA fraccion americana --- + op.create_table('us_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=16), nullable=False, comment='Código de fracción americana'), + sa.Column('prefix', sa.String(length=10), nullable=True, comment='Prefijo de clasificación'), + sa.Column('type_code', sa.String(length=10), nullable=True, comment='Código de tipo'), + sa.Column('ad_valorem', sa.Numeric(precision=10, scale=2), nullable=True, comment='Porcentaje ad valorem'), + sa.Column('fixed_cost', sa.Numeric(precision=15, scale=8), nullable=True, comment='Tasa fija'), + sa.Column('unit_of_measure', sa.String(length=10), nullable=True, comment='Unidad de medida'), + sa.Column('description', sa.String(), nullable=True, comment='Descripción de la fracción'), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + schema='a76' + ) + op.create_index(op.f('ix_a76_us_tariff_fractions_id'), 'us_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_company_id'), 'us_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_us_tariff_fractions_tenant_id'), 'us_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + + # --- CREAR TABLA fraccion historico --- + op.create_table('historical_tariff_fractions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('historical_fraction', sa.String(length=8), nullable=True), + sa.Column('unit_of_measure_code', sa.String(), nullable=True), + sa.Column('country', sa.String(), nullable=True), + sa.Column('fraction_type', sa.String(length=7), nullable=True), + sa.Column('sector', sa.String(length=5), nullable=True), + sa.Column('import_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('export_tax_rate', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('publication_date', sa.DateTime(), nullable=True), + sa.Column('is_immex', sa.Boolean(), nullable=True), + sa.Column('normal_temporality', sa.Boolean(), nullable=True), + sa.Column('services_temporality', sa.Boolean(), nullable=True), + sa.Column('certified_temporality', sa.Boolean(), nullable=True), + sa.Column('by_log', sa.Boolean(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + + # Tenant Scoped Mixin + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + + # Timestamp Mixin + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['core.tenants.id'], source_schema='a76', referent_schema='core'), + sa.ForeignKeyConstraint(['company_id'], ['a76.company.id'], source_schema='a76', referent_schema='a76'), + # Foreign Keys to other tables (assuming they exist or are created before/simultaneously, referencing explicit schemas) + sa.ForeignKeyConstraint(['unit_of_measure_code'], ['a76.unit_of_measure_customs.code'], source_schema='a76', referent_schema='a76'), + sa.ForeignKeyConstraint(['country'], ['public.countries.m3_key'], source_schema='a76', referent_schema='public'), + + schema='a76' + ) + op.create_index(op.f('ix_a76_historical_tariff_fractions_id'), 'historical_tariff_fractions', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_company_id'), 'historical_tariff_fractions', ['company_id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_historical_tariff_fractions_tenant_id'), 'historical_tariff_fractions', ['tenant_id'], unique=False, schema='a76') + # --- UTILIDAD DE FORMATEO --- def format_value(val): if val is None or str(val).strip() == "" or str(val).upper() == "NONE": @@ -520,7 +689,8 @@ def upgrade() -> None: f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, " f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, " f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, " - f"{format_bool(by_log)}, {format_timestamp(end_date)})" + f"{format_bool(by_log)}, {format_timestamp(end_date)}, " + f"1, 1, 'NOW()', 'NOW()')" # tenant_id=1, company_id=1, created_at=NOW(), updated_at=NOW() for historical_fraction, 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") diff --git a/backend/api/v1/modules/a76/audit_log/services/core.py b/backend/api/v1/modules/a76/audit_log/services/core.py index dfb5f2b1..3413b148 100644 --- a/backend/api/v1/modules/a76/audit_log/services/core.py +++ b/backend/api/v1/modules/a76/audit_log/services/core.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/company/dto.py b/backend/api/v1/modules/a76/general_catalogs/company/dto.py index 03f8c9ae..dff8a610 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index e6d4bc52..5dff4d54 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -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, + } diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 3f16f72b..a4214cde 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -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""" diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/__init__.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/models.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/models.py new file mode 100644 index 00000000..f67d68bd --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/models.py @@ -0,0 +1,29 @@ + +from datetime import datetime +from typing import Optional +from sqlalchemy import String, Numeric, TIMESTAMP, func, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from core.database import Base + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin + +class CanadianTariffFraction(Base, TenantScopedMixin, TimestampMixin): + """Model for Canadian Tariff Fractions (GFracEUACan)""" + __tablename__ = "canadian_tariff_fractions" + __table_args__ = ( + UniqueConstraint('fraction', 'country_code', 'company_id', name='uq_canadian_fraction_country_company'), + {"schema": "a76"} + ) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + + # FRACCION + fraction: Mapped[str] = mapped_column(String(13), nullable=False, index=True) + # ADV + ad_valorem: Mapped[Optional[float]] = mapped_column(Numeric(5, 2)) + # UNIDAD + unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5)) + # CLAVEM3 (Part of original PK) + country_code: Mapped[str] = mapped_column(String(3), nullable=False, index=True) + # DESCRIPCION + description: Mapped[Optional[str]] = mapped_column(String(1000)) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py new file mode 100644 index 00000000..55daf80d --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py @@ -0,0 +1,98 @@ + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from core.database import get_core_db +from core.security import get_current_user +from .service import CanadianTariffFractionService +from .schemas import ( + CanadianTariffFractionResponse, + CanadianTariffFractionCreate, + CanadianTariffFractionUpdate, + CanadianTariffFractionListResponse +) + +from api.v1.common.tenant_crud_routes import validate_access_to_resource + +router = APIRouter() + +@router.get("/", response_model=CanadianTariffFractionListResponse) +def list_canadian_fractions( + company_id: int = Query(..., description="Company ID"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=1000), + search: Optional[str] = None, + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + skip = (page - 1) * page_size + service = CanadianTariffFractionService(db) + items, total = service.get_multi( + tenant_id=tenant_id, + company_id=company_id, + skip=skip, + limit=page_size, + search=search + ) + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "pages": (total + page_size - 1) // page_size if page_size > 0 else 1 + } + +@router.get("/{id}", response_model=CanadianTariffFractionResponse) +def get_canadian_fraction( + id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = CanadianTariffFractionService(db) + item = service.get(id, tenant_id, company_id) + if not item: + raise HTTPException(status_code=404, detail="Not found") + return item + +@router.post("/", response_model=CanadianTariffFractionResponse) +def create_canadian_fraction( + item_in: CanadianTariffFractionCreate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = CanadianTariffFractionService(db) + return service.create(item_in, tenant_id, company_id) + +@router.put("/{id}", response_model=CanadianTariffFractionResponse) +def update_canadian_fraction( + id: int, + item_in: CanadianTariffFractionUpdate, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = CanadianTariffFractionService(db) + item = service.get(id, tenant_id, company_id) + if not item: + raise HTTPException(status_code=404, detail="Not found") + return service.update(item, item_in) + +@router.delete("/{id}", response_model=CanadianTariffFractionResponse) +def delete_canadian_fraction( + id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = CanadianTariffFractionService(db) + item = service.get(id, tenant_id, company_id) + if not item: + raise HTTPException(status_code=404, detail="Not found") + return service.delete(id, tenant_id, company_id) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/schemas.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/schemas.py new file mode 100644 index 00000000..4748e4ca --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/schemas.py @@ -0,0 +1,32 @@ + +from typing import Optional, List +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from decimal import Decimal + +class CanadianTariffFractionBase(BaseModel): + fraction: str = Field(..., max_length=13) + ad_valorem: Optional[Decimal] = Field(None, max_digits=5, decimal_places=2) + unit_of_measure: Optional[str] = Field(None, max_length=5) + country_code: str = Field(..., max_length=3) + description: Optional[str] = Field(None, max_length=1000) + +class CanadianTariffFractionCreate(CanadianTariffFractionBase): + pass + +class CanadianTariffFractionUpdate(CanadianTariffFractionBase): + pass + +class CanadianTariffFractionResponse(CanadianTariffFractionBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + +class CanadianTariffFractionListResponse(BaseModel): + items: List[CanadianTariffFractionResponse] + total: int + page: int + page_size: int + pages: int diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py new file mode 100644 index 00000000..8fec1468 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/models.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/models.py index 7e44201e..4f2fc3e6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/models.py @@ -4,9 +4,10 @@ from decimal import Decimal from sqlalchemy import DateTime, ForeignKey, String, Integer, Numeric, Boolean from sqlalchemy.orm import Mapped, mapped_column from core.database import Base +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin -class HistoricalTariffFraction(Base): +class HistoricalTariffFraction(Base, TenantScopedMixin, TimestampMixin): """ Historical tariff fractions catalog. Maps to SQL Server table: GFraccionesHistorico diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py index 1642bc4c..7a020605 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/schemas.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/schemas.py new file mode 100644 index 00000000..e6cc1c53 --- /dev/null +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/schemas.py @@ -0,0 +1,45 @@ + +from typing import Optional, List +from datetime import datetime +from decimal import Decimal +from pydantic import BaseModel, Field + +class HistoricalTariffFractionBase(BaseModel): + """Base schema for Historical Tariff Fraction""" + historical_fraction: Optional[str] = Field(None, max_length=8) + unit_of_measure_code: Optional[str] = None + country: Optional[str] = None + fraction_type: Optional[str] = Field(None, max_length=7) + sector: Optional[str] = Field(None, max_length=5) + import_tax_rate: Optional[Decimal] = None + export_tax_rate: Optional[Decimal] = None + publication_date: Optional[datetime] = None + is_immex: Optional[bool] = None + normal_temporality: Optional[bool] = None + services_temporality: Optional[bool] = None + certified_temporality: Optional[bool] = None + by_log: Optional[bool] = None + end_date: Optional[datetime] = None + +class HistoricalTariffFractionCreate(HistoricalTariffFractionBase): + """Schema for creating a Historical Tariff Fraction""" + pass + +class HistoricalTariffFractionUpdate(HistoricalTariffFractionBase): + """Schema for updating a Historical Tariff Fraction""" + pass + +class HistoricalTariffFractionResponse(HistoricalTariffFractionBase): + """Schema for reading a Historical Tariff Fraction""" + id: int + + class Config: + from_attributes = True + +class HistoricalTariffFractionListResponse(BaseModel): + """Schema for paginated list of Historical Tariff Fractions""" + items: List[HistoricalTariffFractionResponse] + total: int + page: int + page_size: int + pages: int diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py index c1497bc1..01579a4f 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py index da1c2881..5f0f1600 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py @@ -30,6 +30,8 @@ async def list_tariff_fractions( page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(50, ge=1, le=10000, description="Page size"), search: Optional[str] = Query(None, description="Search in code, fraction, description, nico, or umt"), + level: Optional[int] = Query(None, description="Filter by hierarchy level (e.g. 5)"), + catalog: Optional[str] = Query("mex", description="Catalog source: 'mex' (default) or 'usa'"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -37,13 +39,20 @@ async def list_tariff_fractions( filters = {} if search: filters["search"] = search + if level is not None: + filters["level"] = level # Updated to async call with Sitar integration # WARNING: Using async def with blocking DB dependency (Session) run in threadpool by FastAPI. # Service.get_all calls Sitar (async) or DB (sync). # This should be fine. + + tenant_id = current_user.get("tenant_id") + company_id = current_user.get("company_id") # Assuming user is context-aware or we use a default? + # If using headers for selected company, it might be in current_user context if middleware sets it. + items, total = await TariffFractionService.get_all( - db, skip, page_size, filters + db, skip, page_size, filters, catalog, tenant_id, company_id ) return { @@ -72,3 +81,132 @@ async def get_tariff_fraction( raise HTTPException(status_code=404, detail="Tariff fraction not found") return TariffFractionResponseDTO.model_validate(item) + +@router.post( + "/", + response_model=TariffFractionResponseDTO, + summary="Create Tariff Fraction", + description="Create a new tariff fraction (Only supported for 'american' catalog)", +) +async def create_tariff_fraction( + fraction_data: TariffFractionCreateDTO, + catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Crea una nueva fracción. + - MEX/USA: No permitido (Read-Only) + - AMERICAN: Permitido (Local DB) + """ + from fastapi import HTTPException + + tenant_id = current_user.get("tenant_id") + + if catalog == "american": + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionCreateDTO + import re + + # Map generic DTO to US DTO + ad_valorem = None + if fraction_data.adv_impo: + try: + # remove non-numeric chars except dot + clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo) + if clean: + ad_valorem = float(clean) + except: + pass + + us_dto = USTariffFractionCreateDTO( + code=fraction_data.code, + description=fraction_data.description, + unit_of_measure=fraction_data.umt, + ad_valorem=ad_valorem, + # Defaults for others + prefix=None, + type_code=None, + fixed_cost=None + ) + + created = USTariffFractionService.create(db, tenant_id, company_id, us_dto) + return TariffFractionService.to_domain_usa_local(created) + + else: + raise HTTPException(status_code=400, detail=f"Creation not allowed for '{catalog}' catalog (Read-Only)") + + +@router.put( + "/{tariff_fraction_id}", + response_model=TariffFractionResponseDTO, + summary="Update Tariff Fraction", + description="Update a tariff fraction (Only supported for 'american' catalog)", +) +async def update_tariff_fraction( + tariff_fraction_id: int, + fraction_data: TariffFractionUpdateDTO, + catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + from fastapi import HTTPException + + tenant_id = current_user.get("tenant_id") + + if catalog == "american": + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionUpdateDTO + import re + + ad_valorem = None + if fraction_data.adv_impo: + try: + clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo) + if clean: + ad_valorem = float(clean) + except: + pass + + us_dto = USTariffFractionUpdateDTO( + description=fraction_data.description, + unit_of_measure=fraction_data.umt, + ad_valorem=ad_valorem + ) + + updated = USTariffFractionService.update(db, tenant_id, company_id, tariff_fraction_id, us_dto) + if not updated: + raise HTTPException(status_code=404, detail="US Tariff fraction not found") + return TariffFractionService.to_domain_usa_local(updated) + + else: + raise HTTPException(status_code=400, detail=f"Update not allowed for '{catalog}' catalog (Read-Only)") + + +@router.delete( + "/{tariff_fraction_id}", + summary="Delete Tariff Fraction", + description="Delete a tariff fraction (Only supported for 'american' catalog)", +) +async def delete_tariff_fraction( + tariff_fraction_id: int, + catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + from fastapi import HTTPException + tenant_id = current_user.get("tenant_id") + + if catalog == "american": + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService + success = USTariffFractionService.delete(db, tenant_id, company_id, tariff_fraction_id) + if not success: + raise HTTPException(status_code=404, detail="US Tariff fraction not found") + return {"ok": True} + + else: + raise HTTPException(status_code=400, detail=f"Delete not allowed for '{catalog}' catalog (Read-Only)") + diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py index fd719085..7a00b364 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py @@ -14,6 +14,8 @@ from .models import TariffFraction from .dto import TariffFractionCreateDTO, TariffFractionUpdateDTO from api.v1.modules.sitar.fracciones.service import FraccionesService from api.v1.modules.sitar.fracciones.schemas import FraccionesResponse +from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService +from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse logger = logging.getLogger(__name__) @@ -68,30 +70,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 diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/models.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/models.py index cb08b05e..cc9097c4 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/models.py @@ -9,9 +9,10 @@ from sqlalchemy import String, Numeric, TIMESTAMP, func from sqlalchemy.orm import Mapped, mapped_column from core.database import Base +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin -class USTariffFraction(Base): +class USTariffFraction(Base, TenantScopedMixin, TimestampMixin): """Modelo para fracciones arancelarias americanas (US HTS codes)""" __tablename__ = "us_tariff_fractions" @@ -20,10 +21,6 @@ class USTariffFraction(Base): # Primary Key id: Mapped[int] = mapped_column(primary_key=True, index=True) - # Tenant/Company - tenant_id: Mapped[int] = mapped_column(index=True, nullable=False) - company_id: Mapped[int] = mapped_column(index=True, nullable=False) - # Datos principales code: Mapped[str] = mapped_column( String(16), nullable=False, comment="Código de fracción americana" @@ -47,16 +44,5 @@ class USTariffFraction(Base): String, comment="Descripción de la fracción" ) - # Timestamps - created_at: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=True), server_default=func.now(), nullable=False - ) - updated_at: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) - def __repr__(self): return f"" diff --git a/backend/api/v1/modules/a76/general_catalogs/router.py b/backend/api/v1/modules/a76/general_catalogs/router.py index 16a994d6..aaa47326 100644 --- a/backend/api/v1/modules/a76/general_catalogs/router.py +++ b/backend/api/v1/modules/a76/general_catalogs/router.py @@ -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"]) diff --git a/backend/api/v1/modules/public/reference_data/sectors/dto.py b/backend/api/v1/modules/public/reference_data/sectors/dto.py index f25ca5dd..e97e8a1d 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/dto.py +++ b/backend/api/v1/modules/public/reference_data/sectors/dto.py @@ -4,6 +4,6 @@ from pydantic import BaseModel, ConfigDict, Field class SectorDTO(BaseModel): key: str = Field(..., min_length=1, max_length=8) description: str - authorized: int + authorized: bool model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/public/reference_data/sectors/routes.py b/backend/api/v1/modules/public/reference_data/sectors/routes.py index 05aa0f72..da38ac7d 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/routes.py +++ b/backend/api/v1/modules/public/reference_data/sectors/routes.py @@ -1,8 +1,10 @@ -from typing import Any, Dict + +from typing import Any, Dict, Optional from core.database import get_core_db -from core.security import get_current_user, has_role +from core.security import get_current_user from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import or_ from sqlalchemy.orm import Session from .dto import SectorDTO @@ -15,13 +17,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 diff --git a/backend/api/v1/modules/sitar/common/base_service.py b/backend/api/v1/modules/sitar/common/base_service.py index 0795a539..b4dbe284 100644 --- a/backend/api/v1/modules/sitar/common/base_service.py +++ b/backend/api/v1/modules/sitar/common/base_service.py @@ -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}", diff --git a/backend/api/v1/modules/sitar/fracciones_usa/schemas.py b/backend/api/v1/modules/sitar/fracciones_usa/schemas.py index 0022069e..1c5a8ba7 100644 --- a/backend/api/v1/modules/sitar/fracciones_usa/schemas.py +++ b/backend/api/v1/modules/sitar/fracciones_usa/schemas.py @@ -11,7 +11,7 @@ class FraccionesUSAResponse(BaseModel): FRACCION_CON_PUNTO: Optional[str] = None FRACCION_MOSTRAR: Optional[str] = None ESPECIFICO: Optional[str] = None - NIVEL: Optional[str] = None + NIVEL: Optional[int] = None DESCRIPCION: Optional[str] = None UNIDADCANTIDAD: Optional[str] = None TARIFA1: Optional[str] = None diff --git a/backend/api/v1/modules/sitar/fracciones_usa/service.py b/backend/api/v1/modules/sitar/fracciones_usa/service.py index 5109671c..b1f739df 100644 --- a/backend/api/v1/modules/sitar/fracciones_usa/service.py +++ b/backend/api/v1/modules/sitar/fracciones_usa/service.py @@ -1,10 +1,11 @@ """Fracciones USA Service""" -from typing import Optional, List +from typing import Optional, List, Dict, Any from ..common import SitarAPIBaseService from .schemas import FraccionesUSAResponse + class FraccionesUSAService(SitarAPIBaseService): """Service for USA Fracciones operations""" @@ -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) diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index b95e612d..474bd601 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -21,7 +21,7 @@ router.include_router(core_router) router.include_router(a76_router) router.include_router(a24_router) router.include_router(public_router) -router.include_router(sitar_router) +router.include_router(sitar_router, prefix="/sitar") # Health check diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index 16cd3b02..586fedf7 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -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> { export async function uploadCompanyLogo(id: number, file: File): Promise> { 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> { + 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 diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts new file mode 100644 index 00000000..99d9689a --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts @@ -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 { + const params = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString() + }); + + if (historicalFraction) params.append('historical_fraction', historicalFraction); + + const response = await api.get(`/v1/a76/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> { + 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> { + return await api.put(`/v1/a76/fractions/historical-tariff-fractions/${id}/?company_id=${companyId}`, data); +} + +export async function deleteHistoricalFraction( + companyId: number, + id: number +): Promise> { + return await api.delete(`/v1/a76/fractions/historical-tariff-fractions/${id}/?company_id=${companyId}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts index 556dd6c7..5da92ab0 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts @@ -69,22 +69,33 @@ export async function getTariffFractionById( export async function createTariffFraction( data: TariffFractionCreate, - companyId: number + companyId: number, + catalog = 'mex' ): Promise> { - return await api.post(`/v1/a76/tariff-fractions/?company_id=${companyId}`, data); + return await api.post( + `/v1/a76/tariff-fractions/?company_id=${companyId}&catalog=${catalog}`, + data + ); } export async function updateTariffFraction( id: number, data: TariffFractionUpdate, - companyId: number + companyId: number, + catalog = 'mex' ): Promise> { - return await api.put(`/v1/a76/tariff-fractions/${id}/?company_id=${companyId}`, data); + return await api.put( + `/v1/a76/tariff-fractions/${id}/?company_id=${companyId}&catalog=${catalog}`, + data + ); } export async function deleteTariffFraction( id: number, - companyId: number + companyId: number, + catalog = 'mex' ): Promise> { - return await api.delete(`/v1/a76/tariff-fractions/${id}/?company_id=${companyId}`); + return await api.delete( + `/v1/a76/tariff-fractions/${id}/?company_id=${companyId}&catalog=${catalog}` + ); } diff --git a/frontend/src/lib/api/dashboard/general_catalogs/canadian.ts b/frontend/src/lib/api/dashboard/general_catalogs/canadian.ts new file mode 100644 index 00000000..0b5b73f2 --- /dev/null +++ b/frontend/src/lib/api/dashboard/general_catalogs/canadian.ts @@ -0,0 +1,77 @@ + +import { api } from '$lib/api'; + +export interface CanadianFraction { + id: number; + fraction: string; + ad_valorem: number | null; + unit_of_measure: string | null; + country_code: string; + description: string | null; + created_at: string; + updated_at: string; +} + +export interface CanadianFractionList { + items: CanadianFraction[]; + total: number; + page: number; + page_size: number; + pages: number; +} + +export interface CanadianFractionCreate { + fraction: string; + ad_valorem?: number; + unit_of_measure?: string; + country_code: string; + description?: string; +} + +export interface CanadianFractionUpdate { + fraction?: string; + ad_valorem?: number; + unit_of_measure?: string; + country_code?: string; + description?: string; +} + +const BASE_URL = '/v1/a76/fractions/canadian-tariff-fractions/'; + +export async function getCanadianFractions( + companyId: number, + page = 1, + pageSize = 50, + search?: string +): Promise { + const params = new URLSearchParams({ + company_id: companyId.toString(), + page: page.toString(), + page_size: pageSize.toString() + }); + + if (search) params.append('search', search); + + const response = await api.get(`${BASE_URL}?${params.toString()}`); + if (!response.data) throw new Error('Error fetching Canadian fractions'); + return response.data; +} + +export async function createCanadianFraction(companyId: number, data: CanadianFractionCreate): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.post(`${BASE_URL}?${params.toString()}`, data); + if (!response.data) throw new Error('Error creating Canadian fraction'); + return response.data; +} + +export async function updateCanadianFraction(companyId: number, id: number, data: CanadianFractionUpdate): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const response = await api.put(`${BASE_URL}${id}?${params.toString()}`, data); + if (!response.data) throw new Error('Error updating Canadian fraction'); + return response.data; +} + +export async function deleteCanadianFraction(companyId: number, id: number): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + await api.delete(`${BASE_URL}${id}?${params.toString()}`); +} diff --git a/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts b/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts new file mode 100644 index 00000000..b92acd2f --- /dev/null +++ b/frontend/src/lib/api/dashboard/general_catalogs/sectors.ts @@ -0,0 +1,35 @@ + +import { api } from '$lib/api'; +import type { ApiResponse } from '$lib/api'; + +export interface Sector { + key: string; + description: string; + authorized: boolean; +} + +export interface SectorListResponse { + items: Sector[]; + total: number; + page: number; + page_size: number; +} + +export async function getSectors( + page = 1, + pageSize = 50, + search?: string +): Promise { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString() + }); + + if (search) { + params.append('search', search); + } + + const response = await api.get(`/v1/public/reference_data/sectors/?${params.toString()}`); + if (!response.data) throw new Error('Error fetching sectors'); + return response.data; +} diff --git a/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte b/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte new file mode 100644 index 00000000..78137b1f --- /dev/null +++ b/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte @@ -0,0 +1,188 @@ + + +
+
+

{title}

+
+ + + +
+
+ + +
+
+
+ +
+ + + + Clave + Descripción + Estatus + + + + {#if loading && page === 1} + + +
+ +
+
+
+ {:else if sectors.length === 0} + + + No se encontraron sectores. + + + {:else} + {#each sectors as sector} + + {sector.key} + {sector.description} + + {#if sector.authorized} + Autorizado + {:else} + No Autorizado + {/if} + + + {/each} + {/if} + {#if loading && page > 1} + + +
+ +
+
+
+ {/if} +
+
+
+ +
+
+ Mostrando {sectors.length} de {total} registros +
+ +
+
+
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionDialog.svelte b/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionDialog.svelte new file mode 100644 index 00000000..2ccf045e --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionDialog.svelte @@ -0,0 +1,180 @@ + + + + + + {fraction ? 'Editar' : 'Crear'} Fracción Canadiense + + {fraction + ? 'Modifica los detalles de la fracción seleccionada.' + : 'Ingresa los datos para la nueva fracción.'} + + + +
+
+
+ + +
+
+ + +
+
+ +
+ +