Merge remote-tracking branch 'origin/catalogos_a76' into catalogos-frontend
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,7 +27,7 @@ wheels/
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
backend/SCRIPTS/
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -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')
|
||||
@@ -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 ###
|
||||
6
backend/api/v1/modules/a76/GClass/__init__.py
Normal file
6
backend/api/v1/modules/a76/GClass/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de GClass
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
97
backend/api/v1/modules/a76/GClass/dto.py
Normal file
97
backend/api/v1/modules/a76/GClass/dto.py
Normal file
@@ -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
|
||||
|
||||
61
backend/api/v1/modules/a76/GClass/models.py
Normal file
61
backend/api/v1/modules/a76/GClass/models.py
Normal file
@@ -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"<GClass(client_key={self.client_key}, class_code='{self.class_code}', description='{self.description_spanish}')>"
|
||||
|
||||
|
||||
261
backend/api/v1/modules/a76/GClass/routes.py
Normal file
261
backend/api/v1/modules/a76/GClass/routes.py
Normal file
@@ -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
|
||||
}
|
||||
|
||||
|
||||
294
backend/api/v1/modules/a76/GClass/service.py
Normal file
294
backend/api/v1/modules/a76/GClass/service.py
Normal file
@@ -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]
|
||||
|
||||
6
backend/api/v1/modules/a76/GParts/__init__.py
Normal file
6
backend/api/v1/modules/a76/GParts/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de GParts
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
182
backend/api/v1/modules/a76/GParts/dto.py
Normal file
182
backend/api/v1/modules/a76/GParts/dto.py
Normal file
@@ -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
|
||||
|
||||
|
||||
88
backend/api/v1/modules/a76/GParts/models.py
Normal file
88
backend/api/v1/modules/a76/GParts/models.py
Normal file
@@ -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"<GPart(client_key={self.client_key}, part_number='{self.part_number}', description='{self.description_spanish}')>"
|
||||
|
||||
|
||||
273
backend/api/v1/modules/a76/GParts/routes.py
Normal file
273
backend/api/v1/modules/a76/GParts/routes.py
Normal file
@@ -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
|
||||
}
|
||||
|
||||
|
||||
275
backend/api/v1/modules/a76/GParts/service.py
Normal file
275
backend/api/v1/modules/a76/GParts/service.py
Normal file
@@ -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")
|
||||
|
||||
6
backend/api/v1/modules/a76/client_&_provider/__init__.py
Normal file
6
backend/api/v1/modules/a76/client_&_provider/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Client & Provider
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
165
backend/api/v1/modules/a76/client_&_provider/dto.py
Normal file
165
backend/api/v1/modules/a76/client_&_provider/dto.py
Normal file
@@ -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
|
||||
|
||||
108
backend/api/v1/modules/a76/client_&_provider/models.py
Normal file
108
backend/api/v1/modules/a76/client_&_provider/models.py
Normal file
@@ -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")
|
||||
|
||||
|
||||
221
backend/api/v1/modules/a76/client_&_provider/routes.py
Normal file
221
backend/api/v1/modules/a76/client_&_provider/routes.py
Normal file
@@ -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
|
||||
)
|
||||
|
||||
309
backend/api/v1/modules/a76/client_&_provider/service.py
Normal file
309
backend/api/v1/modules/a76/client_&_provider/service.py
Normal file
@@ -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")
|
||||
|
||||
|
||||
6
backend/api/v1/modules/a76/company/__init__.py
Normal file
6
backend/api/v1/modules/a76/company/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Company
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
157
backend/api/v1/modules/a76/company/dto.py
Normal file
157
backend/api/v1/modules/a76/company/dto.py
Normal file
@@ -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
|
||||
|
||||
69
backend/api/v1/modules/a76/company/models.py
Normal file
69
backend/api/v1/modules/a76/company/models.py
Normal file
@@ -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)
|
||||
|
||||
|
||||
176
backend/api/v1/modules/a76/company/routes.py
Normal file
176
backend/api/v1/modules/a76/company/routes.py
Normal file
@@ -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
|
||||
}
|
||||
|
||||
|
||||
184
backend/api/v1/modules/a76/company/service.py
Normal file
184
backend/api/v1/modules/a76/company/service.py
Normal file
@@ -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
|
||||
|
||||
|
||||
174
docs/MODULOS_A76_IMPLEMENTADOS.md
Normal file
174
docs/MODULOS_A76_IMPLEMENTADOS.md
Normal file
@@ -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*
|
||||
107
docs/RELATIONSHIPS.md
Normal file
107
docs/RELATIONSHIPS.md
Normal file
@@ -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.
|
||||
126
docs/SCHEMA_A76_UPDATE.md
Normal file
126
docs/SCHEMA_A76_UPDATE.md
Normal file
@@ -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*
|
||||
Reference in New Issue
Block a user