Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/saldos_temporales_nueva_tabla

This commit is contained in:
2026-03-19 10:43:01 -05:00
23 changed files with 5416 additions and 424 deletions

View File

@@ -0,0 +1,57 @@
from pydantic import BaseModel, ConfigDict, field_validator
from typing import Optional, List, Dict, Any
from datetime import date
class AphisCatalogDTO(BaseModel):
id: Optional[int] = None
# --- Pestaña 1: General ---
program_code: Optional[str] = None
processing_code: Optional[str] = None
aphis_type: Optional[str] = None
disclaimer: Optional[str] = None
electronic_image: Optional[str] = None
confidential: Optional[str] = None
global_product_id: Optional[str] = None
intended_use_code: Optional[str] = None
intended_use_description: Optional[str] = None
item_type: Optional[str] = None
product_code: Optional[str] = None
product_code_2: Optional[str] = None
product_code_3: Optional[str] = None
scientific_genus_name: Optional[str] = None
scientific_species_name: Optional[str] = None
scientific_sub_species_name: Optional[str] = None
common_name_specific: Optional[str] = None
common_name_general: Optional[str] = None
signed_doc: Optional[str] = None
signed_doc_date: Optional[date] = None
signed_doc_id: Optional[str] = None
invoice_number: Optional[str] = None
quantity_1: Optional[str] = None
quantity_2: Optional[str] = None
quantity_3: Optional[str] = None
inspection: Optional[str] = None
inspection_date: Optional[date] = None
inspection_loc_date: Optional[date] = None
inspection_location: Optional[str] = None
country_production: Optional[str] = None
country_source: Optional[str] = None
# --- Pestañas 2-7: Detalles (Listas de objetos) ---
characteristics: Optional[List[Dict[str, Any]]] = []
pitems: Optional[List[Dict[str, Any]]] = []
lpcos: Optional[List[Dict[str, Any]]] = []
entities: Optional[List[Dict[str, Any]]] = []
containers: Optional[List[Dict[str, Any]]] = []
routing: Optional[List[Dict[str, Any]]] = []
model_config = ConfigDict(from_attributes=True)
@field_validator("signed_doc_date", "inspection_date", "inspection_loc_date", mode="before")
@classmethod
def empty_to_none(cls, v):
if v == "":
return None
return v

View File

