Merge branch 'feature/fraccion-arancelaria-nico' into fix/traduccion-bitacora

This commit is contained in:
2026-04-24 12:47:59 -06:00
71 changed files with 1062 additions and 777 deletions

View File

@@ -0,0 +1,49 @@
"""Add has_express_line to transporter; remove from company.
Revision ID: c8d9e0f1a2b3
Revises: 6a7b8c9d0e1f
Create Date: 2026-04-24
Upgrade: add transporter column first (NOT NULL + default), then drop company column.
Downgrade: restore company column, drop transporter column (schema only; data not restored).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c8d9e0f1a2b3"
down_revision: Union[str, Sequence[str], None] = "6a7b8c9d0e1f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "a76"
def upgrade() -> None:
op.add_column(
"transporter",
sa.Column(
"has_express_line",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
schema=SCHEMA,
)
op.drop_column("company", "has_express_line", schema=SCHEMA)
def downgrade() -> None:
op.add_column(
"company",
sa.Column(
"has_express_line",
sa.Boolean(),
server_default=sa.text("false"),
nullable=True,
),
schema=SCHEMA,
)
op.drop_column("transporter", "has_express_line", schema=SCHEMA)

View File

@@ -58,7 +58,6 @@ class CompanyCreateDTO(BaseModel):
# Configuration
logo: Optional[str] = Field(None, max_length=512, description="Company logo (path local o clave S3)")
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"
)
@@ -293,7 +292,6 @@ class CompanyResponseDTO(BaseModel):
# Configuration
logo: Optional[str] = None
has_express_line: Optional[bool] = None
fiscal_deposit: Optional[bool] = None
generate_barcodes_with_fiel: Optional[bool] = None
order_format_type: Optional[str] = None

View File

@@ -61,7 +61,6 @@ class Company(Base, TimestampMixin):
# Configuración básica
logo: Mapped[Optional[str]] = mapped_column(String(512))
has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false")
fiscal_deposit: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false")
generate_barcodes_with_fiel: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false")
order_format_type: Mapped[Optional[str]] = mapped_column(String(19))

View File

@@ -119,7 +119,7 @@ class CompanyService:
"prosec", "prosec_authorization", "sector1", "sector2", "sector3",
"manufacturer_id", "broker_company", "responsible", "responsible_name",
"responsible_last_name", "responsible_mother_last_name", "responsible_rfc",
"position", "logo", "has_express_line", "fiscal_deposit",
"position", "logo", "fiscal_deposit",
"generate_barcodes_with_fiel", "order_format_type",
"is_service_company", "client_name", "subassembly_mode", "previous_code",
"active_labels", "active_fractions", "activate_caat", "trans_interface",

View File

@@ -4,7 +4,7 @@ Catálogo de referencia global (no tenant-scoped)
"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
@@ -32,7 +32,10 @@ async def list_tariff_fractions(
page_size: int = Query(50, ge=1, le=10000, description="Page size"),
search: Optional[str] = Query(None, description="Search in code, fraction, description, nico, or umt"),
level: Optional[int] = Query(None, description="Filter by hierarchy level (e.g. 5)"),
catalog: Optional[str] = Query("mex", description="Catalog source: 'mex' (default) or 'usa'"),
catalog: Optional[str] = Query(
"mex",
description="Catalog: 'mex' (default), 'usa', or 'american' (both US read-only via SITAR)",
),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
@@ -88,7 +91,7 @@ async def get_tariff_fraction(
"/",
response_model=TariffFractionResponseDTO,
summary="Create Tariff Fraction",
description="Create a new tariff fraction (Only supported for 'american' catalog)",
description="Not supported: all catalogs are read-only (SITAR-backed for US).",
)
async def create_tariff_fraction(
fraction_data: TariffFractionCreateDTO,
@@ -97,101 +100,36 @@ async def create_tariff_fraction(
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Crea una nueva fracción.
- MEX/USA: No permitido (Read-Only)
- AMERICAN: Permitido (Local DB)
"""
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionCreateDTO
import re
# Map generic DTO to US DTO
ad_valorem = None
if fraction_data.adv_impo:
try:
# remove non-numeric chars except dot
clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo)
if clean:
ad_valorem = float(clean)
except:
pass
us_dto = USTariffFractionCreateDTO(
code=fraction_data.fraction, # Store the punctuated fraction in the DB
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem,
# Defaults for others
prefix=None,
type_code=None,
fixed_cost=None
)
created = USTariffFractionService.create(db, us_dto, tenant_id, company_id)
return TariffFractionService.to_domain_usa_local(created)
else:
raise HTTPException(status_code=400, detail=f"Creation not allowed for '{catalog}' catalog (Read-Only)")
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail=f"Creation not allowed for '{catalog}' catalog (read-only; US data from SITAR).",
)
@router.put(
"/{tariff_fraction_id}",
response_model=TariffFractionResponseDTO,
summary="Update Tariff Fraction",
description="Update a tariff fraction (Only supported for 'american' catalog)",
description="Not supported: all catalogs are read-only (SITAR-backed for US).",
)
async def update_tariff_fraction(
tariff_fraction_id: int,
fraction_data: TariffFractionUpdateDTO,
catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"),
catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.dto import USTariffFractionUpdateDTO
import re
ad_valorem = None
if fraction_data.adv_impo:
try:
clean = re.sub(r'[^\d.]', '', fraction_data.adv_impo)
if clean:
ad_valorem = float(clean)
except:
pass
us_dto = USTariffFractionUpdateDTO(
code=fraction_data.fraction,
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem
)
updated = USTariffFractionService.update(db, tariff_fraction_id, tenant_id, us_dto, company_id)
if not updated:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return TariffFractionService.to_domain_usa_local(updated)
else:
raise HTTPException(status_code=400, detail=f"Update not allowed for '{catalog}' catalog (Read-Only)")
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail=f"Update not allowed for '{catalog}' catalog (read-only; US data from SITAR).",
)
@router.delete(
"/{tariff_fraction_id}",
summary="Delete Tariff Fraction",
description="Delete a tariff fraction (Only supported for 'american' catalog)",
description="Not supported: all catalogs are read-only (SITAR-backed for US).",
)
async def delete_tariff_fraction(
tariff_fraction_id: int,
@@ -200,16 +138,8 @@ async def delete_tariff_fraction(
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
from fastapi import HTTPException
tenant_id = current_user.get("tenant_id")
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
success = USTariffFractionService.delete(db, tariff_fraction_id, tenant_id, company_id)
if not success:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return {"ok": True}
else:
raise HTTPException(status_code=400, detail=f"Delete not allowed for '{catalog}' catalog (Read-Only)")
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail=f"Delete not allowed for '{catalog}' catalog (read-only; US data from SITAR).",
)

View File

@@ -111,6 +111,49 @@ class TariffFractionService:
adv_expo=None
)
@staticmethod
async def _fetch_usa_catalog_from_sitar(
skip: int,
limit: int,
filters: Optional[Dict[str, Any]],
) -> Tuple[List[TariffFraction], int]:
"""SITAR fracciones-usa for catalog 'usa' and 'american' (read-only, same source)."""
try:
usa_service = FraccionesUSAService.get_instance()
except ValueError:
logger.warning("SITAR USA not configured (missing env)")
return [], 0
search_term = None
search_description = None
if filters and filters.get("search"):
term = filters["search"]
clean_term = term.replace(".", "")
if clean_term.isdigit() and len(clean_term) >= 4:
search_term = term
else:
search_description = term
try:
usa_items = await usa_service.search(
fraccion=search_term,
descripcion=search_description,
skip=skip,
limit=limit,
)
items = [TariffFractionMapper.to_domain_usa(item) for item in usa_items]
total = len(items) + skip
if len(items) == limit:
total += 1
return items, total
except Exception as e:
import traceback
logger.error("Error fetching USA fractions from SITAR: %s", e)
logger.error(traceback.format_exc())
return [], 0
@staticmethod
async def get_all(
db: Session,
@@ -123,64 +166,15 @@ class TariffFractionService:
) -> Tuple[List[TariffFraction], int]:
"""
Obtiene fracciones arancelarias.
Estrategia:
- MEX: Sitar API -> Fallback Local DB
- USA: Local DB (Defined by user requirement)
Estrategia:
- MEX: Sitar API (fracciones)
- USA / AMERICAN: Sitar API (fracciones-usa), solo lectura
"""
# AMERICAN CATALOG HANDLING (LOCAL - 'Fracciones Americanas')
if catalog == "american":
if tenant_id is None or company_id is None:
logger.warning("Solicitud de fracciones Americanas sin tenant/company ID")
return [], 0
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
# Use local service directly
usa_items, total = await USTariffFractionService.get_all(
db, tenant_id, company_id, skip, limit, filters
if catalog in ("american", "usa"):
return await TariffFractionService._fetch_usa_catalog_from_sitar(
skip, limit, filters
)
items = [TariffFractionService.to_domain_usa_local(item) for item in usa_items]
return items, total
# USA CATALOG HANDLING (API - 'Fracciones US')
if catalog == "usa":
try:
usa_service = FraccionesUSAService.get_instance()
search_term = None
search_description = None
if filters and filters.get("search"):
term = filters["search"]
# Simple heuristic: if it looks like a code, use code search, else description
# FIX: Short numeric codes (e.g. "01") often fail strict 'fraccion' search.
# Treat them as description search for partial matching.
clean_term = term.replace(".", "")
if clean_term.isdigit() and len(clean_term) >= 4:
search_term = term
else:
search_description = term
# USA Service search signature: fraccion, descripcion, skip, limit
usa_items = await usa_service.search(
fraccion=search_term,
descripcion=search_description,
skip=skip,
limit=limit
)
items = [TariffFractionMapper.to_domain_usa(item) for item in usa_items]
total = len(items) + skip
if len(items) == limit:
total += 1
return items, total
except Exception as e:
import traceback
logger.error(f"Error fetching USA fractions (API): {e}")
logger.error(traceback.format_exc())
# Return empty list on error as per requirement (since API is broken)
return [], 0
# MEX (SITAR) CATALOG HANDLING
try:

View File

@@ -1,70 +1,131 @@
"""
Endpoints API para fracciones arancelarias americanas
Endpoints API para fracciones arancelarias americanas (solo lectura; fuente SITAR).
"""
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from core.security import get_current_user, validate_access_to_resource
from .dto import (
USTariffFractionCreateDTO,
USTariffFractionResponseDTO,
USTariffFractionUpdateDTO,
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
american_fraction_ad_valorem_from_row,
canonical_code_from_sitar_row,
)
from .service import USTariffFractionService
from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse
from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService
# Create router using TenantCRUDRoutes factory for basic CRUD operations (prefix="" so we mount under main_router)
crud_router = TenantCRUDRoutes(
service=USTariffFractionService,
create_schema=USTariffFractionCreateDTO,
update_schema=USTariffFractionUpdateDTO,
response_schema=USTariffFractionResponseDTO,
prefix="",
from .dto import USTariffFractionResponseDTO
def _sitar_row_to_us_response_payload(item: FraccionesUSAResponse) -> dict:
"""Build payload for USTariffFractionResponseDTO.model_validate (SITAR row)."""
canon = canonical_code_from_sitar_row(item)
now = datetime.now(timezone.utc)
return {
"id": item.CONSECUTIVO,
"code": canon,
"prefix": item.FRACCION_SIN_PUNTO,
"type_code": str(item.NIVEL) if item.NIVEL is not None else None,
"ad_valorem": american_fraction_ad_valorem_from_row(item),
"fixed_cost": None,
"unit_of_measure": item.UNIDADCANTIDAD,
"description": item.DESCRIPCION,
"created_at": now,
"updated_at": now,
}
main_router = APIRouter(
prefix="/us-tariff-fractions",
tags=["a76 / general catalogs / us tariff fractions"],
resource_name="US Tariff Fraction",
id_name="id",
enable_list=False, # We implement our custom list endpoint
)
# Master router with prefix so all routes live under /us-tariff-fractions
from api.v1.modules.a76.layouts_csv.us_tariff_fractions.routes import router as imports_router
main_router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"])
main_router.include_router(imports_router, prefix="/imports", tags=["us_tariff_fractions / csv_import"])
main_router.include_router(crud_router.router)
@main_router.api_route(
"/imports",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
include_in_schema=False,
)
@main_router.api_route(
"/imports/{path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
include_in_schema=False,
)
async def us_tariff_imports_disabled(path: str = ""):
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="CSV import for US tariff fractions is disabled; catalog is read-only from SITAR.",
)
# Custom list endpoint with search filter (under /us-tariff-fractions/)
@main_router.get(
"/",
response_model=Dict[str, Any],
summary="List US Tariff Fractions",
description="Get paginated list of US Tariff Fractions with optional search filter",
description="Paginated list from SITAR fracciones-usa (read-only).",
)
async def list_us_tariff_fractions(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=10000, description="Page size"),
search: Optional[str] = Query(None, description="Search in code, description, or prefix"),
search: Optional[str] = Query(None, description="Search in code or description"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
validate_access_to_resource(db, company_id, current_user)
skip = (page - 1) * page_size
filters = {}
try:
svc = FraccionesUSAService.get_instance()
except ValueError:
return {
"items": [],
"total": 0,
"page": page,
"page_size": page_size,
"pages": 0,
}
search_term = None
search_description = None
if search:
filters["search"] = search
items, total = await USTariffFractionService.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
clean = search.replace(".", "")
if clean.isdigit() and len(clean) >= 4:
search_term = search
else:
search_description = search
try:
sitar_items = await svc.search(
fraccion=search_term,
descripcion=search_description,
skip=skip,
limit=page_size,
)
except Exception:
return {
"items": [],
"total": 0,
"page": page,
"page_size": page_size,
"pages": 0,
}
total = len(sitar_items) + skip
if len(sitar_items) == page_size:
total += 1
items = [
USTariffFractionResponseDTO.model_validate(_sitar_row_to_us_response_payload(row))
for row in sitar_items
]
return {
"items": [USTariffFractionResponseDTO.model_validate(item) for item in items],
"items": items,
"total": total,
"page": page,
"page_size": page_size,
@@ -72,4 +133,28 @@ async def list_us_tariff_fractions(
}
@main_router.post("/")
async def create_us_tariff_fraction_disabled():
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="US tariff fractions are read-only (SITAR).",
)
@main_router.put("/{fraction_id}")
async def update_us_tariff_fraction_disabled(fraction_id: int):
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="US tariff fractions are read-only (SITAR).",
)
@main_router.delete("/{fraction_id}")
async def delete_us_tariff_fraction_disabled(fraction_id: int):
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="US tariff fractions are read-only (SITAR).",
)
router = main_router

