Clientes del exterior — reglas de coherencia del formulario de clientes¶
Fecha: 2026-07-28
Alcance: frontend/src/app/components/business-partners/BusinessParnerForm.tsx, frontend/src/validation/businessPartner.schema.ts, backend/src/modules/business-partners/*
Entregable interactivo: matriz-coherencia.html (en _borradores/, no publicado) — matriz de las 64 combinaciones + simulador de reglas (el JS de esa página es la implementación de referencia del motor propuesto).
1. Tesis¶
El formulario pide tres respuestas independientes —tipo de identidad, grupo y tipo de persona— para una sola pregunta de negocio: ¿este cliente tributa en Paraguay o no?
Ninguna de las tres se valida contra las otras. Las 8 × 4 × 2 = 64 combinaciones son legales hoy; solo 17 tienen sentido fiscal, y apenas 11 sin salvedades. Encima, hay seis caminos por los que el sistema pisa activamente lo que el usuario eligió.
2. Evidencia ya presente en SAP¶
Documentada en backend/src/modules/business-partners/business-partners.classification.spec.ts:
| CardCode | Qué tiene | Qué está mal |
|---|---|---|
| C33012, C33018 | Cédula extranjera (14), U_CENT_TIPO_OPE=4 (B2F) |
GroupCode = 100 (Local) |
| C26571 | GroupCode = 102, cédula PY (12) |
operación B2C, ejes en desacuerdo |
| C19365 "Universidad … Guatemala" | identidad RUC (11) | el tax ID no es paraguayo |
| C18138 "Interbarge Uruguay" | identidad RUC (11) + grupo 102 | mismo patrón |
3. Mecanismos que producen el error¶
| # | Mecanismo | Dónde |
|---|---|---|
| M1 | El lookup por RUC reescribe la clasificación entera. handleRucBlur se dispara al salir del primer campo sin mirar el tipo de identidad y hace formik.setValues(mapped). getFromRucPy siempre devuelve identityType: RUC + groupCode: LOCAL. Un pasaporte/DNI numérico que exista en turuc o SAP reescribe todo a Local+RUC → el usuario recorrige la identidad, no mira el grupo, y guarda grupo Local con pasaporte. Es literalmente el síntoma reportado. |
BusinessParnerForm.tsx:259, business-partners.service.ts:375 |
| M2 | Editar direcciones degrada la clasificación. El tab "Direcciones" manda solo addresses, pero buildSapPayload escribe siempre U_CENT_TIPO_OPE, U_CENT_TIPO_SN y U_CENT_SITUACION. Sin identityType/groupCode en el DTO, getCustomerType cae al fallback → B2C, persona → Física, situación → NO_CONTRIBUYENTE. Afecta también a cualquier empresa B2B local. |
business-partners.service.ts:550-552, BusinessParnerForm.tsx:135 |
| M3 | Los errores de esos 3 campos nunca se ven. getFieldStatus pasa helperText a <Select> de MUI y a CustomSelect (= styled(Select)); ninguno lo renderiza. Solo queda el borde rojo. |
BusinessParnerForm.tsx:375, CustomSelect.tsx |
| M4 | Cero reglas cruzadas en ambos extremos. Yup valida cada campo aislado con oneOf. En el back, groupCode e identityType no tienen @IsEnum en el DTO de creación; el ValidationPipe corre con whitelist pero sin transform. |
businessPartner.schema.ts:31-39, business-partner.models.ts:224-248, main.ts:60 |
| M5 | El grupo no viaja por Algolia. El índice no incluye GroupCode ni Country, y mapHitToBusinessPartnerDto tampoco. Si el modal abre con el hit antes de que resuelva el fetch completo, el select de Grupo aparece vacío → el usuario elige "Local" por reflejo. |
indexing.service.ts:240, algolia.mapper.ts |
| M6 | La búsqueda por documento no discrimina tipo. findByRuc filtra FederalTaxID eq '<doc>'. Un pasaporte numérico matchea la cédula de otro cliente; y el onSaved?.(data.id) del blur cierra el modal asignando ese otro cliente al pedido. |
sap-business-partner.repository.ts:96, BusinessParnerForm.tsx:282 |
| M7 | country: "PY" es el default y nadie lo cuestiona. Los extranjeros quedan con País = Paraguay. La moneda tampoco se toca: todo nace en GS. |
BusinessParnerForm.tsx:104 |
| M8 | Dos de los cuatro grupos son tipos de documento disfrazados. 104 "Diplomático" y 105 "Pasaporte" duplican las identidades 16 y 13 → el mismo cliente se puede cargar de dos formas defendibles. La ambigüedad está en el catálogo. | — |
| M9 | Las opciones se muestran con el nombre de la constante (CEDULA_EXTRANJERO, IDENTIFICACION_TRIBUTARIA, SIN_NOMBRE, CLIENTE_EXTERIOR), sin explicación de cuándo usar cada una. |
BusinessParnerForm.tsx:206 |
4. El modelo: tres ejes visibles, un eje real¶
| Eje | Valores | Naturaleza |
|---|---|---|
| Tipo de identidad | 11 RUC · 12 CI · 13 Pasaporte · 14 CI extranjera · 15 Innominado · 16 Diplomático · 17 Tax ID exterior · 18 Cliente del exterior | Qué documento presenta. El dato más objetivo: el vendedor lo tiene en la mano. |
| Grupo (SAP) | 100 Local · 102 Extranjero · 104 Diplomático · 105 Pasaporte | Debería ser puramente geográfico-fiscal; hoy mezcla eso con tipo de documento. |
| Tipo de persona | 1 Física · 2 Jurídica | Ortogonal… salvo que ciertos documentos solo existen para personas físicas. |
| Residencia fiscal (latente, nunca se pregunta) | LOCAL · EXTERIOR | De acá salen derivados U_CENT_TIPO_OPE, U_CENT_SITUACION y el grupo. |
5. Las 18 reglas propuestas¶
Tres niveles: deriva (el sistema lo calcula, el campo no se edita) · advierte (posible pero raro, pide confirmación explícita) · bloquea (no se guarda).
| # | Nivel | Regla |
|---|---|---|
| R1 | deriva | La residencia fiscal se deriva del tipo de identidad: 11, 12, 15 → LOCAL; 13, 14, 16, 17, 18 → EXTERIOR. |
| R2 | bloquea | El grupo debe coincidir con la residencia derivada del documento. (Grupo Local + documento del exterior = el error reportado.) |
| R3 | bloquea | Identidades 12, 13, 14 y 15 implican persona física. |
| R4 | advierte | Identidad 17 implica persona jurídica; física es posible pero se confirma. |
| R5 | deriva | Identidad 11 admite ambas personas: turuc define cuál. Si no responde, decide el usuario. |
| ~~R6~~ | retirada | País vs documento local. Retirada: el país no se guarda desde el OMS (ver §0c). |
| ~~R7~~ | retirada | País vs documento del exterior. Retirada por el mismo motivo (ver §0c). |
| R8 | deriva | Tipo de operación: EXTERIOR → B2F; LOCAL + jurídica → B2B; LOCAL + física → B2C; entidad pública → B2G. |
| R9 | bloquea | Grupo 104 solo con identidad 16; grupo 105 solo con identidad 13. |
| R10 | deriva | Situación: CONTRIBUYENTE solo si identidad = 11 y turuc lo confirma activo; el resto NO_CONTRIBUYENTE. |
| R11 | advierte | B2F nunca lleva situación CONTRIBUYENTE. |
| R12 | advierte | Identidad 15 (innominado) no debería crear un cliente permanente. |
| R13 | bloquea | Solo se consulta turuc si la identidad es RUC, y el resultado nunca sobrescribe identidad, grupo, persona ni país ya elegidos. (corrige M1) |
| R14 | bloquea | Unicidad del documento por (tipo de identidad, país, documento), no por número suelto. (corrige M6) |
| R15 | bloquea | Formato del documento según tipo: RUC numérico con DV; el resto alfanumérico 4–30. |
| R16 | bloquea | Un update parcial no recalcula clasificación: sin identidad ni grupo en el payload, no se tocan TIPO_OPE, TIPO_SN, SITUACION. (corrige M2) |
| R17 | advierte | Cambiar el grupo de un cliente con documentos emitidos requiere confirmación y queda auditado. |
| R18 | advierte | Operación habitual: se deriva de la residencia (R8) pero puede fijarse a mano. Único lugar donde el usuario puede apartarse de R8; queda auditado. (válvula de escape para E6) |
Moneda: fuera de alcance. Se configura en SAP, no en el OMS.
Resultado sobre las 64 combinaciones (sin considerar el país): 11 coherentes · 6 a confirmar · 47 imposibles.
El país advierte, nunca bloquea. Es el domicilio del cliente y no siempre concuerda con el documento: un extranjero con residencia temporal en Paraguay sigue siendo extranjero, una embajada acreditada está por definición en Paraguay, y un contribuyente paraguayo puede estar domiciliado afuera. Lo que sí bloquea es el desacuerdo entre identidad y grupo, que es donde vive el error reportado.
6. Casos y excepciones¶
| # | Caso | Por qué importa |
|---|---|---|
| E1 | Sucursal de empresa extranjera con RUC paraguayo | Es local: manda la residencia fiscal, no la nacionalidad. |
| E2 | Extranjero residente con cédula paraguaya | Identidad 12, grupo Local, B2C. Es el caso que se confunde con 14. |
| E3 | Paraguayo domiciliado en el exterior | Documento local, operación de exportación. Rompe R8. |
| E4 | Embajada u organismo internacional | Persona jurídica con identidad 16 → por eso R3 excluye al 16. |
| E5 | Turista de paso | País de residencia ≠ país de la dirección de entrega (el hotel). Exige separar ambos. |
| E6 | Brasileño/argentino de frontera comprando en plaza | Cédula extranjera pero venta local con IVA. Derivar B2F automáticamente sería un error fiscal. La excepción más peligrosa. |
| E7 | El mismo humano con dos documentos | Pasaporte hoy, cédula extranjera mañana → duplicados con saldos partidos. |
| E8 | Documento numérico de 8 dígitos | Colisiona con cédulas/RUC paraguayos → motiva R15. |
| E9 | Cliente del exterior sin ningún tax ID | Sin valor canónico, cada vendedor inventa: 0, 00, XXX, SIN RUC. |
| E10 | Cliente que cambia de estatus (saca RUC PY) | Migrar identidad y grupo sin romper histórico ni cuenta contable → motiva R18. |
| E11 | Tax ID del exterior con forma de RUC | C19365, C18138. Solo se evita con etiquetas que expliquen cuándo usar 17. |
| E12 | Diplomático comprando a título personal | La exoneración es de la misión, no del individuo. |
| E13 | Datos históricos ya inconsistentes | Bloquear al editar un cliente viejo lo dejaría ineditable → validar en alta, reportar en histórico. |
7. Solución propuesta — tres capas¶
Capa 0 — Parar la hemorragia (son bugs, no discuten reglas de negocio)¶
- Update parcial no toca la clasificación:
buildSapPayloadescribeU_CENT_TIPO_OPE/TIPO_SN/SITUACIONsolo si el DTO trae los campos de los que dependen. (M2) - El lookup no pisa lo elegido: consultar turuc solo si identidad = RUC, y hacer merge no destructivo. (M1)
- El blur no selecciona clientes: sacar
onSaved?.(data.id)del handler de blur. Buscar no es elegir. (M6) - Buscar por (tipo, documento), no por
FederalTaxIDsuelto. (M6) - Mostrar los errores: envolver los selects en
FormControl+FormHelperText, o migrar aTextField select. (M3) - Etiquetas en español con una línea de ayuda por tipo de identidad. (M9)
Capa 1 — Una pregunta, no tres: "Perfil fiscal"¶
El fix de raíz no es más validación, es menos preguntas. Antes de los tres selects, una sola elección en lenguaje de vendedor, que preselecciona identidad + grupo + persona + país + moneda y deja los tres campos visibles en modo derivado, con un "ajustar" que los desbloquea de a uno mostrando el conflicto en vivo:
| Perfil | Deriva |
|---|---|
| Contribuyente paraguayo | ident 11 · grupo 100 · país PY · B2B/B2C |
| Consumidor final paraguayo | ident 12 · grupo 100 · país PY · B2C |
| Persona del exterior | ident 13/14 · grupo 102 · país ≠ PY · B2F |
| Empresa del exterior | ident 17/18 · grupo 102 · país ≠ PY · B2F |
| Misión diplomática | ident 16 · grupo 104 · exonerado · B2F |
Complementos:
- Semáforo de coherencia en vivo: un chip permanente con el resultado que se va a guardar — "Exterior · B2F · No contribuyente". Hoy
TIPO_OPEes invisible hasta que sale una factura mal. Mostrar lo que el sistema dedujo es el antídoto directo contra la ambigüedad. - Fricción semántica en vez de un warning: ante contradicción fuerte, obligar a elegir entre dos frases de negocio — "Es un extranjero de paso, no tributa en Paraguay" vs "Es un residente que tributa en Paraguay". Nunca entre códigos.
- Deprecar los grupos 104 y 105 en el alta (siguen legibles para el histórico). Un solo eje geográfico → elimina M8 de raíz.
- Documento canónico para clientes del exterior sin tax ID:
EXT-<ISO2>-<correlativo>.
Capa 2 — Que no vuelva¶
- Un motor de reglas, tres consumidores (backend, front, script de auditoría). Ver §9 — y §8 para el recorte que se decidió entregar primero.
- Tests de tabla sobre las 64 combinaciones, no sobre casos sueltos. El spec existente ya tiene el formato; falta la exhaustividad.
- Auditoría de overrides: registrar quién fuerza qué combinación y con qué motivo. En dos semanas hay datos reales de qué regla está mal, en vez de discutirla en abstracto.
- Backfill + semáforo en el buscador: reporte de BP contradictorios (grupo 100 con identidad de exterior, grupo 102 con país PY,
TIPO_OPE≠ derivado) y agregarGroupCode+Countryal índice de Algolia para mostrar un badgeEXTen el picker.
0. Estado de implementación (2026-07-28)¶
Implementado el camino corto de §8. Todo lo demás sigue pendiente.
| Archivo | Qué |
|---|---|
frontend/src/validation/bp-rules.ts |
Nuevo. Tabla declarativa + evaluateBusinessPartner(). TS puro, sin React/Yup/MUI: portable al backend tal cual. |
frontend/src/validation/__tests__/bp-rules.test.ts |
Nuevo. 31 tests, incluye la tabla de las 64 combinaciones (11 ok / 6 warn / 47 block). |
frontend/.../BusinessParnerForm.tsx |
validate de Formik + semáforo + SelectField con FormHelperText (M3) + orden identidad→documento + lookup gateado y no destructivo (M1) + sin onSaved en el blur (M6) + etiquetas en español (M9). |
frontend/.../BusinessPartnerModal.tsx |
Sin preselección RUC+Local en el alta rápida; edición ya no exige salesPersonCode para cargar los datos. |
backend/.../business-partners.service.ts |
M2: el update parcial ya no reescribe U_CENT_TIPO_OPE / TIPO_SN / SITUACION. |
backend/.../business-partners.partial-update.spec.ts |
Nuevo. 7 tests del caso "guardar solo direcciones". |
Decisiones tomadas durante la implementación:
- Los bloqueos solo aplican al alta. Al editar, una combinación imposible se muestra como advertencia con confirmación explícita: bloquear dejaría ineditables a los clientes históricos ya inconsistentes (excepción E13).
- La pestaña Direcciones nunca queda trabada por la clasificación del cliente.
bp-rules.tsnormaliza a string (String(v)) en vez de comparar contra el enum. El enumGroupCodees string en el front y number en el back; esa divergencia ya causó un bug entero y así no puede repetirse al portar el archivo.- Dos arreglos extra en el modal, misma clase de defecto (el formulario arranca con un estado que induce a guardar mal): el alta rápida desde el buscador preseleccionaba RUC + Local, y editar un cliente con un usuario sin
salesPersonCodeabría el formulario vacío en modo alta.
Ajustes tras la primera revisión con el usuario (28-07-2026):
- R6 y R7 pasaron de bloquear a advertir. Un extranjero con residencia temporal en Paraguay sigue siendo extranjero y su domicilio es paraguayo; una embajada acreditada está por definición en Paraguay (R7 ni siquiera se dispara para identidad 16). El país es domicilio, no residencia fiscal.
- Debajo del campo va solo la corrección ("Grupo correcto: Extranjero"). El párrafo explicativo se mostraba envuelto en cuatro líneas bajo un select angosto y desacomodaba la grilla; ahora aparece una sola vez, en el panel al pie.
- Se revirtió el orden de los campos: documento primero, tipo de identidad después, como estaba. Los usuarios ya tienen ese orden incorporado y el objetivo es agregar la capa de validación, no cambiar la experiencia. El lookup ya no depende del orden: solo consulta si el tipo de identidad es RUC, y si se elige después, lo dispara el propio select.
Verificación: npx vitest run → 201 pasan (1 falla preexistente en order.mapper.test.ts, no relacionada). npx jest src/modules/business-partners → 38 pasan. npx next build OK. Sin tests de componente: el vitest.setup.ts del repo reemplaza global.window, así que no hay renderizado de componentes en la suite.
Falta antes de producción: probar el formulario a mano (alta local, alta extranjera, alta con documento que colisiona, edición de un cliente histórico inconsistente, guardado de solo direcciones).
0b. Problemas de direcciones y país (2026-07-28, segunda tanda)¶
Cuatro reportes del usuario tras la primera prueba. Tres tenían la misma raíz que no era la sospechada.
M10 — El país no se guardaba en el alta (caso C33027)¶
Causa: whitelist: true del ValidationPipe, no SAP. El pipe global elimina del objeto validado toda propiedad sin decoradores de class-validator. @ApiProperty / @ApiPropertyOptional son de Swagger y no cuentan.
En CreateBusinessPartnerDto estos campos tenían solo el decorador de Swagger: country, city, cityCode, situation, customerType, legalRepresentative, beneficiary, origin. El formulario mandaba country: "PE", el pipe lo borraba sin error, SAP nunca lo recibía. En UpdateBusinessPartnerDto sí estaban los decoradores — por eso editar "funcionaba" y crear no.
No era una hipótesis: se verificó con un test que reproduce el pipe aislado (@ApiPropertyOptional + whitelist → el campo desaparece).
Además del país, se estaban perdiendo en cada alta: ciudad, código de ciudad (U_CENT_COD_CIU), representante legal (U_CENT_RLEG), beneficiario (U_CENT_BENEF) y origen (U_Origen).
M11 — zipCode must be a string¶
BpAddressCreate.zipCode era @IsString() obligatorio. SAP devuelve null en los campos de dirección nunca cargados, y ese null volvía en el PATCH. En Paraguay no se usa código postal. → @IsOptional(), y el mapper del front normaliza null a cadena vacía.
M12 — addresses.0.city should not be empty¶
La ciudad era obligatoria en el backend y opcional en el schema Yup del front: el formulario dejaba guardar y el backend devolvía 400. → ciudad opcional en el backend, alineada con el front y con SAP, que tampoco la exige.
M13 — No se puede editar un cliente creado en SAP¶
Los clientes cargados directamente en SAP suelen tener direcciones incompletas. Al abrir uno para cambiarle el teléfono, Yup marcaba esas direcciones como inválidas y bloqueaba el guardado, aunque el PATCH de la pestaña "Datos del cliente" ni siquiera las incluye. La única salida era borrar la dirección.
→ Ahora se valida exactamente lo que se va a enviar: clientDataSchema (edición, pestaña datos) y addressesSchema (edición, pestaña direcciones). En el alta sigue validándose todo.
Extra¶
createEmptyAddressfijabacountry: "PY"; ahora hereda el país del cliente.- Mismo defecto en otro módulo:
RequestDirectUploadDto(cloudflare-images) tienemetadatayrequireSignedURLssin decoradores → el pipe los borra. Se recorrieron los 38 DTOs que entran por@Bodyy es el único caso restante. Pendiente, fuera de este alcance.
0c. M14 — El país: OCRD.Country es un espejo, no un campo¶
Verificado end-to-end el 28-07-2026 instrumentando el payload que sale hacia el Service Layer:
POST /BusinessPartners → { CardName: 'JOSH WASHINGTON', Country: 'PE',
GroupCode: 102, BPAddresses: [], … }
SAP devolvió → { CardCode: 'C33035', Country: 'PY', City: null }
Le mandamos PE y SAP guardó PY. No es el whitelist (ese arreglo funciona: el DTO llega al service con country: 'PE'), ni SAP ignorando el campo.
OCRD.Country es el espejo de la dirección bo_BillTo por defecto, no un campo independiente. Los clientes que sí tienen país extranjero lo confirman:
| CardCode | OCRD.Country | BilltoDefault | Dirección |
|---|---|---|---|
| C22784 | AR |
'AS' |
AS bo_BillTo Country AR |
| C09090 | UY |
'Extranjero' |
Extranjero bo_BillTo Country UY |
| C20808 | UY |
'AS' |
AS bo_BillTo Country UY |
Los clientes creados desde el OMS van con BPAddresses: [] y ninguna dirección bo_BillTo, así que SAP recalcula la cabecera y cae al país de la empresa: PY.
Fix pendiente (no implementado): al crear, mandar una dirección bo_BillTo con el país del cliente en vez de BPAddresses: []. Nótese que createEmptyAddress genera bo_ShipTo, así que aun cargando una dirección a mano la cabecera no se actualiza.
Decisión (28-07-2026): se retiran las reglas R6 y R7. Advertir sobre un campo que el usuario no puede corregir desde el formulario es ruido —la advertencia salía en todos los clientes del exterior y no había forma de hacerla desaparecer—. Mientras el país no se guarde de verdad, no se valida.
8. Camino corto — DECIDIDO (2026-07-28)¶
Decisión: se implementa primero en el front, para entregar rápido y ponerlo a prueba en producción. La arquitectura de §9 queda como objetivo, no como requisito de la primera entrega.
Objetivo acotado: evitar la carga errada desde el formulario. No es "garantizar la integridad del maestro de clientes" — eso es §9.
Qué entra¶
Front — un solo archivo, una sola copia.
frontend/src/validation/bp-rules.ts (~120 líneas): la tabla declarativa + evaluate({ident, group, person, country}) → {level, findings, derived}. Es un port directo del motor que corre en matriz-coherencia.html (en _borradores/, no publicado) — ya está escrito y probado sobre las 64 combinaciones.
Enganche en BusinessParnerForm.tsx:
validatede Formik llama aevaluate()y mapea cadafindinga su campo consetFieldError.- Semáforo de coherencia: un chip con
derived("Exterior · B2F · No contribuyente") siempre visible. - Guardar deshabilitado con el motivo cuando el nivel es bloquea; con confirmación explícita cuando es advierte.
Prerrequisito ineludible: M3. Hoy los tres selects son incapaces de mostrar un mensaje (Select de MUI ignora helperText). Sin eso, las reglas se evalúan pero no se comunican, y el usuario ve un botón deshabilitado sin explicación. Hay que envolverlos en FormControl + FormHelperText o migrarlos a TextField select.
Los otros arreglos del front (son bugs, se hacen igual):
- M1 — el lookup no pisa lo elegido: consultar turuc solo si identidad = RUC, y merge no destructivo. Es el mecanismo principal del bug y vive enteramente en
handleRucBlur. - M6 (mitad front) — sacar
onSaved?.(data.id)del handler de blur. Buscar no es elegir. - M9 — etiquetas en español con una línea de ayuda por tipo de identidad.
Backend — exactamente un fix.
- M2 — que
buildSapPayloadescribaU_CENT_TIPO_OPE/TIPO_SN/SITUACIONsolo si el DTO trae los campos de los que dependen. Es la degradación silenciosa al editar direcciones: ninguna regla en el front la puede evitar, y le pasa también a las empresas B2B locales.
Reglas que cubre esta pasada: R1–R13 y R15. Quedan afuera por ahora R14 (unicidad por tipo+país+doc, necesita backend), R16 (es el fix M2 en sí), R17 (auditoría) y R18 (campo nuevo).
Qué NO entra¶
Endpoint GET /business-partners/rules, version, copia del motor en el backend, perfil fiscal (capa 1), campo de operación habitual (R18), backfill del histórico, GroupCode/Country en Algolia.
Riesgo aceptado¶
La validación solo-front se saltea pegándole directo a la API o con un bundle cacheado viejo. Hoy no es un riesgo real: el único camino de alta de clientes es este formulario y es una app interna. Se acepta a conciencia, no por omisión.
Criterio de salida a producción¶
Después de 2–3 semanas en uso, revisar en SAP los BP creados en el período: cero casos de grupo 100 con identidad 13/14/16/17/18, y cero U_CENT_TIPO_OPE distinto del derivado. Si aparece alguno, es evidencia de que la validación se está esquiveando → recién ahí se justifica §9.
Ruta de upgrade¶
bp-rules.ts es TS puro, sin imports de React ni Yup: el día que se quiera la garantía dura, el archivo se copia tal cual al backend y se llama desde create/update. Recién con dos copias aparece el problema de sincronización, y recién ahí vale el endpoint con version.
Esfuerzo estimado: ~1 día, contra ~4 de la solución completa.
9. Dónde viven las reglas (objetivo)¶
Restricción real: oms-ventas-backend y OmsModernize son dos repos de git separados, sin workspaces ni paquete compartido. Eso descarta el packages/bp-rules de manual y obliga a elegir qué cruza el límite.
Principio: las reglas son datos, no código. Lo que cambia cuando cambia el negocio es la tabla (qué grupo admite qué identidad, con qué severidad). El evaluador que la recorre son ~40 líneas que no se tocan en años. Duplicar 18 reglas de negocio en dos repos es lo que produjo el bug de string "102" ≠ number 102; duplicar un evaluador de 40 líneas cubierto por tests de tabla en ambos lados es aceptable. Solo la tabla cruza el límite entre repos.
A · Autoridad — el backend es el único que puede rechazar¶
backend/src/modules/business-partners/rules/ — TypeScript puro, sin decoradores de Nest, sin Yup, sin React:
rules/
├── bp-rules.table.ts # la tabla declarativa + version
├── evaluate.ts # evaluate(input) → { level, findings, derived }
├── evaluate.spec.ts # tests de tabla sobre las 64 combinaciones
└── index.ts
Lo llaman create() y update() antes de armar el payload de SAP. Esa es la garantía dura: aunque el front esté desactualizado o alguien pegue directo contra la API, la combinación imposible no entra. El mismo módulo alimenta los tests y el script de auditoría del histórico.
B · Transporte — la tabla se publica, el front no la reescribe¶
GET /business-partners/rules devuelve la tabla serializada + un version. El front la pide una vez por sesión, la cachea en React Query y la evalúa localmente con su copia del evaluador: feedback instantáneo mientras el usuario tipea, sin round-trip por tecla y sin duplicar una sola regla de negocio.
Si la version del back no coincide con la que el front conoce, el front deja de opinar y delega: muestra los findings que vengan en la respuesta del guardado. Nunca al revés — un front viejo no puede aflojar una regla nueva.
C · Formulario — Yup valida campos, el motor valida el conjunto¶
Nada de esto va dentro de businessPartner.schema.ts. Yup se queda con lo que hace bien (formato, requeridos, email, URL) y el veredicto cruzado entra por la función validate de Formik, que llama a evaluate() y mapea cada finding a su campo con setFieldError. Expresar 18 reglas cruzadas en Yup.test() anidados es cómo se llega a un schema que nadie puede modificar.
- Los findings se anclan al campo que los causa, no a un cartel genérico. Requiere arreglar M3 primero: hoy esos tres selects no pueden mostrar un mensaje.
- El semáforo lee
deriveddel mismoevaluate(): es el resultado, no una segunda implementación. - Guardar deshabilitado con el motivo visible cuando el nivel es bloquea; habilitado con confirmación explícita cuando es advierte.
Orden de despliegue¶
La capa 0 (los seis bugs) y el punto A se pueden hacer y desplegar solos: con el motor únicamente en el backend, las combinaciones imposibles ya dejan de guardarse, aunque el mensaje llegue recién al apretar Guardar. B y C son la experiencia; A es la corrección. No hace falta coordinar los dos repos en un mismo deploy.
10. Fuera de alcance, con consecuencia: la operación sigue en el cliente¶
El OMS emite facturas (invoices.service.ts) y el payload manda solo el CardCode: el add-on de SAP lee U_CENT_TIPO_OPE del maestro del cliente. No existe hoy ningún override por documento. El maestro no es una etiqueta descriptiva: es lo que define cómo sale tipificada cada factura.
La consecuencia concreta es E6, el extranjero que compra en plaza con IVA. Si su ficha dice B2F, la factura sale tipificada como operación con el exterior. Y acá está el punto incómodo: es probable que parte de lo reportado como "el usuario guardó mal" sea un workaround deliberado — el vendedor pone grupo Local para que la factura salga bien. Si bloqueamos esa combinación sin darle salida legítima, el workaround no desaparece: se muda a algo peor (segundo cliente duplicado, o cargarlo con cédula paraguaya).
Por eso la mitigación no es dejarlo abierto ni prohibirlo, sino darle un lugar honesto: R18. Un solo campo en el cliente —operación habitual: exportación / venta en plaza— que arranca derivado de la residencia fiscal y se puede fijar a mano con confirmación y auditoría. Es barato, no toca el flujo de facturación, y permite que R2 sea un bloqueo de verdad. El día que se decida mover la operación al documento, ese campo ya es exactamente el default que el documento va a heredar: no es trabajo tirado, es el paso previo.