@@ -0,0 +1,61 @@
from typing import Optional, List, Dict, Any
from datetime import date
from sqlalchemy import Integer, String, Date, PrimaryKeyConstraint, JSON
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class AphisCatalog(Base):
"""
Catálogo global de registros APHIS por empresa.
Soporta las 7 pestañas de información (General + 6 detalles via JSON).
"""
__tablename__ = "inv_aphis_catalog"
__table_args__ = (
PrimaryKeyConstraint("id", name="inv_aphis_catalog_pkey"),
{"schema": "a24", "extend_existing": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
# --- Pestaña 1: General (Campos principales) ---
program_code: Mapped[Optional[str]] = mapped_column(String(10))
processing_code: Mapped[Optional[str]] = mapped_column(String(10))
aphis_type: Mapped[Optional[str]] = mapped_column(String(10))
disclaimer: Mapped[Optional[str]] = mapped_column(String(10))
electronic_image: Mapped[Optional[str]] = mapped_column(String(50))
confidential: Mapped[Optional[str]] = mapped_column(String(1))
global_product_id: Mapped[Optional[str]] = mapped_column(String(100))
intended_use_code: Mapped[Optional[str]] = mapped_column(String(10))
intended_use_description: Mapped[Optional[str]] = mapped_column(String(200))
item_type: Mapped[Optional[str]] = mapped_column(String(20))
product_code: Mapped[Optional[str]] = mapped_column(String(20))
product_code_2: Mapped[Optional[str]] = mapped_column(String(20))
product_code_3: Mapped[Optional[str]] = mapped_column(String(20))
scientific_genus_name: Mapped[Optional[str]] = mapped_column(String(100))
scientific_species_name: Mapped[Optional[str]] = mapped_column(String(100))
scientific_sub_species_name: Mapped[Optional[str]] = mapped_column(String(100))
common_name_specific: Mapped[Optional[str]] = mapped_column(String(200))
common_name_general: Mapped[Optional[str]] = mapped_column(String(200))
signed_doc: Mapped[Optional[str]] = mapped_column(String(100))
signed_doc_date: Mapped[Optional[date]] = mapped_column(Date)
signed_doc_id: Mapped[Optional[str]] = mapped_column(String(50))
invoice_number: Mapped[Optional[str]] = mapped_column(String(50))
quantity_1: Mapped[Optional[str]] = mapped_column(String(50))
quantity_2: Mapped[Optional[str]] = mapped_column(String(50))
quantity_3: Mapped[Optional[str]] = mapped_column(String(50))
inspection: Mapped[Optional[str]] = mapped_column(String(200))
inspection_date: Mapped[Optional[date]] = mapped_column(Date)
inspection_loc_date: Mapped[Optional[date]] = mapped_column(Date)
inspection_location: Mapped[Optional[str]] = mapped_column(String(200))
country_production: Mapped[Optional[str]] = mapped_column(String(3))
country_source: Mapped[Optional[str]] = mapped_column(String(3))
# --- Pestañas 2-7: Detalles (Almacenados como JSON por flexibilidad) ---
characteristics: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
pitems: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
lpcos: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
entities: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
containers: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)
routing: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSON, default=list)

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from core.database import get_core_db
from .models import AphisCatalog
from .dto import AphisCatalogDTO
router = APIRouter(prefix="/aphis-catalog", tags=["APHIS Catalog"])
@router.get("/", response_model=List[AphisCatalogDTO])
def list_aphis_catalog(company_id: int, db: Session = Depends(get_core_db)):
records = (
db.query(AphisCatalog)
.filter(AphisCatalog.company_id == company_id)
.order_by(AphisCatalog.id)
.all()
)
return records
@router.post("/", response_model=AphisCatalogDTO)
def create_aphis_catalog(data: AphisCatalogDTO, company_id: int, db: Session = Depends(get_core_db)):
payload = data.model_dump(exclude={"id"})
record = AphisCatalog(**payload, company_id=company_id)
db.add(record)
db.commit()
db.refresh(record)
return record
@router.put("/{record_id}", response_model=AphisCatalogDTO)
def update_aphis_catalog(
record_id: int, data: AphisCatalogDTO, company_id: int, db: Session = Depends(get_core_db)
):
record = (
db.query(AphisCatalog)
.filter(AphisCatalog.id == record_id, AphisCatalog.company_id == company_id)
.first()
)
if not record:
raise HTTPException(status_code=404, detail="Registro no encontrado")
for field, value in data.model_dump(exclude={"id"}).items():
setattr(record, field, value)
db.commit()
db.refresh(record)
return record
@router.delete("/{record_id}", status_code=204)
def delete_aphis_catalog(record_id: int, company_id: int, db: Session = Depends(get_core_db)):
record = (
db.query(AphisCatalog)
.filter(AphisCatalog.id == record_id, AphisCatalog.company_id == company_id)
.first()
)
if not record:
raise HTTPException(status_code=404, detail="Registro no encontrado")
db.delete(record)
db.commit()

View File

