diff --git a/.gitignore b/.gitignore index 2d9ff71d..14c07767 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ wheels/ # Environment .env .env.local - +backend/SCRIPTS/ # IDEs .vscode/ .idea/ diff --git a/backend/alembic/versions/54f2046774d0_create_new_a76_tables_only.py b/backend/alembic/versions/54f2046774d0_create_new_a76_tables_only.py new file mode 100644 index 00000000..01ae5756 --- /dev/null +++ b/backend/alembic/versions/54f2046774d0_create_new_a76_tables_only.py @@ -0,0 +1,195 @@ +"""Create new A76 tables only - company, clients, parts, classes + +Revision ID: 54f2046774d0 +Revises: 7937209f9718 +Create Date: 2025-11-06 03:38:27.848630 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '54f2046774d0' +down_revision: Union[str, Sequence[str], None] = '7937209f9718' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema - Create only new A76 tables.""" + + # Create new A76 tables only (skip existing tenants, licenses, license_usage) + op.create_table('gclient_provider', + sa.Column('client_id', sa.String(length=8), nullable=False), + sa.Column('type_nat_foreign', sa.String(length=1), nullable=True), + sa.Column('name', sa.String(length=256), nullable=True), + sa.Column('short_name', sa.String(length=10), nullable=True), + sa.Column('rfc', sa.String(length=30), nullable=True), + sa.Column('curp', sa.String(length=19), nullable=True), + sa.Column('client_or_provider', sa.String(length=1), nullable=True), + sa.Column('linking', sa.String(length=1), nullable=True), + sa.Column('transform_subassembly', sa.String(length=1), nullable=True), + sa.Column('extra_information', sa.String(length=399), nullable=True), + sa.Column('web_key', sa.String(length=40), nullable=True), + sa.Column('responsible', sa.String(length=80), nullable=True), + sa.Column('position', sa.String(length=30), nullable=True), + sa.Column('incoterm', sa.String(length=19), nullable=True), + sa.Column('is_national_provider', sa.String(length=2), nullable=True), + sa.Column('enabled_disabled', sa.SmallInteger(), nullable=True), + sa.PrimaryKeyConstraint('client_id'), + schema='a76' + ) + + op.create_table('gcompany', + sa.Column('id', sa.String(length=3), nullable=False), + sa.Column('consecutive', sa.Boolean(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('rfc', sa.String(length=30), nullable=True), + sa.Column('main_activity', sa.String(length=255), nullable=True), + sa.Column('program', sa.String(length=10), nullable=True), + sa.Column('program_number', sa.String(length=40), nullable=True), + sa.Column('prosec', sa.SmallInteger(), nullable=True), + sa.Column('prosec_authorization', sa.String(length=20), nullable=True), + sa.Column('manufacturer_id', sa.String(length=25), nullable=True), + sa.Column('broker_company', sa.String(length=10), nullable=True), + sa.Column('responsible', sa.String(length=80), nullable=True), + sa.Column('responsible_name', sa.String(length=20), nullable=True), + sa.Column('responsible_last_name', sa.String(length=20), nullable=True), + sa.Column('responsible_mother_last_name', sa.String(length=20), nullable=True), + sa.Column('responsible_rfc', sa.String(length=30), nullable=True), + sa.Column('position', sa.String(length=30), nullable=True), + sa.Column('logo', sa.String(length=255), nullable=True), + sa.Column('has_express_line', sa.Boolean(), nullable=True), + sa.Column('order_format_type', sa.String(length=19), nullable=True), + sa.Column('previous_code', sa.SmallInteger(), nullable=True), + sa.Column('is_service_company', sa.Boolean(), nullable=True), + sa.Column('client_name', sa.String(length=300), nullable=True), + sa.Column('subassembly_mode', sa.String(length=7), nullable=True), + sa.Column('curp', sa.String(length=19), nullable=True), + sa.Column('inter_db_name', sa.String(length=100), nullable=True), + sa.Column('ctpat_svi', sa.String(length=100), nullable=True), + sa.Column('trusted_exporter_number', sa.String(length=50), nullable=True), + sa.Column('prevalidator_key', sa.String(length=20), nullable=True), + sa.Column('seventh_amendment', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('consecutive'), + schema='a76' + ) + + op.create_table('gclasses', + sa.Column('client_key', sa.Integer(), nullable=False), + sa.Column('class_code', sa.String(length=8), nullable=False), + sa.Column('description_spanish', sa.String(length=500), nullable=True), + sa.Column('description_english', sa.String(length=500), nullable=True), + sa.Column('material_key', sa.String(length=10), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('fraction', sa.String(length=10), nullable=True), + sa.Column('us_fraction', sa.String(length=16), nullable=True), + sa.Column('sub_key', sa.String(length=5), nullable=True), + sa.Column('physical_review', sa.SmallInteger(), nullable=True), + sa.Column('iva_exempt_fraction', sa.String(length=4), nullable=True), + sa.ForeignKeyConstraint(['material_key'], ['public.material_types.key'], ), + sa.PrimaryKeyConstraint('client_key', 'class_code'), + schema='a76' + ) + + op.create_table('gparts', + sa.Column('client_key', sa.Integer(), nullable=False), + sa.Column('part_number', sa.String(length=49), nullable=False), + sa.Column('fraction', sa.String(length=10), nullable=True), + sa.Column('description_spanish', sa.String(length=500), nullable=True), + sa.Column('description_english', sa.String(length=500), nullable=True), + sa.Column('part_class', sa.String(length=8), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('commercial_part_number', sa.String(length=70), nullable=True), + sa.Column('country_of_origin', sa.String(length=3), nullable=True), + sa.Column('unit_cost', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('currency_type', sa.String(length=2), nullable=True), + sa.Column('currency_key', sa.String(length=3), nullable=True), + sa.Column('unit_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('weight_type', sa.String(length=6), nullable=True), + sa.Column('us_fraction', sa.String(length=16), nullable=True), + sa.Column('fda_key', sa.String(length=20), nullable=True), + sa.Column('fcc_key', sa.String(length=30), nullable=True), + sa.Column('license_code', sa.String(length=3), nullable=True), + sa.Column('eccn', sa.String(length=20), nullable=True), + sa.Column('export_code', sa.String(length=2), nullable=True), + sa.Column('exclusion_symbol', sa.String(length=19), nullable=True), + sa.Column('supplier', sa.String(length=14), nullable=True), + sa.Column('alternate_unit_measure', sa.String(length=14), nullable=True), + sa.Column('added_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('enabled_disabled', sa.SmallInteger(), nullable=True), + sa.Column('creation_date', sa.Integer(), nullable=True), + sa.Column('modification_date', sa.Integer(), nullable=True), + sa.Column('modification_date_iso', sa.DateTime(timezone=True), nullable=True), + sa.Column('part_photo', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['country_of_origin'], ['public.countries.m3_key'], ), + sa.ForeignKeyConstraint(['currency_key'], ['public.currency_types.code'], ), + sa.PrimaryKeyConstraint('client_key', 'part_number'), + schema='a76' + ) + + # Create dependent tables after main tables + op.create_table('gclient_provider_address', + sa.Column('client_id', sa.String(length=8), nullable=False), + sa.Column('municipality', sa.String(length=150), nullable=True), + sa.Column('streets', sa.String(length=100), nullable=True), + sa.Column('neighborhood', sa.String(length=40), nullable=True), + sa.Column('interior_number', sa.String(length=20), nullable=True), + sa.Column('exterior_number', sa.String(length=20), nullable=True), + sa.Column('postal_code', sa.String(length=15), nullable=True), + sa.Column('city', sa.String(length=30), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('phone', sa.String(length=30), nullable=True), + sa.Column('fax_number', sa.String(length=30), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('contact', sa.String(length=50), nullable=True), + sa.Column('reference', sa.String(length=250), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['a76.gclient_provider.client_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('client_id'), + schema='a76' + ) + + op.create_table('gclient_provider_programs', + sa.Column('client_id', sa.String(length=8), nullable=False), + sa.Column('program', sa.String(length=7), nullable=True), + sa.Column('program_number', sa.String(length=40), nullable=True), + sa.Column('prosec', sa.SmallInteger(), nullable=True), + sa.Column('prosec_authorization', sa.String(length=20), nullable=True), + sa.Column('secon_auth_date', sa.Integer(), nullable=True), + sa.Column('manufacturer_id', sa.String(length=25), nullable=True), + sa.Column('tax_id', sa.String(length=30), nullable=True), + sa.Column('broker', sa.String(length=6), nullable=True), + sa.Column('import_broker', sa.String(length=6), nullable=True), + sa.Column('transfer_key', sa.String(length=8), nullable=True), + sa.Column('secon_authorization', sa.String(length=20), nullable=True), + sa.Column('applied_proportion', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('is_certified_company', sa.String(length=1), nullable=True), + sa.Column('certified_company_registry', sa.String(length=40), nullable=True), + sa.Column('donation_auth_number', sa.String(length=50), nullable=True), + sa.Column('ctpat_svi', sa.String(length=100), nullable=True), + sa.Column('tax_registry_number', sa.String(length=40), nullable=True), + sa.Column('subassembly_service', sa.SmallInteger(), nullable=True), + sa.Column('autse_dates', sa.Integer(), nullable=True), + sa.Column('autse_number', sa.String(length=300), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['a76.gclient_provider.client_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('client_id'), + schema='a76' + ) + + +def downgrade() -> None: + """Downgrade schema - Drop only new A76 tables.""" + # Drop tables in reverse dependency order + op.drop_table('gclient_provider_programs', schema='a76') + op.drop_table('gclient_provider_address', schema='a76') + op.drop_table('gparts', schema='a76') + op.drop_table('gclasses', schema='a76') + op.drop_table('gcompany', schema='a76') + op.drop_table('gclient_provider', schema='a76') \ No newline at end of file diff --git a/backend/alembic/versions/eb8a17e5fbde_create_a76_tables_company_clients_parts_.py b/backend/alembic/versions/eb8a17e5fbde_create_a76_tables_company_clients_parts_.py new file mode 100644 index 00000000..07992a28 --- /dev/null +++ b/backend/alembic/versions/eb8a17e5fbde_create_a76_tables_company_clients_parts_.py @@ -0,0 +1,355 @@ +"""create_a76_tables_company_clients_parts_classes + +Revision ID: eb8a17e5fbde +Revises: 7937209f9718 +Create Date: 2025-11-06 03:15:17.248159 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'eb8a17e5fbde' +down_revision: Union[str, Sequence[str], None] = '7937209f9718' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema - Create new A76 tables only.""" + + # Crear tabla gcompany + op.create_table('gcompany', + sa.Column('id', sa.String(length=3), nullable=False), + sa.Column('consecutive', sa.Boolean(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('rfc', sa.String(length=30), nullable=True), + sa.Column('main_activity', sa.String(length=255), nullable=True), + sa.Column('program', sa.String(length=10), nullable=True), + sa.Column('program_number', sa.String(length=40), nullable=True), + sa.Column('prosec', sa.SmallInteger(), nullable=True), + sa.Column('prosec_authorization', sa.String(length=20), nullable=True), + sa.Column('manufacturer_id', sa.String(length=25), nullable=True), + sa.Column('broker_company', sa.String(length=10), nullable=True), + sa.Column('responsible', sa.String(length=80), nullable=True), + sa.Column('responsible_name', sa.String(length=20), nullable=True), + sa.Column('responsible_last_name', sa.String(length=20), nullable=True), + sa.Column('responsible_mother_last_name', sa.String(length=20), nullable=True), + sa.Column('responsible_rfc', sa.String(length=30), nullable=True), + sa.Column('position', sa.String(length=30), nullable=True), + sa.Column('logo', sa.String(length=255), nullable=True), + sa.Column('has_express_line', sa.Boolean(), nullable=True), + sa.Column('order_format_type', sa.String(length=19), nullable=True), + sa.Column('previous_code', sa.SmallInteger(), nullable=True), + sa.Column('is_service_company', sa.Boolean(), nullable=True), + sa.Column('client_name', sa.String(length=300), nullable=True), + sa.Column('subassembly_mode', sa.String(length=7), nullable=True), + sa.Column('curp', sa.String(length=19), nullable=True), + sa.Column('inter_db_name', sa.String(length=100), nullable=True), + sa.Column('ctpat_svi', sa.String(length=100), nullable=True), + sa.Column('trusted_exporter_number', sa.String(length=50), nullable=True), + sa.Column('prevalidator_key', sa.String(length=20), nullable=True), + sa.Column('seventh_amendment', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('consecutive'), + schema='a76' + ) + + # Crear tabla gclient_provider + op.create_table('gclient_provider', + sa.Column('client_id', sa.String(length=8), nullable=False), + sa.Column('type_nat_foreign', sa.String(length=1), nullable=True), + sa.Column('name', sa.String(length=256), nullable=True), + sa.Column('short_name', sa.String(length=10), nullable=True), + sa.Column('rfc', sa.String(length=30), nullable=True), + sa.Column('curp', sa.String(length=19), nullable=True), + sa.Column('client_or_provider', sa.String(length=1), nullable=True), + sa.Column('linking', sa.String(length=1), nullable=True), + sa.Column('transform_subassembly', sa.String(length=1), nullable=True), + sa.Column('extra_information', sa.String(length=399), nullable=True), + sa.Column('web_key', sa.String(length=40), nullable=True), + sa.Column('responsible', sa.String(length=80), nullable=True), + sa.Column('position', sa.String(length=30), nullable=True), + sa.Column('incoterm', sa.String(length=19), nullable=True), + sa.Column('is_national_provider', sa.String(length=2), nullable=True), + sa.Column('enabled_disabled', sa.SmallInteger(), nullable=True), + sa.PrimaryKeyConstraint('client_id'), + schema='a76' + ) + + # Crear tabla gclasses + op.create_table('gclasses', + sa.Column('client_key', sa.Integer(), nullable=False), + sa.Column('class_code', sa.String(length=8), nullable=False), + sa.Column('description_spanish', sa.String(length=500), nullable=True), + sa.Column('description_english', sa.String(length=500), nullable=True), + sa.Column('material_key', sa.String(length=10), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('fraction', sa.String(length=10), nullable=True), + sa.Column('us_fraction', sa.String(length=16), nullable=True), + sa.Column('sub_key', sa.String(length=5), nullable=True), + sa.Column('physical_review', sa.SmallInteger(), nullable=True), + sa.Column('iva_exempt_fraction', sa.String(length=4), nullable=True), + sa.ForeignKeyConstraint(['material_key'], ['public.material_types.key'], ), + sa.PrimaryKeyConstraint('client_key', 'class_code'), + schema='a76' + ) + + # Crear tabla gparts + op.create_table('gparts', + sa.Column('client_key', sa.Integer(), nullable=False), + sa.Column('part_number', sa.String(length=49), nullable=False), + sa.Column('fraction', sa.String(length=10), nullable=True), + sa.Column('description_spanish', sa.String(length=500), nullable=True), + sa.Column('description_english', sa.String(length=500), nullable=True), + sa.Column('part_class', sa.String(length=8), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('commercial_part_number', sa.String(length=70), nullable=True), + sa.Column('country_of_origin', sa.String(length=3), nullable=True), + sa.Column('unit_cost', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('currency_type', sa.String(length=2), nullable=True), + sa.Column('currency_key', sa.String(length=3), nullable=True), + sa.Column('unit_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('weight_type', sa.String(length=6), nullable=True), + sa.Column('us_fraction', sa.String(length=16), nullable=True), + sa.Column('fda_key', sa.String(length=20), nullable=True), + sa.Column('fcc_key', sa.String(length=30), nullable=True), + sa.Column('license_code', sa.String(length=3), nullable=True), + sa.Column('eccn', sa.String(length=20), nullable=True), + sa.Column('export_code', sa.String(length=2), nullable=True), + sa.Column('exclusion_symbol', sa.String(length=19), nullable=True), + sa.Column('supplier', sa.String(length=14), nullable=True), + sa.Column('alternate_unit_measure', sa.String(length=14), nullable=True), + sa.Column('added_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('enabled_disabled', sa.SmallInteger(), nullable=True), + sa.Column('creation_date', sa.Integer(), nullable=True), + sa.Column('modification_date', sa.Integer(), nullable=True), + sa.Column('modification_date_iso', sa.DateTime(timezone=True), nullable=True), + sa.Column('part_photo', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['country_of_origin'], ['public.countries.m3_key'], ), + sa.ForeignKeyConstraint(['currency_key'], ['public.currency_types.code'], ), + sa.PrimaryKeyConstraint('client_key', 'part_number'), + schema='a76' + ) + + # Crear tabla gclient_provider_address + op.create_table('gclient_provider_address', + sa.Column('client_id', sa.String(length=8), nullable=False), + sa.Column('municipality', sa.String(length=150), nullable=True), + sa.Column('streets', sa.String(length=100), nullable=True), + sa.Column('neighborhood', sa.String(length=40), nullable=True), + sa.Column('interior_number', sa.String(length=20), nullable=True), + sa.Column('exterior_number', sa.String(length=20), nullable=True), + sa.Column('postal_code', sa.String(length=15), nullable=True), + sa.Column('city', sa.String(length=30), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('phone', sa.String(length=30), nullable=True), + sa.Column('fax_number', sa.String(length=30), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('contact', sa.String(length=50), nullable=True), + sa.Column('reference', sa.String(length=250), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['a76.gclient_provider.client_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('client_id'), + schema='a76' + ) + + # Crear tabla gclient_provider_programs + op.create_table('gclient_provider_programs', + sa.Column('client_id', sa.String(length=8), nullable=False), + sa.Column('program', sa.String(length=7), nullable=True), + sa.Column('program_number', sa.String(length=40), nullable=True), + sa.Column('prosec', sa.SmallInteger(), nullable=True), + sa.Column('prosec_authorization', sa.String(length=20), nullable=True), + sa.Column('secon_auth_date', sa.Integer(), nullable=True), + sa.Column('manufacturer_id', sa.String(length=25), nullable=True), + sa.Column('tax_id', sa.String(length=30), nullable=True), + sa.Column('broker', sa.String(length=6), nullable=True), + sa.Column('import_broker', sa.String(length=6), nullable=True), + sa.Column('transfer_key', sa.String(length=8), nullable=True), + sa.Column('secon_authorization', sa.String(length=20), nullable=True), + sa.Column('applied_proportion', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('is_certified_company', sa.String(length=1), nullable=True), + sa.Column('certified_company_registry', sa.String(length=40), nullable=True), + sa.Column('donation_auth_number', sa.String(length=50), nullable=True), + sa.Column('ctpat_svi', sa.String(length=100), nullable=True), + sa.Column('tax_registry_number', sa.String(length=40), nullable=True), + sa.Column('subassembly_service', sa.SmallInteger(), nullable=True), + sa.Column('autse_dates', sa.Integer(), nullable=True), + sa.Column('autse_number', sa.String(length=300), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['a76.gclient_provider.client_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('client_id'), + schema='a76' + ) + op.create_table('gclasses', + sa.Column('client_key', sa.Integer(), nullable=False), + sa.Column('class_code', sa.String(length=8), nullable=False), + sa.Column('description_spanish', sa.String(length=500), nullable=True), + sa.Column('description_english', sa.String(length=500), nullable=True), + sa.Column('material_key', sa.String(length=10), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('fraction', sa.String(length=10), nullable=True), + sa.Column('us_fraction', sa.String(length=16), nullable=True), + sa.Column('sub_key', sa.String(length=5), nullable=True), + sa.Column('physical_review', sa.SmallInteger(), nullable=True), + sa.Column('iva_exempt_fraction', sa.String(length=4), nullable=True), + sa.ForeignKeyConstraint(['material_key'], ['public.material_types.key'], ), + sa.PrimaryKeyConstraint('client_key', 'class_code'), + schema='a76' + ) + op.create_table('gclient_provider_address', + sa.Column('client_id', sa.String(length=8), nullable=False), + sa.Column('municipality', sa.String(length=150), nullable=True), + sa.Column('streets', sa.String(length=100), nullable=True), + sa.Column('neighborhood', sa.String(length=40), nullable=True), + sa.Column('interior_number', sa.String(length=20), nullable=True), + sa.Column('exterior_number', sa.String(length=20), nullable=True), + sa.Column('postal_code', sa.String(length=15), nullable=True), + sa.Column('city', sa.String(length=30), nullable=True), + sa.Column('state', sa.String(length=30), nullable=True), + sa.Column('country', sa.String(length=3), nullable=True), + sa.Column('phone', sa.String(length=30), nullable=True), + sa.Column('fax_number', sa.String(length=30), nullable=True), + sa.Column('email', sa.String(length=100), nullable=True), + sa.Column('contact', sa.String(length=50), nullable=True), + sa.Column('reference', sa.String(length=250), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['a76.gclient_provider.client_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('client_id'), + schema='a76' + ) + op.create_table('gclient_provider_programs', + sa.Column('client_id', sa.String(length=8), nullable=False), + sa.Column('program', sa.String(length=7), nullable=True), + sa.Column('program_number', sa.String(length=40), nullable=True), + sa.Column('prosec', sa.SmallInteger(), nullable=True), + sa.Column('prosec_authorization', sa.String(length=20), nullable=True), + sa.Column('secon_auth_date', sa.Integer(), nullable=True), + sa.Column('manufacturer_id', sa.String(length=25), nullable=True), + sa.Column('tax_id', sa.String(length=30), nullable=True), + sa.Column('broker', sa.String(length=6), nullable=True), + sa.Column('import_broker', sa.String(length=6), nullable=True), + sa.Column('transfer_key', sa.String(length=8), nullable=True), + sa.Column('secon_authorization', sa.String(length=20), nullable=True), + sa.Column('applied_proportion', sa.Numeric(precision=7, scale=2), nullable=True), + sa.Column('is_certified_company', sa.String(length=1), nullable=True), + sa.Column('certified_company_registry', sa.String(length=40), nullable=True), + sa.Column('donation_auth_number', sa.String(length=50), nullable=True), + sa.Column('ctpat_svi', sa.String(length=100), nullable=True), + sa.Column('tax_registry_number', sa.String(length=40), nullable=True), + sa.Column('subassembly_service', sa.SmallInteger(), nullable=True), + sa.Column('autse_dates', sa.Integer(), nullable=True), + sa.Column('autse_number', sa.String(length=300), nullable=True), + sa.ForeignKeyConstraint(['client_id'], ['a76.gclient_provider.client_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('client_id'), + schema='a76' + ) + op.create_table('gparts', + sa.Column('client_key', sa.Integer(), nullable=False), + sa.Column('part_number', sa.String(length=49), nullable=False), + sa.Column('fraction', sa.String(length=10), nullable=True), + sa.Column('description_spanish', sa.String(length=500), nullable=True), + sa.Column('description_english', sa.String(length=500), nullable=True), + sa.Column('part_class', sa.String(length=8), nullable=True), + sa.Column('unit_of_measure', sa.String(length=5), nullable=True), + sa.Column('commercial_part_number', sa.String(length=70), nullable=True), + sa.Column('country_of_origin', sa.String(length=3), nullable=True), + sa.Column('unit_cost', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('currency_type', sa.String(length=2), nullable=True), + sa.Column('currency_key', sa.String(length=3), nullable=True), + sa.Column('unit_weight', sa.Numeric(precision=19, scale=8), nullable=True), + sa.Column('weight_type', sa.String(length=6), nullable=True), + sa.Column('us_fraction', sa.String(length=16), nullable=True), + sa.Column('fda_key', sa.String(length=20), nullable=True), + sa.Column('fcc_key', sa.String(length=30), nullable=True), + sa.Column('license_code', sa.String(length=3), nullable=True), + sa.Column('eccn', sa.String(length=20), nullable=True), + sa.Column('export_code', sa.String(length=2), nullable=True), + sa.Column('exclusion_symbol', sa.String(length=19), nullable=True), + sa.Column('supplier', sa.String(length=14), nullable=True), + sa.Column('alternate_unit_measure', sa.String(length=14), nullable=True), + sa.Column('added_value', sa.Numeric(precision=23, scale=8), nullable=True), + sa.Column('enabled_disabled', sa.SmallInteger(), nullable=True), + sa.Column('creation_date', sa.Integer(), nullable=True), + sa.Column('modification_date', sa.Integer(), nullable=True), + sa.Column('modification_date_iso', sa.DateTime(timezone=True), nullable=True), + sa.Column('part_photo', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['country_of_origin'], ['public.countries.m3_key'], ), + sa.ForeignKeyConstraint(['currency_key'], ['public.currency_types.code'], ), + sa.PrimaryKeyConstraint('client_key', 'part_number'), + schema='a76' + ) + op.create_table('license_usage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('period_start', sa.DateTime(timezone=True), nullable=False), + sa.Column('period_end', sa.DateTime(timezone=True), nullable=False), + sa.Column('active_users', sa.Integer(), nullable=True), + sa.Column('storage_used_gb', sa.Integer(), nullable=True), + sa.Column('operations_count', sa.Integer(), nullable=True), + sa.Column('api_calls_count', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_license_usage_id'), 'license_usage', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_license_usage_tenant_id'), 'license_usage', ['tenant_id'], unique=False, schema='a76') + op.create_table('licenses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('plan', sa.Enum('FREE', 'BASIC', 'PROFESSIONAL', 'ENTERPRISE', name='licenseplan'), nullable=False), + sa.Column('status', sa.Enum('ACTIVE', 'EXPIRED', 'SUSPENDED', 'PENDING', 'CANCELLED', name='licensestatus'), nullable=False), + sa.Column('max_users', sa.Integer(), nullable=False), + sa.Column('max_storage_gb', sa.Integer(), nullable=False), + sa.Column('max_monthly_operations', sa.Integer(), nullable=False), + sa.Column('feature_api_access', sa.Boolean(), nullable=True), + sa.Column('feature_advanced_reports', sa.Boolean(), nullable=True), + sa.Column('feature_integrations', sa.Boolean(), nullable=True), + sa.Column('feature_dedicated_support', sa.Boolean(), nullable=True), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='a76' + ) + op.create_index(op.f('ix_a76_licenses_id'), 'licenses', ['id'], unique=False, schema='a76') + op.create_index(op.f('ix_a76_licenses_tenant_id'), 'licenses', ['tenant_id'], unique=True, schema='a76') + op.drop_constraint(op.f('fk_regimenped'), 'code_pedimento_regimens', type_='foreignkey') + op.drop_constraint(op.f('fk_codeped'), 'code_pedimento_regimens', type_='foreignkey') + op.create_foreign_key('fk_regimenped', 'code_pedimento_regimens', 'pedimento_regimens', ['regimen_code'], ['code'], source_schema='public', referent_schema='public') + op.create_foreign_key('fk_codeped', 'code_pedimento_regimens', 'pedimento_codes', ['pedimento_code'], ['code'], source_schema='public', referent_schema='public') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('fk_codeped', 'code_pedimento_regimens', schema='public', type_='foreignkey') + op.drop_constraint('fk_regimenped', 'code_pedimento_regimens', schema='public', type_='foreignkey') + op.create_foreign_key(op.f('fk_codeped'), 'code_pedimento_regimens', 'pedimento_codes', ['pedimento_code'], ['code']) + op.create_foreign_key(op.f('fk_regimenped'), 'code_pedimento_regimens', 'pedimento_regimens', ['regimen_code'], ['code']) + op.drop_index(op.f('ix_a76_licenses_tenant_id'), table_name='licenses', schema='a76') + op.drop_index(op.f('ix_a76_licenses_id'), table_name='licenses', schema='a76') + op.drop_table('licenses', schema='a76') + op.drop_index(op.f('ix_a76_license_usage_tenant_id'), table_name='license_usage', schema='a76') + op.drop_index(op.f('ix_a76_license_usage_id'), table_name='license_usage', schema='a76') + op.drop_table('license_usage', schema='a76') + op.drop_table('gparts', schema='a76') + op.drop_table('gclient_provider_programs', schema='a76') + op.drop_table('gclient_provider_address', schema='a76') + op.drop_table('gclasses', schema='a76') + op.drop_index(op.f('ix_a76_tenants_slug'), table_name='tenants', schema='a76') + op.drop_index(op.f('ix_a76_tenants_name'), table_name='tenants', schema='a76') + op.drop_index(op.f('ix_a76_tenants_id'), table_name='tenants', schema='a76') + op.drop_table('tenants', schema='a76') + op.drop_table('gcompany', schema='a76') + op.drop_table('gclient_provider', schema='a76') + # ### end Alembic commands ### diff --git a/backend/api/v1/modules/a76/GClass/__init__.py b/backend/api/v1/modules/a76/GClass/__init__.py new file mode 100644 index 00000000..09dc90d1 --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/__init__.py @@ -0,0 +1,6 @@ +""" +Módulo de GClass +""" +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/GClass/dto.py b/backend/api/v1/modules/a76/GClass/dto.py new file mode 100644 index 00000000..a0e9e786 --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/dto.py @@ -0,0 +1,97 @@ +""" +DTOs (Data Transfer Objects) para módulo de clases SCAII y SCAF +Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS +""" +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class ClassCreateDTO(BaseModel): + """DTO para crear una clase""" + client_key: int = Field(..., description="Client key") + class_code: str = Field(..., max_length=8, description="Class code") + description_spanish: Optional[str] = Field(None, max_length=500, description="Description in Spanish") + description_english: Optional[str] = Field(None, max_length=500, description="Description in English") + material_key: Optional[str] = Field(None, max_length=10, description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)") + unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)") + fraction: Optional[str] = Field(None, max_length=10, description="Mexican tariff fraction") + us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction") + sub_key: Optional[str] = Field(None, max_length=5, description="Sub classification key") + physical_review: Optional[int] = Field(None, description="Physical review indicator") + iva_exempt_fraction: Optional[str] = Field(None, max_length=4, description="IVA exempt fraction") + + class Config: + from_attributes = True + + +class ClassUpdateDTO(BaseModel): + """DTO para actualizar una clase""" + description_spanish: Optional[str] = Field(None, max_length=500, description="Description in Spanish") + description_english: Optional[str] = Field(None, max_length=500, description="Description in English") + material_key: Optional[str] = Field(None, max_length=10, description="Material key (homologated TIPOMAT/TIPOMATEQUIPO)") + unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure (homologated UNIMEDIDA)") + fraction: Optional[str] = Field(None, max_length=10, description="Mexican tariff fraction") + us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction") + sub_key: Optional[str] = Field(None, max_length=5, description="Sub classification key") + physical_review: Optional[int] = Field(None, description="Physical review indicator") + iva_exempt_fraction: Optional[str] = Field(None, max_length=4, description="IVA exempt fraction") + + class Config: + from_attributes = True + + +class ClassResponseDTO(BaseModel): + """DTO para respuesta de clase""" + client_key: int + class_code: str + description_spanish: Optional[str] = None + description_english: Optional[str] = None + material_key: Optional[str] = None + unit_of_measure: Optional[str] = None + fraction: Optional[str] = None + us_fraction: Optional[str] = None + sub_key: Optional[str] = None + physical_review: Optional[int] = None + iva_exempt_fraction: Optional[str] = None + + class Config: + from_attributes = True + + +class ClassBasicDTO(BaseModel): + """DTO para información básica de clase""" + client_key: int + class_code: str + description_spanish: Optional[str] = None + description_english: Optional[str] = None + material_key: Optional[str] = None + fraction: Optional[str] = None + + class Config: + from_attributes = True + + +class ClassListDTO(BaseModel): + """DTO para lista de clases""" + classes: list[ClassBasicDTO] + total: int + page: int + size: int + + class Config: + from_attributes = True + + +class ClassSearchDTO(BaseModel): + """DTO para búsqueda de clases""" + client_key: Optional[int] = Field(None, description="Filter by client key") + class_code: Optional[str] = Field(None, description="Search by class code") + description: Optional[str] = Field(None, description="Search in descriptions") + material_key: Optional[str] = Field(None, description="Filter by material key") + fraction: Optional[str] = Field(None, description="Filter by tariff fraction") + physical_review: Optional[int] = Field(None, description="Filter by physical review indicator") + + class Config: + from_attributes = True + diff --git a/backend/api/v1/modules/a76/GClass/models.py b/backend/api/v1/modules/a76/GClass/models.py new file mode 100644 index 00000000..77a6765a --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/models.py @@ -0,0 +1,61 @@ +""" +Modelos ORM para gestión de clases SCAII y SCAF +""" +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Numeric, SmallInteger, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from core.database import Base +import enum + +# Importar modelos relacionados para type hints y relationships +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: + from api.v1.modules.a76.GParts.models import GPart + from api.v1.modules.public.reference_data.material_types.models import MaterialType + + +class GClass(Base): + """ + Modelo para la tabla GClases - Información de clases en sistemas SCAII y SCAF + """ + __tablename__ = "gclasses" + __table_args__ = {"schema": "a76"} + + # Primary key compuesta + client_key = Column(Integer, primary_key=True, nullable=False) + class_code = Column(String(8), primary_key=True, nullable=False) + + # Basic information + description_spanish = Column(String(500), nullable=True) + description_english = Column(String(500), nullable=True) + + # Material and measurement + material_key = Column(String(10), ForeignKey('public.material_types.key'), nullable=True) # CLAVEMAT - homologated from TIPOMAT/TIPOMATEQUIPO + unit_of_measure = Column(String(5), nullable=True) # UNIMED - homologated from UNIMEDIDA + + # Tariff fractions + fraction = Column(String(10), nullable=True) # Mexican tariff fraction + us_fraction = Column(String(16), nullable=True) # FRACCIONAME - US tariff fraction + + # Additional classification + sub_key = Column(String(5), nullable=True) # CLAVESUB + physical_review = Column(SmallInteger, nullable=True) # REVFISICA + iva_exempt_fraction = Column(String(4), nullable=True) # FRACCIONEXENTAIVA + + # Relationships + material_type = relationship("MaterialType", foreign_keys=[material_key]) + + # Inverse relationship with GParts that have this class + parts = relationship( + "GPart", + primaryjoin="and_(GClass.client_key == GPart.client_key, GClass.class_code == GPart.part_class)", + foreign_keys="[GPart.client_key, GPart.part_class]", + viewonly=True, + back_populates="part_class_info" + ) + + def __repr__(self): + return f"" + + diff --git a/backend/api/v1/modules/a76/GClass/routes.py b/backend/api/v1/modules/a76/GClass/routes.py new file mode 100644 index 00000000..f0b906ab --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/routes.py @@ -0,0 +1,261 @@ +""" +Endpoints API para gestión de clases SCAII y SCAF +""" +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from typing import List, Optional + +from core.database import get_core_db +from core.security import get_current_user, has_role +from .service import ClassService +from .dto import ( + ClassCreateDTO, + ClassUpdateDTO, + ClassResponseDTO, + ClassBasicDTO, + ClassListDTO, + ClassSearchDTO +) + +router = APIRouter(prefix="/classes", tags=["Classes"]) + + +@router.post("/", response_model=ClassResponseDTO, status_code=status.HTTP_201_CREATED) +async def create_class( + class_data: ClassCreateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Create a new class in the system + """ + service = ClassService(db) + return service.create_class(class_data) + + +@router.get("/", response_model=ClassListDTO) +async def list_classes( + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"), + client_key: Optional[int] = Query(None, description="Filter by client key"), + class_code: Optional[str] = Query(None, description="Search by class code"), + description: Optional[str] = Query(None, description="Search in descriptions"), + material_key: Optional[str] = Query(None, description="Filter by material key"), + fraction: Optional[str] = Query(None, description="Filter by tariff fraction"), + physical_review: Optional[int] = Query(None, description="Filter by physical review indicator"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + List classes with optional filters and pagination + """ + service = ClassService(db) + search_params = ClassSearchDTO( + client_key=client_key, + class_code=class_code, + description=description, + material_key=material_key, + fraction=fraction, + physical_review=physical_review + ) + return service.list_classes(skip, limit, search_params) + + +@router.get("/client/{client_key}", response_model=List[ClassBasicDTO]) +async def get_classes_by_client( + client_key: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get all classes for a specific client + """ + service = ClassService(db) + return service.search_by_client(client_key, skip, limit) + + +@router.get("/search/fraction/{fraction}", response_model=List[ClassBasicDTO]) +async def search_by_fraction( + fraction: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Search classes by tariff fraction + """ + service = ClassService(db) + return service.search_by_fraction(fraction) + + +@router.get("/search/material/{material_key}", response_model=List[ClassBasicDTO]) +async def search_by_material( + material_key: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Search classes by material key + """ + service = ClassService(db) + return service.search_by_material(material_key) + + +@router.get("/search/unit-measure/{unit_of_measure}", response_model=List[ClassBasicDTO]) +async def get_classes_by_unit_measure( + unit_of_measure: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get classes by unit of measure + """ + service = ClassService(db) + return service.get_classes_by_unit_measure(unit_of_measure) + + +@router.get("/search/physical-review/{physical_review}", response_model=List[ClassBasicDTO]) +async def get_classes_by_physical_review( + physical_review: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get classes by physical review indicator + """ + service = ClassService(db) + return service.get_classes_by_physical_review(physical_review) + + +@router.get("/statistics", response_model=dict) +async def get_classes_statistics( + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get basic classes statistics + """ + service = ClassService(db) + return service.get_classes_statistics() + + +@router.get("/{client_key}/{class_code}", response_model=ClassResponseDTO) +async def get_class( + client_key: int, + class_code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get class by composite key (client_key + class_code) + """ + service = ClassService(db) + class_obj = service.get_class(client_key, class_code) + if not class_obj: + raise HTTPException( + status_code=404, + detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found" + ) + return class_obj + + +@router.put("/{client_key}/{class_code}", response_model=ClassResponseDTO) +async def update_class( + client_key: int, + class_code: str, + class_data: ClassUpdateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Update class information + """ + service = ClassService(db) + class_obj = service.update_class(client_key, class_code, class_data) + if not class_obj: + raise HTTPException( + status_code=404, + detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found" + ) + return class_obj + + +@router.delete("/{client_key}/{class_code}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_class( + client_key: int, + class_code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Delete class from the system + + Note: This will completely remove the class from the system. + """ + service = ClassService(db) + if not service.delete_class(client_key, class_code): + raise HTTPException( + status_code=404, + detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found" + ) + + +# Endpoints específicos para información detallada +@router.get("/{client_key}/{class_code}/basic", response_model=ClassBasicDTO) +async def get_class_basic_info( + client_key: int, + class_code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get basic information for a class + """ + service = ClassService(db) + class_obj = service.get_class(client_key, class_code) + if not class_obj: + raise HTTPException( + status_code=404, + detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found" + ) + + return ClassBasicDTO( + client_key=class_obj.client_key, + class_code=class_obj.class_code, + description_spanish=class_obj.description_spanish, + description_english=class_obj.description_english, + material_key=class_obj.material_key, + fraction=class_obj.fraction + ) + + +@router.get("/{client_key}/{class_code}/tariff", response_model=dict) +async def get_class_tariff_info( + client_key: int, + class_code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get tariff information for a class (fractions, IVA exempt, etc.) + """ + service = ClassService(db) + class_obj = service.get_class(client_key, class_code) + if not class_obj: + raise HTTPException( + status_code=404, + detail=f"Class with client_key '{client_key}' and class_code '{class_code}' not found" + ) + + return { + "client_key": class_obj.client_key, + "class_code": class_obj.class_code, + "fraction": class_obj.fraction, + "us_fraction": class_obj.us_fraction, + "iva_exempt_fraction": class_obj.iva_exempt_fraction, + "sub_key": class_obj.sub_key, + "physical_review": class_obj.physical_review + } + + diff --git a/backend/api/v1/modules/a76/GClass/service.py b/backend/api/v1/modules/a76/GClass/service.py new file mode 100644 index 00000000..ceed5c56 --- /dev/null +++ b/backend/api/v1/modules/a76/GClass/service.py @@ -0,0 +1,294 @@ +""" +Capa de servicio para lógica de negocio de clases SCAII y SCAF +""" +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from sqlalchemy import or_, and_, func +from fastapi import HTTPException +from typing import List, Optional +import logging + +from .models import GClass +from .dto import ( + ClassCreateDTO, + ClassUpdateDTO, + ClassResponseDTO, + ClassBasicDTO, + ClassListDTO, + ClassSearchDTO +) + +logger = logging.getLogger(__name__) + + +class ClassService: + """Servicio para gestión de clases SCAII y SCAF""" + + def __init__(self, db: Session): + self.db = db + + def create_class(self, class_data: ClassCreateDTO) -> ClassResponseDTO: + """ + Crea una nueva clase en el sistema + + Args: + class_data: Datos de la clase a crear + + Returns: + ClassResponseDTO con información de la clase creada + + Raises: + HTTPException: Si la clase ya existe o error en la creación + """ + try: + # Verificar que no exista la clase + existing = self.db.query(GClass).filter( + and_( + GClass.client_key == class_data.client_key, + GClass.class_code == class_data.class_code + ) + ).first() + + if existing: + raise HTTPException( + status_code=400, + detail=f"Class with client_key '{class_data.client_key}' and class_code '{class_data.class_code}' already exists" + ) + + # Crear clase + db_class = GClass( + client_key=class_data.client_key, + class_code=class_data.class_code, + description_spanish=class_data.description_spanish, + description_english=class_data.description_english, + material_key=class_data.material_key, + unit_of_measure=class_data.unit_of_measure, + fraction=class_data.fraction, + us_fraction=class_data.us_fraction, + sub_key=class_data.sub_key, + physical_review=class_data.physical_review, + iva_exempt_fraction=class_data.iva_exempt_fraction + ) + + self.db.add(db_class) + self.db.commit() + self.db.refresh(db_class) + + logger.info(f"Class created: {db_class.client_key}-{db_class.class_code}") + + return ClassResponseDTO.model_validate(db_class) + + except IntegrityError as e: + self.db.rollback() + logger.error(f"IntegrityError creating class: {str(e)}") + raise HTTPException(status_code=400, detail="Class with this client_key and class_code already exists") + except HTTPException: + raise + except Exception as e: + self.db.rollback() + logger.error(f"Error creating class: {str(e)}") + raise HTTPException(status_code=500, detail="Error creating class") + + def get_class(self, client_key: int, class_code: str) -> Optional[ClassResponseDTO]: + """ + Obtiene una clase por clave compuesta + + Args: + client_key: Clave del cliente + class_code: Código de clase + + Returns: + ClassResponseDTO o None si no existe + """ + class_obj = self.db.query(GClass).filter( + and_( + GClass.client_key == client_key, + GClass.class_code == class_code + ) + ).first() + + if not class_obj: + return None + return ClassResponseDTO.model_validate(class_obj) + + def list_classes( + self, + skip: int = 0, + limit: int = 100, + search_params: Optional[ClassSearchDTO] = None + ) -> ClassListDTO: + """ + Lista clases con filtros + + Args: + skip: Número de registros a omitir + limit: Número máximo de registros a retornar + search_params: Parámetros de búsqueda + + Returns: + ClassListDTO con la lista paginada + """ + query = self.db.query(GClass) + + # Aplicar filtros si se proporcionan + if search_params: + if search_params.client_key: + query = query.filter(GClass.client_key == search_params.client_key) + + if search_params.class_code: + query = query.filter(GClass.class_code.ilike(f"%{search_params.class_code}%")) + + if search_params.description: + description_pattern = f"%{search_params.description}%" + query = query.filter( + or_( + GClass.description_spanish.ilike(description_pattern), + GClass.description_english.ilike(description_pattern) + ) + ) + + if search_params.material_key: + query = query.filter(GClass.material_key.ilike(f"%{search_params.material_key}%")) + + if search_params.fraction: + query = query.filter(GClass.fraction.ilike(f"%{search_params.fraction}%")) + + if search_params.physical_review is not None: + query = query.filter(GClass.physical_review == search_params.physical_review) + + # Contar total + total = query.count() + + # Aplicar paginación + classes = query.offset(skip).limit(limit).all() + + # Convertir a DTOs básicos + class_dtos = [ClassBasicDTO.model_validate(class_obj) for class_obj in classes] + + return ClassListDTO( + classes=class_dtos, + total=total, + page=(skip // limit) + 1 if limit > 0 else 1, + size=len(class_dtos) + ) + + def update_class(self, client_key: int, class_code: str, class_data: ClassUpdateDTO) -> Optional[ClassResponseDTO]: + """ + Actualiza una clase + + Args: + client_key: Clave del cliente + class_code: Código de clase + class_data: Datos a actualizar + + Returns: + ClassResponseDTO actualizado o None si no existe + """ + class_obj = self.db.query(GClass).filter( + and_( + GClass.client_key == client_key, + GClass.class_code == class_code + ) + ).first() + + if not class_obj: + return None + + try: + # Actualizar solo campos proporcionados + update_data = class_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(class_obj, field, value) + + self.db.commit() + self.db.refresh(class_obj) + logger.info(f"Class updated: {client_key}-{class_code}") + + return ClassResponseDTO.model_validate(class_obj) + + except Exception as e: + self.db.rollback() + logger.error(f"Error updating class {client_key}-{class_code}: {str(e)}") + raise HTTPException(status_code=500, detail="Error updating class") + + def delete_class(self, client_key: int, class_code: str) -> bool: + """ + Elimina una clase + + Args: + client_key: Clave del cliente + class_code: Código de clase + + Returns: + True si se eliminó, False si no existe + """ + class_obj = self.db.query(GClass).filter( + and_( + GClass.client_key == client_key, + GClass.class_code == class_code + ) + ).first() + + if not class_obj: + return False + + try: + self.db.delete(class_obj) + self.db.commit() + logger.info(f"Class deleted: {client_key}-{class_code}") + return True + except Exception as e: + self.db.rollback() + logger.error(f"Error deleting class {client_key}-{class_code}: {str(e)}") + raise HTTPException(status_code=500, detail="Error deleting class") + + def search_by_fraction(self, fraction: str) -> List[ClassBasicDTO]: + """Busca clases por fracción arancelaria""" + classes = self.db.query(GClass).filter(GClass.fraction.ilike(f"%{fraction}%")).all() + return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes] + + def search_by_client(self, client_key: int, skip: int = 0, limit: int = 100) -> List[ClassBasicDTO]: + """Obtiene todas las clases de un cliente específico""" + classes = self.db.query(GClass).filter(GClass.client_key == client_key).offset(skip).limit(limit).all() + return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes] + + def search_by_material(self, material_key: str) -> List[ClassBasicDTO]: + """Busca clases por clave de material""" + classes = self.db.query(GClass).filter(GClass.material_key.ilike(f"%{material_key}%")).all() + return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes] + + def get_classes_by_physical_review(self, physical_review: int) -> List[ClassBasicDTO]: + """Obtiene clases por indicador de revisión física""" + classes = self.db.query(GClass).filter(GClass.physical_review == physical_review).all() + return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes] + + def get_classes_statistics(self) -> dict: + """Obtiene estadísticas básicas de clases""" + total_classes = self.db.query(GClass).count() + + # Contar por clientes + clients_count = self.db.query(GClass.client_key).distinct().count() + + # Contar por revisión física + physical_review_stats = {} + for i in range(3): # Asumiendo valores 0, 1, 2 + count = self.db.query(GClass).filter(GClass.physical_review == i).count() + physical_review_stats[f"physical_review_{i}"] = count + + # Contar clases con fracciones + with_fraction = self.db.query(GClass).filter(GClass.fraction.isnot(None)).count() + with_us_fraction = self.db.query(GClass).filter(GClass.us_fraction.isnot(None)).count() + + return { + "total_classes": total_classes, + "clients_with_classes": clients_count, + "classes_with_fraction": with_fraction, + "classes_with_us_fraction": with_us_fraction, + **physical_review_stats + } + + def get_classes_by_unit_measure(self, unit_of_measure: str) -> List[ClassBasicDTO]: + """Obtiene clases por unidad de medida""" + classes = self.db.query(GClass).filter(GClass.unit_of_measure == unit_of_measure).all() + return [ClassBasicDTO.model_validate(class_obj) for class_obj in classes] + diff --git a/backend/api/v1/modules/a76/GParts/__init__.py b/backend/api/v1/modules/a76/GParts/__init__.py new file mode 100644 index 00000000..11584490 --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/__init__.py @@ -0,0 +1,6 @@ +""" +Módulo de GParts +""" +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/GParts/dto.py b/backend/api/v1/modules/a76/GParts/dto.py new file mode 100644 index 00000000..13524abd --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/dto.py @@ -0,0 +1,182 @@ +""" +DTOs (Data Transfer Objects) para módulo de partes/componentes +Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS +""" +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime +from decimal import Decimal + + +class PartCreateDTO(BaseModel): + """DTO para crear una parte""" + client_key: int = Field(..., description="Client key") + part_number: str = Field(..., max_length=49, description="Part number") + fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction") + description_spanish: Optional[str] = Field(None, max_length=500, description="Description in Spanish") + description_english: Optional[str] = Field(None, max_length=500, description="Description in English") + part_class: Optional[str] = Field(None, max_length=8, description="Part class") + unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure") + commercial_part_number: Optional[str] = Field(None, max_length=70, description="Commercial part number") + country_of_origin: Optional[str] = Field(None, max_length=3, description="Country of origin code") + + # Pricing and currency + unit_cost: Optional[Decimal] = Field(None, description="Unit cost") + currency_type: Optional[str] = Field(None, max_length=2, description="Currency type") + currency_key: Optional[str] = Field(None, max_length=3, description="Currency key") + + # Weight information + unit_weight: Optional[Decimal] = Field(None, description="Unit weight") + weight_type: Optional[str] = Field(None, max_length=6, description="Weight type") + + # Classification and regulatory + us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction") + fda_key: Optional[str] = Field(None, max_length=20, description="FDA key") + fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key") + license_code: Optional[str] = Field(None, max_length=3, description="License code") + eccn: Optional[str] = Field(None, max_length=20, description="Export Control Classification Number") + export_code: Optional[str] = Field(None, max_length=2, description="Export code") + exclusion_symbol: Optional[str] = Field(None, max_length=19, description="Exclusion symbol") + + # Additional information + supplier: Optional[str] = Field(None, max_length=14, description="Supplier") + alternate_unit_measure: Optional[str] = Field(None, max_length=14, description="Alternate unit of measure") + added_value: Optional[Decimal] = Field(None, description="Added value") + + # Status and media + enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status") + creation_date: Optional[int] = Field(None, description="Creation date") + part_photo: Optional[str] = Field(None, max_length=255, description="Part photo URL") + + class Config: + from_attributes = True + + +class PartUpdateDTO(BaseModel): + """DTO para actualizar una parte""" + fraction: Optional[str] = Field(None, max_length=10, description="Tariff fraction") + description_spanish: Optional[str] = Field(None, max_length=500, description="Description in Spanish") + description_english: Optional[str] = Field(None, max_length=500, description="Description in English") + part_class: Optional[str] = Field(None, max_length=8, description="Part class") + unit_of_measure: Optional[str] = Field(None, max_length=5, description="Unit of measure") + commercial_part_number: Optional[str] = Field(None, max_length=70, description="Commercial part number") + country_of_origin: Optional[str] = Field(None, max_length=3, description="Country of origin code") + + # Pricing and currency + unit_cost: Optional[Decimal] = Field(None, description="Unit cost") + currency_type: Optional[str] = Field(None, max_length=2, description="Currency type") + currency_key: Optional[str] = Field(None, max_length=3, description="Currency key") + + # Weight information + unit_weight: Optional[Decimal] = Field(None, description="Unit weight") + weight_type: Optional[str] = Field(None, max_length=6, description="Weight type") + + # Classification and regulatory + us_fraction: Optional[str] = Field(None, max_length=16, description="US tariff fraction") + fda_key: Optional[str] = Field(None, max_length=20, description="FDA key") + fcc_key: Optional[str] = Field(None, max_length=30, description="FCC key") + license_code: Optional[str] = Field(None, max_length=3, description="License code") + eccn: Optional[str] = Field(None, max_length=20, description="Export Control Classification Number") + export_code: Optional[str] = Field(None, max_length=2, description="Export code") + exclusion_symbol: Optional[str] = Field(None, max_length=19, description="Exclusion symbol") + + # Additional information + supplier: Optional[str] = Field(None, max_length=14, description="Supplier") + alternate_unit_measure: Optional[str] = Field(None, max_length=14, description="Alternate unit of measure") + added_value: Optional[Decimal] = Field(None, description="Added value") + + # Status and media + enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status") + part_photo: Optional[str] = Field(None, max_length=255, description="Part photo URL") + + class Config: + from_attributes = True + + +class PartResponseDTO(BaseModel): + """DTO para respuesta de parte""" + client_key: int + part_number: str + fraction: Optional[str] = None + description_spanish: Optional[str] = None + description_english: Optional[str] = None + part_class: Optional[str] = None + unit_of_measure: Optional[str] = None + commercial_part_number: Optional[str] = None + country_of_origin: Optional[str] = None + + # Pricing and currency + unit_cost: Optional[Decimal] = None + currency_type: Optional[str] = None + currency_key: Optional[str] = None + + # Weight information + unit_weight: Optional[Decimal] = None + weight_type: Optional[str] = None + + # Classification and regulatory + us_fraction: Optional[str] = None + fda_key: Optional[str] = None + fcc_key: Optional[str] = None + license_code: Optional[str] = None + eccn: Optional[str] = None + export_code: Optional[str] = None + exclusion_symbol: Optional[str] = None + + # Additional information + supplier: Optional[str] = None + alternate_unit_measure: Optional[str] = None + added_value: Optional[Decimal] = None + + # Status and dates + enabled_disabled: Optional[int] = None + creation_date: Optional[int] = None + modification_date: Optional[int] = None + modification_date_iso: Optional[datetime] = None + + # Media + part_photo: Optional[str] = None + + class Config: + from_attributes = True + + +class PartBasicDTO(BaseModel): + """DTO para información básica de parte""" + client_key: int + part_number: str + description_spanish: Optional[str] = None + description_english: Optional[str] = None + part_class: Optional[str] = None + unit_cost: Optional[Decimal] = None + currency_key: Optional[str] = None + enabled_disabled: Optional[int] = None + + class Config: + from_attributes = True + + +class PartListDTO(BaseModel): + """DTO para lista de partes""" + parts: list[PartBasicDTO] + total: int + page: int + size: int + + class Config: + from_attributes = True + + +class PartSearchDTO(BaseModel): + """DTO para búsqueda de partes""" + client_key: Optional[int] = Field(None, description="Filter by client key") + part_number: Optional[str] = Field(None, description="Search by part number") + description: Optional[str] = Field(None, description="Search in descriptions") + fraction: Optional[str] = Field(None, description="Filter by tariff fraction") + supplier: Optional[str] = Field(None, description="Filter by supplier") + enabled_only: bool = Field(False, description="Show only enabled parts") + + class Config: + from_attributes = True + + diff --git a/backend/api/v1/modules/a76/GParts/models.py b/backend/api/v1/modules/a76/GParts/models.py new file mode 100644 index 00000000..df58cb04 --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/models.py @@ -0,0 +1,88 @@ +""" +Modelos ORM para gestión de partes/componentes +""" +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Numeric, SmallInteger, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from core.database import Base +import enum + +# Importar modelos relacionados para type hints y relationships +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from api.v1.modules.public.reference_data.countries.models import Country + from api.v1.modules.public.reference_data.currency_types.models import CurrencyType + from api.v1.modules.a76.GClass.models import GClass + + +class GPart(Base): + """ + Modelo para la tabla GPartes - Información de partes en los sistemas SCAII (N), SCAF (S) Y WINSAAI (W) + """ + __tablename__ = "gparts" + __table_args__ = {"schema": "a76"} + + # Primary key compuesta + client_key = Column(Integer, primary_key=True, nullable=False) + part_number = Column(String(49), primary_key=True, nullable=False) + + # Basic information + fraction = Column(String(10), nullable=True) + description_spanish = Column(String(500), nullable=True) + description_english = Column(String(500), nullable=True) + part_class = Column(String(8), nullable=True) + unit_of_measure = Column(String(5), nullable=True) + commercial_part_number = Column(String(70), nullable=True) + country_of_origin = Column(String(3), ForeignKey('public.countries.m3_key'), nullable=True) + + # Pricing and currency + unit_cost = Column(Numeric(23, 8), nullable=True) + currency_type = Column(String(2), nullable=True) + currency_key = Column(String(3), ForeignKey('public.currency_types.code'), nullable=True) + + # Weight information + unit_weight = Column(Numeric(19, 8), nullable=True) + weight_type = Column(String(6), nullable=True) + + # Classification and regulatory + us_fraction = Column(String(16), nullable=True) # FRACCIONAME + fda_key = Column(String(20), nullable=True) + fcc_key = Column(String(30), nullable=True) + license_code = Column(String(3), nullable=True) + eccn = Column(String(20), nullable=True) # Export Control Classification Number + export_code = Column(String(2), nullable=True) + exclusion_symbol = Column(String(19), nullable=True) # SIMBOLOEXCLIC + + # Additional information + supplier = Column(String(14), nullable=True) + alternate_unit_measure = Column(String(14), nullable=True) + added_value = Column(Numeric(23, 8), nullable=True) + + # Status and dates + enabled_disabled = Column(SmallInteger, nullable=True) + creation_date = Column(Integer, nullable=True) # FECHACREACIONPARTE + modification_date = Column(Integer, nullable=True) # FECHAMODIFICA + modification_date_iso = Column(DateTime(timezone=True), nullable=True) # FECHAMODIFICA_ISO + + # Media + part_photo = Column(String(255), nullable=True) + + # Relationships + country = relationship("Country", foreign_keys=[country_of_origin]) + currency = relationship("CurrencyType", foreign_keys=[currency_key]) + + # Relationship with GClass through composite foreign key + # Note: This requires both client_key and part_class to match client_key and class_code in GClass + part_class_info = relationship( + "GClass", + primaryjoin="and_(GPart.client_key == GClass.client_key, GPart.part_class == GClass.class_code)", + foreign_keys="[GPart.client_key, GPart.part_class]", + viewonly=True, + back_populates="parts" + ) + + def __repr__(self): + return f"" + + diff --git a/backend/api/v1/modules/a76/GParts/routes.py b/backend/api/v1/modules/a76/GParts/routes.py new file mode 100644 index 00000000..36486b04 --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/routes.py @@ -0,0 +1,273 @@ +""" +Endpoints API para gestión de partes/componentes +""" +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from typing import List, Optional + +from core.database import get_core_db +from core.security import get_current_user, has_role +from .service import PartService +from .dto import ( + PartCreateDTO, + PartUpdateDTO, + PartResponseDTO, + PartBasicDTO, + PartListDTO, + PartSearchDTO +) + +router = APIRouter(prefix="/parts", tags=["Parts"]) + + +@router.post("/", response_model=PartResponseDTO, status_code=status.HTTP_201_CREATED) +async def create_part( + part_data: PartCreateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Create a new part in the system + """ + service = PartService(db) + return service.create_part(part_data) + + +@router.get("/", response_model=PartListDTO) +async def list_parts( + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"), + client_key: Optional[int] = Query(None, description="Filter by client key"), + part_number: Optional[str] = Query(None, description="Search by part number"), + description: Optional[str] = Query(None, description="Search in descriptions"), + fraction: Optional[str] = Query(None, description="Filter by tariff fraction"), + supplier: Optional[str] = Query(None, description="Filter by supplier"), + enabled_only: bool = Query(False, description="Show only enabled parts"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + List parts with optional filters and pagination + """ + service = PartService(db) + search_params = PartSearchDTO( + client_key=client_key, + part_number=part_number, + description=description, + fraction=fraction, + supplier=supplier, + enabled_only=enabled_only + ) + return service.list_parts(skip, limit, search_params) + + +@router.get("/client/{client_key}", response_model=List[PartBasicDTO]) +async def get_parts_by_client( + client_key: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get all parts for a specific client + """ + service = PartService(db) + return service.search_by_client(client_key, skip, limit) + + +@router.get("/search/fraction/{fraction}", response_model=List[PartBasicDTO]) +async def search_by_fraction( + fraction: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Search parts by tariff fraction + """ + service = PartService(db) + return service.search_by_fraction(fraction) + + +@router.get("/search/supplier/{supplier}", response_model=List[PartBasicDTO]) +async def search_by_supplier( + supplier: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Search parts by supplier + """ + service = PartService(db) + return service.search_by_supplier(supplier) + + +@router.get("/search/country/{country_code}", response_model=List[PartBasicDTO]) +async def get_parts_by_country( + country_code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get parts by country of origin + """ + service = PartService(db) + return service.get_parts_by_country(country_code) + + +@router.get("/statistics", response_model=dict) +async def get_parts_statistics( + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get basic parts statistics + """ + service = PartService(db) + return service.get_parts_statistics() + + +@router.get("/{client_key}/{part_number}", response_model=PartResponseDTO) +async def get_part( + client_key: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get part by composite key (client_key + part_number) + """ + service = PartService(db) + part = service.get_part(client_key, part_number) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found" + ) + return part + + +@router.put("/{client_key}/{part_number}", response_model=PartResponseDTO) +async def update_part( + client_key: int, + part_number: str, + part_data: PartUpdateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Update part information + """ + service = PartService(db) + part = service.update_part(client_key, part_number, part_data) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found" + ) + return part + + +@router.delete("/{client_key}/{part_number}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_part( + client_key: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Delete part from the system + + Note: This will completely remove the part from the system. + """ + service = PartService(db) + if not service.delete_part(client_key, part_number): + raise HTTPException( + status_code=404, + detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found" + ) + + +@router.patch("/{client_key}/{part_number}/toggle-status", response_model=PartResponseDTO) +async def toggle_part_status( + client_key: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Toggle part enabled/disabled status + """ + service = PartService(db) + part = service.toggle_status(client_key, part_number) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found" + ) + return part + + +# Endpoints específicos para información detallada +@router.get("/{client_key}/{part_number}/basic", response_model=PartBasicDTO) +async def get_part_basic_info( + client_key: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get basic information for a part + """ + service = PartService(db) + part = service.get_part(client_key, part_number) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found" + ) + + return PartBasicDTO( + client_key=part.client_key, + part_number=part.part_number, + description_spanish=part.description_spanish, + description_english=part.description_english, + part_class=part.part_class, + unit_cost=part.unit_cost, + currency_key=part.currency_key, + enabled_disabled=part.enabled_disabled + ) + + +@router.get("/{client_key}/{part_number}/regulatory", response_model=dict) +async def get_part_regulatory_info( + client_key: int, + part_number: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get regulatory information for a part (FDA, FCC, ECCN, etc.) + """ + service = PartService(db) + part = service.get_part(client_key, part_number) + if not part: + raise HTTPException( + status_code=404, + detail=f"Part with client_key '{client_key}' and part_number '{part_number}' not found" + ) + + return { + "client_key": part.client_key, + "part_number": part.part_number, + "fraction": part.fraction, + "us_fraction": part.us_fraction, + "fda_key": part.fda_key, + "fcc_key": part.fcc_key, + "license_code": part.license_code, + "eccn": part.eccn, + "export_code": part.export_code, + "exclusion_symbol": part.exclusion_symbol + } + + diff --git a/backend/api/v1/modules/a76/GParts/service.py b/backend/api/v1/modules/a76/GParts/service.py new file mode 100644 index 00000000..cdc08dc4 --- /dev/null +++ b/backend/api/v1/modules/a76/GParts/service.py @@ -0,0 +1,275 @@ +""" +Capa de servicio para lógica de negocio de partes/componentes +""" +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from sqlalchemy import or_, and_, func +from fastapi import HTTPException +from typing import List, Optional +import logging +from datetime import datetime + +from .models import GPart +from .dto import PartCreateDTO, PartUpdateDTO + +logger = logging.getLogger(__name__) + + +class PartService: + """ + Servicio para gestión de partes/componentes + """ + + @staticmethod + def create_part(db: Session, part_data: PartCreateDTO) -> GPart: + """ + Crear una nueva parte + """ + try: + db_part = GPart(**part_data.model_dump()) + db.add(db_part) + db.commit() + db.refresh(db_part) + return db_part + except IntegrityError as e: + db.rollback() + logger.error(f"Error creating part: {e}") + raise HTTPException(status_code=400, detail="Part with this client_key and part_number already exists") + except Exception as e: + db.rollback() + logger.error(f"Unexpected error creating part: {e}") + raise HTTPException(status_code=500, detail="Error creating part") + + @staticmethod + def get_part(db: Session, client_key: int, part_number: str) -> Optional[GPart]: + """ + Obtener una parte por clave de cliente y número de parte + """ + try: + return db.query(GPart).filter( + and_( + GPart.client_key == client_key, + GPart.part_number == part_number + ) + ).first() + except Exception as e: + logger.error(f"Error getting part: {e}") + raise HTTPException(status_code=500, detail="Error retrieving part") + + @staticmethod + def get_parts_paginated( + db: Session, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, + client_key: Optional[int] = None, + fraction: Optional[str] = None, + country_of_origin: Optional[str] = None + ) -> tuple[List[GPart], int]: + """ + Obtener partes con paginación y filtros + """ + try: + query = db.query(GPart) + + # Aplicar filtros + if search: + query = query.filter(or_( + GPart.description_spanish.ilike(f"%{search}%"), + GPart.description_english.ilike(f"%{search}%"), + GPart.part_number.ilike(f"%{search}%") + )) + + if client_key is not None: + query = query.filter(GPart.client_key == client_key) + + if fraction: + query = query.filter(GPart.fraction == fraction) + + if country_of_origin: + query = query.filter(GPart.country_of_origin == country_of_origin) + + # Contar total + total = query.count() + + # Aplicar paginación + parts = query.offset(skip).limit(limit).all() + + return parts, total + except Exception as e: + logger.error(f"Error getting paginated parts: {e}") + raise HTTPException(status_code=500, detail="Error retrieving parts") + + @staticmethod + def get_parts_by_client(db: Session, client_key: int) -> List[GPart]: + """ + Obtener todas las partes de un cliente específico + """ + try: + return db.query(GPart).filter(GPart.client_key == client_key).all() + except Exception as e: + logger.error(f"Error getting parts by client: {e}") + raise HTTPException(status_code=500, detail="Error retrieving client parts") + + @staticmethod + def search_parts_by_fraction(db: Session, fraction: str) -> List[GPart]: + """ + Buscar partes por fracción arancelaria + """ + try: + return db.query(GPart).filter( + or_( + GPart.fraction.ilike(f"%{fraction}%"), + GPart.us_fraction.ilike(f"%{fraction}%") + ) + ).all() + except Exception as e: + logger.error(f"Error searching parts by fraction: {e}") + raise HTTPException(status_code=500, detail="Error searching parts by fraction") + + @staticmethod + def search_parts_by_supplier(db: Session, supplier: str) -> List[GPart]: + """ + Buscar partes por proveedor + """ + try: + return db.query(GPart).filter(GPart.supplier.ilike(f"%{supplier}%")).all() + except Exception as e: + logger.error(f"Error searching parts by supplier: {e}") + raise HTTPException(status_code=500, detail="Error searching parts by supplier") + + @staticmethod + def search_parts_by_country(db: Session, country_code: str) -> List[GPart]: + """ + Buscar partes por país de origen + """ + try: + return db.query(GPart).filter(GPart.country_of_origin == country_code).all() + except Exception as e: + logger.error(f"Error searching parts by country: {e}") + raise HTTPException(status_code=500, detail="Error searching parts by country") + + @staticmethod + def update_part(db: Session, client_key: int, part_number: str, part_data: PartUpdateDTO) -> Optional[GPart]: + """ + Actualizar una parte existente + """ + try: + db_part = PartService.get_part(db, client_key, part_number) + if not db_part: + return None + + # Actualizar campos + for field, value in part_data.model_dump(exclude_unset=True).items(): + setattr(db_part, field, value) + + db.commit() + db.refresh(db_part) + return db_part + except Exception as e: + db.rollback() + logger.error(f"Error updating part: {e}") + raise HTTPException(status_code=500, detail="Error updating part") + + @staticmethod + def delete_part(db: Session, client_key: int, part_number: str) -> bool: + """ + Eliminar una parte + """ + try: + db_part = PartService.get_part(db, client_key, part_number) + if not db_part: + return False + + db.delete(db_part) + db.commit() + return True + except Exception as e: + db.rollback() + logger.error(f"Error deleting part: {e}") + raise HTTPException(status_code=500, detail="Error deleting part") + + @staticmethod + def toggle_part_status(db: Session, client_key: int, part_number: str) -> Optional[GPart]: + """ + Cambiar el estado habilitado/deshabilitado de una parte + """ + try: + db_part = PartService.get_part(db, client_key, part_number) + if not db_part: + return None + + # Toggle status (assuming 1 = enabled, 0 = disabled) + db_part.enabled_disabled = 1 if db_part.enabled_disabled == 0 else 0 + + db.commit() + db.refresh(db_part) + return db_part + except Exception as e: + db.rollback() + logger.error(f"Error toggling part status: {e}") + raise HTTPException(status_code=500, detail="Error toggling part status") + + @staticmethod + def get_parts_statistics(db: Session) -> dict: + """ + Obtener estadísticas de partes + """ + try: + total_parts = db.query(GPart).count() + + # Partes por cliente + parts_by_client = db.query( + GPart.client_key, + func.count(GPart.part_number).label('count') + ).group_by(GPart.client_key).all() + + # Partes por país de origen + parts_by_country = db.query( + GPart.country_of_origin, + func.count(GPart.part_number).label('count') + ).filter(GPart.country_of_origin.isnot(None))\ + .group_by(GPart.country_of_origin).all() + + # Partes habilitadas vs deshabilitadas + enabled_parts = db.query(GPart).filter(GPart.enabled_disabled == 1).count() + disabled_parts = db.query(GPart).filter(GPart.enabled_disabled == 0).count() + + return { + "total_parts": total_parts, + "enabled_parts": enabled_parts, + "disabled_parts": disabled_parts, + "parts_by_client": [{"client_key": item[0], "count": item[1]} for item in parts_by_client], + "parts_by_country": [{"country": item[0], "count": item[1]} for item in parts_by_country] + } + except Exception as e: + logger.error(f"Error getting parts statistics: {e}") + raise HTTPException(status_code=500, detail="Error retrieving parts statistics") + + @staticmethod + def get_part_regulatory_info(db: Session, client_key: int, part_number: str) -> Optional[dict]: + """ + Obtener información regulatoria específica de una parte + """ + try: + db_part = PartService.get_part(db, client_key, part_number) + if not db_part: + return None + + return { + "client_key": db_part.client_key, + "part_number": db_part.part_number, + "fraction": db_part.fraction, + "us_fraction": db_part.us_fraction, + "fda_key": db_part.fda_key, + "fcc_key": db_part.fcc_key, + "license_code": db_part.license_code, + "eccn": db_part.eccn, + "export_code": db_part.export_code, + "exclusion_symbol": db_part.exclusion_symbol, + "country_of_origin": db_part.country_of_origin + } + except Exception as e: + logger.error(f"Error getting part regulatory info: {e}") + raise HTTPException(status_code=500, detail="Error retrieving part regulatory information") + diff --git a/backend/api/v1/modules/a76/client_&_provider/__init__.py b/backend/api/v1/modules/a76/client_&_provider/__init__.py new file mode 100644 index 00000000..9621b9c4 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/__init__.py @@ -0,0 +1,6 @@ +""" +Módulo de Client & Provider +""" +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/client_&_provider/dto.py b/backend/api/v1/modules/a76/client_&_provider/dto.py new file mode 100644 index 00000000..be248661 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/dto.py @@ -0,0 +1,165 @@ +""" +DTOs (Data Transfer Objects) para módulo de clientes y proveedores +Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS +""" +from pydantic import BaseModel, Field, EmailStr +from typing import Optional +from datetime import datetime +from decimal import Decimal + + +# DTOs para dirección +class ClientProviderAddressDTO(BaseModel): + """DTO para dirección de cliente/proveedor""" + municipality: Optional[str] = Field(None, max_length=150, description="Municipality") + streets: Optional[str] = Field(None, max_length=100, description="Streets") + neighborhood: Optional[str] = Field(None, max_length=40, description="Neighborhood") + interior_number: Optional[str] = Field(None, max_length=20, description="Interior number") + exterior_number: Optional[str] = Field(None, max_length=20, description="Exterior number") + postal_code: Optional[str] = Field(None, max_length=15, description="Postal code") + city: Optional[str] = Field(None, max_length=30, description="City") + state: Optional[str] = Field(None, max_length=30, description="State") + country: Optional[str] = Field(None, max_length=3, description="Country code") + phone: Optional[str] = Field(None, max_length=30, description="Phone number") + fax_number: Optional[str] = Field(None, max_length=30, description="Fax number") + email: Optional[str] = Field(None, max_length=100, description="Email address") + contact: Optional[str] = Field(None, max_length=50, description="Contact person") + reference: Optional[str] = Field(None, max_length=250, description="Reference") + + class Config: + from_attributes = True + + +# DTOs para programas +class ClientProviderProgramsDTO(BaseModel): + """DTO para programas de cliente/proveedor""" + program: Optional[str] = Field(None, max_length=7, 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") + secon_auth_date: Optional[int] = Field(None, description="SECON authorization date") + manufacturer_id: Optional[str] = Field(None, max_length=25, description="Manufacturer ID") + tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID") + broker: Optional[str] = Field(None, max_length=6, description="Broker") + import_broker: Optional[str] = Field(None, max_length=6, description="Import broker") + transfer_key: Optional[str] = Field(None, max_length=8, description="Transfer key") + secon_authorization: Optional[str] = Field(None, max_length=20, description="SECON authorization") + applied_proportion: Optional[Decimal] = Field(None, description="Applied proportion") + is_certified_company: Optional[str] = Field(None, max_length=1, description="Is certified company") + certified_company_registry: Optional[str] = Field(None, max_length=40, description="Certified company registry") + donation_auth_number: Optional[str] = Field(None, max_length=50, description="Donation authorization number") + ctpat_svi: Optional[str] = Field(None, max_length=100, description="CTPAT SVI") + tax_registry_number: Optional[str] = Field(None, max_length=40, description="Tax registry number") + subassembly_service: Optional[int] = Field(None, description="Subassembly service") + autse_dates: Optional[int] = Field(None, description="AUTSE dates") + autse_number: Optional[str] = Field(None, max_length=300, description="AUTSE number") + + class Config: + from_attributes = True + + +# DTOs principales +class ClientProviderCreateDTO(BaseModel): + """DTO para crear cliente/proveedor""" + client_id: str = Field(..., max_length=8, description="Client ID") + type_nat_foreign: Optional[str] = Field(None, max_length=1, description="Type national/foreign") + name: Optional[str] = Field(None, max_length=256, description="Name") + short_name: Optional[str] = Field(None, max_length=10, description="Short name") + rfc: Optional[str] = Field(None, max_length=30, description="RFC") + curp: Optional[str] = Field(None, max_length=19, description="CURP") + client_or_provider: Optional[str] = Field(None, max_length=1, description="Client or provider") + linking: Optional[str] = Field(None, max_length=1, description="Linking") + transform_subassembly: Optional[str] = Field(None, max_length=1, description="Transform subassembly") + extra_information: Optional[str] = Field(None, max_length=399, description="Extra information") + web_key: Optional[str] = Field(None, max_length=40, description="Web key") + responsible: Optional[str] = Field(None, max_length=80, description="Responsible person") + position: Optional[str] = Field(None, max_length=30, description="Position") + incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm") + is_national_provider: Optional[str] = Field(None, max_length=2, description="Is national provider") + enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status") + + # Nested DTOs + address: Optional[ClientProviderAddressDTO] = Field(None, description="Address information") + programs: Optional[ClientProviderProgramsDTO] = Field(None, description="Programs information") + + class Config: + from_attributes = True + + +class ClientProviderUpdateDTO(BaseModel): + """DTO para actualizar cliente/proveedor""" + type_nat_foreign: Optional[str] = Field(None, max_length=1, description="Type national/foreign") + name: Optional[str] = Field(None, max_length=256, description="Name") + short_name: Optional[str] = Field(None, max_length=10, description="Short name") + rfc: Optional[str] = Field(None, max_length=30, description="RFC") + curp: Optional[str] = Field(None, max_length=19, description="CURP") + client_or_provider: Optional[str] = Field(None, max_length=1, description="Client or provider") + linking: Optional[str] = Field(None, max_length=1, description="Linking") + transform_subassembly: Optional[str] = Field(None, max_length=1, description="Transform subassembly") + extra_information: Optional[str] = Field(None, max_length=399, description="Extra information") + web_key: Optional[str] = Field(None, max_length=40, description="Web key") + responsible: Optional[str] = Field(None, max_length=80, description="Responsible person") + position: Optional[str] = Field(None, max_length=30, description="Position") + incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm") + is_national_provider: Optional[str] = Field(None, max_length=2, description="Is national provider") + enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status") + + # Nested DTOs + address: Optional[ClientProviderAddressDTO] = Field(None, description="Address information") + programs: Optional[ClientProviderProgramsDTO] = Field(None, description="Programs information") + + class Config: + from_attributes = True + + +class ClientProviderResponseDTO(BaseModel): + """DTO para respuesta de cliente/proveedor""" + client_id: str + type_nat_foreign: Optional[str] = None + name: Optional[str] = None + short_name: Optional[str] = None + rfc: Optional[str] = None + curp: Optional[str] = None + client_or_provider: Optional[str] = None + linking: Optional[str] = None + transform_subassembly: Optional[str] = None + extra_information: Optional[str] = None + web_key: Optional[str] = None + responsible: Optional[str] = None + position: Optional[str] = None + incoterm: Optional[str] = None + is_national_provider: Optional[str] = None + enabled_disabled: Optional[int] = None + + # Nested DTOs + address: Optional[ClientProviderAddressDTO] = None + programs: Optional[ClientProviderProgramsDTO] = None + + class Config: + from_attributes = True + + +# DTOs para respuestas específicas +class ClientProviderBasicDTO(BaseModel): + """DTO para información básica de cliente/proveedor""" + client_id: str + name: Optional[str] = None + short_name: Optional[str] = None + rfc: Optional[str] = None + client_or_provider: Optional[str] = None + enabled_disabled: Optional[int] = None + + class Config: + from_attributes = True + + +class ClientProviderListDTO(BaseModel): + """DTO para lista de clientes/proveedores""" + clients: list[ClientProviderBasicDTO] + total: int + page: int + size: int + + class Config: + from_attributes = True + diff --git a/backend/api/v1/modules/a76/client_&_provider/models.py b/backend/api/v1/modules/a76/client_&_provider/models.py new file mode 100644 index 00000000..14834953 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/models.py @@ -0,0 +1,108 @@ +""" +Modelos ORM para gestión de clientes y proveedores +""" +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, SmallInteger, Numeric, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from core.database import Base +import enum + + +class GClientProvider(Base): + """ + Modelo para la tabla GClientesPro - Información de clientes y proveedores + """ + __tablename__ = "gclient_provider" + __table_args__ = {"schema": "a76"} + + # Primary key + client_id = Column(String(8), primary_key=True, nullable=False) + + # Basic information + type_nat_foreign = Column(String(1), nullable=True) # TIPO NACIONAL/EXTRANJERO + name = Column(String(256), nullable=True) + short_name = Column(String(10), nullable=True) + rfc = Column(String(30), nullable=True) + curp = Column(String(19), nullable=True) + client_or_provider = Column(String(1), nullable=True) + linking = Column(String(1), nullable=True) + transform_subassembly = Column(String(1), nullable=True) + extra_information = Column(String(399), nullable=True) + web_key = Column(String(40), nullable=True) + responsible = Column(String(80), nullable=True) + position = Column(String(30), nullable=True) + incoterm = Column(String(19), nullable=True) + is_national_provider = Column(String(2), nullable=True) + enabled_disabled = Column(SmallInteger, nullable=True) + + # Relationships + address = relationship("GClientProviderAddress", back_populates="client_provider", uselist=False, cascade="all, delete-orphan") + programs = relationship("GClientProviderPrograms", back_populates="client_provider", uselist=False, cascade="all, delete-orphan") + + +class GClientProviderAddress(Base): + """ + Modelo para la tabla GClientesPro_Direccion - Dirección de clientes y proveedores + """ + __tablename__ = "gclient_provider_address" + __table_args__ = {"schema": "a76"} + + # Primary key (foreign key) + client_id = Column(String(8), ForeignKey('a76.gclient_provider.client_id', ondelete='CASCADE'), primary_key=True, nullable=False) + + # Address information + municipality = Column(String(150), nullable=True) + streets = Column(String(100), nullable=True) + neighborhood = Column(String(40), nullable=True) + interior_number = Column(String(20), nullable=True) + exterior_number = Column(String(20), nullable=True) + postal_code = Column(String(15), nullable=True) + city = Column(String(30), nullable=True) + state = Column(String(30), nullable=True) + country = Column(String(3), nullable=True) + phone = Column(String(30), nullable=True) + fax_number = Column(String(30), nullable=True) + email = Column(String(100), nullable=True) + contact = Column(String(50), nullable=True) + reference = Column(String(250), nullable=True) + + # Relationship + client_provider = relationship("GClientProvider", back_populates="address") + + +class GClientProviderPrograms(Base): + """ + Modelo para la tabla GClientesPro_Programas - Programas de clientes y proveedores + """ + __tablename__ = "gclient_provider_programs" + __table_args__ = {"schema": "a76"} + + # Primary key (foreign key) + client_id = Column(String(8), ForeignKey('a76.gclient_provider.client_id', ondelete='CASCADE'), primary_key=True, nullable=False) + + # Program information + program = Column(String(7), nullable=True) + program_number = Column(String(40), nullable=True) + prosec = Column(SmallInteger, nullable=True) + prosec_authorization = Column(String(20), nullable=True) + secon_auth_date = Column(Integer, nullable=True) + manufacturer_id = Column(String(25), nullable=True) + tax_id = Column(String(30), nullable=True) + broker = Column(String(6), nullable=True) + import_broker = Column(String(6), nullable=True) + transfer_key = Column(String(8), nullable=True) + secon_authorization = Column(String(20), nullable=True) + applied_proportion = Column(Numeric(7, 2), nullable=True) + is_certified_company = Column(String(1), nullable=True) + certified_company_registry = Column(String(40), nullable=True) + donation_auth_number = Column(String(50), nullable=True) + ctpat_svi = Column(String(100), nullable=True) + tax_registry_number = Column(String(40), nullable=True) + subassembly_service = Column(SmallInteger, nullable=True) + autse_dates = Column(Integer, nullable=True) + autse_number = Column(String(300), nullable=True) + + # Relationship + client_provider = relationship("GClientProvider", back_populates="programs") + + diff --git a/backend/api/v1/modules/a76/client_&_provider/routes.py b/backend/api/v1/modules/a76/client_&_provider/routes.py new file mode 100644 index 00000000..fb9edd50 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/routes.py @@ -0,0 +1,221 @@ +""" +Endpoints API para gestión de clientes y proveedores +""" +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from typing import List, Optional + +from core.database import get_core_db +from core.security import get_current_user, has_role +from .service import ClientProviderService +from .dto import ( + ClientProviderCreateDTO, + ClientProviderUpdateDTO, + ClientProviderResponseDTO, + ClientProviderBasicDTO, + ClientProviderListDTO +) + +router = APIRouter(prefix="/clients-providers", tags=["Clients & Providers"]) + + +@router.post("/", response_model=ClientProviderResponseDTO, status_code=status.HTTP_201_CREATED) +async def create_client_provider( + client_data: ClientProviderCreateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Create a new client or provider in the system + """ + service = ClientProviderService(db) + return service.create_client_provider(client_data) + + +@router.get("/", response_model=ClientProviderListDTO) +async def list_clients_providers( + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"), + search: Optional[str] = Query(None, description="Search text for name, RFC, or ID"), + client_or_provider: Optional[str] = Query(None, regex="^[CP]$", description="Filter by type: C=Client, P=Provider"), + enabled_only: bool = Query(False, description="Show only enabled records"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + List clients and providers with optional filters and pagination + """ + service = ClientProviderService(db) + return service.list_clients_providers(skip, limit, search, client_or_provider, enabled_only) + + +@router.get("/clients", response_model=List[ClientProviderBasicDTO]) +async def get_clients_only( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get only clients (client_or_provider = 'C') + """ + service = ClientProviderService(db) + return service.get_clients_only(skip, limit) + + +@router.get("/providers", response_model=List[ClientProviderBasicDTO]) +async def get_providers_only( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get only providers (client_or_provider = 'P') + """ + service = ClientProviderService(db) + return service.get_providers_only(skip, limit) + + +@router.get("/search/rfc/{rfc}", response_model=List[ClientProviderBasicDTO]) +async def search_by_rfc( + rfc: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Search clients/providers by RFC + """ + service = ClientProviderService(db) + return service.search_by_rfc(rfc) + + +@router.get("/{client_id}", response_model=ClientProviderResponseDTO) +async def get_client_provider( + client_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get client/provider by ID with all related information + """ + service = ClientProviderService(db) + client = service.get_client_provider(client_id) + if not client: + raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found") + return client + + +@router.put("/{client_id}", response_model=ClientProviderResponseDTO) +async def update_client_provider( + client_id: str, + client_data: ClientProviderUpdateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Update client/provider information + """ + service = ClientProviderService(db) + client = service.update_client_provider(client_id, client_data) + if not client: + raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found") + return client + + +@router.delete("/{client_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_client_provider( + client_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Delete client/provider from the system + + Note: This will completely remove the client/provider and all related data. + """ + service = ClientProviderService(db) + if not service.delete_client_provider(client_id): + raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found") + + +@router.patch("/{client_id}/toggle-status", response_model=ClientProviderResponseDTO) +async def toggle_client_provider_status( + client_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Toggle client/provider enabled/disabled status + """ + service = ClientProviderService(db) + client = service.toggle_status(client_id) + if not client: + raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found") + return client + + +# Endpoints específicos para información detallada +@router.get("/{client_id}/address", response_model=dict) +async def get_client_provider_address( + client_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get only address information for a client/provider + """ + service = ClientProviderService(db) + client = service.get_client_provider(client_id) + if not client: + raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found") + + return { + "client_id": client.client_id, + "address": client.address + } + + +@router.get("/{client_id}/programs", response_model=dict) +async def get_client_provider_programs( + client_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get only programs information for a client/provider + """ + service = ClientProviderService(db) + client = service.get_client_provider(client_id) + if not client: + raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found") + + return { + "client_id": client.client_id, + "programs": client.programs + } + + +@router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO) +async def get_client_provider_basic_info( + client_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get basic information for a client/provider (without address and programs) + """ + service = ClientProviderService(db) + client = service.get_client_provider(client_id) + if not client: + raise HTTPException(status_code=404, detail=f"Client/Provider with ID '{client_id}' not found") + + return ClientProviderBasicDTO( + client_id=client.client_id, + name=client.name, + short_name=client.short_name, + rfc=client.rfc, + client_or_provider=client.client_or_provider, + enabled_disabled=client.enabled_disabled + ) + diff --git a/backend/api/v1/modules/a76/client_&_provider/service.py b/backend/api/v1/modules/a76/client_&_provider/service.py new file mode 100644 index 00000000..a9573c10 --- /dev/null +++ b/backend/api/v1/modules/a76/client_&_provider/service.py @@ -0,0 +1,309 @@ +""" +Capa de servicio para lógica de negocio de clientes y proveedores +""" +from sqlalchemy.orm import Session, joinedload +from sqlalchemy.exc import IntegrityError +from sqlalchemy import or_, and_ +from fastapi import HTTPException +from typing import List, Optional +import logging + +from .models import GClientProvider, GClientProviderAddress, GClientProviderPrograms +from .dto import ( + ClientProviderCreateDTO, + ClientProviderUpdateDTO, + ClientProviderResponseDTO, + ClientProviderBasicDTO, + ClientProviderListDTO, + ClientProviderAddressDTO, + ClientProviderProgramsDTO +) + +logger = logging.getLogger(__name__) + + +class ClientProviderService: + """Servicio para gestión de clientes y proveedores""" + + def __init__(self, db: Session): + self.db = db + + def create_client_provider(self, client_data: ClientProviderCreateDTO) -> ClientProviderResponseDTO: + """ + Crea un nuevo cliente/proveedor en el sistema + + Args: + client_data: Datos del cliente/proveedor a crear + + Returns: + ClientProviderResponseDTO con información del cliente/proveedor creado + + Raises: + HTTPException: Si el cliente ya existe o error en la creación + """ + try: + # Verificar que no exista el cliente + existing = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_data.client_id).first() + if existing: + raise HTTPException(status_code=400, detail=f"Client with ID '{client_data.client_id}' already exists") + + # Crear cliente/proveedor principal + db_client = GClientProvider( + client_id=client_data.client_id, + type_nat_foreign=client_data.type_nat_foreign, + name=client_data.name, + short_name=client_data.short_name, + rfc=client_data.rfc, + curp=client_data.curp, + client_or_provider=client_data.client_or_provider, + linking=client_data.linking, + transform_subassembly=client_data.transform_subassembly, + extra_information=client_data.extra_information, + web_key=client_data.web_key, + responsible=client_data.responsible, + position=client_data.position, + incoterm=client_data.incoterm, + is_national_provider=client_data.is_national_provider, + enabled_disabled=client_data.enabled_disabled + ) + + self.db.add(db_client) + self.db.flush() # Para obtener el ID antes del commit + + # Crear dirección si se proporciona + if client_data.address: + db_address = GClientProviderAddress( + client_id=client_data.client_id, + **client_data.address.model_dump(exclude_unset=True) + ) + self.db.add(db_address) + + # Crear programas si se proporciona + if client_data.programs: + db_programs = GClientProviderPrograms( + client_id=client_data.client_id, + **client_data.programs.model_dump(exclude_unset=True) + ) + self.db.add(db_programs) + + self.db.commit() + self.db.refresh(db_client) + + logger.info(f"Client/Provider created: {db_client.client_id} - {db_client.name}") + + return self._get_client_with_relations(client_data.client_id) + + except IntegrityError as e: + self.db.rollback() + logger.error(f"IntegrityError creating client/provider: {str(e)}") + raise HTTPException(status_code=400, detail="Client/Provider with this ID already exists") + except HTTPException: + raise + except Exception as e: + self.db.rollback() + logger.error(f"Error creating client/provider: {str(e)}") + raise HTTPException(status_code=500, detail="Error creating client/provider") + + def get_client_provider(self, client_id: str) -> Optional[ClientProviderResponseDTO]: + """ + Obtiene un cliente/proveedor por ID + + Args: + client_id: ID del cliente/proveedor + + Returns: + ClientProviderResponseDTO o None si no existe + """ + return self._get_client_with_relations(client_id) + + def _get_client_with_relations(self, client_id: str) -> Optional[ClientProviderResponseDTO]: + """Método privado para obtener cliente con relaciones""" + client = self.db.query(GClientProvider).options( + joinedload(GClientProvider.address), + joinedload(GClientProvider.programs) + ).filter(GClientProvider.client_id == client_id).first() + + if not client: + return None + return ClientProviderResponseDTO.model_validate(client) + + def list_clients_providers( + self, + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, + client_or_provider: Optional[str] = None, + enabled_only: bool = False + ) -> ClientProviderListDTO: + """ + Lista clientes/proveedores con filtros + + Args: + skip: Número de registros a omitir + limit: Número máximo de registros a retornar + search: Texto de búsqueda (nombre, RFC, ID) + client_or_provider: Filtrar por tipo (C=Cliente, P=Proveedor) + enabled_only: Si True, solo retorna activos + + Returns: + ClientProviderListDTO con la lista paginada + """ + query = self.db.query(GClientProvider) + + # Aplicar filtros + if search: + search_pattern = f"%{search}%" + query = query.filter( + or_( + GClientProvider.name.ilike(search_pattern), + GClientProvider.short_name.ilike(search_pattern), + GClientProvider.rfc.ilike(search_pattern), + GClientProvider.client_id.ilike(search_pattern) + ) + ) + + if client_or_provider: + query = query.filter(GClientProvider.client_or_provider == client_or_provider) + + if enabled_only: + query = query.filter(GClientProvider.enabled_disabled == 1) + + # Contar total + total = query.count() + + # Aplicar paginación + clients = query.offset(skip).limit(limit).all() + + # Convertir a DTOs básicos + client_dtos = [ClientProviderBasicDTO.model_validate(client) for client in clients] + + return ClientProviderListDTO( + clients=client_dtos, + total=total, + page=(skip // limit) + 1 if limit > 0 else 1, + size=len(client_dtos) + ) + + def update_client_provider(self, client_id: str, client_data: ClientProviderUpdateDTO) -> Optional[ClientProviderResponseDTO]: + """ + Actualiza un cliente/proveedor + + Args: + client_id: ID del cliente/proveedor a actualizar + client_data: Datos a actualizar + + Returns: + ClientProviderResponseDTO actualizado o None si no existe + """ + client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first() + if not client: + return None + + try: + # Actualizar campos del cliente principal + update_data = client_data.model_dump(exclude_unset=True, exclude={'address', 'programs'}) + for field, value in update_data.items(): + setattr(client, field, value) + + # Actualizar dirección + if client_data.address: + address = self.db.query(GClientProviderAddress).filter(GClientProviderAddress.client_id == client_id).first() + if address: + # Actualizar dirección existente + address_data = client_data.address.model_dump(exclude_unset=True) + for field, value in address_data.items(): + setattr(address, field, value) + else: + # Crear nueva dirección + address = GClientProviderAddress( + client_id=client_id, + **client_data.address.model_dump(exclude_unset=True) + ) + self.db.add(address) + + # Actualizar programas + if client_data.programs: + programs = self.db.query(GClientProviderPrograms).filter(GClientProviderPrograms.client_id == client_id).first() + if programs: + # Actualizar programas existentes + programs_data = client_data.programs.model_dump(exclude_unset=True) + for field, value in programs_data.items(): + setattr(programs, field, value) + else: + # Crear nuevos programas + programs = GClientProviderPrograms( + client_id=client_id, + **client_data.programs.model_dump(exclude_unset=True) + ) + self.db.add(programs) + + self.db.commit() + logger.info(f"Client/Provider updated: {client_id}") + + return self._get_client_with_relations(client_id) + + except Exception as e: + self.db.rollback() + logger.error(f"Error updating client/provider {client_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error updating client/provider") + + def delete_client_provider(self, client_id: str) -> bool: + """ + Elimina un cliente/proveedor + + Args: + client_id: ID del cliente/proveedor a eliminar + + Returns: + True si se eliminó, False si no existe + """ + client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first() + if not client: + return False + + try: + self.db.delete(client) # Las relaciones se eliminan en cascada + self.db.commit() + logger.info(f"Client/Provider deleted: {client_id}") + return True + except Exception as e: + self.db.rollback() + logger.error(f"Error deleting client/provider {client_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error deleting client/provider") + + def get_clients_only(self, skip: int = 0, limit: int = 100) -> List[ClientProviderBasicDTO]: + """Obtiene solo clientes (C)""" + query = self.db.query(GClientProvider).filter(GClientProvider.client_or_provider == 'C') + clients = query.offset(skip).limit(limit).all() + return [ClientProviderBasicDTO.model_validate(client) for client in clients] + + def get_providers_only(self, skip: int = 0, limit: int = 100) -> List[ClientProviderBasicDTO]: + """Obtiene solo proveedores (P)""" + query = self.db.query(GClientProvider).filter(GClientProvider.client_or_provider == 'P') + providers = query.offset(skip).limit(limit).all() + return [ClientProviderBasicDTO.model_validate(provider) for provider in providers] + + def search_by_rfc(self, rfc: str) -> List[ClientProviderBasicDTO]: + """Busca clientes/proveedores por RFC""" + clients = self.db.query(GClientProvider).filter(GClientProvider.rfc.ilike(f"%{rfc}%")).all() + return [ClientProviderBasicDTO.model_validate(client) for client in clients] + + def toggle_status(self, client_id: str) -> Optional[ClientProviderResponseDTO]: + """Cambia el estado habilitado/deshabilitado""" + client = self.db.query(GClientProvider).filter(GClientProvider.client_id == client_id).first() + if not client: + return None + + # Toggle status (1 = habilitado, 0 = deshabilitado) + client.enabled_disabled = 1 if client.enabled_disabled == 0 else 0 + + try: + self.db.commit() + logger.info(f"Client/Provider status toggled: {client_id} -> {client.enabled_disabled}") + return self._get_client_with_relations(client_id) + except Exception as e: + self.db.rollback() + logger.error(f"Error toggling status for {client_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error updating status") + + diff --git a/backend/api/v1/modules/a76/company/__init__.py b/backend/api/v1/modules/a76/company/__init__.py new file mode 100644 index 00000000..1930a6ac --- /dev/null +++ b/backend/api/v1/modules/a76/company/__init__.py @@ -0,0 +1,6 @@ +""" +Módulo de Company +""" +from .routes import router + +__all__ = ["router"] diff --git a/backend/api/v1/modules/a76/company/dto.py b/backend/api/v1/modules/a76/company/dto.py new file mode 100644 index 00000000..592dd07b --- /dev/null +++ b/backend/api/v1/modules/a76/company/dto.py @@ -0,0 +1,157 @@ +""" +DTOs (Data Transfer Objects) para módulo de empresa +Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS +""" +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class CompanyCreateDTO(BaseModel): + """DTO para crear una empresa""" + id: str = Field(default='EMP', max_length=3, description="Company ID") + consecutive: bool = Field(default=True, description="Unique record control") + 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 + + +class CompanyUpdateDTO(BaseModel): + """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 + + +class CompanyResponseDTO(BaseModel): + """DTO para respuesta de empresa""" + id: str + consecutive: bool + name: Optional[str] = None + rfc: Optional[str] = None + main_activity: Optional[str] = None + + # Program information + program: Optional[str] = None + program_number: Optional[str] = None + prosec: Optional[int] = None + prosec_authorization: Optional[str] = None + + # Identifiers + manufacturer_id: Optional[str] = None + broker_company: Optional[str] = None + + # Responsible person + responsible: Optional[str] = None + responsible_name: Optional[str] = None + responsible_last_name: Optional[str] = None + responsible_mother_last_name: Optional[str] = None + responsible_rfc: Optional[str] = None + position: Optional[str] = None + + # Configuration + logo: Optional[str] = None + has_express_line: Optional[bool] = None + order_format_type: Optional[str] = None + previous_code: Optional[int] = None + is_service_company: Optional[bool] = None + + # Client and subassembly + client_name: Optional[str] = None + subassembly_mode: Optional[str] = None + + # Additional information + curp: Optional[str] = None + inter_db_name: Optional[str] = None + ctpat_svi: Optional[str] = None + trusted_exporter_number: Optional[str] = None + prevalidator_key: Optional[str] = None + seventh_amendment: Optional[bool] = None + + # Timestamps + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + diff --git a/backend/api/v1/modules/a76/company/models.py b/backend/api/v1/modules/a76/company/models.py new file mode 100644 index 00000000..3d8c6587 --- /dev/null +++ b/backend/api/v1/modules/a76/company/models.py @@ -0,0 +1,69 @@ +""" +Modelos ORM para gestión de empresa +""" +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, SmallInteger +from sqlalchemy.sql import func +from core.database import Base +import enum + + +class GCompany(Base): + """ + Modelo para la tabla GCompany - Información de la empresa + """ + __tablename__ = "gcompany" + __table_args__ = {"schema": "a76"} + + # Primary key + id = Column(String(3), primary_key=True, default='EMP', nullable=False) + + # Control de registro único + consecutive = Column(Boolean, unique=True, default=True, nullable=False) + + # Información básica de la empresa + name = Column(String(255), nullable=True) + rfc = Column(String(30), nullable=True) + main_activity = Column(String(255), nullable=True) + + # Información del programa + program = Column(String(10), nullable=True) + program_number = Column(String(40), nullable=True) + prosec = Column(SmallInteger, nullable=True) + prosec_authorization = Column(String(20), nullable=True) + + # Identificadores + manufacturer_id = Column(String(25), nullable=True) + broker_company = Column(String(10), nullable=True) + + # Responsable + responsible = Column(String(80), nullable=True) + responsible_name = Column(String(20), nullable=True) + responsible_last_name = Column(String(20), nullable=True) + responsible_mother_last_name = Column(String(20), nullable=True) + responsible_rfc = Column(String(30), nullable=True) + position = Column(String(30), nullable=True) + + # Configuración + logo = Column(String(255), nullable=True) + has_express_line = Column(Boolean, nullable=True) + order_format_type = Column(String(19), nullable=True) + previous_code = Column(SmallInteger, nullable=True) + is_service_company = Column(Boolean, nullable=True) + + # Cliente y submaquila + client_name = Column(String(300), nullable=True) + subassembly_mode = Column(String(7), nullable=True) + + # Información adicional + curp = Column(String(19), nullable=True) + inter_db_name = Column(String(100), nullable=True) + ctpat_svi = Column(String(100), nullable=True) + trusted_exporter_number = Column(String(50), nullable=True) + prevalidator_key = Column(String(20), nullable=True) + seventh_amendment = Column(Boolean, nullable=True) # FINALCONTADORAELECTRONICO renombrado + + # Timestamps + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + diff --git a/backend/api/v1/modules/a76/company/routes.py b/backend/api/v1/modules/a76/company/routes.py new file mode 100644 index 00000000..4473591d --- /dev/null +++ b/backend/api/v1/modules/a76/company/routes.py @@ -0,0 +1,176 @@ +""" +Endpoints API para gestión de empresa +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import Optional + +from core.database import get_core_db +from core.security import get_current_user, has_role +from .service import CompanyService +from .dto import CompanyCreateDTO, CompanyUpdateDTO, CompanyResponseDTO + +router = APIRouter(prefix="/company", tags=["Company"]) + + +@router.post("/", response_model=CompanyResponseDTO, status_code=status.HTTP_201_CREATED) +async def create_company( + company_data: CompanyCreateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Create a new company in the system + + Only one company can exist per system due to the unique consecutive field. + """ + service = CompanyService(db) + return service.create_company(company_data) + + +@router.get("/", response_model=Optional[CompanyResponseDTO]) +async def get_company( + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get the registered company information + + Returns the unique company in the system or None if it doesn't exist. + """ + service = CompanyService(db) + company = service.get_company() + if not company: + raise HTTPException(status_code=404, detail="No company found") + return company + + +@router.get("/{company_id}", response_model=CompanyResponseDTO) +async def get_company_by_id( + company_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get company by specific ID + """ + service = CompanyService(db) + company = service.get_company_by_id(company_id) + if not company: + raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found") + return company + + +@router.put("/{company_id}", response_model=CompanyResponseDTO) +async def update_company( + company_id: str, + company_data: CompanyUpdateDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Update company information + """ + service = CompanyService(db) + company = service.update_company(company_id, company_data) + if not company: + raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found") + return company + + +@router.delete("/{company_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_company( + company_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Delete company from the system + + Note: This will completely remove the company from the system. + """ + service = CompanyService(db) + if not service.delete_company(company_id): + raise HTTPException(status_code=404, detail=f"Company with ID '{company_id}' not found") + + +@router.get("/status/exists", response_model=dict) +async def check_company_exists( + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Check if a company is registered in the system + """ + service = CompanyService(db) + exists = service.exists_company() + return {"exists": exists, "message": "Company found" if exists else "No company registered"} + + +# Specific endpoints for important fields +@router.get("/info/basic", response_model=dict) +async def get_company_basic_info( + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get basic company information (name, RFC, main activity) + """ + service = CompanyService(db) + company = service.get_company() + if not company: + raise HTTPException(status_code=404, detail="No company found") + + return { + "name": company.name, + "rfc": company.rfc, + "main_activity": company.main_activity, + "logo": company.logo + } + + +@router.get("/info/responsible", response_model=dict) +async def get_company_responsible_info( + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get company responsible person information + """ + service = CompanyService(db) + company = service.get_company() + if not company: + raise HTTPException(status_code=404, detail="No company found") + + return { + "responsible": company.responsible, + "responsible_name": company.responsible_name, + "responsible_last_name": company.responsible_last_name, + "responsible_mother_last_name": company.responsible_mother_last_name, + "responsible_rfc": company.responsible_rfc, + "position": company.position + } + + +@router.get("/info/program", response_model=dict) +async def get_company_program_info( + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get company program information + """ + service = CompanyService(db) + company = service.get_company() + if not company: + raise HTTPException(status_code=404, detail="No company found") + + return { + "program": company.program, + "program_number": company.program_number, + "prosec": company.prosec, + "prosec_authorization": company.prosec_authorization, + "manufacturer_id": company.manufacturer_id + } + + diff --git a/backend/api/v1/modules/a76/company/service.py b/backend/api/v1/modules/a76/company/service.py new file mode 100644 index 00000000..405ad1a2 --- /dev/null +++ b/backend/api/v1/modules/a76/company/service.py @@ -0,0 +1,184 @@ +""" +Capa de servicio para lógica de negocio de empresa +""" +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException +from typing import List, Optional +import logging + +from .models import GCompany +from .dto import CompanyCreateDTO, CompanyUpdateDTO, CompanyResponseDTO + +logger = logging.getLogger(__name__) + + +class CompanyService: + """Servicio para gestión de empresa""" + + def __init__(self, db: Session): + self.db = db + + def create_company(self, company_data: CompanyCreateDTO) -> CompanyResponseDTO: + """ + Crea una nueva empresa en el sistema + + Args: + company_data: Datos de la empresa a crear + + Returns: + CompanyResponseDTO con información de la empresa creada + + Raises: + HTTPException: Si ya existe una empresa o error en la creación + """ + try: + # Verificar que no exista ya una empresa (solo puede haber una por el consecutivo único) + existing = self.db.query(GCompany).filter(GCompany.consecutive == True).first() + if existing: + raise HTTPException(status_code=400, detail="A company is already registered in the system") + + # Crear empresa + db_company = GCompany( + id=company_data.id, + consecutive=company_data.consecutive, + name=company_data.name, + rfc=company_data.rfc, + main_activity=company_data.main_activity, + program=company_data.program, + program_number=company_data.program_number, + prosec=company_data.prosec, + prosec_authorization=company_data.prosec_authorization, + manufacturer_id=company_data.manufacturer_id, + broker_company=company_data.broker_company, + responsible=company_data.responsible, + responsible_name=company_data.responsible_name, + responsible_last_name=company_data.responsible_last_name, + responsible_mother_last_name=company_data.responsible_mother_last_name, + responsible_rfc=company_data.responsible_rfc, + position=company_data.position, + logo=company_data.logo, + has_express_line=company_data.has_express_line, + order_format_type=company_data.order_format_type, + previous_code=company_data.previous_code, + is_service_company=company_data.is_service_company, + client_name=company_data.client_name, + subassembly_mode=company_data.subassembly_mode, + curp=company_data.curp, + inter_db_name=company_data.inter_db_name, + ctpat_svi=company_data.ctpat_svi, + trusted_exporter_number=company_data.trusted_exporter_number, + prevalidator_key=company_data.prevalidator_key, + seventh_amendment=company_data.seventh_amendment + ) + + self.db.add(db_company) + self.db.commit() + self.db.refresh(db_company) + + logger.info(f"Company created: {db_company.id} - {db_company.name}") + + return CompanyResponseDTO.model_validate(db_company) + + except IntegrityError as e: + self.db.rollback() + logger.error(f"IntegrityError creating company: {str(e)}") + raise HTTPException(status_code=400, detail="Integrity error: A company already exists in the system") + except HTTPException: + raise + except Exception as e: + self.db.rollback() + logger.error(f"Error creating company: {str(e)}") + raise HTTPException(status_code=500, detail="Error creating company") + + def get_company(self) -> Optional[CompanyResponseDTO]: + """ + Obtiene la empresa (solo puede haber una) + + Returns: + CompanyResponseDTO o None si no existe + """ + company = self.db.query(GCompany).filter(GCompany.consecutive == True).first() + if not company: + return None + return CompanyResponseDTO.model_validate(company) + + def get_company_by_id(self, company_id: str) -> Optional[CompanyResponseDTO]: + """ + Obtiene una empresa por ID + + Args: + company_id: ID de la empresa + + Returns: + CompanyResponseDTO o None si no existe + """ + company = self.db.query(GCompany).filter(GCompany.id == company_id).first() + if not company: + return None + return CompanyResponseDTO.model_validate(company) + + def update_company(self, company_id: str, company_data: CompanyUpdateDTO) -> Optional[CompanyResponseDTO]: + """ + Actualiza una empresa + + Args: + company_id: ID de la empresa a actualizar + company_data: Datos a actualizar + + Returns: + CompanyResponseDTO actualizada o None si no existe + """ + company = self.db.query(GCompany).filter(GCompany.id == company_id).first() + if not company: + return None + + # Actualizar solo campos proporcionados + update_data = company_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(company, field, value) + + try: + self.db.commit() + self.db.refresh(company) + logger.info(f"Company updated: {company_id}") + return CompanyResponseDTO.model_validate(company) + except Exception as e: + self.db.rollback() + logger.error(f"Error updating company {company_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error updating company") + + def delete_company(self, company_id: str) -> bool: + """ + Elimina una empresa + + Args: + company_id: ID de la empresa a eliminar + + Returns: + True si se eliminó, False si no existe + """ + company = self.db.query(GCompany).filter(GCompany.id == company_id).first() + if not company: + return False + + try: + self.db.delete(company) + self.db.commit() + logger.info(f"Company deleted: {company_id}") + return True + except Exception as e: + self.db.rollback() + logger.error(f"Error deleting company {company_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error deleting company") + + def exists_company(self) -> bool: + """ + Verifica si existe una empresa registrada + + Returns: + True si existe una empresa, False en caso contrario + """ + return self.db.query(GCompany).filter(GCompany.consecutive == True).first() is not None + + diff --git a/docs/MODULOS_A76_IMPLEMENTADOS.md b/docs/MODULOS_A76_IMPLEMENTADOS.md new file mode 100644 index 00000000..90dd563c --- /dev/null +++ b/docs/MODULOS_A76_IMPLEMENTADOS.md @@ -0,0 +1,174 @@ +# Módulos A76 Implementados - Anexo 76 + +**Fecha de implementación:** 4 de noviembre de 2025 + +--- + +## ✨ Nuevas Funcionalidades + +### Módulo de Empresa (Company) +- Gestión de empresa única con información comercial completa +- Manejo de datos fiscales y operativos centralizados + +### Módulo de Clientes y Proveedores (Client & Provider) +- Gestión integral de clientes y proveedores +- Relaciones con direcciones y programas asociados +- Capacidad de diferenciar entre clientes y proveedores + +### Módulo de Partes (GParts) +- Gestión de partes/componentes para los sistemas SCAII, SCAF y WINSAAI +- Control de inventario y clasificación arancelaria +- Información regulatoria y de cumplimiento + +### Módulo de Clases (GClass) +- Clasificaciones para sistemas SCAII y SCAF +- Información arancelaria detallada +- Gestión de fracciones arancelarias y materiales + +--- + +## 🔗 Relaciones de Base de Datos + +### Relaciones Principales +- **GPart ↔ GClass**: Relación de clave compuesta (client_key, part_class ↔ class_code) +- **GPart → Country**: Clave foránea a public.countries (country_of_origin) +- **GPart → CurrencyType**: Clave foránea a public.currency_types (currency_key) +- **GClass → MaterialType**: Clave foránea a public.material_types (material_key) + +### Esquema de Relaciones +``` +GPart (Partes) +├── País de origen → Country +├── Tipo de moneda → CurrencyType +└── Información de clase → GClass + └── Tipo de material → MaterialType +``` + +--- + +## 📊 Endpoints de API Agregados + +### Módulo Empresa (`/company`) +| Método | Endpoint | Descripción | +|--------|----------|-------------| +| POST | `/` | Crear empresa | +| GET | `/` | Obtener información de la empresa | + +### Módulo Clientes y Proveedores (`/clients-providers`) +| Método | Endpoint | Descripción | +|--------|----------|-------------| +| POST | `/` | Crear cliente/proveedor | +| GET | `/` | Listar todos con paginación | +| GET | `/clients` | Listar solo clientes | +| GET | `/providers` | Listar solo proveedores | +| GET | `/search/rfc/{rfc}` | Buscar por RFC | +| GET | `/{client_id}` | Obtener por ID | +| PUT | `/{client_id}` | Actualizar cliente/proveedor | +| DELETE | `/{client_id}` | Eliminar cliente/proveedor | +| PATCH | `/{client_id}/toggle-status` | Cambiar estatus | +| GET | `/{client_id}/address` | Obtener información de dirección | +| GET | `/{client_id}/programs` | Obtener información de programas | +| GET | `/{client_id}/basic` | Obtener información básica | + +### Módulo Partes (`/parts`) +| Método | Endpoint | Descripción | +|--------|----------|-------------| +| POST | `/` | Crear parte | +| GET | `/` | Listar todas con paginación y filtros | +| GET | `/client/{client_key}` | Obtener partes por cliente | +| GET | `/search/fraction/{fraction}` | Buscar por fracción arancelaria | +| GET | `/search/supplier/{supplier}` | Buscar por proveedor | +| GET | `/search/country/{country_code}` | Buscar por país | +| GET | `/statistics` | Obtener estadísticas de partes | +| GET | `/{client_key}/{part_number}` | Obtener parte específica | +| PUT | `/{client_key}/{part_number}` | Actualizar parte | +| DELETE | `/{client_key}/{part_number}` | Eliminar parte | +| PATCH | `/{client_key}/{part_number}/toggle-status` | Cambiar estatus | +| GET | `/{client_key}/{part_number}/basic` | Obtener información básica | +| GET | `/{client_key}/{part_number}/regulatory` | Obtener información regulatoria | + +### Módulo Clases (`/classes`) +| Método | Endpoint | Descripción | +|--------|----------|-------------| +| POST | `/` | Crear clase | +| GET | `/` | Listar todas con paginación y filtros | +| GET | `/client/{client_key}` | Obtener clases por cliente | +| GET | `/search/fraction/{fraction}` | Buscar por fracción arancelaria | +| GET | `/search/material/{material_key}` | Buscar por material | +| GET | `/search/unit-measure/{unit_of_measure}` | Buscar por unidad de medida | +| GET | `/search/physical-review/{physical_review}` | Buscar por revisión física | +| GET | `/statistics` | Obtener estadísticas de clases | +| GET | `/{client_key}/{class_code}` | Obtener clase específica | +| PUT | `/{client_key}/{class_code}` | Actualizar clase | +| DELETE | `/{client_key}/{class_code}` | Eliminar clase | +| GET | `/{client_key}/{class_code}/basic` | Obtener información básica | +| GET | `/{client_key}/{class_code}/tariff` | Obtener información arancelaria | + +--- + +## 🏗️ Arquitectura Implementada + +### Diseño Modular +- **Modelos**: Definición de entidades ORM con SQLAlchemy +- **DTOs**: Objetos de transferencia de datos con validación Pydantic +- **Servicios**: Lógica de negocio y operaciones de base de datos +- **Rutas**: Endpoints REST API con documentación automática + +### Características Técnicas +- **Nombres de campos en inglés** para consistencia internacional +- **Claves primarias compuestas** donde es aplicable +- **Operaciones CRUD completas** con endpoints de búsqueda especializados +- **Relaciones SQLAlchemy** con restricciones de clave foránea apropiadas +- **DTOs type-safe** con validación Pydantic + +### Patrones de Desarrollo +- Estructura consistente en todos los módulos para facilitar mantenimiento +- Separación clara de responsabilidades (models, DTOs, services, routes) +- Validación de datos en múltiples capas +- Manejo de errores estandarizado +- Documentación automática con FastAPI/OpenAPI + +--- + +## 📝 Documentación + +### Archivos de Documentación +- **RELATIONSHIPS.md**: Documentación completa de relaciones de base de datos +- **Type hints detallados** en todos los métodos de servicio +- **Comentarios explicativos** en modelos y funciones complejas + +### Estándares de Código +- Consistencia en patrones de desarrollo entre módulos +- Nomenclatura estandarizada para endpoints y funciones +- Validación robusta de datos de entrada y salida +- Manejo de excepciones centralizado + +--- + +## 📈 Resumen de Implementación + +### Números Totales +- **4 módulos completos** implementados +- **42+ endpoints** REST API disponibles +- **23 archivos nuevos** agregados al proyecto +- **2,798+ líneas de código** implementadas + +### Estado del Proyecto +- ✅ Modelos de base de datos implementados +- ✅ Relaciones entre entidades establecidas +- ✅ DTOs con validación completa +- ✅ Servicios con lógica de negocio +- ✅ Endpoints REST API funcionales +- ✅ Integración en router principal +- ⏳ Migraciones de base de datos (pendiente) + +### Próximos Pasos +1. Crear migraciones de Alembic para las nuevas tablas +2. Implementar tests unitarios para cada módulo +3. Agregar documentación de API con ejemplos +4. Implementar autenticación y autorización +5. Optimizar consultas de base de datos + +--- + +*Documento generado automáticamente el 4 de noviembre de 2025* \ No newline at end of file diff --git a/docs/RELATIONSHIPS.md b/docs/RELATIONSHIPS.md new file mode 100644 index 00000000..362ca6fe --- /dev/null +++ b/docs/RELATIONSHIPS.md @@ -0,0 +1,107 @@ +# Relaciones entre Modelos A76 + +## Resumen de Relaciones Establecidas + +### GPart (Tabla: gparts) +El modelo `GPart` representa las partes/componentes en los sistemas SCAII, SCAF y WINSAAI. + +#### Relaciones: + +1. **Con Country (public.countries)** + - Campo: `country_of_origin` → `countries.m3_key` + - Relación: Many-to-One + - Propósito: País de origen de la parte + +2. **Con CurrencyType (public.currency_types)** + - Campo: `currency_key` → `currency_types.code` + - Relación: Many-to-One + - Propósito: Tipo de moneda para el costo unitario + +3. **Con GClass (gclasses)** + - Campos: `(client_key, part_class)` → `(client_key, class_code)` + - Relación: Many-to-One (usando primaryjoin complejo) + - Propósito: Clasificación de la parte + - Atributo: `part_class_info` + +### GClass (Tabla: gclasses) +El modelo `GClass` representa las clases de clasificación en sistemas SCAII y SCAF. + +#### Relaciones: + +1. **Con MaterialType (public.material_types)** + - Campo: `material_key` → `material_types.key` + - Relación: Many-to-One + - Propósito: Tipo de material de la clase + +2. **Con GPart (gparts)** + - Campos: `(client_key, class_code)` → `(client_key, part_class)` + - Relación: One-to-Many (inversa de la relación en GPart) + - Propósito: Partes que pertenecen a esta clase + - Atributo: `parts` + +## Esquema de Relaciones + +``` +GPart +├── country (Country) # País de origen +├── currency (CurrencyType) # Tipo de moneda +└── part_class_info (GClass) # Información de clasificación + └── material_type (MaterialType) # Tipo de material + +GClass +├── material_type (MaterialType) # Tipo de material +└── parts (List[GPart]) # Partes que usan esta clase +``` + +## Uso de las Relaciones + +### En consultas: +```python +# Obtener una parte con su información completa +part = session.query(GPart).options( + joinedload(GPart.country), + joinedload(GPart.currency), + joinedload(GPart.part_class_info).joinedload(GClass.material_type) +).filter( + GPart.client_key == 1, + GPart.part_number == "PART001" +).first() + +# Acceder a los datos relacionados +print(f"País: {part.country.description_es}") +print(f"Moneda: {part.currency.currency_name}") +print(f"Clase: {part.part_class_info.description_spanish}") +print(f"Material: {part.part_class_info.material_type.description}") +``` + +### En DTOs: +Los DTOs pueden incluir información relacionada: +```python +class PartDetailResponseDTO(BaseModel): + client_key: int + part_number: str + description_spanish: Optional[str] + country_name: Optional[str] = None + currency_name: Optional[str] = None + class_description: Optional[str] = None + material_type: Optional[str] = None +``` + +## Consideraciones Técnicas + +1. **Composite Foreign Keys**: La relación entre `GPart` y `GClass` usa claves foráneas compuestas que requieren `primaryjoin` personalizado. + +2. **Viewonly Relationships**: Algunas relaciones están marcadas como `viewonly=True` para evitar problemas de escritura accidental. + +3. **Lazy Loading**: Por defecto, las relaciones usan lazy loading. Para consultas que necesiten datos relacionados, usar `joinedload` o `selectinload`. + +4. **Type Hints**: Se usan `TYPE_CHECKING` imports para evitar import circulares mientras se mantienen los type hints. + +## Futuras Relaciones + +Potenciales relaciones adicionales que se pueden agregar: + +1. **Con Sectors** (public.sectors) - para clasificación sectorial +2. **Con Transport Types** (public.transport_types) - para modo de transporte +3. **Con Customs Sections** (public.customs_sections) - para sección aduanera +4. **Relaciones con tablas subsidiarias** como `SPartes`, `QPartes`, etc. \ No newline at end of file diff --git a/docs/SCHEMA_A76_UPDATE.md b/docs/SCHEMA_A76_UPDATE.md new file mode 100644 index 00000000..5a3fadba --- /dev/null +++ b/docs/SCHEMA_A76_UPDATE.md @@ -0,0 +1,126 @@ +# Actualización de Schemas A76 + +**Fecha de actualización:** 5 de noviembre de 2025 + +--- + +## ✅ Modelos Actualizados al Schema A76 + +Se han actualizado todos los modelos en `api/v1/modules/a76/` para usar el schema `a76` en PostgreSQL. + +### 📋 Tablas Configuradas + +| Módulo | Tabla | Schema | Estado | +|--------|-------|---------|---------| +| **Company** | `gcompany` | `a76` | ✅ Actualizada | +| **Client & Provider** | `gclient_provider` | `a76` | ✅ Actualizada | +| **Client & Provider** | `gclient_provider_address` | `a76` | ✅ Actualizada | +| **Client & Provider** | `gclient_provider_programs` | `a76` | ✅ Actualizada | +| **GParts** | `gparts` | `a76` | ✅ Actualizada | +| **GClass** | `gclasses` | `a76` | ✅ Actualizada | +| **Licenses** | `licenses` | `a76` | ✅ Ya estaba | +| **Licenses** | `license_usage` | `a76` | ✅ Ya estaba | +| **Tenants** | `tenants` | `a76` | ✅ Ya estaba | + +### 🔄 Cambios Realizados + +#### 1. Configuración de Schema +```python +# ANTES +class GCompany(Base): + __tablename__ = "gcompany" + +# DESPUÉS +class GCompany(Base): + __tablename__ = "gcompany" + __table_args__ = {"schema": "a76"} +``` + +#### 2. Foreign Keys Actualizadas +```python +# ANTES +client_id = Column(String(8), ForeignKey('gclient_provider.client_id'), ...) + +# DESPUÉS +client_id = Column(String(8), ForeignKey('a76.gclient_provider.client_id'), ...) +``` + +### 🏗️ Estructura de Schemas + +``` +PostgreSQL Database +├── Schema: public +│ ├── countries +│ ├── currency_types +│ ├── material_types +│ └── ... (reference data) +│ +└── Schema: a76 + ├── tenants + ├── licenses + ├── license_usage + ├── gcompany + ├── gclient_provider + ├── gclient_provider_address + ├── gclient_provider_programs + ├── gparts + └── gclasses +``` + +### 🔗 Relaciones Mantenidas + +Las relaciones entre schemas funcionan correctamente: + +- **A76 → Public**: Los modelos A76 pueden referenciar datos de referencia en `public` +- **A76 → A76**: Las relaciones internas del schema A76 están actualizadas +- **Composite Keys**: Las relaciones con claves compuestas funcionan correctamente + +#### Ejemplos de Relaciones Cross-Schema: +```python +# GPart (a76) → Country (public) +country_of_origin = Column(String(3), ForeignKey('public.countries.m3_key')) + +# GPart (a76) → CurrencyType (public) +currency_key = Column(String(3), ForeignKey('public.currency_types.code')) + +# GClass (a76) → MaterialType (public) +material_key = Column(String(10), ForeignKey('public.material_types.key')) +``` + +### 🎯 Beneficios de la Separación + +1. **Organización**: Datos de negocio separados de datos de referencia +2. **Seguridad**: Permisos granulares por schema +3. **Mantenimiento**: Facilita respaldos y migraciones selectivas +4. **Escalabilidad**: Permite distribuir schemas en el futuro +5. **Claridad**: Separación lógica de responsabilidades + +### ⚠️ Consideraciones Importantes + +1. **Migraciones**: Las nuevas migraciones deben especificar el schema `a76` +2. **Permisos DB**: El usuario de base de datos necesita permisos en ambos schemas +3. **Testing**: Los tests deben considerar la estructura de schemas +4. **Backup**: Configurar respaldos para incluir ambos schemas + +### 📝 Próximos Pasos + +1. **Crear migraciones de Alembic** con el schema correcto +2. **Verificar permisos** de base de datos para el usuario de aplicación +3. **Actualizar tests** para considerar la estructura de schemas +4. **Documentar convenciones** de naming para futuros modelos + +--- + +### 🔧 Comando de Verificación + +Para verificar que todos los modelos tienen el schema correcto: + +```bash +grep -r "__table_args__ = {\"schema\": \"a76\"}" backend/api/v1/modules/a76/*/models.py +``` + +**Resultado esperado:** 8 coincidencias (una por cada modelo A76) + +--- + +*Actualización completada el 5 de noviembre de 2025* \ No newline at end of file