Merge pull request 'feature/client-provider-taxid-rfc-fix' (#218) from feature/client-provider-taxid-rfc-fix into development
Reviewed-on: ADUANASOFT/anexo76#218
This commit is contained in:
@@ -6,7 +6,9 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
from decimal import Decimal
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from .validators import is_valid_rfc, is_valid_tax_id
|
||||
|
||||
|
||||
# DTOs para dirección
|
||||
@@ -54,7 +56,6 @@ class ClientProviderProgramsDTO(BaseModel):
|
||||
manufacturer_id: Optional[str] = Field(
|
||||
None, max_length=25, description="Manufacturer ID"
|
||||
)
|
||||
tax_id: Optional[str] = Field(None, max_length=30, description="Tax ID")
|
||||
broker: Optional[str] = Field(None, max_length=6, description="Broker")
|
||||
import_broker: Optional[str] = Field(
|
||||
None, max_length=6, description="Import broker"
|
||||
@@ -116,7 +117,6 @@ class ClientProviderCreateDTO(BaseModel):
|
||||
)
|
||||
position: Optional[str] = Field(None, max_length=30, description="Position")
|
||||
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
|
||||
is_national_provider: Optional[bool] = None
|
||||
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
|
||||
|
||||
# Nested DTOs
|
||||
@@ -127,6 +127,24 @@ class ClientProviderCreateDTO(BaseModel):
|
||||
None, description="Programs information"
|
||||
)
|
||||
|
||||
@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."
|
||||
)
|
||||
return self
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -157,7 +175,6 @@ class ClientProviderUpdateDTO(BaseModel):
|
||||
)
|
||||
position: Optional[str] = Field(None, max_length=30, description="Position")
|
||||
incoterm: Optional[str] = Field(None, max_length=19, description="Incoterm")
|
||||
is_national_provider: Optional[bool] = None
|
||||
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
|
||||
|
||||
# Nested DTOs
|
||||
@@ -168,6 +185,24 @@ class ClientProviderUpdateDTO(BaseModel):
|
||||
None, description="Programs information"
|
||||
)
|
||||
|
||||
@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."
|
||||
)
|
||||
return self
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -189,7 +224,6 @@ class ClientProviderResponseDTO(BaseModel):
|
||||
responsible: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
incoterm: Optional[str] = None
|
||||
is_national_provider: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
@@ -45,6 +45,7 @@ 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
|
||||
rfc: 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)
|
||||
@@ -55,7 +56,6 @@ class ClientProvider(Base, TenantScopedMixin, TimestampMixin):
|
||||
responsible: Mapped[Optional[str]] = mapped_column(String(80))
|
||||
position: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
incoterm: Mapped[Optional[str]] = mapped_column(String(19))
|
||||
is_national_provider: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
|
||||
# Relationships
|
||||
@@ -140,7 +140,6 @@ class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin):
|
||||
prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25))
|
||||
tax_id: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
broker: Mapped[Optional[str]] = mapped_column(String(6))
|
||||
import_broker: Mapped[Optional[str]] = mapped_column(String(6))
|
||||
transfer_key: Mapped[Optional[str]] = mapped_column(String(8))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Validadores de formato para RFC (Nacional) y TAX-ID (Extranjero) en clientes y proveedores.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Formato RFC México: 3-4 letras (A-Z, &, Ñ), 6 dígitos (fecha), 3 caracteres homoclave. Ej: XAXX010101000
|
||||
RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE)
|
||||
|
||||
# TAX-ID extranjero: 2 dígitos, guión, resto alfanumérico. Ej: 12-3456789 (EIN US). Total máx 30.
|
||||
TAX_ID_PATTERN = re.compile(r"^\d{2}-[A-Z0-9]{1,27}$", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_valid_rfc(value: str) -> bool:
|
||||
"""Valida formato RFC mexicano. Acepta cadena vacía/None como inválida (no opcional aquí)."""
|
||||
if not value or not isinstance(value, str):
|
||||
return False
|
||||
normalized = value.strip().upper()
|
||||
return bool(normalized and RFC_PATTERN.match(normalized))
|
||||
|
||||
|
||||
def is_valid_tax_id(value: str) -> bool:
|
||||
"""Valida formato TAX-ID (extranjero): 2 dígitos, guión y resto alfanumérico. Ej: 12-3456789."""
|
||||
if not value or not isinstance(value, str):
|
||||
return False
|
||||
normalized = value.strip()
|
||||
return bool(normalized and len(normalized) <= 30 and TAX_ID_PATTERN.match(normalized))
|
||||
@@ -12,6 +12,22 @@ from ..common.cell_value import cell_to_str
|
||||
# Valores que indican que la primera fila es cabecera (primera columna normalizada)
|
||||
FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE")
|
||||
|
||||
_ENCODING_FALLBACKS: Tuple[str, ...] = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
|
||||
|
||||
|
||||
def _read_text_sample(file_path: str, sample_bytes: int = 2048) -> str:
|
||||
with open(file_path, "rb") as f:
|
||||
raw = f.read(sample_bytes)
|
||||
last_err: Optional[Exception] = None
|
||||
for enc in _ENCODING_FALLBACKS:
|
||||
try:
|
||||
return raw.decode(enc)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if last_err:
|
||||
raise last_err
|
||||
return ""
|
||||
|
||||
|
||||
def detect_headers_or_data(
|
||||
file_path: str,
|
||||
@@ -26,8 +42,15 @@ def detect_headers_or_data(
|
||||
- Si no -> has_header=False, fieldnames=TEMPLATE_DOWNLOAD_HEADERS (la primera fila es dato).
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding=encoding) as f:
|
||||
sample = f.read(2048)
|
||||
# `encoding` se mantiene por compatibilidad; si falla, hacemos fallback para CSVs tipo Excel (cp1252/latin-1).
|
||||
if encoding and encoding.lower() not in ("auto", "detect"):
|
||||
try:
|
||||
with open(file_path, "r", encoding=encoding) as f:
|
||||
sample = f.read(2048)
|
||||
except Exception:
|
||||
sample = _read_text_sample(file_path, sample_bytes=2048)
|
||||
else:
|
||||
sample = _read_text_sample(file_path, sample_bytes=2048)
|
||||
except Exception:
|
||||
return None, True
|
||||
lines = sample.splitlines()
|
||||
|
||||
@@ -3,11 +3,16 @@ Validadores reutilizables para import CSV de clientes y proveedores.
|
||||
Paridad Clarion: procedencia E/N, tipo C/P/A, clave máx 8, SECON, Prosec, Vinculación,
|
||||
Es Empresa Certificada, Transformador/SubMaq, desfase.
|
||||
"""
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from api.v1.modules.a76.clients_and_providers.models import ClientOrProviderEnum
|
||||
|
||||
RFC_MAX = 30
|
||||
|
||||
# Formato RFC México: 3-4 letras, 6 dígitos, 3 homoclave. TAX-ID: 2 dígitos, guión, resto. Ej: 12-3456789.
|
||||
RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE)
|
||||
TAX_ID_PATTERN = re.compile(r"^\d{2}-[A-Z0-9]{1,27}$", re.IGNORECASE)
|
||||
NAME_MAX = 256
|
||||
SHORT_NAME_MAX = 10
|
||||
# Clarion: Col C máx 8 caracteres
|
||||
@@ -29,6 +34,36 @@ 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()
|
||||
if not val:
|
||||
return None
|
||||
if not RFC_PATTERN.match(val.upper()):
|
||||
return {
|
||||
"line": line_num,
|
||||
"col": "RFC",
|
||||
"msg": "El formato del RFC es inválido.",
|
||||
"solution": "Capturar en la columna E 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()
|
||||
if not val:
|
||||
return None
|
||||
if len(val) > 30 or not TAX_ID_PATTERN.match(val):
|
||||
return {
|
||||
"line": line_num,
|
||||
"col": "RFC",
|
||||
"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.",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def check_max_length(row: Dict[str, Any], col: str, max_len: int, line_num: int) -> Optional[Dict[str, Any]]:
|
||||
val = (row.get(col) or "").strip()
|
||||
if not val:
|
||||
|
||||
@@ -43,7 +43,6 @@ MAX_LEN = {
|
||||
"prosec_authorization": 20,
|
||||
"secon_authorization": 20,
|
||||
"manufacturer_id": 25,
|
||||
"tax_id_programs": 30,
|
||||
"broker": 6,
|
||||
"import_broker": 6,
|
||||
"transfer_key": 8,
|
||||
@@ -85,11 +84,15 @@ 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).
|
||||
Para compatibilidad con Clarion: se requiere RFC o SHORT_NAME para considerar la fila válida.
|
||||
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.
|
||||
"""
|
||||
rfc = _str_or_none(row_norm.get("RFC"), MAX_LEN["rfc"])
|
||||
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 and not 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
|
||||
@@ -110,7 +113,7 @@ def row_to_client_provider_data(
|
||||
cp_data = {
|
||||
"tenant_id": tenant_id,
|
||||
"company_id": company_id,
|
||||
"rfc": rfc or None,
|
||||
"rfc": rfc_unified 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"]),
|
||||
@@ -124,7 +127,6 @@ def row_to_client_provider_data(
|
||||
"position": _str_or_none(row_norm.get("POSICION"), MAX_LEN["position"]),
|
||||
"incoterm": _str_or_none(row_norm.get("INCOTERM"), MAX_LEN["incoterm"]),
|
||||
"is_active": parse_active(row_norm.get("ACTIVO")),
|
||||
"is_national_provider": True if procedencia == "N" else (False if procedencia == "E" else None),
|
||||
}
|
||||
|
||||
# Address
|
||||
@@ -174,12 +176,12 @@ def row_to_client_provider_data(
|
||||
tipo_prog or num_prog or fecha_secon is not None or prosec_val or num_aut_prosec
|
||||
or is_certified or reg_cert
|
||||
or _str_or_none(row_norm.get("MANUFACTURER_ID"))
|
||||
or _str_or_none(row_norm.get("TAX_ID_PROGRAMS"))
|
||||
or _str_or_none(row_norm.get("BROKER_EXPO"))
|
||||
or _str_or_none(row_norm.get("BROKER_IMPO"))
|
||||
or _str_or_none(row_norm.get("CLAVE_TRANSFER"))
|
||||
or applied_proportion is not None
|
||||
):
|
||||
# Identificador fiscal solo en ClientProvider.rfc (columna única)
|
||||
programs_data = {
|
||||
"program": tipo_prog[:7] if tipo_prog else None,
|
||||
"program_number": num_prog,
|
||||
@@ -190,7 +192,6 @@ def row_to_client_provider_data(
|
||||
"is_certified_company": is_certified,
|
||||
"certified_company_registry": reg_cert,
|
||||
"manufacturer_id": _str_or_none(row_norm.get("MANUFACTURER_ID"), MAX_LEN["manufacturer_id"]),
|
||||
"tax_id": _str_or_none(row_norm.get("TAX_ID_PROGRAMS"), MAX_LEN["tax_id_programs"]),
|
||||
"broker": _str_or_none(row_norm.get("BROKER_EXPO"), MAX_LEN["broker"]),
|
||||
"import_broker": _str_or_none(row_norm.get("BROKER_IMPO"), MAX_LEN["import_broker"]),
|
||||
"transfer_key": _str_or_none(row_norm.get("CLAVE_TRANSFER"), MAX_LEN["transfer_key"]),
|
||||
|
||||
@@ -13,6 +13,8 @@ from ..common.common_validators import (
|
||||
CURP_MAX,
|
||||
check_required_max,
|
||||
check_max_length,
|
||||
check_rfc_format,
|
||||
check_tax_id_format,
|
||||
check_tipo_client_provider,
|
||||
check_procedencia,
|
||||
check_short_name_max_clarion,
|
||||
@@ -161,8 +163,23 @@ def validate_row_client_provider(
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Compatibilidad: RFC requerido y longitudes (como antes)
|
||||
# Identificador fiscal requerido: Nacional = RFC, Extranjero = TAX-ID (mismo campo "RFC" en layout).
|
||||
procedencia = (row.get("PROCEDENCIA") or "").strip().upper()[:1]
|
||||
err = check_required_max(row, "RFC", RFC_MAX, line_num)
|
||||
if err:
|
||||
if procedencia == "E":
|
||||
err = {
|
||||
"line": line_num,
|
||||
"col": "RFC",
|
||||
"msg": "Requerido (TAX-ID)",
|
||||
"solution": "Capturar el TAX-ID del cliente/proveedor extranjero en la columna E (RFC/TAX-ID).",
|
||||
}
|
||||
return err
|
||||
# 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)
|
||||
if err:
|
||||
return err
|
||||
err = check_max_length(row, "NOMBRE", NAME_MAX, line_num)
|
||||
|
||||
@@ -5,7 +5,7 @@ Si se pasa headerless_first_cell_values, se detecta si la primera fila es cabece
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from typing import Iterator, Tuple, Dict, Any, Optional, List, Set
|
||||
from typing import Iterator, Tuple, Dict, Any, Optional, List, Set, Sequence
|
||||
|
||||
|
||||
def _normalize_empty_headers(headers: List[str]) -> List[str]:
|
||||
@@ -21,6 +21,32 @@ def _normalize_empty_headers(headers: List[str]) -> List[str]:
|
||||
return result
|
||||
|
||||
|
||||
_ENCODING_FALLBACKS: Sequence[str] = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
|
||||
|
||||
|
||||
def _detect_text_encoding(
|
||||
file_path: str,
|
||||
encodings: Sequence[str] = _ENCODING_FALLBACKS,
|
||||
sample_bytes: int = 8192,
|
||||
) -> str:
|
||||
"""
|
||||
Detecta encoding por prueba de decodificación en un sample en binario.
|
||||
Nota: latin-1 decodifica cualquier byte; por eso debe ir al final.
|
||||
"""
|
||||
with open(file_path, "rb") as f:
|
||||
raw = f.read(sample_bytes)
|
||||
last_err: Optional[Exception] = None
|
||||
for enc in encodings:
|
||||
try:
|
||||
raw.decode(enc)
|
||||
return enc
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if last_err:
|
||||
raise last_err
|
||||
return "utf-8-sig"
|
||||
|
||||
|
||||
def iter_csv_rows(
|
||||
file_path: str,
|
||||
fieldnames: Optional[List[str]] = None,
|
||||
@@ -34,7 +60,8 @@ def iter_csv_rows(
|
||||
(quitando BOM, strip, upper) está en headerless_first_cell_values, se trata como dato y se usan fieldnames.
|
||||
headerless_second_cell_key_pattern se ignora si no se usa (reservado para otros layouts).
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||
encoding = _detect_text_encoding(file_path)
|
||||
with open(file_path, "r", encoding=encoding) as f:
|
||||
sample = f.read(2048)
|
||||
f.seek(0)
|
||||
try:
|
||||
@@ -91,6 +118,7 @@ def iter_csv_rows(
|
||||
|
||||
def count_csv_rows(file_path: str, has_header: bool = True) -> int:
|
||||
"""Cuenta filas del CSV. Si has_header=True (por defecto), no cuenta la cabecera."""
|
||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||
encoding = _detect_text_encoding(file_path)
|
||||
with open(file_path, "r", encoding=encoding) as f:
|
||||
total_lines = sum(1 for _ in f)
|
||||
return total_lines if not has_header else max(0, total_lines - 1)
|
||||
|
||||
@@ -136,12 +136,8 @@ class AvisoConsolidadoExportacionService:
|
||||
if client_obj:
|
||||
# Fetch Address
|
||||
c_addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == target_client_id).first()
|
||||
# Fetch Fiscal Data (RFC)
|
||||
c_prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == target_client_id).first()
|
||||
|
||||
c_rfc = ""
|
||||
if c_prog and c_prog.tax_id: c_rfc = c_prog.tax_id
|
||||
elif hasattr(client_obj, 'rfc'): c_rfc = client_obj.rfc
|
||||
# Identificador fiscal único en ClientProvider.rfc (RFC o TAX-ID)
|
||||
c_rfc = getattr(client_obj, "rfc", "") or ""
|
||||
|
||||
c_dir_str = "DOMICILIO NO REGISTRADO"
|
||||
if c_addr:
|
||||
|
||||
@@ -127,11 +127,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=(
|
||||
prog.tax_id
|
||||
if (prog and prog.tax_id)
|
||||
else (getattr(main, "rfc", "") or "")
|
||||
),
|
||||
tax_id=getattr(main, "rfc", "") or "",
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=(
|
||||
|
||||
@@ -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=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
tax_id=getattr(main, "rfc", "") 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 "",
|
||||
|
||||
@@ -159,11 +159,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=(
|
||||
prog.tax_id
|
||||
if (prog and prog.tax_id)
|
||||
else (getattr(main, "rfc", "") or "")
|
||||
),
|
||||
tax_id=getattr(main, "rfc", "") or "",
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=(
|
||||
|
||||
@@ -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=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
tax_id=getattr(main, "rfc", "") 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 "",
|
||||
|
||||
@@ -153,11 +153,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=(
|
||||
prog.tax_id
|
||||
if (prog and prog.tax_id)
|
||||
else (getattr(main, "rfc", "") or "")
|
||||
),
|
||||
tax_id=getattr(main, "rfc", "") or "",
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=(
|
||||
|
||||
@@ -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=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
tax_id=getattr(main, "rfc", "") 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 "",
|
||||
|
||||
@@ -108,22 +108,24 @@ class DatabaseHelper:
|
||||
if not client_code:
|
||||
return {"name": None, "rfc": None, "tax_id": None}
|
||||
|
||||
client_type = 'PROVIDER' if is_supplier else 'CLIENT'
|
||||
# In our schema, enum values are lowercase: 'client', 'provider', 'both'
|
||||
client_type = 'provider' if is_supplier else 'client'
|
||||
|
||||
try:
|
||||
sql = text("""
|
||||
SELECT cp.name, cp.rfc, cpp.tax_id
|
||||
SELECT cp.name, cp.rfc
|
||||
FROM a76.clients_and_providers cp
|
||||
LEFT JOIN a76.clients_and_providers_programs cpp ON cpp.client_id = cp.id
|
||||
WHERE cp.id = :client_code AND cp.client_or_provider = :client_type
|
||||
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)
|
||||
return {
|
||||
"name": result[0],
|
||||
"rfc": result[1],
|
||||
"tax_id": result[2]
|
||||
"tax_id": result[1],
|
||||
}
|
||||
else:
|
||||
logger.debug(f"Client {client_code} not found as {client_type}")
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface ClientProviderPrograms {
|
||||
secon_auth_date?: number | null; // YYYYMMDD
|
||||
prosec?: number | null;
|
||||
manufacturer_id?: string | null;
|
||||
tax_id?: string | null;
|
||||
ctpat_svi?: string | null;
|
||||
is_certified_company?: string | null;
|
||||
}
|
||||
@@ -45,8 +44,6 @@ export interface ClientProvider {
|
||||
responsible?: string | null;
|
||||
position?: string | null;
|
||||
|
||||
// Booleanos (Coincidiendo con la BD)
|
||||
is_national_provider?: boolean | null;
|
||||
is_active?: boolean;
|
||||
|
||||
// Relaciones Anidadas
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createColumns(onSuccess) {
|
||||
},
|
||||
{
|
||||
accessorKey: "rfc",
|
||||
header: "RFC",
|
||||
header: "RFC / TAX-ID",
|
||||
cell: ({ row }) => {
|
||||
const snippet = createRawSnippet((getData) => {
|
||||
const { val } = getData();
|
||||
|
||||
@@ -21,21 +21,26 @@
|
||||
rfc: "",
|
||||
name: "",
|
||||
curp: "",
|
||||
residence_country: "",
|
||||
domicile_fiscal: "",
|
||||
foreign_tax_id: "",
|
||||
type_nat_foreign: "N",
|
||||
client_or_provider: "client",
|
||||
is_active: true,
|
||||
// Address fields
|
||||
street: "",
|
||||
streets: "",
|
||||
exterior_number: "",
|
||||
interior_number: "",
|
||||
neighborhood: "",
|
||||
municipality: "",
|
||||
city: "",
|
||||
state: "",
|
||||
country: "",
|
||||
zip_code: "",
|
||||
// Programs fields
|
||||
program_code: "",
|
||||
authorization_date: ""
|
||||
postal_code: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
contact: "",
|
||||
// Programs fields (subset)
|
||||
program: "",
|
||||
program_number: "",
|
||||
manufacturer_id: ""
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
@@ -48,43 +53,59 @@
|
||||
rfc: item.rfc,
|
||||
name: item.name,
|
||||
curp: item.curp || "",
|
||||
residence_country: item.residence_country || "",
|
||||
domicile_fiscal: item.domicile_fiscal || "",
|
||||
foreign_tax_id: item.foreign_tax_id || "",
|
||||
type_nat_foreign: item.type_nat_foreign || "N",
|
||||
client_or_provider: item.client_or_provider || "client",
|
||||
is_active: item.is_active ?? true,
|
||||
street: item.address?.street || "",
|
||||
streets: item.address?.streets || "",
|
||||
exterior_number: item.address?.exterior_number || "",
|
||||
interior_number: item.address?.interior_number || "",
|
||||
neighborhood: item.address?.neighborhood || "",
|
||||
municipality: item.address?.municipality || "",
|
||||
city: item.address?.city || "",
|
||||
state: item.address?.state || "",
|
||||
country: item.address?.country || "",
|
||||
zip_code: item.address?.zip_code || "",
|
||||
program_code: item.programs?.program_code || "",
|
||||
authorization_date: item.programs?.authorization_date || ""
|
||||
postal_code: item.address?.postal_code || "",
|
||||
email: item.address?.email || "",
|
||||
phone: item.address?.phone || "",
|
||||
contact: item.address?.contact || "",
|
||||
program: item.programs?.program || "",
|
||||
program_number: item.programs?.program_number || "",
|
||||
manufacturer_id: item.programs?.manufacturer_id || ""
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
rfc: "",
|
||||
name: "",
|
||||
curp: "",
|
||||
residence_country: "",
|
||||
domicile_fiscal: "",
|
||||
foreign_tax_id: "",
|
||||
type_nat_foreign: "N",
|
||||
client_or_provider: "client",
|
||||
is_active: true,
|
||||
street: "",
|
||||
streets: "",
|
||||
exterior_number: "",
|
||||
interior_number: "",
|
||||
neighborhood: "",
|
||||
municipality: "",
|
||||
city: "",
|
||||
state: "",
|
||||
country: "",
|
||||
zip_code: "",
|
||||
program_code: "",
|
||||
authorization_date: ""
|
||||
postal_code: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
contact: "",
|
||||
program: "",
|
||||
program_number: "",
|
||||
manufacturer_id: ""
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const isEditing = $derived(!!item);
|
||||
const isForeign = $derived((formData.type_nat_foreign || "N").toUpperCase() === "E");
|
||||
|
||||
// Validación de formato: RFC (Nacional) o TAX-ID (Extranjero)
|
||||
const RFC_REGEX = /^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/i;
|
||||
// TAX-ID: 2 dígitos, guión, resto alfanumérico. Ej: 12-3456789. Máx 30 caracteres.
|
||||
const TAX_ID_REGEX = /^\d{2}-[A-Z0-9]{1,27}$/i;
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
@@ -94,6 +115,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const rfcVal = (formData.rfc || "").trim();
|
||||
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.";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!RFC_REGEX.test(rfcVal)) {
|
||||
error = "El RFC no tiene el formato correcto. Ejemplo: XAXX010101000.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
@@ -104,23 +138,27 @@
|
||||
rfc: formData.rfc,
|
||||
name: formData.name,
|
||||
curp: formData.curp || null,
|
||||
residence_country: formData.residence_country || null,
|
||||
domicile_fiscal: formData.domicile_fiscal || null,
|
||||
foreign_tax_id: formData.foreign_tax_id || null,
|
||||
client_or_provider: formData.client_or_provider as "client" | "provider" | "both" | null,
|
||||
type_nat_foreign: (formData.type_nat_foreign || null) as any,
|
||||
is_active: formData.is_active,
|
||||
address: {
|
||||
street: formData.street || null,
|
||||
streets: formData.streets || null,
|
||||
exterior_number: formData.exterior_number || null,
|
||||
interior_number: formData.interior_number || null,
|
||||
neighborhood: formData.neighborhood || null,
|
||||
municipality: formData.municipality || null,
|
||||
city: formData.city || null,
|
||||
state: formData.state || null,
|
||||
country: formData.country || null,
|
||||
zip_code: formData.zip_code || null,
|
||||
postal_code: formData.postal_code || null,
|
||||
email: formData.email || null,
|
||||
phone: formData.phone || null,
|
||||
contact: formData.contact || null,
|
||||
},
|
||||
programs: {
|
||||
program_code: formData.program_code || null,
|
||||
authorization_date: formData.authorization_date || null,
|
||||
|
||||
program: formData.program || null,
|
||||
program_number: formData.program_number || null,
|
||||
manufacturer_id: formData.manufacturer_id || null,
|
||||
}
|
||||
};
|
||||
response = await clientsProvidersApi.update(item.id, companyStore.activeCompany.id, payload);
|
||||
@@ -129,22 +167,27 @@
|
||||
rfc: formData.rfc,
|
||||
name: formData.name,
|
||||
curp: formData.curp || null,
|
||||
residence_country: formData.residence_country || null,
|
||||
domicile_fiscal: formData.domicile_fiscal || null,
|
||||
foreign_tax_id: formData.foreign_tax_id || null,
|
||||
client_or_provider: formData.client_or_provider as "client" | "provider" | "both" | null,
|
||||
type_nat_foreign: (formData.type_nat_foreign || null) as any,
|
||||
is_active: formData.is_active,
|
||||
address: {
|
||||
street: formData.street || null,
|
||||
streets: formData.streets || null,
|
||||
exterior_number: formData.exterior_number || null,
|
||||
interior_number: formData.interior_number || null,
|
||||
neighborhood: formData.neighborhood || null,
|
||||
municipality: formData.municipality || null,
|
||||
city: formData.city || null,
|
||||
state: formData.state || null,
|
||||
country: formData.country || null,
|
||||
zip_code: formData.zip_code || null,
|
||||
postal_code: formData.postal_code || null,
|
||||
email: formData.email || null,
|
||||
phone: formData.phone || null,
|
||||
contact: formData.contact || null,
|
||||
},
|
||||
programs: {
|
||||
program_code: formData.program_code || null,
|
||||
authorization_date: formData.authorization_date || null,
|
||||
program: formData.program || null,
|
||||
program_number: formData.program_number || null,
|
||||
manufacturer_id: formData.manufacturer_id || null,
|
||||
}
|
||||
};
|
||||
response = await clientsProvidersApi.create(companyStore.activeCompany.id, payload);
|
||||
@@ -182,19 +225,24 @@
|
||||
rfc: "",
|
||||
name: "",
|
||||
curp: "",
|
||||
residence_country: "",
|
||||
domicile_fiscal: "",
|
||||
foreign_tax_id: "",
|
||||
type_nat_foreign: "N",
|
||||
client_or_provider: "client",
|
||||
is_active: true,
|
||||
street: "",
|
||||
streets: "",
|
||||
exterior_number: "",
|
||||
interior_number: "",
|
||||
neighborhood: "",
|
||||
municipality: "",
|
||||
city: "",
|
||||
state: "",
|
||||
country: "",
|
||||
zip_code: "",
|
||||
program_code: "",
|
||||
authorization_date: ""
|
||||
postal_code: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
contact: "",
|
||||
program: "",
|
||||
program_number: "",
|
||||
manufacturer_id: ""
|
||||
};
|
||||
error = null;
|
||||
}
|
||||
@@ -226,14 +274,27 @@
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Información Básica</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="rfc">RFC *</Label>
|
||||
<Label for="type_nat_foreign">Procedencia *</Label>
|
||||
<select
|
||||
id="type_nat_foreign"
|
||||
bind:value={formData.type_nat_foreign}
|
||||
required
|
||||
disabled={loading}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="N">Nacional</option>
|
||||
<option value="E">Extranjero</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="rfc">{isForeign ? "TAX-ID" : "RFC"} *</Label>
|
||||
<Input
|
||||
id="rfc"
|
||||
bind:value={formData.rfc}
|
||||
placeholder="Ej: XAXX010101000"
|
||||
maxlength={13}
|
||||
placeholder={isForeign ? "Ej: 12-3456789 (según país)" : "Ej: XAXX010101000"}
|
||||
maxlength={30}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -292,48 +353,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información fiscal -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Información Fiscal</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="domicile_fiscal">Domicilio Fiscal</Label>
|
||||
<Input
|
||||
id="domicile_fiscal"
|
||||
bind:value={formData.domicile_fiscal}
|
||||
placeholder="Domicilio fiscal completo"
|
||||
maxlength={256}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="foreign_tax_id">ID Fiscal Extranjero</Label>
|
||||
<Input
|
||||
id="foreign_tax_id"
|
||||
bind:value={formData.foreign_tax_id}
|
||||
placeholder="Para contribuyentes extranjeros"
|
||||
maxlength={64}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dirección -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Dirección</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="street">Calle</Label>
|
||||
<Label for="streets">Calles</Label>
|
||||
<Input
|
||||
id="street"
|
||||
bind:value={formData.street}
|
||||
placeholder="Calle y número"
|
||||
id="streets"
|
||||
bind:value={formData.streets}
|
||||
placeholder="Calle y referencias"
|
||||
maxlength={256}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="exterior_number">Número exterior</Label>
|
||||
<Input
|
||||
id="exterior_number"
|
||||
bind:value={formData.exterior_number}
|
||||
placeholder="Ej: 123"
|
||||
maxlength={20}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="interior_number">Número interior</Label>
|
||||
<Input
|
||||
id="interior_number"
|
||||
bind:value={formData.interior_number}
|
||||
placeholder="Ej: 4B"
|
||||
maxlength={20}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="neighborhood">Colonia</Label>
|
||||
@@ -347,10 +405,10 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="zip_code">Código Postal</Label>
|
||||
<Label for="postal_code">Código Postal</Label>
|
||||
<Input
|
||||
id="zip_code"
|
||||
bind:value={formData.zip_code}
|
||||
id="postal_code"
|
||||
bind:value={formData.postal_code}
|
||||
placeholder="Ej: 12345"
|
||||
maxlength={10}
|
||||
disabled={loading}
|
||||
@@ -359,6 +417,17 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="municipality">Municipio</Label>
|
||||
<Input
|
||||
id="municipality"
|
||||
bind:value={formData.municipality}
|
||||
placeholder="Municipio"
|
||||
maxlength={150}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="city">Ciudad</Label>
|
||||
<Input
|
||||
@@ -392,30 +461,76 @@
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
placeholder="correo@dominio.com"
|
||||
maxlength={100}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="phone">Teléfono</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
bind:value={formData.phone}
|
||||
placeholder="Teléfono"
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="contact">Contacto</Label>
|
||||
<Input
|
||||
id="contact"
|
||||
bind:value={formData.contact}
|
||||
placeholder="Persona de contacto"
|
||||
maxlength={50}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Programas -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Programas</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="program_code">Código de Programa</Label>
|
||||
<Label for="program">Programa</Label>
|
||||
<Input
|
||||
id="program_code"
|
||||
bind:value={formData.program_code}
|
||||
placeholder="Código del programa"
|
||||
maxlength={32}
|
||||
id="program"
|
||||
bind:value={formData.program}
|
||||
placeholder="Ej: IMMEX"
|
||||
maxlength={7}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="authorization_date">Fecha de Autorización</Label>
|
||||
<Label for="program_number">Número de Programa</Label>
|
||||
<Input
|
||||
id="authorization_date"
|
||||
type="date"
|
||||
bind:value={formData.authorization_date}
|
||||
id="program_number"
|
||||
bind:value={formData.program_number}
|
||||
placeholder="Número"
|
||||
maxlength={40}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="manufacturer_id">Manufacturer ID</Label>
|
||||
<Input
|
||||
id="manufacturer_id"
|
||||
bind:value={formData.manufacturer_id}
|
||||
placeholder="Manufacturer ID"
|
||||
maxlength={25}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import type { ClientProvider } from "./columns.js";
|
||||
import type { ClientProvider } from "$lib/api/dashboard/a76/clients-providers";
|
||||
import DetailsDialog from "./details-dialog.svelte";
|
||||
import DeleteDialog from "./delete-dialog.svelte";
|
||||
import { clientsProvidersApi } from "$lib/api/dashboard/a76/clients-providers";
|
||||
@@ -29,6 +29,9 @@
|
||||
navigator.clipboard.writeText(item.rfc);
|
||||
}
|
||||
|
||||
const isForeign = (cp: ClientProvider) =>
|
||||
(cp.type_nat_foreign || "N").toUpperCase() === "E";
|
||||
|
||||
function handleViewDetails() {
|
||||
showDetailsDialog = true;
|
||||
}
|
||||
@@ -77,7 +80,7 @@
|
||||
Copiar ID
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleCopyRfc}>
|
||||
Copiar RFC
|
||||
Copiar {isForeign(item) ? 'TAX-ID' : 'RFC'}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
}
|
||||
open = newOpen;
|
||||
}
|
||||
|
||||
const isForeign = (cp: ClientProvider) =>
|
||||
(cp.type_nat_foreign || "N").toUpperCase() === "E";
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
@@ -66,7 +69,7 @@
|
||||
<span class="font-semibold">{item.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium">RFC:</span>
|
||||
<span class="font-medium">{item ? (isForeign(item) ? 'TAX-ID:' : 'RFC:') : 'RFC:'}</span>
|
||||
<code class="font-mono font-semibold">{item.rfc}</code>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
open = newOpen;
|
||||
}
|
||||
|
||||
const isForeign = (cp: ClientProvider) =>
|
||||
(cp.type_nat_foreign || "N").toUpperCase() === "E";
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
@@ -39,7 +42,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">RFC</span>
|
||||
<span class="text-xs font-medium text-muted-foreground">
|
||||
{isForeign(item) ? "TAX-ID" : "RFC"}
|
||||
</span>
|
||||
<code class="text-sm font-mono font-semibold">{item.rfc}</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +68,7 @@
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Clasificación</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Tipo</span>
|
||||
{#if item.client_or_provider === 'client'}
|
||||
@@ -82,6 +87,13 @@
|
||||
<span class="text-sm text-muted-foreground">No especificado</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Procedencia</span>
|
||||
<span class="text-sm font-medium">
|
||||
{isForeign(item) ? "Extranjero" : "Nacional"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Estado</span>
|
||||
@@ -100,44 +112,16 @@
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<!-- Información fiscal -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Información Fiscal</h3>
|
||||
|
||||
{#if item.residence_country}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">País de Residencia</span>
|
||||
<span class="text-sm">{item.residence_country}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.domicile_fiscal}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Domicilio Fiscal</span>
|
||||
<span class="text-sm">{item.domicile_fiscal}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.foreign_tax_id}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">ID Fiscal Extranjero</span>
|
||||
<code class="text-sm font-mono">{item.foreign_tax_id}</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<!-- Dirección (si existe) -->
|
||||
{#if item.address}
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Dirección</h3>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#if item.address.street}
|
||||
{#if item.address.streets}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Calle</span>
|
||||
<span class="text-sm">{item.address.street}</span>
|
||||
<span class="text-xs font-medium text-muted-foreground">Calles</span>
|
||||
<span class="text-sm">{item.address.streets}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -149,15 +133,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.address.zip_code}
|
||||
{#if item.address.postal_code}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Código Postal</span>
|
||||
<span class="text-sm">{item.address.zip_code}</span>
|
||||
<span class="text-sm">{item.address.postal_code}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#if item.address.municipality}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Municipio</span>
|
||||
<span class="text-sm">{item.address.municipality}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.address.city}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Ciudad</span>
|
||||
@@ -179,6 +170,29 @@
|
||||
<span class="text-sm">{item.address.country}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.address.email || item.address.phone || item.address.contact}
|
||||
<div class="grid grid-cols-2 gap-2 pt-2">
|
||||
{#if item.address.email}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Email</span>
|
||||
<span class="text-sm">{item.address.email}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.address.phone}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Teléfono</span>
|
||||
<span class="text-sm">{item.address.phone}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.address.contact}
|
||||
<div class="flex flex-col gap-1 col-span-2">
|
||||
<span class="text-xs font-medium text-muted-foreground">Contacto</span>
|
||||
<span class="text-sm">{item.address.contact}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -190,17 +204,27 @@
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Programas</h3>
|
||||
|
||||
{#if item.programs.program_code}
|
||||
{#if item.programs.program}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Código de Programa</span>
|
||||
<code class="text-sm font-mono">{item.programs.program_code}</code>
|
||||
<span class="text-xs font-medium text-muted-foreground">Programa</span>
|
||||
<code class="text-sm font-mono">{item.programs.program}</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.programs.authorization_date}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Fecha de Autorización</span>
|
||||
<span class="text-sm">{new Date(item.programs.authorization_date).toLocaleDateString()}</span>
|
||||
{#if item.programs.program_number || item.programs.manufacturer_id}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#if item.programs.program_number}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Número</span>
|
||||
<span class="text-sm">{item.programs.program_number}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.programs.manufacturer_id}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">Manufacturer ID</span>
|
||||
<span class="text-sm font-mono">{item.programs.manufacturer_id}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,12 @@
|
||||
selectedItem = item;
|
||||
}
|
||||
|
||||
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 (selectedItem) goto(`/dashboard/clients_and_providers/edit/${selectedItem.id}`);
|
||||
}
|
||||
@@ -182,7 +188,7 @@
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">Filtros</h2>
|
||||
<span class="text-xs text-muted-foreground">Busque por nombre, RFC o tipo</span>
|
||||
<span class="text-xs text-muted-foreground">Busque por nombre, RFC/TAX-ID o tipo</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="space-y-2">
|
||||
@@ -195,10 +201,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">RFC</Label>
|
||||
<Label class="text-xs">RFC / TAX-ID</Label>
|
||||
<Input
|
||||
bind:value={searchRfc}
|
||||
placeholder="RFC..."
|
||||
placeholder="RFC / TAX-ID..."
|
||||
class="h-9"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
@@ -249,7 +255,7 @@
|
||||
<thead class="bg-muted text-muted-foreground border-b">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left w-8">#</th>
|
||||
<th class="px-3 py-2 text-left">RFC</th>
|
||||
<th class="px-3 py-2 text-left">RFC / TAX-ID</th>
|
||||
<th class="px-3 py-2 text-left">Nombre</th>
|
||||
<th class="px-3 py-2 text-left">Tipo</th>
|
||||
<th class="px-3 py-2 text-left">Estatus</th>
|
||||
@@ -352,6 +358,7 @@
|
||||
{selectedItem?.name || '---'}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs font-medium text-muted-foreground">{taxIdOrRfcLabel(selectedItem)}:</span>
|
||||
<span class="text-xs font-mono text-muted-foreground">{selectedItem?.rfc || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,10 +441,6 @@
|
||||
<span class="text-xs text-muted-foreground block">Número</span>
|
||||
<span class="font-medium">{selectedItem.programs.program_number || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground block">TAX ID</span>
|
||||
<span class="font-medium">{selectedItem.programs.tax_id || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
responsible: '',
|
||||
position: '',
|
||||
is_active: true,
|
||||
is_national_provider: false,
|
||||
|
||||
streets: '',
|
||||
exterior_number: '',
|
||||
@@ -94,7 +93,6 @@
|
||||
authorization_date_str: '', // String para input date
|
||||
prosec: '',
|
||||
manufacturer_id: '',
|
||||
tax_id: '',
|
||||
ctpat_svi: '',
|
||||
is_certified_company: false
|
||||
});
|
||||
@@ -152,7 +150,6 @@
|
||||
responsible: item.responsible || '',
|
||||
position: item.position || '',
|
||||
is_active: !!item.is_active,
|
||||
is_national_provider: !!item.is_national_provider,
|
||||
|
||||
streets: addr.streets || '',
|
||||
exterior_number: addr.exterior_number || '',
|
||||
@@ -172,7 +169,6 @@
|
||||
authorization_date_str: intDateToString(prog.secon_auth_date),
|
||||
prosec: prog.prosec ? String(prog.prosec) : '',
|
||||
manufacturer_id: prog.manufacturer_id || '',
|
||||
tax_id: prog.tax_id || '',
|
||||
ctpat_svi: prog.ctpat_svi || '',
|
||||
is_certified_company: prog.is_certified_company === '1'
|
||||
};
|
||||
@@ -185,6 +181,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Validación de formato: RFC (Nacional) o TAX-ID (Extranjero)
|
||||
const RFC_REGEX = /^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/i;
|
||||
// TAX-ID: 2 dígitos, guión, resto alfanumérico. Ej: 12-3456789. Máx 30 caracteres.
|
||||
const TAX_ID_REGEX = /^\d{2}-[A-Z0-9]{1,27}$/i;
|
||||
|
||||
// --- ENVÍO DE DATOS ---
|
||||
async function handleSubmit() {
|
||||
if (!companyStore.activeCompany) {
|
||||
@@ -193,11 +194,27 @@
|
||||
return;
|
||||
}
|
||||
if (!formData.rfc.trim() || !formData.name.trim()) {
|
||||
error = 'RFC y Nombre obligatorios';
|
||||
error = 'Identificador fiscal (RFC/TAX-ID) y Nombre son obligatorios';
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rfcVal = formData.rfc.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;
|
||||
}
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
@@ -213,7 +230,6 @@
|
||||
responsible: clean(formData.responsible),
|
||||
position: clean(formData.position),
|
||||
is_active: formData.is_active,
|
||||
is_national_provider: formData.is_national_provider,
|
||||
|
||||
address: {
|
||||
streets: clean(formData.streets),
|
||||
@@ -236,7 +252,6 @@
|
||||
secon_auth_date: stringDateToInt(formData.authorization_date_str),
|
||||
prosec: clean(formData.prosec),
|
||||
manufacturer_id: clean(formData.manufacturer_id),
|
||||
tax_id: clean(formData.tax_id),
|
||||
ctpat_svi: clean(formData.ctpat_svi),
|
||||
is_certified_company: formData.is_certified_company ? '1' : '0'
|
||||
}
|
||||
@@ -333,12 +348,12 @@
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="rfc">RFC <span class="text-destructive">*</span></Label>
|
||||
<Label for="rfc">{formData.type_nat_foreign?.toUpperCase() === 'E' ? 'TAX-ID' : 'RFC'} <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="rfc"
|
||||
bind:value={formData.rfc}
|
||||
placeholder="XAXX010101000"
|
||||
maxlength={13}
|
||||
placeholder={formData.type_nat_foreign?.toUpperCase() === 'E' ? 'Ej: 12-3456789 (según país)' : 'XAXX010101000'}
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
@@ -659,19 +674,6 @@
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="tax_id" class="flex items-center gap-2">
|
||||
<Globe size={14} class="text-muted-foreground" />
|
||||
Tax ID (Extranjero)
|
||||
</Label>
|
||||
<Input
|
||||
id="tax_id"
|
||||
bind:value={formData.tax_id}
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
placeholder="Identificador fiscal extranjero"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="man_id" class="flex items-center gap-2">
|
||||
<FileText size={14} class="text-muted-foreground" />
|
||||
@@ -746,17 +748,6 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-lg border bg-card p-4">
|
||||
<Switch
|
||||
id="is_national"
|
||||
bind:checked={formData.is_national_provider}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div class="grid gap-0.5">
|
||||
<Label for="is_national">Proveedor Nacional</Label>
|
||||
<p class="text-xs text-muted-foreground">Marcar si es un proveedor nacional</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
Reference in New Issue
Block a user