diff --git a/backend/alembic/versions/b3c4d5e6f7a8_split_rfc_tax_id_clients_and_providers.py b/backend/alembic/versions/b3c4d5e6f7a8_split_rfc_tax_id_clients_and_providers.py new file mode 100644 index 00000000..8d63e5ef --- /dev/null +++ b/backend/alembic/versions/b3c4d5e6f7a8_split_rfc_tax_id_clients_and_providers.py @@ -0,0 +1,79 @@ +"""split rfc and tax_id in clients_and_providers + +Separa el identificador fiscal en dos columnas: +- rfc: RFC mexicano (nacional) +- tax_id: identificador fiscal extranjero + +Antes, ambos compartían la columna `rfc` discriminados por `type_nat_foreign`. +La migración mueve el valor de los extranjeros (type_nat_foreign='E') de `rfc` a +`tax_id` y deja `rfc` en NULL para esos registros. Los nacionales no se tocan. + +Revision ID: b3c4d5e6f7a8 +Revises: 506969b0a256 +Create Date: 2026-06-02 10:00:00.000000 + +""" +import logging +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b3c4d5e6f7a8" +down_revision: Union[str, None] = "506969b0a256" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +logger = logging.getLogger("alembic.runtime.migration") + +# Extranjero ⟺ type_nat_foreign empieza con 'E' (misma convención que DTOs/modelo/frontend). +_FOREIGN_PREDICATE = "UPPER(COALESCE(type_nat_foreign, '')) LIKE 'E%'" + + +def upgrade() -> None: + # 1) Nueva columna tax_id (nullable: un registro puede no tener identificador extranjero). + op.add_column( + "clients_and_providers", + sa.Column("tax_id", sa.String(length=30), nullable=True), + schema="a76", + ) + + conn = op.get_bind() + + # 2) Conteo previo (visibilidad antes de mover datos; UPDATE precedido de SELECT COUNT). + to_move = conn.execute( + sa.text( + "SELECT COUNT(*) FROM a76.clients_and_providers " + f"WHERE {_FOREIGN_PREDICATE} AND rfc IS NOT NULL" + ) + ).scalar() + logger.info( + "split_rfc_tax_id: %s registros extranjeros con rfc serán movidos a tax_id", + to_move, + ) + + # 3) Mover rfc -> tax_id para extranjeros (idempotente por el guard tax_id IS NULL). + conn.execute( + sa.text( + "UPDATE a76.clients_and_providers SET tax_id = rfc " + f"WHERE {_FOREIGN_PREDICATE} AND tax_id IS NULL AND rfc IS NOT NULL" + ) + ) + # 4) Limpiar rfc en extranjeros (la columna rfc queda solo para RFC nacional). + conn.execute( + sa.text( + f"UPDATE a76.clients_and_providers SET rfc = NULL WHERE {_FOREIGN_PREDICATE}" + ) + ) + + +def downgrade() -> None: + # Reconsolidar: regresar el identificador extranjero a rfc antes de eliminar la columna. + conn = op.get_bind() + conn.execute( + sa.text( + "UPDATE a76.clients_and_providers SET rfc = tax_id " + f"WHERE {_FOREIGN_PREDICATE} AND rfc IS NULL AND tax_id IS NOT NULL" + ) + ) + op.drop_column("clients_and_providers", "tax_id", schema="a76") diff --git a/backend/api/v1/modules/a76/clients_and_providers/dto.py b/backend/api/v1/modules/a76/clients_and_providers/dto.py index fbe8d766..8e7af628 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/dto.py +++ b/backend/api/v1/modules/a76/clients_and_providers/dto.py @@ -10,6 +10,34 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from .validators import is_valid_rfc, is_valid_tax_id +# Mensajes de error reutilizados por las validaciones de identificador fiscal +_RFC_FORMAT_ERROR = "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000." +_TAX_ID_FORMAT_ERROR = ( + "El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres." +) + + +def _validate_fiscal_format(rfc: Optional[str], tax_id: Optional[str]) -> None: + """ + Valida el formato de cada identificador fiscal de forma independiente (no excluyente): + un registro puede traer RFC y TAX-ID a la vez; cada uno valida solo si tiene valor. + """ + if (rfc or "").strip() and not is_valid_rfc(rfc): + raise ValueError(_RFC_FORMAT_ERROR) + if (tax_id or "").strip() and not is_valid_tax_id(tax_id): + raise ValueError(_TAX_ID_FORMAT_ERROR) + + +def _require_fiscal_id_by_procedencia( + rfc: Optional[str], tax_id: Optional[str], type_nat_foreign: Optional[str] +) -> None: + """Exige el identificador que corresponde a la procedencia: E ⇒ TAX-ID, otro ⇒ RFC.""" + is_foreign = (type_nat_foreign or "").strip().upper().startswith("E") + if is_foreign and not (tax_id or "").strip(): + raise ValueError("El TAX-ID es obligatorio para registros extranjeros.") + if not is_foreign and not (rfc or "").strip(): + raise ValueError("El RFC es obligatorio para registros nacionales.") + # DTOs para dirección class ClientProviderAddressDTO(BaseModel): @@ -97,7 +125,10 @@ class ClientProviderCreateDTO(BaseModel): ) 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") + rfc: Optional[str] = Field(None, max_length=30, description="RFC (nacional)") + tax_id: Optional[str] = Field( + None, max_length=30, description="TAX-ID (identificador fiscal extranjero)" + ) curp: Optional[str] = Field(None, max_length=19, description="CURP") client_or_provider: Optional[Literal["client", "provider", "both"]] = Field( None, description="Client or provider" @@ -127,20 +158,9 @@ class ClientProviderCreateDTO(BaseModel): @model_validator(mode="after") def validate_rfc_or_tax_id_format(self): - rfc = self.rfc - if not rfc or not (rfc := (rfc or "").strip()): - return self - proc = (self.type_nat_foreign or "N").strip().upper()[:1] - if proc == "E": - if not is_valid_tax_id(rfc): - raise ValueError( - "El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres." - ) - else: - if not is_valid_rfc(rfc): - raise ValueError( - "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000." - ) + # Valida formato de ambos campos y exige el que corresponda a la procedencia + _validate_fiscal_format(self.rfc, self.tax_id) + _require_fiscal_id_by_procedencia(self.rfc, self.tax_id, self.type_nat_foreign) return self model_config = ConfigDict(from_attributes=True) @@ -154,7 +174,10 @@ class ClientProviderUpdateDTO(BaseModel): ) 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") + rfc: Optional[str] = Field(None, max_length=30, description="RFC (nacional)") + tax_id: Optional[str] = Field( + None, max_length=30, description="TAX-ID (identificador fiscal extranjero)" + ) curp: Optional[str] = Field(None, max_length=19, description="CURP") client_or_provider: Optional[Literal["client", "provider", "both"]] = Field( None, description="Client or provider" @@ -184,20 +207,8 @@ class ClientProviderUpdateDTO(BaseModel): @model_validator(mode="after") def validate_rfc_or_tax_id_format(self): - rfc = self.rfc - if not rfc or not (rfc := (rfc or "").strip()): - return self - proc = (self.type_nat_foreign or "N").strip().upper()[:1] - if proc == "E": - if not is_valid_tax_id(rfc): - raise ValueError( - "El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres." - ) - else: - if not is_valid_rfc(rfc): - raise ValueError( - "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000." - ) + # Update es parcial (PATCH): solo se valida formato de lo que venga, sin exigir requerido + _validate_fiscal_format(self.rfc, self.tax_id) return self model_config = ConfigDict(from_attributes=True) @@ -211,6 +222,7 @@ class ClientProviderResponseDTO(BaseModel): name: Optional[str] = None short_name: Optional[str] = None rfc: Optional[str] = None + tax_id: Optional[str] = None curp: Optional[str] = None client_or_provider: Optional[str] = None linking: Optional[str] = None @@ -239,6 +251,7 @@ class ClientProviderBasicDTO(BaseModel): name: Optional[str] = None short_name: Optional[str] = None rfc: Optional[str] = None + tax_id: Optional[str] = None client_or_provider: Optional[str] = None is_active: Optional[bool] = None diff --git a/backend/api/v1/modules/a76/clients_and_providers/models.py b/backend/api/v1/modules/a76/clients_and_providers/models.py index 6333449d..37f45358 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/models.py +++ b/backend/api/v1/modules/a76/clients_and_providers/models.py @@ -45,8 +45,10 @@ class ClientProvider(Base, TenantScopedMixin, TimestampMixin): type_nat_foreign: Mapped[Optional[str]] = mapped_column(String(1)) # TIPO NACIONAL/EXTRANJERO name: Mapped[Optional[str]] = mapped_column(String(256)) short_name: Mapped[Optional[str]] = mapped_column(String(10)) - # Identificador fiscal único: RFC (nacional) o TAX-ID (extranjero); no usar programs.tax_id para lo mismo + # Identificadores fiscales separados: rfc = RFC mexicano (nacional); tax_id = identificador fiscal extranjero. + # La procedencia (type_nat_foreign) rige cuál es obligatorio, pero un registro puede traer ambos. rfc: Mapped[Optional[str]] = mapped_column(String(30)) + tax_id: Mapped[Optional[str]] = mapped_column(String(30)) curp: Mapped[Optional[str]] = mapped_column(String(19)) client_or_provider: Mapped[ClientOrProviderEnum] = mapped_column(PgEnum(ClientOrProviderEnum, name="entity_client_or_provider", create_type=True, native_enum=True),nullable=False) linking: Mapped[Optional[str]] = mapped_column(String(1)) @@ -66,6 +68,18 @@ class ClientProvider(Base, TenantScopedMixin, TimestampMixin): back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan" ) + @property + def fiscal_id(self) -> Optional[str]: + """ + Identificador fiscal efectivo según procedencia: RFC para nacionales, + TAX-ID para extranjeros. Punto único de verdad para consumidores downstream + (facturas, reportes, transmisiones) que antes leían solo `rfc`. + Extranjero ⟺ type_nat_foreign == 'E' (misma convención que DTOs/frontend); + cualquier otro valor (N, NULL, vacío) se trata como nacional. + """ + is_foreign = (self.type_nat_foreign or "").strip().upper().startswith("E") + return self.tax_id if is_foreign else self.rfc + class ClientProviderAddress(Base, TenantScopedMixin, TimestampMixin): """ diff --git a/backend/api/v1/modules/a76/clients_and_providers/routes.py b/backend/api/v1/modules/a76/clients_and_providers/routes.py index f595e6dc..9eb49246 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/clients_and_providers/routes.py @@ -34,7 +34,8 @@ router.include_router(imports_router, prefix="/imports", tags=["clients_and_prov async def get_clients_and_providers( company_id: int = Query(..., description="Company ID"), name: Optional[str] = Query(None, description="Filter by name (contains)"), - rfc: Optional[str] = Query(None, description="Filter by RFC/TAX-ID (contains)"), + rfc: Optional[str] = Query(None, description="Filter by RFC (contains)"), + tax_id: Optional[str] = Query(None, description="Filter by TAX-ID (contains)"), short_name: Optional[str] = Query( None, description="Filter by short name / clave (exact match, case-insensitive)" ), @@ -71,6 +72,9 @@ async def get_clients_and_providers( if rfc: query = query.filter(ClientProvider.rfc.ilike(f"%{rfc.strip()}%")) + if tax_id: + query = query.filter(ClientProvider.tax_id.ilike(f"%{tax_id.strip()}%")) + if short_name: sn = short_name.strip().upper() query = query.filter(func.upper(ClientProvider.short_name) == sn) diff --git a/backend/api/v1/modules/a76/clients_and_providers/service.py b/backend/api/v1/modules/a76/clients_and_providers/service.py index f710a83d..745c407d 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/service.py +++ b/backend/api/v1/modules/a76/clients_and_providers/service.py @@ -56,6 +56,7 @@ class ClientProviderService: ClientProvider.name.ilike(search_pattern), ClientProvider.short_name.ilike(search_pattern), ClientProvider.rfc.ilike(search_pattern), + ClientProvider.tax_id.ilike(search_pattern), ) ) if filters.get("client_or_provider"): @@ -360,6 +361,7 @@ class ClientProviderService: ClientProvider.name.ilike(search_pattern), ClientProvider.short_name.ilike(search_pattern), ClientProvider.rfc.ilike(search_pattern), + ClientProvider.tax_id.ilike(search_pattern), ClientProvider.client_id.ilike(search_pattern), ) ) diff --git a/backend/api/v1/modules/a76/factura_cove/service.py b/backend/api/v1/modules/a76/factura_cove/service.py index 73d6ddb6..5c9a472e 100644 --- a/backend/api/v1/modules/a76/factura_cove/service.py +++ b/backend/api/v1/modules/a76/factura_cove/service.py @@ -318,7 +318,8 @@ class FacturaCoveDomainService: tipo_nat = (cp.type_nat_foreign or "").strip().upper() tipo_identificador = "0" if tipo_nat == "E" else "1" - identificacion = (cp.rfc or "").strip().upper() + # Identificador fiscal efectivo: RFC (nacional) o TAX-ID (extranjero) + identificacion = (cp.fiscal_id or "").strip().upper() raw_country = (addr.country or "") if addr and getattr(addr, "country", None) else "" country_code = self._normalize_country_to_m3(raw_country) diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index fd965c33..d1037983 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -1452,8 +1452,9 @@ class InvoiceService: def _get_rfc(client_id): if not client_id: return '' - row = db.query(ClientProvider.rfc).filter(ClientProvider.id == client_id).first() - return row.rfc if row else '' + # Identificador fiscal efectivo: RFC (nacional) o TAX-ID (extranjero) + cp = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + return (cp.fiscal_id or '') if cp else '' provider_rfc = _get_rfc(compliance.provider_id) shipped_to_rfc = _get_rfc(compliance.shipped_to_id) @@ -1738,8 +1739,9 @@ class InvoiceService: def _get_rfc(client_id): if not client_id: return '' - row = db.query(ClientProvider.rfc).filter(ClientProvider.id == client_id).first() - return row.rfc if row else '' + # Identificador fiscal efectivo: RFC (nacional) o TAX-ID (extranjero) + cp = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() + return (cp.fiscal_id or '') if cp else '' provider_rfc = _get_rfc(compliance.provider_id) shipped_to_rfc = _get_rfc(compliance.shipped_to_id) @@ -2376,9 +2378,10 @@ class InvoiceService: # Clarion: TIPOEXTNAC N→RFC, E→TAXID truncado a 9 chars if not cp: return '' + val = cp.fiscal_id or '' if cp.type_nat_foreign == 'E': - return (cp.rfc or '')[:9] - return cp.rfc or '' + return val[:9] + return val def _proceso(prov): # Dirección de tráfico según ciudad del proveedor diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py index aff20495..efd1f60d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py @@ -40,32 +40,32 @@ def check_required_max(row: Dict[str, Any], col: str, max_len: int, line_num: in return None -def check_rfc_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: - """Col E (RFC): si tiene valor, debe cumplir formato RFC México.""" - val = (row.get("RFC") or "").strip() +def check_rfc_format(row: Dict[str, Any], line_num: int, col: str = "RFC") -> Optional[Dict[str, Any]]: + """Columna RFC: si tiene valor, debe cumplir formato RFC México.""" + val = (row.get(col) or "").strip() if not val: return None if not RFC_PATTERN.match(val.upper()): return { "line": line_num, - "col": "RFC", + "col": col, "msg": "El formato del RFC es inválido.", - "solution": "Capturar en la columna E un RFC con formato válido (ej. XAXX010101000).", + "solution": "Capturar un RFC con formato válido (ej. XAXX010101000).", } return None -def check_tax_id_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: - """Col E (TAX-ID): 2 dígitos, guión y resto alfanumérico. Ej: 12-3456789. Máx 30 caracteres.""" - val = (row.get("RFC") or "").strip() +def check_tax_id_format(row: Dict[str, Any], line_num: int, col: str = "TAX_ID") -> Optional[Dict[str, Any]]: + """Columna TAX-ID: 2 dígitos, guión y resto alfanumérico. Ej: 12-3456789. Máx 30 caracteres.""" + val = (row.get(col) or "").strip() if not val: return None if len(val) > 30 or not TAX_ID_PATTERN.match(val): return { "line": line_num, - "col": "RFC", + "col": col, "msg": "El formato del TAX-ID es inválido.", - "solution": "Capturar en la columna E un TAX-ID con formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres.", + "solution": "Capturar un TAX-ID con formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres.", } return None diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py index e04ddf65..06ab1a52 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py @@ -10,6 +10,7 @@ CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS: Dict[str, int] = { "SHORT_NAME": 10, "NOMBRE": 256, "RFC": 30, + "TAX_ID": 30, "DIRECCION": 100, "NUM_EXT": 20, "CODIGO POSTAL": 15, diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py index bd13b242..030a6c6a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py @@ -9,6 +9,7 @@ El CSV se alimenta en base a las tablas/modelos: Las validaciones (validators) aplican reglas de negocio Clarion y respetan longitudes máximas de los modelos. """ +import logging from datetime import datetime from decimal import Decimal from typing import Dict, Any, Optional, Tuple @@ -20,8 +21,11 @@ from .common_validators import ( parse_active, ) +logger = logging.getLogger(__name__) + MAX_LEN = { "rfc": 30, + "tax_id": 30, "name": 256, "short_name": 10, "curp": 19, @@ -84,21 +88,37 @@ def row_to_client_provider_data( """ Mapea fila normalizada a datos para ClientProvider, ClientProviderAddress y ClientProviderPrograms. Devuelve (cp_data, address_data_or_none, programs_data_or_none). - Identificador fiscal unificado: solo se guarda en ClientProvider.rfc (RFC o TAX-ID según procedencia). - Se toma de columna RFC (E); si viene vacía y hay TAX_ID_PROGRAMS (AB), se usa esa para la misma columna rfc. + Identificadores fiscales separados: RFC (nacional) → cp.rfc; TAX-ID (extranjero) → cp.tax_id. + Compatibilidad: archivos legacy de columna única traían el TAX-ID en la columna RFC; si el registro es + extranjero y no hay columna TAX_ID dedicada, ese valor se rutea a tax_id (salvaguarda no silenciosa). """ - rfc_col = _str_or_none(row_norm.get("RFC"), MAX_LEN["rfc"]) - tax_id_programs_col = _str_or_none(row_norm.get("TAX_ID_PROGRAMS"), MAX_LEN["rfc"]) - # Una sola columna: identificador fiscal en cp.rfc (nacional=RFC, extranjero=TAX-ID) - rfc_unified = rfc_col or tax_id_programs_col - short_name = _str_or_none(row_norm.get("SHORT_NAME"), MAX_LEN["short_name"]) - if not rfc_unified and not short_name: - return ({}, None, None) - - client_or_provider = parse_client_or_provider(row_norm.get("TIPO")) or ClientOrProviderEnum.BOTH procedencia = _str_or_none(row_norm.get("PROCEDENCIA"), 1) if procedencia: procedencia = procedencia.upper()[:1] + is_foreign = procedencia == "E" + + rfc_col = _str_or_none(row_norm.get("RFC"), MAX_LEN["rfc"]) + tax_id_col = _str_or_none(row_norm.get("TAX_ID"), MAX_LEN["tax_id"]) + tax_id_programs_col = _str_or_none(row_norm.get("TAX_ID_PROGRAMS"), MAX_LEN["tax_id"]) + + rfc_final = rfc_col + tax_id_final = tax_id_col + if is_foreign and not tax_id_final: + # Salvaguarda legacy: extranjero sin columna TAX_ID dedicada → el TAX-ID estaba en RFC (o AB). + if rfc_col: + tax_id_final = rfc_col + rfc_final = None + logger.info( + "CSV clients_providers: TAX-ID tomado de columna RFC (archivo legacy de columna única)" + ) + elif tax_id_programs_col: + tax_id_final = tax_id_programs_col + + short_name = _str_or_none(row_norm.get("SHORT_NAME"), MAX_LEN["short_name"]) + if not rfc_final and not tax_id_final and not short_name: + return ({}, None, None) + + client_or_provider = parse_client_or_provider(row_norm.get("TIPO")) or ClientOrProviderEnum.BOTH # Vinculación 0/1/2 → string vinc = (row_norm.get("VINCULACION") or "").strip() @@ -113,7 +133,8 @@ def row_to_client_provider_data( cp_data = { "tenant_id": tenant_id, "company_id": company_id, - "rfc": rfc_unified or None, + "rfc": rfc_final or None, + "tax_id": tax_id_final or None, "name": _str_or_none(row_norm.get("NOMBRE"), MAX_LEN["name"]), "short_name": short_name, "curp": _str_or_none(row_norm.get("CURP"), MAX_LEN["curp"]), diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py index 09d39d0c..b0b9de12 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py @@ -221,8 +221,8 @@ def _do_commit(job_id: str) -> Dict[str, Any]: skipped_details.append( { "line": i, - "reason": "Fila sin RFC ni Clave", - "solution": "Capturar en el CSV el RFC (columna RFC) o la clave del cliente/proveedor (columna SHORT_NAME).", + "reason": "Fila sin RFC/TAX-ID ni Clave", + "solution": "Capturar en el CSV el RFC (columna RFC), el TAX-ID (columna TAX_ID) o la clave del cliente/proveedor (columna SHORT_NAME).", } ) continue diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py index e70c6fea..ce333046 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py @@ -1,7 +1,9 @@ """ Configuración de plantilla CSV para Clientes y Proveedores (EstructuraCatClienteProv.xls). Layout Clarion: Col A = PROCEDENCIA (E/N), B = TIPO (C/P/A), C = CLAVE (máx 8), D = NOMBRE, E = RFC, F–AG. -Solo se leen columnas definidas aquí; el resto se ignora. +Solo se leen columnas definidas aquí; el resto se ignora. El parseo es por nombre de cabecera (no posicional). +RFC y TAX-ID viven en columnas separadas: "RFC" (nacional) y "TAX_ID" (identificador fiscal extranjero). +Compatibilidad: archivos legacy de columna única (TAX-ID en "RFC") se rutean a tax_id según procedencia (ver mappers). Correspondencia Clarion → canonical: A=PROCEDENCIA, B=TIPO, C=SHORT_NAME, D=NOMBRE, E=RFC, F=CALLES(DIRECCION), G=NUM_EXT, H=CODIGO POSTAL, I=COLONIA, J=CIUDAD, K=ESTADO, L=PAIS, M=TELEFONO, N=FAX, O=EMAIL, P=CURP, Q=TIPO_PROGRAMA_SECON, R=NUM_PROGRAMA_SECON, S=FECHA_AUT_SECON, T=ES_PROSEC, U=NUM_AUT_PROSEC, V=VINCULACION, @@ -38,8 +40,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "SHORT_NAME", "aliases": ["CLAVE", "CLAVE CORTA", "NOMBRE CORTO", "SIGLAS"]}, # Col D - Nombre {"canonical": "NOMBRE", "aliases": ["RAZON SOCIAL", "NAME", "RAZON SOCIAL O NOMBRE"]}, - # Col E - RFC - {"canonical": "RFC", "aliases": ["TAX_ID", "TAXID", "IDENTIFICADOR FISCAL", "IDENTIFICACION FISCAL"]}, + # Col E - RFC (solo RFC mexicano / nacional) + {"canonical": "RFC", "aliases": ["RFC NACIONAL"]}, + # Col E2 - TAX-ID dedicado (identificador fiscal extranjero), separado del RFC + {"canonical": "TAX_ID", "aliases": ["TAXID", "TAX-ID", "IDENTIFICADOR FISCAL", "IDENTIFICACION FISCAL"]}, # Col F - Calles {"canonical": "DIRECCION", "aliases": ["CALLES", "DOMICILIO", "DIRECCION FISCAL", "CALLE"]}, # Col G - Número exterior @@ -84,8 +88,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CONTACTO", "aliases": ["CONTACT", "PERSONA CONTACTO"]}, # Col AA - Manufacturer ID {"canonical": "MANUFACTURER_ID", "aliases": ["MANUFACTURERID", "MANUFACTURER ID"]}, - # Col AB - Tax ID (programas) - {"canonical": "TAX_ID_PROGRAMS", "aliases": ["TAX ID", "TAXID PROGRAMS"]}, + # Col AB - Tax ID (programas). Alias "TAX ID" se quitó para no colisionar con la columna TAX_ID dedicada. + {"canonical": "TAX_ID_PROGRAMS", "aliases": ["TAXID PROGRAMS"]}, # Col AC - Broker exportación {"canonical": "BROKER_EXPO", "aliases": ["BROKER EXPO", "BROKER EXPORTACION"]}, # Col AD - Broker importación diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py index fc17aa29..e57a63e7 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py @@ -149,32 +149,40 @@ def validate_row_client_provider( else: errors.extend(valida_toda_cliente_o_prov(row, line_num, actualizar=actualizar)) - # Identificador fiscal requerido: Nacional = RFC, Extranjero = TAX-ID (mismo campo "RFC" en layout). + # Identificador fiscal requerido y validado por procedencia: + # Nacional (N) -> RFC en columna RFC + # Extranjero (E) -> TAX-ID en columna TAX_ID (compat: archivos legacy lo traían en columna RFC) procedencia = (row.get("PROCEDENCIA") or "").strip().upper()[:1] - err = check_required_max(row, "RFC", RFC_MAX, line_num) - if err: - if procedencia == "E": - err = { + if procedencia == "E": + # Columna efectiva: TAX_ID dedicada o, en su defecto, RFC (archivo legacy de columna única). + effective_col = "TAX_ID" if (row.get("TAX_ID") or "").strip() else "RFC" + if not (row.get(effective_col) or "").strip(): + errors.append({ "line": line_num, - "col": "RFC", + "col": "TAX_ID", "msg": "Requerido (TAX-ID)", - "solution": "Capturar el TAX-ID del cliente/proveedor extranjero en la columna E (RFC/TAX-ID).", - } - errors.append(err) + "solution": "Capturar el TAX-ID del cliente/proveedor extranjero en la columna TAX_ID.", + }) + else: + err = check_tax_id_format(row, line_num, col=effective_col) + if err: + errors.append(err) else: - # Validar formato según procedencia: RFC (N) o TAX-ID (E). - if procedencia == "N": - err = check_rfc_format(row, line_num) - elif procedencia == "E": - err = check_tax_id_format(row, line_num) + # Nacional: RFC requerido + formato + err = check_required_max(row, "RFC", RFC_MAX, line_num) if err: errors.append(err) + else: + err = check_rfc_format(row, line_num) + if err: + errors.append(err) for err in [ check_max_length(row, "NOMBRE", NAME_MAX, line_num), check_max_length(row, "SHORT_NAME", 10, line_num), # modelo permite 10 check_max_length(row, "CURP", CURP_MAX, line_num), + check_max_length(row, "TAX_ID", RFC_MAX, line_num), ]: if err: errors.append(err) diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py index 2d3b3f4f..58fb59e5 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/service.py @@ -136,8 +136,8 @@ class AvisoConsolidadoExportacionService: if client_obj: # Fetch Address c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == target_client_id).first() - # Identificador fiscal único en ClientProvider.rfc (RFC o TAX-ID) - c_rfc = getattr(client_obj, "rfc", "") or "" + # Identificador fiscal efectivo: RFC (nacional) o TAX-ID (extranjero) + c_rfc = getattr(client_obj, "fiscal_id", "") or "" c_dir_str = "DOMICILIO NO REGISTRADO" if c_addr: diff --git a/backend/api/v1/modules/a76/reports/exportacion/transmission/MAINX30/processors.py b/backend/api/v1/modules/a76/reports/exportacion/transmission/MAINX30/processors.py index 9500fea6..b0282e5d 100644 --- a/backend/api/v1/modules/a76/reports/exportacion/transmission/MAINX30/processors.py +++ b/backend/api/v1/modules/a76/reports/exportacion/transmission/MAINX30/processors.py @@ -31,17 +31,8 @@ class ScaiiProcessor: else: pais = pais_raw[:2] - tax_id = "" - if pais == "MX": - tax_id = cliente.rfc or "" - else: - # Try generic tax_id field if exists, else generic field or RFC as fallback - # Providing a fallback to extra_information or web_key if needed, but per model inspection: - # We don't see a specific 'tax_id' field in ClientProvider model snippet. - # We see 'rfc'. Let's use RFC as generic holder or look for 'tax_id' if I missed it. - # Re-reading model: rfc is the only obvious one. - # Let's use RFC field for foreign tax id too unless instructed otherwise. - tax_id = cliente.rfc or "" + # Identificador fiscal del cliente: RFC (nacional) o TAX-ID (extranjero) + tax_id = cliente.fiscal_id or "" data = { "nombre": (cliente.name or "")[:39], @@ -123,10 +114,10 @@ class ScaiiProcessor: if factura.compliance_mx: if factura.compliance_mx.provider_id: s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first() - if s_obj: s_rfc = s_obj.rfc or "" + if s_obj: s_rfc = s_obj.fiscal_id or "" if factura.compliance_mx.sold_to_id: c_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first() - if c_obj: c_rfc = c_obj.rfc or "" + if c_obj: c_rfc = c_obj.fiscal_id or "" lineas.append( f"IV01{factura.invoice_number[:15]:<15}" diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py index 28e17498..0422907a 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -128,7 +128,7 @@ class ConsolidadoImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=getattr(main, "rfc", "") or "", + tax_id=getattr(main, "fiscal_id", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=( diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py index 6aa530ae..630e1351 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py @@ -82,7 +82,7 @@ class ConsolidadoImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=getattr(main, "rfc", "") or "", + tax_id=getattr(main, "fiscal_id", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 6aa4e0d3..4974dc46 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -162,7 +162,7 @@ class FacturaImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=getattr(main, "rfc", "") or "", + tax_id=getattr(main, "fiscal_id", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=( diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py index 3d0b86d5..5fef67bc 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py @@ -82,7 +82,7 @@ class FacturaImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=getattr(main, "rfc", "") or "", + tax_id=getattr(main, "fiscal_id", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py index 41f38561..d98b7878 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -156,7 +156,7 @@ class FacturaImportacionUsaService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "USA") if addr else "USA", - tax_id=getattr(main, "rfc", "") or "", + tax_id=getattr(main, "fiscal_id", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=( diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index 6ca748f3..6653b159 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -103,7 +103,7 @@ class PackingListService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=getattr(main, "rfc", "") or "", + tax_id=getattr(main, "fiscal_id", "") or "", programa="IMMEX" if (prog and prog.program) else "", autorizacion=prog.program_number if prog else "", prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", diff --git a/backend/api/v1/modules/a76/reports/importacion/transmission/definitive/MAINX30/processors.py b/backend/api/v1/modules/a76/reports/importacion/transmission/definitive/MAINX30/processors.py index d807f091..c5b917f9 100644 --- a/backend/api/v1/modules/a76/reports/importacion/transmission/definitive/MAINX30/processors.py +++ b/backend/api/v1/modules/a76/reports/importacion/transmission/definitive/MAINX30/processors.py @@ -31,8 +31,9 @@ class ScaiiProcessor: pais = "US" else: pais = pais_raw[:2] - - tax_id = cliente.rfc or "" + + # Identificador fiscal del cliente: RFC (nacional) o TAX-ID (extranjero) + tax_id = cliente.fiscal_id or "" data = { "nombre": (cliente.name or "")[:39], @@ -161,10 +162,10 @@ class ScafDefProcessor(ScaiiProcessor): if factura.compliance_mx: if factura.compliance_mx.sold_to_id: c_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first() - if c_obj: c_tax = (c_obj.rfc or "")[:12] + if c_obj: c_tax = (c_obj.fiscal_id or "")[:12] if factura.compliance_mx.provider_id: s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first() - if s_obj: s_tax = (s_obj.rfc or "")[:12] + if s_obj: s_tax = (s_obj.fiscal_id or "")[:12] # IV01 uses 11 spaces then 'C' per Clarion logic # Adjusted validation for RFCs to avoid crashes or None diff --git a/backend/api/v1/modules/a76/reports/importacion/transmission/temporal/MAINX30/processors.py b/backend/api/v1/modules/a76/reports/importacion/transmission/temporal/MAINX30/processors.py index ce0fb934..18b06692 100644 --- a/backend/api/v1/modules/a76/reports/importacion/transmission/temporal/MAINX30/processors.py +++ b/backend/api/v1/modules/a76/reports/importacion/transmission/temporal/MAINX30/processors.py @@ -31,8 +31,9 @@ class ScaiiProcessor: pais = "US" else: pais = pais_raw[:2] - - tax_id = cliente.rfc or "" + + # Identificador fiscal del cliente: RFC (nacional) o TAX-ID (extranjero) + tax_id = cliente.fiscal_id or "" data = { "nombre": (cliente.name or "")[:39], @@ -178,10 +179,10 @@ class ScafTempProcessor(ScaiiProcessor): if factura.compliance_mx: if factura.compliance_mx.sold_to_id: c_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first() - if c_obj: c_tax = c_obj.rfc[:12] if c_obj.rfc else "" + if c_obj: c_tax = c_obj.fiscal_id[:12] if c_obj.fiscal_id else "" if factura.compliance_mx.provider_id: s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first() - if s_obj: s_tax = s_obj.rfc[:12] if s_obj.rfc else "" + if s_obj: s_tax = s_obj.fiscal_id[:12] if s_obj.fiscal_id else "" lineas.append( f"IV01{factura.invoice_number[:15]:<15}{f_fecha}{entry_port:<5}{' ':<11}C" diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py index 77954f96..bdfd3039 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py @@ -113,19 +113,19 @@ class DatabaseHelper: try: sql = text(""" - SELECT cp.name, cp.rfc + SELECT cp.name, cp.rfc, cp.tax_id FROM a76.clients_and_providers cp WHERE cp.id = :client_code AND (cp.client_or_provider = :client_type OR cp.client_or_provider = 'both') """) result = db.execute(sql, {"client_code": client_code, "client_type": client_type}).fetchone() - + if result: - # Unified identifier: cp.rfc contains either RFC (national) or TAX-ID (foreign) + # Identificadores separados: rfc (nacional) y tax_id (extranjero), cada uno en su columna return { "name": result[0], "rfc": result[1], - "tax_id": result[1], + "tax_id": result[2], } else: logger.debug(f"Client {client_code} not found as {client_type}") diff --git a/frontend/src/lib/api/dashboard/a76/clients-providers.ts b/frontend/src/lib/api/dashboard/a76/clients-providers.ts index d7ad1566..c6d5fbfb 100644 --- a/frontend/src/lib/api/dashboard/a76/clients-providers.ts +++ b/frontend/src/lib/api/dashboard/a76/clients-providers.ts @@ -33,7 +33,9 @@ export interface ClientProviderPrograms { export interface ClientProvider { id: number; - rfc: string; + // Identificadores fiscales separados: rfc (nacional), tax_id (extranjero). Ambos opcionales/nullable. + rfc?: string | null; + tax_id?: string | null; name: string; short_name?: string | null; curp?: string | null; @@ -74,6 +76,7 @@ export const clientsProvidersApi = { filters?: Partial<{ name: string; rfc: string; + tax_id: string; /** Clave corta; el backend filtra por coincidencia exacta sin distinguir mayúsculas/minúsculas */ short_name: string; type: string; diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts b/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts index 08e39769..48ea85e6 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts +++ b/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts @@ -30,7 +30,7 @@ export function createColumns(onSuccess: () => void, options: ClientsProvidersCo const { val } = getData(); return { render: () => `${val}` }; }); - return renderSnippet(snippet, { val: row.original.rfc }); + return renderSnippet(snippet, { val: row.original.rfc || row.original.tax_id || '—' }); } }, { diff --git a/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte index 6ebb1cf0..b4ad1f71 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte @@ -25,10 +25,11 @@ // Filtro reactivo local let filteredClients = $derived( - clients.filter(c => + clients.filter(c => (c.client_or_provider === 'client' || c.client_or_provider === 'both') && - (c.name.toLowerCase().includes(searchTerm.toLowerCase()) || - c.rfc.toLowerCase().includes(searchTerm.toLowerCase()) || + (c.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (c.rfc ?? '').toLowerCase().includes(searchTerm.toLowerCase()) || + (c.tax_id ?? '').toLowerCase().includes(searchTerm.toLowerCase()) || c.id.toString().includes(searchTerm)) ) ); @@ -107,7 +108,7 @@ ID - RFC + RFC / TAX-ID Razón Social Estado @@ -119,7 +120,7 @@ onclick={() => handleSelect(client)} > {client.id} - {client.rfc} + {client.rfc || client.tax_id || '—'}
{#if client.client_or_provider === 'client'} diff --git a/frontend/src/lib/components/dashboard/goods/parts/TextileProviderSelectorDialog.svelte b/frontend/src/lib/components/dashboard/goods/parts/TextileProviderSelectorDialog.svelte index 1c8858c2..fda33ec6 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/TextileProviderSelectorDialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/TextileProviderSelectorDialog.svelte @@ -29,7 +29,8 @@ (p) => (p.client_or_provider === 'provider' || p.client_or_provider === 'both') && (p.name.toLowerCase().includes(searchTerm.toLowerCase()) || - p.rfc.toLowerCase().includes(searchTerm.toLowerCase()) || + (p.rfc ?? '').toLowerCase().includes(searchTerm.toLowerCase()) || + (p.tax_id ?? '').toLowerCase().includes(searchTerm.toLowerCase()) || p.id.toString().includes(searchTerm)) ) ); @@ -101,7 +102,7 @@ ID - RFC + RFC / TAX-ID Nombre @@ -112,7 +113,7 @@ onclick={() => handleSelect(provider)} > {provider.id} - {provider.rfc} + {provider.rfc || provider.tax_id || '—'}
diff --git a/frontend/src/lib/components/dashboard/goods/parts/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/goods/parts/create-edit-dialog.svelte index 8ef08685..748c64aa 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/create-edit-dialog.svelte @@ -7,7 +7,7 @@ import * as Select from "$lib/components/ui/select"; import { classesApi, type A76Class, type A76ClassCreate, type A76ClassUpdate } from "$lib/api/dashboard/a76/classes"; import { materialTypesApi, type MaterialType } from "$lib/api/dashboard/reference_data/material_types"; - import { clientsProvidersApi, type ClientProviderBasic } from "$lib/api/dashboard/a76/clients-providers"; + import { clientsProvidersApi, type ClientProvider } from "$lib/api/dashboard/a76/clients-providers"; import { companyStore } from "$lib/stores/company.svelte"; import { onMount } from 'svelte'; @@ -44,7 +44,7 @@ let error = $state(null); let materialTypes = $state([]); let loadingMaterialTypes = $state(false); - let clients = $state([]); + let clients = $state([]); let loadingClients = $state(false); // Variables para controlar los selects @@ -320,7 +320,7 @@ {#each clients as client} {/each} diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index 3deb0c4a..dc71547a 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -846,7 +846,8 @@ } function handleTextileProviderSelect(provider: any) { - formData.supplier = provider.rfc; + // Identificador fiscal efectivo del proveedor (RFC nacional o TAX-ID extranjero) + formData.supplier = provider.rfc || provider.tax_id || ''; } function handleSectorSelect(item: Sector) { diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte index 5976c7c8..add0f341 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte @@ -62,6 +62,7 @@ // Filter state let searchName = $state(''); let searchRfc = $state(''); + let searchTaxId = $state(''); let searchType = $state($page.url.searchParams.get('type') || 'both'); let searchTimeout: ReturnType; let hasMounted = false; @@ -100,6 +101,7 @@ const type = searchType; const name = searchName; const rfc = searchRfc; + const taxId = searchTaxId; if (!browser || !hasMounted) return; clearTimeout(searchTimeout); @@ -145,6 +147,7 @@ // Ideally backend should handle this. I will assume backend filters for now or add query params. if (searchName) filters.name = searchName; if (searchRfc) filters.rfc = searchRfc; + if (searchTaxId) filters.tax_id = searchTaxId; const response = await clientsProvidersApi.list(companyId, pageToLoad, pageSize, filters); @@ -193,12 +196,6 @@ goto(`/dashboard/clients_and_providers/edit/${item.id}`); } - function taxIdOrRfcLabel(cp: ClientProvider | null): string { - if (!cp) return 'RFC'; - const proc = (cp.type_nat_foreign || 'N').toUpperCase(); - return proc === 'E' ? 'TAX-ID' : 'RFC'; - } - function handleEdit() { if (!canEditPartner) { toast.error('No tienes permiso para editar clientes o proveedores'); @@ -279,7 +276,7 @@

{t('Filtros', 'Filters')}

{t('Busque por nombre, RFC/TAX-ID o tipo', 'Search by name, RFC/TAX-ID or type')}
-
+
- + +
+
+ +
@@ -342,7 +347,8 @@ # - RFC / TAX-ID + RFC + TAX-ID Nombre Tipo Estatus @@ -351,12 +357,12 @@ {#if isLoading && allItems.length === 0} {t('Cargando...', 'Loading...')}{t('Cargando...', 'Loading...')} {:else if allItems.length === 0} {t('No se encontraron registros', 'No records found')} @@ -371,7 +377,8 @@ ondblclick={() => handleRowDoubleClick(item)} > {item.id} - {item.rfc} + {item.rfc || '—'} + {item.tax_id || '—'} {item.name} 0} - +
{#if isLoading} {t('Cargando más registros...', 'Loading more records...')} @@ -433,9 +440,19 @@ > {selectedItem?.name || '---'} -
- {taxIdOrRfcLabel(selectedItem)}: - {selectedItem?.rfc || ''} +
+ {#if selectedItem?.rfc} +
+ RFC: + {selectedItem.rfc} +
+ {/if} + {#if selectedItem?.tax_id} +
+ TAX-ID: + {selectedItem.tax_id} +
+ {/if}
diff --git a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte index 27af838d..61d4d2bd 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte @@ -84,6 +84,7 @@ // --- ESTADO INICIAL (PLANO) --- const getEmptyForm = () => ({ rfc: '', + tax_id: '', name: '', short_name: '', curp: '', @@ -162,6 +163,7 @@ // Mapear respuesta anidada -> formulario plano formData = { rfc: item.rfc || '', + tax_id: item.tax_id || '', name: item.name || '', short_name: item.short_name || '', curp: item.curp || '', @@ -217,26 +219,38 @@ toast.error(error); return; } - if (!formData.rfc.trim() || !formData.name.trim()) { - error = t('Identificador fiscal (RFC/TAX-ID) y Nombre son obligatorios', 'Tax Identifier (RFC/TAX-ID) and Name are required'); + if (!formData.name.trim()) { + error = t('El Nombre es obligatorio', 'Name is required'); toast.error(error); return; } const rfcVal = formData.rfc.trim(); + const taxIdVal = formData.tax_id.trim(); const isForeign = (formData.type_nat_foreign || 'N').toUpperCase() === 'E'; - if (isForeign) { - if (!TAX_ID_REGEX.test(rfcVal)) { - error = 'El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres.'; - toast.error(error); - return; - } - } else { - if (!RFC_REGEX.test(rfcVal)) { - error = 'El RFC no tiene el formato correcto. Ejemplo: XAXX010101000.'; - toast.error(error); - return; - } + + // Requerido según procedencia: Extranjero ⇒ TAX-ID, Nacional ⇒ RFC + if (isForeign && !taxIdVal) { + error = t('El TAX-ID es obligatorio para registros extranjeros', 'TAX-ID is required for foreign records'); + toast.error(error); + return; + } + if (!isForeign && !rfcVal) { + error = t('El RFC es obligatorio para registros nacionales', 'RFC is required for national records'); + toast.error(error); + return; + } + + // Formato: cada identificador valida solo si tiene valor (ambos pueden coexistir) + if (rfcVal && !RFC_REGEX.test(rfcVal)) { + error = 'El RFC no tiene el formato correcto. Ejemplo: XAXX010101000.'; + toast.error(error); + return; + } + if (taxIdVal && !TAX_ID_REGEX.test(taxIdVal)) { + error = 'El TAX-ID debe tener formato: 2 dígitos, guión y resto (ej. 12-3456789). Máx 30 caracteres.'; + toast.error(error); + return; } loading = true; @@ -246,6 +260,7 @@ // Reconstruir objeto anidado para el backend const payload = { rfc: clean(formData.rfc), + tax_id: clean(formData.tax_id), name: clean(formData.name), short_name: clean(formData.short_name), curp: clean(formData.curp), @@ -374,13 +389,27 @@ > -
+
- + +
+
+ +