View File

@@ -14,8 +14,9 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
resolve_american_fraction_from_sitar,
store_canonical_american_code,
)
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.public.reference_data.countries.models import Country
@@ -363,48 +364,8 @@ def validate_common(
)
if line.customs.american_fraction:
def _normalize_american_fraction_code(raw_code: str) -> list[str]:
normalized_raw = (raw_code or "").strip()
if not normalized_raw:
return []
digits_only = normalized_raw.replace(".", "").replace(" ", "").replace("-", "")
candidates = [normalized_raw]
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]}")
candidates.append(digits_only)
seen: set[str] = set()
deduped: list[str] = []
for candidate in candidates:
if not candidate or candidate in seen:
continue
seen.add(candidate)
deduped.append(candidate)
return deduped
candidates = _normalize_american_fraction_code(line.customs.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()
)
if us_fraction:
break
if not us_fraction:
resolved = resolve_american_fraction_from_sitar(str(line.customs.american_fraction))
if not resolved:
errors.add_error(
field=f"line[{line_number}].customs.american_fraction",
message="La fracción americana especificada no existe.",
@@ -412,7 +373,8 @@ def validate_common(
code="AMERICAN_FRACTION_NOT_FOUND",
)
else:
line.customs.american_fraction = us_fraction.code
_, canon = resolved
line.customs.american_fraction = store_canonical_american_code(canon)
if line.order:
if len(line.order) > 20:

View File

@@ -10,8 +10,10 @@ from ...models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
american_fraction_ad_valorem_from_row,
resolve_american_fraction_from_sitar,
store_canonical_american_code,
)
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.app_settings.service import AppSettingsService
@@ -488,25 +490,15 @@ def validate_create(
if not line.customs.american_fraction and class_info and class_info.us_fraction:
line.customs.american_fraction = class_info.us_fraction
# Buscar el advalorem de la fracción americana
# Ad valorem desde catálogo SITAR (fracciones-usa)
if line.customs.american_fraction:
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
resolved = resolve_american_fraction_from_sitar(str(line.customs.american_fraction))
if resolved:
sitar_row, canon = resolved
line.customs.american_fraction = store_canonical_american_code(canon)
line.customs.advalorem_american = american_fraction_ad_valorem_from_row(
sitar_row
)
.first()
)
if us_fraction:
# Si el tipo es 'ME' (Moneda Extranjera), usar costo fijo
# De lo contrario, usar ad valorem
if us_fraction.type_code == "foreign":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
# ==========================================
# ASIGNAR DESCRIPCIONES POR DEFECTO

