catalogmx 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- catalogmx/__init__.py +133 -19
- catalogmx/calculators/__init__.py +113 -0
- catalogmx/calculators/costo_trabajador.py +213 -0
- catalogmx/calculators/impuestos.py +920 -0
- catalogmx/calculators/imss.py +370 -0
- catalogmx/calculators/isr.py +290 -0
- catalogmx/calculators/resico.py +154 -0
- catalogmx/catalogs/banxico/__init__.py +29 -3
- catalogmx/catalogs/banxico/cetes_sqlite.py +279 -0
- catalogmx/catalogs/banxico/inflacion_sqlite.py +302 -0
- catalogmx/catalogs/banxico/salarios_minimos_sqlite.py +295 -0
- catalogmx/catalogs/banxico/tiie_sqlite.py +279 -0
- catalogmx/catalogs/banxico/tipo_cambio_usd_sqlite.py +255 -0
- catalogmx/catalogs/banxico/udis_sqlite.py +332 -0
- catalogmx/catalogs/cnbv/__init__.py +9 -0
- catalogmx/catalogs/cnbv/sectores.py +173 -0
- catalogmx/catalogs/conapo/__init__.py +15 -0
- catalogmx/catalogs/conapo/sistema_urbano_nacional.py +50 -0
- catalogmx/catalogs/conapo/zonas_metropolitanas.py +230 -0
- catalogmx/catalogs/ift/__init__.py +1 -1
- catalogmx/catalogs/ift/codigos_lada.py +517 -313
- catalogmx/catalogs/inegi/__init__.py +17 -0
- catalogmx/catalogs/inegi/scian.py +127 -0
- catalogmx/catalogs/mexico/__init__.py +2 -0
- catalogmx/catalogs/mexico/giros_mercantiles.py +119 -0
- catalogmx/catalogs/sat/carta_porte/material_peligroso.py +5 -1
- catalogmx/catalogs/sat/cfdi_4/clave_prod_serv.py +78 -0
- catalogmx/catalogs/sat/cfdi_4/tasa_o_cuota.py +2 -1
- catalogmx/catalogs/sepomex/__init__.py +2 -1
- catalogmx/catalogs/sepomex/codigos_postales.py +30 -2
- catalogmx/catalogs/sepomex/codigos_postales_completo.py +261 -0
- catalogmx/cli.py +12 -9
- catalogmx/data/__init__.py +10 -0
- catalogmx/data/mexico_dynamic.sqlite3 +0 -0
- catalogmx/data/updater.py +362 -0
- catalogmx/generators/__init__.py +20 -0
- catalogmx/generators/identity.py +582 -0
- catalogmx/helpers.py +177 -3
- catalogmx/utils/__init__.py +29 -0
- catalogmx/utils/clabe_utils.py +417 -0
- catalogmx/utils/text.py +7 -1
- catalogmx/validators/clabe.py +52 -2
- catalogmx/validators/nss.py +32 -27
- catalogmx/validators/rfc.py +185 -52
- catalogmx-0.4.0.dist-info/METADATA +905 -0
- {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/RECORD +51 -25
- {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/WHEEL +1 -1
- catalogmx/catalogs/banxico/udis.py +0 -279
- catalogmx-0.3.0.dist-info/METADATA +0 -644
- {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/entry_points.txt +0 -0
- {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {catalogmx-0.3.0.dist-info → catalogmx-0.4.0.dist-info}/top_level.txt +0 -0
catalogmx/validators/clabe.py
CHANGED
|
@@ -167,7 +167,7 @@ class CLABEValidator:
|
|
|
167
167
|
return self.clabe[17]
|
|
168
168
|
return None
|
|
169
169
|
|
|
170
|
-
def get_parts(self) -> dict[str, str] | None:
|
|
170
|
+
def get_parts(self) -> dict[str, str | None] | None:
|
|
171
171
|
"""
|
|
172
172
|
Returns all CLABE parts as a dictionary
|
|
173
173
|
:return: Dictionary with bank_code, branch_code, account_number, check_digit
|
|
@@ -222,7 +222,7 @@ def generate_clabe(bank_code: str | int, branch_code: str | int, account_number:
|
|
|
222
222
|
return clabe_17 + check_digit
|
|
223
223
|
|
|
224
224
|
|
|
225
|
-
def get_clabe_info(clabe: str | None) -> dict[str, str] | None:
|
|
225
|
+
def get_clabe_info(clabe: str | None) -> dict[str, str | None] | None:
|
|
226
226
|
"""
|
|
227
227
|
Helper function to get information from a CLABE
|
|
228
228
|
|
|
@@ -231,3 +231,53 @@ def get_clabe_info(clabe: str | None) -> dict[str, str] | None:
|
|
|
231
231
|
"""
|
|
232
232
|
validator = CLABEValidator(clabe)
|
|
233
233
|
return validator.get_parts()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def generate_clabe_random(
|
|
237
|
+
bank_code: str | int | None = None,
|
|
238
|
+
plaza_code: str | int | None = None,
|
|
239
|
+
account_number: str | int | None = None,
|
|
240
|
+
) -> str:
|
|
241
|
+
"""
|
|
242
|
+
Generates a random valid CLABE with optional parameters.
|
|
243
|
+
Any parameter not provided will be randomly generated.
|
|
244
|
+
|
|
245
|
+
:param bank_code: Optional 3-digit bank code (e.g., '012' for BBVA)
|
|
246
|
+
:param plaza_code: Optional 3-digit plaza code
|
|
247
|
+
:param account_number: Optional 11-digit account number
|
|
248
|
+
:return: Complete 18-digit CLABE
|
|
249
|
+
|
|
250
|
+
Example:
|
|
251
|
+
>>> # Fully random CLABE
|
|
252
|
+
>>> clabe = generate_clabe_random()
|
|
253
|
+
>>> len(clabe)
|
|
254
|
+
18
|
|
255
|
+
>>> # CLABE for specific bank
|
|
256
|
+
>>> clabe = generate_clabe_random(bank_code='012')
|
|
257
|
+
>>> clabe[:3]
|
|
258
|
+
'012'
|
|
259
|
+
>>> # Fully specified
|
|
260
|
+
>>> clabe = generate_clabe_random('002', '010', '12345678901')
|
|
261
|
+
>>> clabe
|
|
262
|
+
'002010123456789018'
|
|
263
|
+
"""
|
|
264
|
+
import random
|
|
265
|
+
|
|
266
|
+
# Generate bank code if not provided
|
|
267
|
+
if bank_code is None:
|
|
268
|
+
# Common SPEI bank codes
|
|
269
|
+
common_banks = ["002", "012", "014", "021", "036", "044", "058", "072", "127"]
|
|
270
|
+
bank_code = random.choice(common_banks)
|
|
271
|
+
bank_str = str(bank_code).zfill(3)
|
|
272
|
+
|
|
273
|
+
# Generate plaza code if not provided
|
|
274
|
+
if plaza_code is None:
|
|
275
|
+
plaza_code = random.randint(1, 999)
|
|
276
|
+
plaza_str = str(plaza_code).zfill(3)
|
|
277
|
+
|
|
278
|
+
# Generate account number if not provided
|
|
279
|
+
if account_number is None:
|
|
280
|
+
account_number = random.randint(0, 99999999999)
|
|
281
|
+
account_str = str(account_number).zfill(11)
|
|
282
|
+
|
|
283
|
+
return generate_clabe(bank_str, plaza_str, account_str)
|
catalogmx/validators/nss.py
CHANGED
|
@@ -4,18 +4,19 @@ NSS (Número de Seguridad Social) Validator
|
|
|
4
4
|
|
|
5
5
|
NSS is the 11-digit social security number issued by IMSS (Instituto Mexicano del Seguro Social).
|
|
6
6
|
|
|
7
|
-
Structure:
|
|
8
|
-
- 2 digits: Subdelegation code
|
|
9
|
-
- 2 digits:
|
|
10
|
-
- 2 digits:
|
|
11
|
-
-
|
|
7
|
+
Structure (11 digits):
|
|
8
|
+
- 2 digits: Subdelegation code (IMSS office)
|
|
9
|
+
- 2 digits: Registration year (last 2 digits)
|
|
10
|
+
- 2 digits: Birth year (last 2 digits)
|
|
11
|
+
- 4 digits: Sequential number
|
|
12
12
|
- 1 digit: Check digit (modified Luhn algorithm)
|
|
13
13
|
|
|
14
14
|
Example: 12345678903
|
|
15
15
|
12: Subdelegation
|
|
16
|
-
34:
|
|
17
|
-
56:
|
|
18
|
-
|
|
16
|
+
34: Registration year (2034 or 1934)
|
|
17
|
+
56: Birth year (2056 or 1956)
|
|
18
|
+
7890: Sequential
|
|
19
|
+
3: Check digit
|
|
19
20
|
|
|
20
21
|
Note: The check digit uses a modified Luhn algorithm specific to NSS.
|
|
21
22
|
"""
|
|
@@ -145,7 +146,7 @@ class NSSValidator:
|
|
|
145
146
|
return self.nss[:2]
|
|
146
147
|
return None
|
|
147
148
|
|
|
148
|
-
def
|
|
149
|
+
def get_registration_year(self) -> str | None:
|
|
149
150
|
"""
|
|
150
151
|
Extracts the registration year (digits 3-4, last 2 digits of year)
|
|
151
152
|
Note: This is ambiguous - could be 19XX or 20XX
|
|
@@ -155,10 +156,11 @@ class NSSValidator:
|
|
|
155
156
|
return self.nss[2:4]
|
|
156
157
|
return None
|
|
157
158
|
|
|
158
|
-
def
|
|
159
|
+
def get_birth_year(self) -> str | None:
|
|
159
160
|
"""
|
|
160
|
-
Extracts the
|
|
161
|
-
:
|
|
161
|
+
Extracts the birth year (digits 5-6, last 2 digits of year)
|
|
162
|
+
Note: This is ambiguous - could be 19XX or 20XX
|
|
163
|
+
:return: Year suffix as string
|
|
162
164
|
"""
|
|
163
165
|
if len(self.nss) >= 6:
|
|
164
166
|
return self.nss[4:6]
|
|
@@ -182,18 +184,18 @@ class NSSValidator:
|
|
|
182
184
|
return self.nss[10]
|
|
183
185
|
return None
|
|
184
186
|
|
|
185
|
-
def get_parts(self) -> dict[str, str] | None:
|
|
187
|
+
def get_parts(self) -> dict[str, str | None] | None:
|
|
186
188
|
"""
|
|
187
189
|
Returns all NSS parts as a dictionary
|
|
188
|
-
:return: Dictionary with subdelegation,
|
|
190
|
+
:return: Dictionary with subdelegation, registration_year, birth_year, sequential, check_digit
|
|
189
191
|
"""
|
|
190
192
|
if not self.is_valid():
|
|
191
193
|
return None
|
|
192
194
|
|
|
193
195
|
return {
|
|
194
196
|
"subdelegation": self.get_subdelegation(),
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
+
"registration_year": self.get_registration_year(),
|
|
198
|
+
"birth_year": self.get_birth_year(),
|
|
197
199
|
"sequential": self.get_sequential(),
|
|
198
200
|
"check_digit": self.get_check_digit(),
|
|
199
201
|
"nss": self.nss,
|
|
@@ -212,39 +214,42 @@ def validate_nss(nss: str | None) -> bool:
|
|
|
212
214
|
|
|
213
215
|
|
|
214
216
|
def generate_nss(
|
|
215
|
-
subdelegation: str | int,
|
|
217
|
+
subdelegation: str | int,
|
|
218
|
+
registration_year: str | int,
|
|
219
|
+
birth_year: str | int,
|
|
220
|
+
sequential: str | int,
|
|
216
221
|
) -> str:
|
|
217
222
|
"""
|
|
218
223
|
Generates a complete NSS with check digit
|
|
219
224
|
|
|
220
225
|
:param subdelegation: 2-digit subdelegation code
|
|
221
|
-
:param
|
|
222
|
-
:param
|
|
226
|
+
:param registration_year: 2-digit registration year (last 2 digits)
|
|
227
|
+
:param birth_year: 2-digit birth year (last 2 digits)
|
|
223
228
|
:param sequential: 4-digit sequential number
|
|
224
229
|
:return: Complete 11-digit NSS
|
|
225
230
|
"""
|
|
226
231
|
# Ensure all parts are strings and properly formatted
|
|
227
232
|
subdelegation = str(subdelegation).zfill(2)
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
registration_year = str(registration_year).zfill(2)
|
|
234
|
+
birth_year = str(birth_year).zfill(2)
|
|
230
235
|
sequential = str(sequential).zfill(4)
|
|
231
236
|
|
|
232
237
|
if len(subdelegation) != 2:
|
|
233
238
|
raise NSSStructureError("Subdelegation must be 2 digits")
|
|
234
|
-
if len(
|
|
235
|
-
raise NSSStructureError("
|
|
236
|
-
if len(
|
|
237
|
-
raise NSSStructureError("
|
|
239
|
+
if len(registration_year) != 2:
|
|
240
|
+
raise NSSStructureError("Registration year must be 2 digits")
|
|
241
|
+
if len(birth_year) != 2:
|
|
242
|
+
raise NSSStructureError("Birth year must be 2 digits")
|
|
238
243
|
if len(sequential) != 4:
|
|
239
244
|
raise NSSStructureError("Sequential must be 4 digits")
|
|
240
245
|
|
|
241
|
-
nss_10 = subdelegation +
|
|
246
|
+
nss_10 = subdelegation + registration_year + birth_year + sequential
|
|
242
247
|
check_digit = NSSValidator.calculate_check_digit(nss_10)
|
|
243
248
|
|
|
244
249
|
return nss_10 + check_digit
|
|
245
250
|
|
|
246
251
|
|
|
247
|
-
def get_nss_info(nss: str | None) -> dict[str, str] | None:
|
|
252
|
+
def get_nss_info(nss: str | None) -> dict[str, str | None] | None:
|
|
248
253
|
"""
|
|
249
254
|
Helper function to get information from an NSS
|
|
250
255
|
|
catalogmx/validators/rfc.py
CHANGED
|
@@ -264,8 +264,7 @@ class RFCValidator(RFCGeneral):
|
|
|
264
264
|
return "Persona Física"
|
|
265
265
|
if self.is_moral():
|
|
266
266
|
return "Persona Moral"
|
|
267
|
-
|
|
268
|
-
return "RFC Inválido"
|
|
267
|
+
return "RFC Inválido"
|
|
269
268
|
|
|
270
269
|
def is_generic(self) -> bool:
|
|
271
270
|
"""
|
|
@@ -322,7 +321,7 @@ class RFCValidator(RFCGeneral):
|
|
|
322
321
|
return False
|
|
323
322
|
|
|
324
323
|
@classmethod
|
|
325
|
-
def calculate_last_digit(cls, rfc: str, with_checksum: bool = True) -> str
|
|
324
|
+
def calculate_last_digit(cls, rfc: str, with_checksum: bool = True) -> str:
|
|
326
325
|
"""
|
|
327
326
|
Calculates the checksum of an RFC.
|
|
328
327
|
|
|
@@ -332,7 +331,7 @@ class RFCValidator(RFCGeneral):
|
|
|
332
331
|
if bool(rfc) and isinstance(rfc, str):
|
|
333
332
|
str_rfc = rfc.strip().upper()
|
|
334
333
|
else:
|
|
335
|
-
return
|
|
334
|
+
return ""
|
|
336
335
|
if with_checksum:
|
|
337
336
|
str_rfc = str_rfc[:-1]
|
|
338
337
|
assert len(str_rfc) in (11, 12)
|
|
@@ -412,14 +411,13 @@ class RFCGeneratorUtils(RFCGeneral):
|
|
|
412
411
|
"MI",
|
|
413
412
|
"COMPAÑIA",
|
|
414
413
|
"COMPAÑÍA",
|
|
414
|
+
"COMPANIA", # Without accent
|
|
415
415
|
"CIA",
|
|
416
416
|
"CIA.",
|
|
417
417
|
"SOCIEDAD",
|
|
418
418
|
"SOC",
|
|
419
419
|
"SOC.",
|
|
420
|
-
|
|
421
|
-
"COOP",
|
|
422
|
-
"COOP.",
|
|
420
|
+
# Note: COOPERATIVA is NOT excluded according to SAT spec
|
|
423
421
|
"S.A.",
|
|
424
422
|
"SA",
|
|
425
423
|
"S.A",
|
|
@@ -511,6 +509,31 @@ class RFCGeneratorUtils(RFCGeneral):
|
|
|
511
509
|
"18": "DIECIOCHO",
|
|
512
510
|
"19": "DIECINUEVE",
|
|
513
511
|
"20": "VEINTE",
|
|
512
|
+
"21": "VEINTIUNO",
|
|
513
|
+
"22": "VEINTIDOS",
|
|
514
|
+
"23": "VEINTITRES",
|
|
515
|
+
"24": "VEINTICUATRO",
|
|
516
|
+
"25": "VEINTICINCO",
|
|
517
|
+
"26": "VEINTISEIS",
|
|
518
|
+
"27": "VEINTISIETE",
|
|
519
|
+
"28": "VEINTIOCHO",
|
|
520
|
+
"29": "VEINTINUEVE",
|
|
521
|
+
"30": "TREINTA",
|
|
522
|
+
"40": "CUARENTA",
|
|
523
|
+
"50": "CINCUENTA",
|
|
524
|
+
"60": "SESENTA",
|
|
525
|
+
"70": "SETENTA",
|
|
526
|
+
"80": "OCHENTA",
|
|
527
|
+
"90": "NOVENTA",
|
|
528
|
+
"100": "CIEN",
|
|
529
|
+
"200": "DOSCIENTOS",
|
|
530
|
+
"300": "TRESCIENTOS",
|
|
531
|
+
"400": "CUATROCIENTOS",
|
|
532
|
+
"500": "QUINIENTOS",
|
|
533
|
+
"600": "SEISCIENTOS",
|
|
534
|
+
"700": "SETECIENTOS",
|
|
535
|
+
"800": "OCHOCIENTOS",
|
|
536
|
+
"900": "NOVECIENTOS",
|
|
514
537
|
}
|
|
515
538
|
|
|
516
539
|
# Tabla de números romanos a arábigos
|
|
@@ -535,8 +558,61 @@ class RFCGeneratorUtils(RFCGeneral):
|
|
|
535
558
|
"XVIII": 18,
|
|
536
559
|
"XIX": 19,
|
|
537
560
|
"XX": 20,
|
|
561
|
+
"XXI": 21,
|
|
562
|
+
"XXII": 22,
|
|
563
|
+
"XXIII": 23,
|
|
564
|
+
"XXIV": 24,
|
|
565
|
+
"XXV": 25,
|
|
566
|
+
"XXVI": 26,
|
|
567
|
+
"XXVII": 27,
|
|
568
|
+
"XXVIII": 28,
|
|
569
|
+
"XXIX": 29,
|
|
570
|
+
"XXX": 30,
|
|
538
571
|
}
|
|
539
572
|
|
|
573
|
+
@classmethod
|
|
574
|
+
def _convert_arabigo_a_texto(cls, num: int) -> str:
|
|
575
|
+
"""Convert an Arabic number to text (supports 0-100000)"""
|
|
576
|
+
if num < 0:
|
|
577
|
+
return str(num)
|
|
578
|
+
if str(num) in cls.numeros_texto:
|
|
579
|
+
return cls.numeros_texto[str(num)]
|
|
580
|
+
if num == 100000:
|
|
581
|
+
return "CIEN MIL"
|
|
582
|
+
|
|
583
|
+
parts = []
|
|
584
|
+
|
|
585
|
+
# Thousands (1000-99999)
|
|
586
|
+
if num >= 1000:
|
|
587
|
+
thousands = num // 1000
|
|
588
|
+
if thousands == 1:
|
|
589
|
+
parts.append("MIL")
|
|
590
|
+
else:
|
|
591
|
+
parts.append(cls._convert_arabigo_a_texto(thousands))
|
|
592
|
+
parts.append("MIL")
|
|
593
|
+
num = num % 1000
|
|
594
|
+
|
|
595
|
+
# Hundreds (100-999)
|
|
596
|
+
if num >= 100:
|
|
597
|
+
hundreds = (num // 100) * 100
|
|
598
|
+
if str(hundreds) in cls.numeros_texto:
|
|
599
|
+
parts.append(cls.numeros_texto[str(hundreds)])
|
|
600
|
+
num = num % 100
|
|
601
|
+
|
|
602
|
+
# Tens and units (1-99)
|
|
603
|
+
if num > 0:
|
|
604
|
+
if str(num) in cls.numeros_texto:
|
|
605
|
+
parts.append(cls.numeros_texto[str(num)])
|
|
606
|
+
elif num >= 30:
|
|
607
|
+
tens = (num // 10) * 10
|
|
608
|
+
units = num % 10
|
|
609
|
+
if str(tens) in cls.numeros_texto:
|
|
610
|
+
parts.append(cls.numeros_texto[str(tens)])
|
|
611
|
+
if units > 0 and str(units) in cls.numeros_texto:
|
|
612
|
+
parts.append(cls.numeros_texto[str(units)])
|
|
613
|
+
|
|
614
|
+
return " ".join(parts) if parts else str(num)
|
|
615
|
+
|
|
540
616
|
@classmethod
|
|
541
617
|
def convertir_numero_a_texto(cls, numero_str: str) -> str:
|
|
542
618
|
"""Convierte un número (arábigo o romano) a su representación en texto"""
|
|
@@ -544,19 +620,14 @@ class RFCGeneratorUtils(RFCGeneral):
|
|
|
544
620
|
|
|
545
621
|
# Intentar como número romano
|
|
546
622
|
if numero_str in cls.numeros_romanos:
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
return cls.numeros_texto[numero_arabigo]
|
|
623
|
+
arabigo = cls.numeros_romanos[numero_str]
|
|
624
|
+
return cls._convert_arabigo_a_texto(arabigo)
|
|
550
625
|
|
|
551
626
|
# Intentar como número arábigo
|
|
552
|
-
if numero_str in cls.numeros_texto:
|
|
553
|
-
return cls.numeros_texto[numero_str]
|
|
554
|
-
|
|
555
|
-
# Si no está en la tabla, intentar convertir dígitos
|
|
556
627
|
try:
|
|
557
628
|
num = int(numero_str)
|
|
558
|
-
if
|
|
559
|
-
return cls.
|
|
629
|
+
if num >= 0:
|
|
630
|
+
return cls._convert_arabigo_a_texto(num)
|
|
560
631
|
except ValueError:
|
|
561
632
|
pass
|
|
562
633
|
|
|
@@ -564,18 +635,25 @@ class RFCGeneratorUtils(RFCGeneral):
|
|
|
564
635
|
|
|
565
636
|
@classmethod
|
|
566
637
|
def clean_name(cls, nombre: str) -> str:
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
.upper()
|
|
575
|
-
)
|
|
576
|
-
.strip()
|
|
577
|
-
.upper()
|
|
638
|
+
# Remove apostrophes without adding space (O'farril -> OFARRIL)
|
|
639
|
+
nombre = nombre.replace("'", "")
|
|
640
|
+
# Remove dots
|
|
641
|
+
nombre = nombre.replace(".", " ")
|
|
642
|
+
# Process and filter excluded words
|
|
643
|
+
cleaned = " ".join(
|
|
644
|
+
elem for elem in nombre.split() if elem.upper() not in cls.excluded_words_fisicas
|
|
578
645
|
)
|
|
646
|
+
# Convert each character: preserve allowed chars, use unidecode for others
|
|
647
|
+
result = []
|
|
648
|
+
for char in cleaned.upper():
|
|
649
|
+
if char in cls.allowed_chars or char == " ":
|
|
650
|
+
result.append(char)
|
|
651
|
+
else:
|
|
652
|
+
# Convert non-allowed characters via unidecode
|
|
653
|
+
decoded = unidecode.unidecode(char).upper()
|
|
654
|
+
result.append("".join(c for c in decoded if c in cls.allowed_chars or c == " "))
|
|
655
|
+
final = "".join(result)
|
|
656
|
+
return final.strip()
|
|
579
657
|
|
|
580
658
|
@staticmethod
|
|
581
659
|
def name_adapter(name: str, non_strict: bool = False) -> str:
|
|
@@ -607,7 +685,7 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
|
|
|
607
685
|
return self._paterno
|
|
608
686
|
|
|
609
687
|
@paterno.setter
|
|
610
|
-
def paterno(self, name: str):
|
|
688
|
+
def paterno(self, name: str) -> None:
|
|
611
689
|
self._paterno = self.name_adapter(name)
|
|
612
690
|
|
|
613
691
|
@property
|
|
@@ -615,7 +693,7 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
|
|
|
615
693
|
return self._materno
|
|
616
694
|
|
|
617
695
|
@materno.setter
|
|
618
|
-
def materno(self, name: str):
|
|
696
|
+
def materno(self, name: str) -> None:
|
|
619
697
|
self._materno = self.name_adapter(name, non_strict=True)
|
|
620
698
|
|
|
621
699
|
@property
|
|
@@ -623,7 +701,7 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
|
|
|
623
701
|
return self._nombre
|
|
624
702
|
|
|
625
703
|
@nombre.setter
|
|
626
|
-
def nombre(self, name: str):
|
|
704
|
+
def nombre(self, name: str) -> None:
|
|
627
705
|
self._nombre = self.name_adapter(name)
|
|
628
706
|
|
|
629
707
|
@property
|
|
@@ -631,7 +709,7 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
|
|
|
631
709
|
return self._dob
|
|
632
710
|
|
|
633
711
|
@dob.setter
|
|
634
|
-
def dob(self, date: datetime.date):
|
|
712
|
+
def dob(self, date: datetime.date) -> None:
|
|
635
713
|
if isinstance(date, datetime.date):
|
|
636
714
|
self._dob = date
|
|
637
715
|
|
|
@@ -648,27 +726,55 @@ class RFCGeneratorFisicas(RFCGeneratorUtils):
|
|
|
648
726
|
return self.dob.strftime("%y%m%d")
|
|
649
727
|
|
|
650
728
|
def generate_letters(self) -> str:
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
729
|
+
# Get parts of compound surnames
|
|
730
|
+
paterno_parts = [p for p in self.paterno_calculo.split() if p]
|
|
731
|
+
materno_parts = [p for p in self.materno_calculo.split() if p]
|
|
732
|
+
|
|
733
|
+
paterno_first = paterno_parts[0] if paterno_parts else ""
|
|
734
|
+
nombre_safe = self.nombre_iniciales if self.nombre_iniciales else "X"
|
|
735
|
+
|
|
736
|
+
# Check if materno originally had prepositions (de, del, la, los, las)
|
|
737
|
+
original_materno_upper = self.materno.upper().strip()
|
|
738
|
+
materno_had_prepositions = (
|
|
739
|
+
original_materno_upper.startswith("DE ")
|
|
740
|
+
or original_materno_upper.startswith("DEL ")
|
|
741
|
+
or original_materno_upper.startswith("LA ")
|
|
742
|
+
or original_materno_upper.startswith("LOS ")
|
|
743
|
+
or original_materno_upper.startswith("LAS ")
|
|
656
744
|
)
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
else
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
745
|
+
|
|
746
|
+
# Determine effective materno: use second word of paterno if materno was preposition-based or empty
|
|
747
|
+
effective_materno_first = materno_parts[0] if materno_parts else ""
|
|
748
|
+
if len(paterno_parts) >= 2 and (materno_had_prepositions or not effective_materno_first):
|
|
749
|
+
effective_materno_first = paterno_parts[1] if len(paterno_parts) >= 2 else ""
|
|
750
|
+
|
|
751
|
+
clave = ""
|
|
752
|
+
|
|
753
|
+
# Regla 4: Apellido paterno de 1-2 letras
|
|
754
|
+
if paterno_first and len(paterno_first) <= 2 and effective_materno_first:
|
|
755
|
+
clave = paterno_first[0] if paterno_first else "X"
|
|
756
|
+
clave += effective_materno_first[0] if effective_materno_first else "X"
|
|
757
|
+
clave += nombre_safe[0] if nombre_safe else "X"
|
|
758
|
+
clave += nombre_safe[1] if len(nombre_safe) > 1 else "X"
|
|
759
|
+
# Regla 7: Un solo apellido (sin materno)
|
|
760
|
+
elif not effective_materno_first:
|
|
761
|
+
apellido = paterno_first if paterno_first else "X"
|
|
762
|
+
clave = apellido[0] if apellido else "X"
|
|
763
|
+
clave += apellido[1] if len(apellido) > 1 else "X"
|
|
764
|
+
clave += nombre_safe[0] if nombre_safe else "X"
|
|
765
|
+
clave += nombre_safe[1] if len(nombre_safe) > 1 else "X"
|
|
766
|
+
# Regla 1: Formación básica
|
|
663
767
|
else:
|
|
664
|
-
if
|
|
665
|
-
|
|
768
|
+
clave = paterno_first[0] if paterno_first else "X"
|
|
769
|
+
# Find first vowel after first letter
|
|
770
|
+
second_value = list(filter(lambda x: x >= 0, map(paterno_first[1:].find, self.vocales)))
|
|
771
|
+
if second_value:
|
|
772
|
+
clave += paterno_first[min(second_value) + 1]
|
|
666
773
|
else:
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
clave = "".join(clave)
|
|
774
|
+
clave += "X"
|
|
775
|
+
clave += effective_materno_first[0] if effective_materno_first else "X"
|
|
776
|
+
clave += nombre_safe[0] if nombre_safe else "X"
|
|
777
|
+
|
|
672
778
|
if clave in self.cacophonic_words:
|
|
673
779
|
clave = clave[:-1] + "X"
|
|
674
780
|
return clave
|
|
@@ -758,7 +864,7 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
|
|
|
758
864
|
return self._razon_social
|
|
759
865
|
|
|
760
866
|
@razon_social.setter
|
|
761
|
-
def razon_social(self, name: str):
|
|
867
|
+
def razon_social(self, name: str) -> None:
|
|
762
868
|
if isinstance(name, str):
|
|
763
869
|
self._razon_social = name.upper().strip()
|
|
764
870
|
else:
|
|
@@ -769,7 +875,7 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
|
|
|
769
875
|
return self._fecha
|
|
770
876
|
|
|
771
877
|
@fecha.setter
|
|
772
|
-
def fecha(self, date: datetime.date):
|
|
878
|
+
def fecha(self, date: datetime.date) -> None:
|
|
773
879
|
if isinstance(date, datetime.date):
|
|
774
880
|
self._fecha = date
|
|
775
881
|
else:
|
|
@@ -789,6 +895,16 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
|
|
|
789
895
|
"""Generate date portion in YYMMDD format"""
|
|
790
896
|
return self.fecha.strftime("%y%m%d")
|
|
791
897
|
|
|
898
|
+
# Special character to word conversions according to SAT specification
|
|
899
|
+
# These only apply when the character is standalone (surrounded by spaces/boundaries)
|
|
900
|
+
special_char_words = {
|
|
901
|
+
"@": "ARROBA",
|
|
902
|
+
"%": "PORCIENTO",
|
|
903
|
+
"#": "NUMERO",
|
|
904
|
+
"(": "ABRE",
|
|
905
|
+
"/": "DIAGONAL",
|
|
906
|
+
}
|
|
907
|
+
|
|
792
908
|
@property
|
|
793
909
|
def razon_social_calculo(self) -> str:
|
|
794
910
|
"""
|
|
@@ -800,8 +916,21 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
|
|
|
800
916
|
- Convert numbers (arabic and roman) to text
|
|
801
917
|
- Handle consonant compounds (CH → C, LL → L)
|
|
802
918
|
"""
|
|
919
|
+
import re
|
|
920
|
+
|
|
803
921
|
razon = self.razon_social.upper().strip()
|
|
804
922
|
|
|
923
|
+
# Step 0: Handle special characters - different treatment for standalone vs embedded
|
|
924
|
+
# Standalone special chars get converted to words, embedded ones are removed
|
|
925
|
+
for char, word in self.special_char_words.items():
|
|
926
|
+
# Replace standalone special chars with their word equivalents
|
|
927
|
+
escaped = re.escape(char)
|
|
928
|
+
razon = re.sub(rf"(^|\s){escaped}(\s|$)", rf"\1{word}\2", razon)
|
|
929
|
+
|
|
930
|
+
# Remove special characters that are embedded inside words (not standalone)
|
|
931
|
+
for char in self.special_char_words.keys():
|
|
932
|
+
razon = razon.replace(char, "")
|
|
933
|
+
|
|
805
934
|
# Step 1: First pass - remove excluded words with punctuation patterns
|
|
806
935
|
# This handles cases like "S.A.", "S. A.", etc.
|
|
807
936
|
# Process longer words first to avoid partial matches (e.g., S.A.B. before S.A.)
|
|
@@ -878,8 +1007,12 @@ class RFCGeneratorMorales(RFCGeneratorUtils):
|
|
|
878
1007
|
elif word_clean not in self.excluded_words_morales:
|
|
879
1008
|
filtered_words.append(word_clean)
|
|
880
1009
|
|
|
1010
|
+
# If ALL words were excluded, keep the original words (the company name itself)
|
|
1011
|
+
# This handles cases like "Al" where "AL" is an excluded word but is the actual name
|
|
1012
|
+
words_to_use = filtered_words if filtered_words else [w.upper() for w in words_converted]
|
|
1013
|
+
|
|
881
1014
|
# Step 7: Clean remaining special characters and accents
|
|
882
|
-
cleaned = " ".join(
|
|
1015
|
+
cleaned = " ".join(words_to_use)
|
|
883
1016
|
result = ""
|
|
884
1017
|
for char in cleaned:
|
|
885
1018
|
if char in self.allowed_chars:
|