@@ -8,6 +8,10 @@ from fastapi import APIRouter
from .fa.fa_classes.routes import router as fa_classes_router
from .fa.fa_item_lines.routes import router as fa_item_lines_router
from .inv.part_countries.routes import router as part_countries_router
from .inv.inv_aphis.inv_aphis_catalog.router import router as aphis_catalog_router
# Importar modelo para que SQLAlchemy cree la tabla automáticamente
import api.v1.modules.a24.inv.inv_aphis.inv_aphis_catalog.models # noqa: F401
# Router principal de A24
@@ -21,3 +25,5 @@ router.include_router(
# Registrar routers de INV (Inventory)
router.include_router(part_countries_router, prefix="/a24", tags=["a24 / inv / part-countries"])
router.include_router(aphis_catalog_router, prefix="/a24", tags=["a24 / inv / aphis-catalog"])

View File

@@ -0,0 +1,150 @@
# Importar modelos para Audit Log
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails
from api.v1.modules.a76.audit_log.events import register_audit_listeners
# Core Modules
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.general_catalogs.company.models import Company
# Reference Data
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.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.customs_warehouses.models import (
CustomsWarehouse,
)
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.public.reference_data.pedimento_transport_catalog.models import (
PedimentoTransportCatalog,
)
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import (
RegimenPedimento,
)
from api.v1.modules.public.reference_data.states.models import State
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.public.reference_data.license_exceptions.models import LicenseException
from api.v1.modules.public.reference_data.agency_tariff_codes.models import AgencyTariffCode
from api.v1.modules.public.reference_data.identifiers.models import IdentifierCatalog
from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.classification_concepts.models import (
ClassificationConcept,
)
from api.v1.modules.a76.general_catalogs.concepts.models import Concept
from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import (
CustomsBrokerConcept,
)
from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import (
DepreciationCatalog,
)
from api.v1.modules.a76.general_catalogs.doda.models import Doda
from api.v1.modules.a76.general_catalogs.electronic_notices.models import (
ElectronicNotice,
)
from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency
from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog
from api.v1.modules.a76.general_catalogs.inpc.models import INPC
from api.v1.modules.a76.general_catalogs.legends.models import Legend
from api.v1.modules.a76.general_catalogs.multi_currency_types.models import (
MultiCurrencyType,
)
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.ports.models import Port
from api.v1.modules.a76.general_catalogs.location.models import Location, FaLocationExt
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
from api.v1.modules.a76.general_catalogs.seal.models import Seal
from api.v1.modules.a76.general_catalogs.signatures.models import Signature
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import (
TariffFraction,
)
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
# Registrar Listeners de Auditoría
def register_audit():
register_audit_listeners(
[
# Core Transactions
Pedimentos,
InvoiceHeader,
InvoiceSalesDetails,
LineItem,
# Sidebar Core Modules
ClientProvider,
CustomsBroker,
Part,
Company,
# Transportation Modules
Trailer,
Transporter,
Vehicle,
# Reference Data
Country,
CurrencyType,
CustomsSection,
CustomsWarehouse,
Incoterm,
InvoiceType,
MaterialType,
PaymentMethod,
PedimentoTransportCatalog,
PedimentoCode,
RegimenPedimento,
Sector,
State,
TransportMode,
TransportType,
ValuationMethod,
LicenseException,
AgencyTariffCode,
IdentifierCatalog,
CartaPorte,
UnitOfMeasure,
ExchangeRate,
Identifier,
Class,
ClassificationConcept,
Concept,
CustomsBrokerConcept,
DepreciationCatalog,
Doda,
ElectronicNotice,
Equivalency,
ErrorCatalog,
FDACatalog,
INPC,
Legend,
MultiCurrencyType,
Package,
Port,
Prevalidator,
Seal,
Signature,
TariffFraction,
UnitConversion,
USTariffFraction,
]
)

View File

@@ -9,6 +9,9 @@ from ...common.fractions import search_fraction_preference
from ...common.common_validators import item_exists
from ...models import LineItem
from ...line_customs.models import FractionType, LineCustom
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
@@ -22,6 +25,8 @@ from api.v1.modules.public.reference_data.valuation_methods.models import (
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.company.models import Company
import re
def validate_common(
db: Session,
@@ -284,18 +289,75 @@ def validate_common(
)
if line.customs.american_fraction:
american_fraction_exists = db.query(
exists().where(
LineCustom.american_fraction == line.customs.american_fraction
def _normalize_american_fraction_code(raw_code: str) -> list[str]:
"""
Attempts to map user input to the canonical USTariffFraction.code.
The catalog commonly stores dotted HTS codes (e.g. 3802.20.00.00),
but users may paste/enter digits-only or use different separators.
"""
normalized_raw = (raw_code or "").strip()
if not normalized_raw:
return []
digits_only = re.sub(r"[.\s\-]", "", normalized_raw)
candidates: list[str] = []
# 1) Exact input
candidates.append(normalized_raw)
# 2) Canonical with dots if length matches common patterns
if len(digits_only) == 10:
candidates.append(
f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}.{digits_only[8:10]}"
)
elif len(digits_only) == 8:
candidates.append(
f"{digits_only[:4]}.{digits_only[4:6]}.{digits_only[6:8]}"
)
# 3) Digits-only (if catalog stores without dots)
candidates.append(digits_only)
# De-duplicate while preserving order
seen: set[str] = set()
deduped: list[str] = []
for c in candidates:
if not c or c in seen:
continue
seen.add(c)
deduped.append(c)
return deduped
raw_american_fraction = str(line.customs.american_fraction)
candidates = _normalize_american_fraction_code(raw_american_fraction)
us_fraction: USTariffFraction | None = None
for candidate in candidates:
us_fraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == candidate,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
).scalar()
if not american_fraction_exists:
if us_fraction:
break
if not us_fraction:
errors.add_error(
field=f"line[{line_number}].customs.american_fraction",
message="La fracción americana especificada no existe.",
solution=["Proporciona una fracción americana valida."],
code="AMERICAN_FRACTION_NOT_FOUND",
)
else:
# Keep canonical value so downstream validators can use it safely.
line.customs.american_fraction = us_fraction.code
if line.order:
if len(line.order) > 20:

View File

@@ -44,15 +44,15 @@ def scan_file(self, job_id: str, model_target: str, config: str = None):
if model_target == "invoice_header":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_scan_file
return _do_scan_file(job_id, "invoice_header", config, job_type_override="exp")
return _do_scan_file(self, job_id, "invoice_header", config, job_type_override="exp")
if model_target == "invoice_details":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_scan_file
return _do_scan_file(job_id, "invoice_details", config, job_type_override="exp")
return _do_scan_file(self, job_id, "invoice_details", config, job_type_override="exp")
if model_target == "invoice_series":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_scan_file
return _do_scan_file(job_id, "invoice_series", config, job_type_override="exp")
return _do_scan_file(self, job_id, "invoice_series", config, job_type_override="exp")
# Fallback (e.g. unknown model_target)
file_path = _ensure_file(job_id)

View File

@@ -185,10 +185,10 @@ def _validate_customs_broker_ref(
@celery_app.task(bind=True)
def scan_file(self, job_id: str, model_target: str, config: str = None, job_type_override: Optional[str] = None):
"""Pass 1: Read CSV, Validate types, Write Errors to JSONL. Delegates to _do_scan_file."""
return _do_scan_file(job_id, model_target, config, job_type_override)
return _do_scan_file(self, job_id, model_target, config, job_type_override)
def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None, job_type_override: Optional[str] = None) -> Dict[str, Any]:
def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = None, job_type_override: Optional[str] = None) -> Dict[str, Any]:
"""Pass 1 body: load file/meta from storage, run validations, store error lines. Uses effective_job_type for storage."""
effective_job_type = job_type_override if job_type_override is not None else JOB_TYPE
log_prefix = "Exportación import" if effective_job_type else "Invoices import"
@@ -4487,6 +4487,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
InvoiceFinancials,
InvoiceLogistics,
InvoiceSalesDetails,
InvoiceStatus,
OperationType,
TransportType,
WeightUnit,
@@ -4925,7 +4926,10 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
header = existing_header
header.invoice_date = invoice_date
header.operation_type = op_type_value
header.status = True # Mark as updated
# CSV import only captures data; "processed" is set by the manual/import processing flow.
# Legacy CSV imports may have persisted booleans ('True'/'False') into status.
if header.status not in (InvoiceStatus.PENDING, InvoiceStatus.PROCESSED, InvoiceStatus.REVERSED):
header.status = InvoiceStatus.PENDING
header.updated_date = datetime.utcnow()
capture_user = meta.get("capture_user") or "CSV"
header.who_processed = capture_user
@@ -4964,7 +4968,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
invoice_number=invoice_number,
invoice_date=invoice_date,
operation_type=op_type_value,
status=False,
status=InvoiceStatus.PENDING,
system="CSV",
capture_date=datetime.utcnow(),
capture_user=capture_user,

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Mapped, mapped_column
class CartaPorte(Base):
__tablename__ = "carta_porte"
__tablename__ = "carta_porte_codes"
__table_args__ = (
{"schema": "public", "extend_existing": True},
)

View File

@@ -39,10 +39,8 @@ def seed_carta_porte(db: Session):
if len(batch) >= batch_size:
db.bulk_save_objects(batch)
db.commit()
batch = []
print(f"Inserted {batch_size} records...")
batch = []
if batch:
db.bulk_save_objects(batch)
db.commit()
print(f"Finished seeding with {len(batch)} remaining records.")
db.commit()

View File

@@ -6,7 +6,7 @@ Agrega todos los módulos de la aplicación
from fastapi import APIRouter
from .agency_tariff_codes.routes import router as agency_tariff_codes_router
from .carta_porte.routes import router as carta_porte_router
from .carta_porte_codes.routes import router as carta_porte_router
from .code_pedimento_regimens.routes import router as code_pedimento_regimens_router
from .containers.routes import router as containers_router
from .countries.routes import router as countries_router
@@ -108,7 +108,7 @@ router.include_router(
router.include_router(
carta_porte_router,
prefix="/reference_data",
tags=["public / reference_data / carta_porte"],
tags=["public / reference_data / carta_porte_codes"],
)
router.include_router(
customs_sections_router,