View File

@@ -6,8 +6,10 @@ from core.exceptions import ErrorCollector
from ...models import LineItem
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
american_fraction_ad_valorem_from_row,
resolve_american_fraction_from_sitar,
store_canonical_american_code,
)
from api.v1.modules.a76.classes.models import Class
from .common import validate_common
@@ -158,24 +160,15 @@ def validate_update(
if not line.customs.sector:
line.customs.sector = existing_line.customs.sector
# Fracción americana y su advalorem
# Fracción americana y su advalorem (SITAR)
if line.customs.american_fraction:
# Se proporcionó nueva fracción americana, buscar su advalorem
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
resolved = resolve_american_fraction_from_sitar(str(line.customs.american_fraction))
if resolved:
sitar_row, canon = resolved
line.customs.american_fraction = store_canonical_american_code(canon)
line.customs.advalorem_american = american_fraction_ad_valorem_from_row(
sitar_row
)
.first()
)
if us_fraction:
if us_fraction.type_code == "ME":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
else:
# Mantener fracción americana existente
line.customs.american_fraction = existing_line.customs.american_fraction

View File

@@ -9,8 +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.sitar.fracciones_usa.catalog_resolve import (
resolve_american_fraction_from_sitar,
store_canonical_american_code,
)
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.invoices.models import InvoiceHeader
@@ -26,9 +27,6 @@ from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.app_settings.service import AppSettingsService
from api.v1.modules.a76.general_catalogs.company.models import Company
import re
def validate_common(
db: Session,
line: LineItemCreate,
@@ -291,66 +289,9 @@ def validate_common(
)
if 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()
)
if us_fraction:
break
if not us_fraction:
resolved = resolve_american_fraction_from_sitar(raw_american_fraction)
if not resolved:
errors.add_error(
field=f"line[{line_number}].customs.american_fraction",
message="La fracción americana especificada no existe.",
@@ -358,8 +299,8 @@ def validate_common(
code="AMERICAN_FRACTION_NOT_FOUND",
)
else:
# Keep canonical value so downstream validators can use it safely.
line.customs.american_fraction = us_fraction.code
_, canon = resolved
line.customs.american_fraction = store_canonical_american_code(canon)
if line.order:
if len(line.order) > 20:

View File

@@ -10,8 +10,10 @@ from ...models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
american_fraction_ad_valorem_from_row,
resolve_american_fraction_from_sitar,
store_canonical_american_code,
)
from api.v1.modules.a76.app_settings.service import AppSettingsService
from .common import validate_common
@@ -381,25 +383,15 @@ def validate_create(
if not line.customs.american_fraction and class_info and class_info.us_fraction:
line.customs.american_fraction = class_info.us_fraction
# Buscar el advalorem de la fracción americana
# Ad valorem desde catálogo SITAR (fracciones-usa)
if line.customs.american_fraction:
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
resolved = resolve_american_fraction_from_sitar(str(line.customs.american_fraction))
if resolved:
sitar_row, canon = resolved
line.customs.american_fraction = store_canonical_american_code(canon)
line.customs.advalorem_american = american_fraction_ad_valorem_from_row(
sitar_row
)
.first()
)
if us_fraction:
# Si el tipo es 'ME' (Moneda Extranjera), usar costo fijo
# De lo contrario, usar ad valorem
if us_fraction.type_code == "foreign":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
# ==========================================
# ASIGNAR DESCRIPCIONES POR DEFECTO

View File

@@ -4,8 +4,10 @@ from core.exceptions import ErrorCollector
from ...models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
american_fraction_ad_valorem_from_row,
resolve_american_fraction_from_sitar,
store_canonical_american_code,
)
from .common import validate_common
@@ -155,24 +157,15 @@ def validate_update(
if not line.customs.sector:
line.customs.sector = existing_line.customs.sector
# Fracción americana y su advalorem
# Fracción americana y su advalorem (SITAR)
if line.customs.american_fraction:
# Se proporcionó nueva fracción americana, buscar su advalorem
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
resolved = resolve_american_fraction_from_sitar(str(line.customs.american_fraction))
if resolved:
sitar_row, canon = resolved
line.customs.american_fraction = store_canonical_american_code(canon)
line.customs.advalorem_american = american_fraction_ad_valorem_from_row(
sitar_row
)
.first()
)
if us_fraction:
if us_fraction.type_code == "ME":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
else:
# Mantener fracción americana existente
line.customs.american_fraction = existing_line.customs.american_fraction

View File

@@ -1,6 +1,6 @@
"""
Carga de conjuntos FK para validación/mapeo de import CSV de clases de materiales.
Clarion: Tipo Activo Fijo, U.M., Fracción Mex (GFracGenSifra + histórico), Fracción Ame (GFracAme), Código Producto CP (si existe).
Carga de conjuntos FK para validaci?n/mapeo de import CSV de clases de materiales.
Clarion: Tipo Activo Fijo, U.M., Fracci?n Mex (GFracGenSifra + hist?rico), Fracci?n Ame (GFracAme), C?digo Producto CP (si existe).
"""
from typing import Set, Tuple
@@ -12,11 +12,11 @@ def load_classes_fk_sets(
company_id: int,
) -> Tuple[Set[str], Set[str], Set[str], Set[str], Set[str]]:
"""
Carga todos los conjuntos necesarios para validación CSV de clases (paridad Clarion).
Carga todos los conjuntos necesarios para validaci?n CSV de clases (paridad Clarion).
Devuelve (valid_material_keys, valid_uom_codes, valid_fraction_mex_8, valid_fraction_ame, valid_product_codes_cp).
- valid_fraction_mex_8: códigos de 8 caracteres válidos (TariffFraction + HistoricalTariffFraction).
- valid_fraction_ame: códigos de fracción americana (USTariffFraction por tenant/company).
- valid_product_codes_cp: códigos de producto/servicio CP (vacío si no existe catálogo).
- valid_fraction_mex_8: c?digos de 8 caracteres v?lidos (TariffFraction + HistoricalTariffFraction).
- valid_fraction_ame: c?digos de fracci?n americana (USTariffFraction por tenant/company).
- valid_product_codes_cp: c?digos de producto/servicio CP (vac?o si no existe cat?logo).
"""
valid_material_keys: Set[str] = set()
valid_uom_codes: Set[str] = set()
@@ -29,8 +29,6 @@ def load_classes_fk_sets(
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import HistoricalTariffFraction
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
for m in session.query(MaterialType.key).all():
if m[0]:
valid_material_keys.add(m[0])
@@ -63,16 +61,7 @@ def load_classes_fk_sets(
if row[0] and row[0].strip():
valid_fraction_mex_8.add(row[0].strip()[:8])
for row in (
session.query(USTariffFraction.code)
.filter(
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.all()
):
if row[0]:
valid_fraction_ame.add(row[0].strip())
# Fracci?n americana: validaci?n por fila contra SITAR (no se precarga el cat?logo completo).
except Exception as e:
import logging

View File

@@ -144,20 +144,23 @@ def validate_row_fraction_mex_catalog(
def validate_row_fraction_ame_catalog(
row: Dict[str, Any],
line_num: int,
valid_fraction_ame: Optional[Set[str]],
valid_fraction_ame: Optional[Set[str]] = None,
) -> Optional[Dict[str, Any]]:
"""Col G: si no vacía, debe existir en catálogo Fracciones Americanas (Clarion GFracAme)."""
"""Col G: si no vacía, debe existir en SITAR fracciones-usa (valid_fraction_ame ignorado; compat)."""
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
resolve_american_fraction_from_sitar,
)
val = (row.get("FRACCIONAME") or "").strip()
if not val or valid_fraction_ame is None:
if not val:
return None
if val in valid_fraction_ame:
if resolve_american_fraction_from_sitar(val):
return None
return {
"line": line_num,
"col": "FRACCIONAME",
"msg": (
f"Error: (Col. G) La Fraccion Americana: {val} no existe en el Catálogo de Fracciones Americanas. "
"Dar de alta la Fracción Americana en el Catálogo de Fracciones Americanas."
f"Error: (Col. G) La Fraccion Americana: {val} no existe en el catálogo SITAR (fracciones USA)."
),
}

View File

@@ -11,8 +11,10 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
american_fraction_ad_valorem_from_row,
resolve_american_fraction_from_sitar,
store_canonical_american_code,
)
from api.v1.modules.a24.fa.fa_item_lines.dto import FaLineItemCreateDTO
@@ -215,20 +217,15 @@ def apply_import_defaults_and_calculations_for_csv(
line_data.customs.american_fraction = class_info.us_fraction
if line_data.customs.american_fraction:
us_fraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line_data.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
resolved = resolve_american_fraction_from_sitar(
str(line_data.customs.american_fraction)
)
if us_fraction:
if getattr(us_fraction, "type_code", None) == "foreign":
line_data.customs.advalorem_american = us_fraction.fixed_cost
else:
line_data.customs.advalorem_american = us_fraction.ad_valorem
if resolved:
sitar_row, canon = resolved
line_data.customs.american_fraction = store_canonical_american_code(canon)
line_data.customs.advalorem_american = american_fraction_ad_valorem_from_row(
sitar_row
)
# Fallback explícito: primero descripción de parte (si existe), luego clase.
if not line_data.description.description_spanish and part and part.description_spanish:

View File

@@ -1248,7 +1248,6 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.parts.models import Part
from .validators.partidas_impo_temp import validate_row_partidas_impo_temp
@@ -1369,10 +1368,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if row[1]:
valid_country_keys.add((row[1] or "").strip().upper())
valid_fraction_ame: Set[str] = set()
for row in session.query(USTariffFraction.code).filter(USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id).all():
if row[0]:
valid_fraction_ame.add((row[0] or "").strip())
valid_fraction_ame: Set[str] = set() # unused; SITAR resolve per row in validators
authorized_sectors: Set[str] = set()
for row in session.query(Sector.key).filter(
@@ -1630,7 +1626,6 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.parts.models import Part
from .validators.partidas_impo_def import validate_row_partidas_impo_def
@@ -1753,10 +1748,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if row[1]:
valid_country_keys.add((row[1] or "").strip().upper())
valid_fraction_ame: Set[str] = set()
for row in session.query(USTariffFraction.code).filter(USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id).all():
if row[0]:
valid_fraction_ame.add((row[0] or "").strip())
valid_fraction_ame: Set[str] = set() # unused; SITAR resolve per row in validators
authorized_sectors: Set[str] = set()
for row in session.query(Sector.key).filter(
@@ -1915,7 +1907,6 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.parts.models import Part
from .validators.partidas_expo import validate_row_partidas_expo
@@ -2075,10 +2066,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if row[0] is not None:
valid_payment_methods.add(str(row[0]).strip())
valid_fraction_ame: Set[str] = set()
for row in session.query(USTariffFraction.code).filter(USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id).all():
if row[0]:
valid_fraction_ame.add((row[0] or "").strip())
valid_fraction_ame: Set[str] = set() # unused; SITAR resolve per row in validators
valid_part_numbers: Set[str] = set()
for row in session.query(Part.part_number).filter(Part.tenant_id == tenant_id, Part.company_id == company_id).all():
@@ -2209,7 +2197,6 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
from api.v1.modules.a76.general_catalogs.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.parts.models import Part
from .validators.partidas_impo_def import validate_row_partidas_impo_def
@@ -2331,10 +2318,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if row[1]:
valid_country_keys.add((row[1] or "").strip().upper())
valid_fraction_ame: Set[str] = set()
for row in session.query(USTariffFraction.code).filter(USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id).all():
if row[0]:
valid_fraction_ame.add((row[0] or "").strip())
valid_fraction_ame: Set[str] = set() # unused; SITAR resolve per row in validators
authorized_sectors: Set[str] = set()
for row in session.query(Sector.key).filter(

View File

@@ -400,14 +400,18 @@ def _validaciones_par_expo(
f"Error: (Celda M{line_num}) La Forma de Pago Capturado no es Válido. "
"Capturar en la Celda M una Forma de Pago dentro del Catálogo General de Formas de Pago.",
)
# Fracción americana (Clarion Col R)
# Fracción americana (Clarion Col R) — SITAR fracciones-usa
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
resolve_american_fraction_from_sitar,
)
frac_ame = _get(row, "FRACCION AMERICANA", "FRACCIONAMERICANA")
if frac_ame and valid_fraction_ame and frac_ame not in valid_fraction_ame:
if frac_ame and not resolve_american_fraction_from_sitar(frac_ame):
return _err(
line_num,
"FRACCION AMERICANA",
f"Error: (Celda R{line_num}) La Fracción Americana: {frac_ame} no está en el Catálogo de Fracciones Americanas. "
"Capturar en la Celda R una fracción que se encuentre en el catálogo o dar la de alta.",
f"Error: (Celda R{line_num}) La Fracción Americana: {frac_ame} no está en el catálogo SITAR (fracciones USA). "
"Capturar en la Celda R una fracción válida según SITAR.",
)
# Orden de compra / orden venta (Clarion Col S) máx 20
orden = _get(row, "ORDEN DE COMPRA", "ORDENCOMPRA", "ORDEN DE VENTA")

View File

@@ -366,10 +366,17 @@ def _validaciones_parimpo_tem(
f"Error: (Celda M{line_num} y N{line_num}) La Preferencia Arancelaria es: {pref_ctx} y en la columna N tiene sector.",
)
# O: Fracción americana
# O: Fracción americana (SITAR fracciones-usa; valid_fraction_ame ignorado)
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
resolve_american_fraction_from_sitar,
)
frac_ame = _get(row, "FRACCION AMERICANA", "FRACCIONAMERICANA")
if frac_ame and frac_ame not in valid_fraction_ame:
return err("FRACCION AMERICANA", f"Advertencia: (Celda O{line_num}) La Fracción Americana: {frac_ame} no existe en el Catálogo de Fracciones Americanas.")
if frac_ame and not resolve_american_fraction_from_sitar(frac_ame):
return err(
"FRACCION AMERICANA",
f"Advertencia: (Celda O{line_num}) La Fracción Americana: {frac_ame} no existe en el catálogo SITAR (fracciones USA).",
)
# P: Orden de compra máx 20
orden = _get(row, "ORDEN DE COMPRA", "ORDENCOMPRA")

View File

@@ -15,15 +15,13 @@ def load_fa_fk_sets(
Carga conjuntos para validación CSV Fracciones Americanas (paridad Clarion).
Devuelve (valid_uom_codes, existing_fraction_codes).
- valid_uom_codes: códigos de Unidad de Medida (a76.units_of_measure, code UPPER, max 5 chars).
- existing_fraction_codes: códigos de USTariffFraction ya existentes por tenant/company.
- existing_fraction_codes: reservado (vacío); existencia se valida contra SITAR por fila.
"""
valid_uom_codes: Set[str] = set()
existing_fraction_codes: Set[str] = set()
try:
with CoreSessionLocal() as session:
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
for row in (
session.query(UnitOfMeasure.code)
.filter(
@@ -35,17 +33,6 @@ def load_fa_fk_sets(
if row[0]:
valid_uom_codes.add((row[0].strip() or "").upper()[:5])
for row in (
session.query(USTariffFraction.code)
.filter(
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.all()
):
if row[0]:
existing_fraction_codes.add(row[0].strip())
except Exception as e:
import logging
logging.getLogger(__name__).warning("FA import: could not load FK sets: %s", e)

View File

@@ -16,6 +16,9 @@ from ..common.common_validators import (
CODE_MAX,
)
from ..common.common_validators import TIPO_ADVALOREM_VALIDOS # noqa: F401 re-export
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
resolve_american_fraction_from_sitar,
)
# Mensajes Clarion
MSG_COL_A_VACIO = (
@@ -156,7 +159,8 @@ def validate_row_us_tariff_fraction(
code_norm = normalize_code((row.get("FRACCION_ARANCELARIA") or "").strip())
fraction_exists = (
existing_fraction_codes is not None and code_norm in existing_fraction_codes
bool(code_norm)
and resolve_american_fraction_from_sitar(code_norm) is not None
)
if actualizar and not fraction_exists:

View File

@@ -138,6 +138,7 @@ class PartService:
def create(cls, db: Session, part_data: PartCreateDTO, tenant_id: int, company_id: int) -> Part:
# 1. Preparar datos
data = part_data.model_dump()
print(f"DEBUG: PartService.create - full data: {data}")
# Separar datos anidados
fa_dict = data.pop('fa_data', None)
@@ -316,6 +317,7 @@ class PartService:
@classmethod
def update(cls, db: Session, part_id: int, tenant_id: int, part_data: PartUpdateDTO, company_id: int) -> Optional[Part]:
print(f"DEBUG: PartService.update - part_id={part_id}, data={part_data.model_dump(exclude_unset=True)}")
db_part = PartService.get_by_id(db, part_id, tenant_id, company_id)
if not db_part:
return None

View File

@@ -277,7 +277,7 @@ class Mainx30Service:
broker=(empresa.broker_company or "")[:5],
responsable=(empresa.responsible or "")[:30],
rfc=(empresa.rfc or "")[:13],
tiene_linea_express=empresa.has_express_line or "N",
tiene_linea_express="N",
nombre_empresa=(empresa.name or "")[:40],
manufacturer_id=(empresa.manufacturer_id or "")[:10],
ftp_key=(empresa.ftp_key or "")[:10]

View File

@@ -50,8 +50,9 @@ from .schemas import (
FacturaImportacionCompleta,
)
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
american_fraction_ad_valorem_from_row,
resolve_american_fraction_from_sitar,
)
@@ -700,21 +701,17 @@ class ConsolidadoImportacionMexService:
current_agg = aggregated_data[agg_key]
if not current_agg["description"]:
us_frac_db = (
db.query(USTariffFraction)
.filter(USTariffFraction.code == us_frac_clean)
.first()
resolved = (
resolve_american_fraction_from_sitar(us_frac_clean)
if us_frac_clean
else None
)
if us_frac_db:
if resolved:
sitar_row, _canon = resolved
current_agg["description"] = (
us_frac_db.description or "Sin Descripción"
(sitar_row.DESCRIPCION or "").strip() or "Sin Descripción"
)
# Parse AdValorem from DB if available, else 0 ??
# Creating logical placeholder. The provided Clarion code used `FraAme.Adv`
adv_val = (
us_frac_db.ad_valorem
) # Assuming field exists based on viewing file later?
# Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`.
adv_val = american_fraction_ad_valorem_from_row(sitar_row)
current_agg["advalorem_txt"] = (
f"{adv_val}%" if adv_val is not None else "0%"
)

View File

@@ -370,7 +370,10 @@ class ConsolidadoImportacionMexService:
invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all()
invoice_map = {inv.id: inv for inv in invoices_list}
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction
from api.v1.modules.sitar.fracciones_usa.catalog_resolve import (
american_fraction_ad_valorem_from_row,
resolve_american_fraction_from_sitar,
)
for line in lines:
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
@@ -510,14 +513,20 @@ class ConsolidadoImportacionMexService:
current_agg = aggregated_data[agg_key]
if not current_agg["description"]:
us_frac_db = db.query(USTariffFraction).filter(USTariffFraction.code == us_frac_clean).first()
if us_frac_db:
current_agg["description"] = us_frac_db.description or "Sin Descripción"
# Parse AdValorem from DB if available, else 0 ??
# Creating logical placeholder. The provided Clarion code used `FraAme.Adv`
adv_val = us_frac_db.ad_valorem # Assuming field exists based on viewing file later?
# Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`.
current_agg["advalorem_txt"] = f"{adv_val}%" if adv_val is not None else "0%"
resolved = (
resolve_american_fraction_from_sitar(us_frac_clean)
if us_frac_clean
else None
)
if resolved:
sitar_row, _canon = resolved
current_agg["description"] = (
(sitar_row.DESCRIPCION or "").strip() or "Sin Descripción"
)
adv_val = american_fraction_ad_valorem_from_row(sitar_row)
current_agg["advalorem_txt"] = (
f"{adv_val}%" if adv_val is not None else "0%"
)
else:
current_agg["description"] = part_master.description_spanish if part_master else "S/D"

View File

@@ -166,7 +166,7 @@ class Mainx30DefinitiveService:
broker=(empresa.broker_company or "")[:5],
responsable=(empresa.responsible or "")[:30],
rfc=(empresa.rfc or "")[:13],
tiene_linea_express=empresa.has_express_line or "N",
tiene_linea_express="N",
nombre_empresa=(empresa.name or "")[:40],
manufacturer_id=(empresa.manufacturer_id or "")[:10],
ftp_key=(empresa.ftp_key or "")[:10],

View File

@@ -168,7 +168,7 @@ class Mainx30Service:
broker=(empresa.broker_company or "")[:5],
responsable=(empresa.responsible or "")[:30],
rfc=(empresa.rfc or "")[:13],
tiene_linea_express=empresa.has_express_line or "N",
tiene_linea_express="N",
nombre_empresa=(empresa.name or "")[:40],
manufacturer_id=(empresa.manufacturer_id or "")[:10],
ftp_key=(empresa.ftp_key or "")[:10],

View File

@@ -24,6 +24,7 @@ class TransporterBaseDTO(BaseModel):
ftp_password: Optional[str] = None
ftp_directory: Optional[str] = None
filler_code: Optional[str] = None
has_express_line: Optional[bool] = False
class TransporterCreateDTO(TransporterBaseDTO):

View File

@@ -1,6 +1,6 @@
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import BigInteger, Column, ForeignKeyConstraint, String
from sqlalchemy import BigInteger, Boolean, Column, ForeignKeyConstraint, String
class Transporter(Base, TenantScopedMixin, TimestampMixin):
@@ -30,3 +30,4 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin):
ftp_password = Column(String(100), nullable=True)
ftp_directory = Column(String(1000), nullable=True)
filler_code = Column(String(20), nullable=True)
has_express_line = Column(Boolean, nullable=False, server_default="false")

View File

@@ -159,6 +159,7 @@ class TransporterService:
"ftp_password": transporter.ftp_password,
"ftp_directory": transporter.ftp_directory,
"filler_code": transporter.filler_code,
"has_express_line": transporter.has_express_line,
}
merged.update(update_data)
validate_transporter_row_for_api(

View File

@@ -116,3 +116,47 @@ class SitarAPIBaseService:
pass
return response.json()
def _get_token_sync(self) -> str:
"""Same token cache as async path; safe for sync validators (no running asyncio loop)."""
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
login_url = f"{self.base_url}/fractions/api/v1/auth/login"
payload = {"username": self.username, "password": self.password}
with httpx.Client(timeout=self.timeout) as client:
response = client.post(login_url, json=payload)
response.raise_for_status()
data = response.json()
token = data.get("access_token") or data.get("token")
if not token:
raise ValueError("No token received from SITAR API")
self._token = token
self._token_expires = datetime.now() + timedelta(hours=1)
return token
def _make_request_sync(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
) -> Any:
token = self._get_token_sync()
endpoint = endpoint.lstrip("/")
url = f"{self.base_url}/fractions/{endpoint}"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
with httpx.Client(timeout=self.timeout) as client:
response = client.request(
method=method,
url=url,
params=params,
json=json_data,
headers=headers,
)
response.raise_for_status()
return response.json()

View File

@@ -0,0 +1,124 @@
"""
Resolve American (US) tariff fraction codes against SITAR fracciones-usa.
Used by sync validators and CSV/layout enrichment (no asyncio.run).
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple
from .schemas import FraccionesUSAResponse
from .service import FraccionesUSAService
def normalize_american_fraction_code_candidates(raw_code: str) -> List[str]:
"""
Map user input to candidate strings to query SITAR (dotted HTS vs digits-only).
Mirrors logic in items imports/validators/common.py.
"""
normalized_raw = (raw_code or "").strip()
if not normalized_raw:
return []
digits_only = re.sub(r"[.\s\-]", "", normalized_raw)
candidates: List[str] = []
candidates.append(normalized_raw)
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]}"
)
candidates.append(digits_only)
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
def _digits(code: str) -> str:
return re.sub(r"[.\s\-]", "", code or "")
def canonical_code_from_sitar_row(item: FraccionesUSAResponse) -> str:
"""Same precedence as USTariffFractionMapper.to_domain for stored line value."""
return (
(item.FRACCION_CON_PUNTO or item.FRACCION_MOSTRAR or item.FRACCION_SIN_PUNTO or "")
.strip()
)
def resolve_american_fraction_from_sitar(
raw_code: str,
) -> Optional[Tuple[FraccionesUSAResponse, str]]:
"""
Returns (SITAR row, canonical_code) if found, else None.
canonical_code is suitable for persisting on line customs / exports.
"""
if not (raw_code or "").strip():
return None
target_digits = _digits(raw_code)
if not target_digits:
return None
candidates = normalize_american_fraction_code_candidates(raw_code)
tried_searches: set[tuple[Optional[str], Optional[str]]] = set()
for cand in candidates:
clean = cand.replace(".", "").replace("-", "").replace(" ", "")
if clean.isdigit() and len(clean) >= 4:
search_frac, search_desc = cand, None
else:
search_frac, search_desc = None, cand
key = (search_frac, search_desc)
if key in tried_searches:
continue
tried_searches.add(key)
rows = FraccionesUSAService.search_sync(
fraccion=search_frac,
descripcion=search_desc,
skip=0,
limit=100,
)
for row in rows:
canon = canonical_code_from_sitar_row(row)
if _digits(canon) == target_digits:
return (row, canon)
return None
MAX_AMERICAN_FRACTION_DB_LEN = 16
def store_canonical_american_code(canon: str) -> str:
"""Fit SITAR canonical HTS string into DB column (VARCHAR 16)."""
s = (canon or "").strip()
if len(s) <= MAX_AMERICAN_FRACTION_DB_LEN:
return s
digits = re.sub(r"[.\s\-]", "", s)
return digits[:MAX_AMERICAN_FRACTION_DB_LEN]
def american_fraction_ad_valorem_from_row(item: FraccionesUSAResponse) -> Optional[float]:
"""Parse TARIFA1 like USTariffFractionMapper."""
if not item.TARIFA1:
return None
try:
return float(str(item.TARIFA1).replace("%", "").strip())
except (ValueError, TypeError):
return None

View File

@@ -1,9 +1,13 @@
"""Fracciones USA Service"""
from typing import Optional, List, Dict, Any
import logging
from typing import Optional, List
from ..common import SitarAPIBaseService
from .schemas import FraccionesUSAResponse
logger = logging.getLogger(__name__)
class FraccionesUSAService(SitarAPIBaseService):
@@ -39,3 +43,35 @@ class FraccionesUSAService(SitarAPIBaseService):
"""Get single USA Fraccion record by CONSECUTIVO"""
data = await self._make_request("GET", f"api/v1/fracciones-usa/{consecutivo}")
return FraccionesUSAResponse(**data)
@classmethod
def search_sync(
cls,
fraccion: Optional[str] = None,
descripcion: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesUSAResponse]:
"""
Synchronous SITAR search for use from sync validators (FastAPI async routes
run sync code on the event loop; asyncio.run must not be used there).
"""
try:
service = cls.get_instance()
except ValueError:
return []
params: dict = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if descripcion:
params["descripcion"] = descripcion
try:
data = service._make_request_sync("GET", "api/v1/fracciones-usa/", params=params)
if not isinstance(data, list):
return []
return [FraccionesUSAResponse(**item) for item in data]
except Exception as exc:
logger.warning("SITAR fracciones-usa search_sync failed: %s", exc)
return []