Wiki 🔍 Search
Qiiub · Diseño · Definitivo

Supplier — modernización completa (Plan B + C, Modelo B híbrido)

Diseño definitivo: Phone E.164 + 5 campos nuevos + entidad SupplierContact[] con multi-contacto. La investigación (glosario, problema, industria, opciones evaluadas) vive en research-supplier-phone.md; la decisión en ADR-0102; el control en @qiiub/ui (<PhoneInput>, #435).

Creado: 2026-05-21 Última actualización: 2026-06-17 Author: JJ Flores · Aria Status: Definitivo · implementado — ADR-0102, backend #287/#432, control @qiiub/ui #435; frontend #288 en curso

Resumen de decisiones (equipo, 2026-05-21)

✅ Plan B + C juntos en este sprint: normalizar Phone a E.164 + agregar 5 columnas (Notes, Address2, TaxNumber, Terms, Website) + crear entidad SupplierContact con multi-contacto (modelo Square / Shopify B2B).

🏛️ Modelo B (híbrido): Supplier.Phone se conserva como centralita de la empresa. Supplier.ContactName y Supplier.Email se eliminan (pertenecen a la persona, no a la empresa). Cada SupplierContact tiene su propio Phone + PhoneExt.

📞 Extensión telefónica: columna SupplierContact.PhoneExt varchar(10). El campo Notes queda libre para notas genéricas (delivery, terms, alternativos).

📦 Tareas: se dividen en dos task files separados (-backend.md y -frontend.md) compartiendo este HTML como source-of-truth visual. PR backend primero, frontend consume la API.

Contenido
  1. Propuesta elegida — input sin "+1" visible para PR
  2. Estados del input — vacío, escribiendo, completo, extensión, internacional, error
  3. Flujo end-to-end: frontend → API → DB → display
  4. Schema y migración (Plan B + C) — Supplier + SupplierContact
  5. Separación backend / frontend
  6. Display en la vista de detalle
  7. Casos límite y FAQ
  8. Resumen de decisiones

04Propuesta elegida (D-lite) — input simple + Notes

Punto clave que motiva el diseño: en Puerto Rico nadie marca +1 para llamadas locales. La gente escribe 787-555-1234 y listo. El +1 es transparente — solo existe en el almacenamiento, no en la pantalla.

Decisión de diseño: extensión va en Notes (por ahora)

No agregamos columna PhoneExt en este sprint. La UI tiene un solo campo Teléfono (E.164 detrás) y un nuevo campo Notes de texto libre donde el usuario escribe la extensión y cualquier otra info útil del proveedor (días de delivery, terms, instrucciones, contactos alternos).

Si después un merchant pide buscar/dedupe por extensión, agregamos la columna estructurada. En dev solo es schema change — los seeds se regeneran.

Cómo se ve el drawer

Nuevo proveedor

×
Auto-formato. Solo escribe los dígitos.
Hasta 500 caracteres. Texto libre.

Por qué Notes resuelve más que solo extensión

  • Extensión (caso original): "ext. 5102 (Wanda)"
  • Días de delivery: "lunes y miércoles AM"
  • Terms de pago: "Net 30 días"
  • Persona de contacto: "hablar con María en compras"
  • Historial: "cambiar a César Castillo si Wanda no contesta"
  • Restricciones: "no toma órdenes después del jueves"

Un campo Notes en Supplier es estándar en ERPs serios (SAP MM-LFA1-MEMO, NetSuite, Dynamics). Resolvemos extensión y el caso real de "necesito recordar X de este proveedor".

Lo que ve el usuario (95% de casos PR/US)

Nuevo proveedor

×
Auto-formato. Solo escribe los dígitos.

Un solo campo. Sin dropdown de país. Sin +1. El usuario teclea 7875551234 y el frontend lo formatea como (787) 555-1234 en vivo.

Lo que pasa internamente

Display
(787) 555-1234
Form state
7875551234
API request body
+17875551234
DB column
+17875551234

El +1 se agrega en el frontend justo antes de enviar a la API. El usuario nunca lo ve.

Si necesita un número internacional (raro, ~5%)

Nuevo proveedor

×
🇵🇷+1 (787) 555-1234
Toca la banderita si tu proveedor está fuera de PR/US.

Nuevo proveedor

×
🇩🇴+1 809 555-9876
✓ Válido — República Dominicana

La banderita es casi invisible por defecto (un chip pequeño a la izquierda con 🇵🇷). Solo si el usuario hace click expande un dropdown con los 200 países. Es la misma UI que usa react-phone-number-input.

05Estados del input

A · Vacío
(787) 555-1234
B · Escribiendo (parcial)
(787) 555-
Auto-formato mientras tecleas.
C · Completo, local
(787) 555-1234

→ DB: +17875551234

D · Con extensión (en Notes)
(787) 555-1234
ext. 5102 (Wanda)

→ DB: Phone=+17875551234, Notes="ext. 5102 (Wanda)"

E · Internacional
🇲🇽+52 55 1234 5678

→ DB: +525512345678

F · Inválido
(787) 555-12
⚠ Número incompleto — un teléfono PR/US tiene 10 dígitos.

06Flujo end-to-end

Desde que el usuario teclea hasta lo que se ve en la página de detalle.

Crear / editar (write)

Usuario teclea
7875551234
Solo dígitos. Sin +1.
react-phone-number-input
Form state (React)
+17875551234
Normalizado con libphonenumber-js. País default: PR.
POST /suppliers
API valida
+17875551234
FluentValidation + libphonenumber-csharp. 400 si inválido.
API recibe
+17875551234
+ Notes = "ext. 5102"
EF Core
Merchant DB
+17875551234
Supplier.Phone varchar(16)
Supplier.Notes varchar(500)
GET /suppliers/:id
Display en detalle
(787) 555-1234
Notes se muestra como bloque separado abajo.

07Schema y migración

Schema completo (Plan B + C — Modelo B híbrido)

Dos cambios al schema: modificar Supplier y crear nueva entidad SupplierContact. Una sola migración EF Core.

-- ===== Supplier (modificar) ===== ALTER TABLE dbo.Supplier ALTER COLUMN Phone varchar(16) NULL; -- E.164 — ahora es la CENTRALITA ALTER TABLE dbo.Supplier DROP COLUMN ContactName, -- se mueve a SupplierContact.Name Email; -- se mueve a SupplierContact.Email ALTER TABLE dbo.Supplier ADD Address2 varchar(60) NULL, Website varchar(255) NULL, TaxNumber varchar(30) NULL, -- EIN/SSN para W-9/1099 Terms varchar(50) NULL, -- "Net 30", "2/10 Net 30", "COD" Notes varchar(1000) NULL; -- delivery days, terms, contactos alt -- ===== SupplierContact (nueva entidad) ===== CREATE TABLE dbo.SupplierContact ( Id bigint IDENTITY PRIMARY KEY, GlobalId uniqueidentifier NOT NULL, -- UUIDv7 PublicId varchar(32) NOT NULL, -- TypeID (ADR-0082) MerchantId int NOT NULL, SupplierId bigint NOT NULL, Name nvarchar(120) NOT NULL, Title nvarchar(120) NULL, -- "Compras", "AR", "Sales rep" Email varchar(255) NULL, Phone varchar(16) NULL, -- E.164 PhoneExt varchar(10) NULL, -- aquí SÍ aplica Ordinal int NOT NULL DEFAULT 0, -- orden de visualización IsPrimary bit NOT NULL DEFAULT 0, IsActive bit NOT NULL DEFAULT 1, RowVersion rowversion NOT NULL, CreatedAtUtc datetime2(3) NOT NULL, UpdatedAtUtc datetime2(3) NOT NULL, IsDeleted bit NOT NULL DEFAULT 0, CONSTRAINT FK_SupplierContact_Supplier FOREIGN KEY (MerchantId, SupplierId) REFERENCES dbo.Supplier(MerchantId, Id) ); CREATE INDEX IX_SupplierContact_MerchantId_SupplierId ON dbo.SupplierContact(MerchantId, SupplierId) WHERE IsDeleted = 0; -- Para dev: el seeder S04 crea suppliers + 1-3 SupplierContact por cada uno. -- Para producción (futuro): no aplica — no hay datos reales.

Drawer Modelo B — dos zonas

El drawer ahora tiene dos zonas claras: arriba la información del proveedor (la empresa), abajo el array de contactos (personas). El usuario puede crear un Supplier sin contactos y agregarlos después.

Nuevo proveedor

×
Información de la empresa
La línea principal. Las extensiones de personas van en sus contactos.
Contactos (3)
Wanda Ramos Primario
✏️ 🗑
Título: Compras
Email: wramos@abc.com
Phone: (787) 765-2929
Ext: 6645
César Castillo (cell)
✏️ 🗑
Título: Owner / backup
Email:
Phone: (787) 555-1234
Ext:
AR / Cuentas por cobrar
✏️ 🗑
Título: Departamento
Email: ar@abc.com
Phone: (787) 765-2929
Ext: 6612

Notar: Wanda y AR comparten el mismo phone (la centralita) con extensiones distintas. César tiene celular directo (sin ext). Eso es el modelo B funcionando: Supplier.Phone es la centralita compartida, cada SupplierContact puede o no compartirla.

Datos existentes (solo dev — no hay producción)

Estamos en pure dev. Las únicas filas Supplier.Phone que existen vienen de S04_CategoriesAndSuppliersSeed.cs (Faker con formato "(###) ###-####") y cualquier dev DB local.

  1. Actualizar el seeder para emitir E.164 directamente (+1 + 10 dígitos, sin formato).
  2. Aplicar la EF Core migration (crea columnas nuevas, altera ancho de Phone 30→16).
  3. El seed regenera datos limpios. Listo.

No se necesita script de data migration, tabla DataMigrationIssue, parser tolerante, ni reporte. Eso aplicaría post-launch cuando haya datos de merchants reales — pero eso es problema de otro sprint.

Validador (FluentValidation)

// CreateSupplierEndpoint.cs RuleFor(x => x.Phone) .Must(BeValidE164).When(x => x.Phone is not null) .WithMessage("Phone must be a valid international number"); RuleFor(x => x.PhoneExt) .Matches(@"^\d{1,10}$").When(x => x.PhoneExt is not null) .WithMessage("Extension must be 1-10 digits"); private static bool BeValidE164(string? phone) { if (string.IsNullOrWhiteSpace(phone)) return true; var util = PhoneNumberUtil.GetInstance(); try { var num = util.Parse(phone, "PR"); // default region return util.IsValidNumber(num); } catch (NumberParseException) { return false; } } RuleFor(x => x.Notes) .MaximumLength(500).When(x => x.Notes is not null);

08Separación backend / frontend

El trabajo se divide en dos task files con el mismo prefijo de fecha. El HTML completo y el research son compartidos como source-of-truth visual / documental. Backend mergea primero; frontend consume la API ya disponible.

Task 1 — Backend

2026-05-21-supplier-phone-and-schema-backend.md

  • EF Core migration: alter Supplier, create SupplierContact
  • Domain entities: actualizar Supplier, crear SupplierContact : BaseEntity, IHasPublicId
  • EF configuration con composite FK (MerchantId, SupplierId)
  • NuGet: libphonenumber-csharp
  • FluentValidation helpers: BeValidE164
  • Endpoints Supplier (CRUD): actualizar DTOs con los 5 campos nuevos
  • Endpoints SupplierContact (nuevos): Create / Update / Delete / List by Supplier
  • Seeder S04: actualizar para emitir E.164 + crear 1–3 SupplierContact por supplier
  • Integration tests: cross-merchant isolation incluida
  • ADR para PublicId convention en SupplierContact

Task 2 — Frontend

2026-05-21-supplier-phone-and-schema-frontend.md

  • npm: react-phone-number-input, libphonenumber-js
  • Componente <PhoneInput> en @qiiub/ui con tokens del design system
  • Tipos TypeScript: Supplier, SupplierContact
  • API hooks: useSuppliers, useSupplierContacts
  • SupplierDrawer.tsx refactor: dos zonas (empresa + contactos)
  • Sub-componente <SupplierContactList> con add/remove rows
  • Sub-componente <SupplierContactRow> con Phone + Ext inline
  • SupplierDetailPage.tsx: secciones empresa + contactos
  • i18n: keys nuevas en EN + ES
  • Tests: validación E.164, display formatNational, primary contact

Orden de mergeo

  1. Backend PR primero → migración + endpoints + seeder + tests
  2. Frontend PR después → consume la API ya disponible y la UI funciona end-to-end al final
  3. Tracking en Notion como dos tasks separados con dependencia explícita

Modelo elegido: B (híbrido) — referencia

Modelo Supplier.Phone SupplierContact.Phone Estado
A — Square strict ❌ no existe ✓ todo aquí Descartado
B — Híbrido ✓ centralita ✓ phone + ext por persona Elegido
C — Tipos ❌ no existe ✓ con campo Type Descartado

Domain entity esperada (referencia)

// =========================================================== // NO CORRER. Schema de referencia para cuando se levante Plan C. // =========================================================== CREATE TABLE dbo.SupplierContact ( Id bigint IDENTITY PRIMARY KEY, GlobalId uniqueidentifier NOT NULL, PublicId varchar(32) NOT NULL, MerchantId int NOT NULL, SupplierId bigint NOT NULL, Name nvarchar(120) NOT NULL, Email varchar(255) NULL, Phone varchar(16) NULL, -- E.164 PhoneExt varchar(10) NULL, -- aquí SÍ tendría sentido (no en Plan B) Title nvarchar(120) NULL, Ordinal int NOT NULL DEFAULT 0, IsActive bit NOT NULL DEFAULT 1, -- + RowVersion, audit cols, soft delete CONSTRAINT FK_SupplierContact_Supplier FOREIGN KEY (MerchantId, SupplierId) REFERENCES dbo.Supplier(MerchantId, Id) ); -- Modelo B: Supplier conserva Phone (centralita). -- ContactName y Email se mueven a SupplierContact (Phone también, opcional ahí).

Patrón de referencia interno: Qiiub ya tiene Account ↔ AccountContact — reusar las convenciones (composite FK, soft delete, audit cols, RowVersion). Estamos en pure dev — schema change + seed regen, no migración de datos.

09Display en la vista de detalle

Hoy (SupplierDetailPage.tsx)

Teléfono
llámame al cel 787 555 1234 ext 5 o al fax
⚠ tel: link no funciona

Propuesto (Modelo B con contactos)

Teléfono (centralita)
(787) 765-2929
✓ tel: link funciona
Contactos
Wanda Ramos Primario · Compras
📞 (787) 765-2929 ext. 6645
César Castillo · Owner cell
📞 (787) 555-1234
AR Dept · Departamento
📞 (787) 765-2929 ext. 6612
Notas
Delivery: lunes y miércoles AM Cierre: viernes 4pm

El tel: link de cada contacto incluye su extensión con pausa (tel:+17877652929,,6645) — el móvil del usuario marca la centralita, espera, y luego marca la extensión automáticamente.

10Casos límite y FAQ

¿Quién usa extensiones de verdad? (research específico)

Después de cuestionar el supuesto, busqué evidencia concreta de prevalencia por tipo de entidad. La realidad:

Entidad¿Usa ext?Evidencia
Supplier (B2B) SÍ, común Distribuidores, mayoristas, reguladores. Ejemplo PR real: Board of Pharmacy 787-765-2929 ext 6645. Sales teams monitorean "switchboard rate" como métrica.
Customer retail (B2C) NO típicamente Shopper individual = celular. POS de cell phone stores (Lightspeed, CellSmart) no mencionan ext.
Customer empresarial A veces Cuenta comercial (farmacia que vende a una clínica). Pero ahí la persona específica suele ser un Contact.
Contact (persona dentro de Customer) SÍ, común Por eso Salesforce y HubSpot ponen ext en Contact, no en Account. La persona específica tiene extensión, no la empresa.

Implicación para Qiiub: Supplier.PhoneExt sí vale la pena. Customer.PhoneExt probablemente no — el customer típico de un POS retail es B2C. Si en el futuro agregamos Contact (persona dentro de un Customer empresarial), ahí sí va PhoneExt.

¿Y si el proveedor solo dejó un fax?

Mismo schema. Si quieres campo aparte para fax, lo agregamos como Fax varchar(16) separado (también E.164). RMH/GP tienen Fax como campo independiente — vale la pena considerarlo.

¿Y los celulares vs líneas fijas?

No los distinguimos en almacenamiento. libphonenumber-csharp puede decirte el tipo (FIXED_LINE, MOBILE) en runtime si algún día necesitamos saber para SMS vs llamada.

¿Importación masiva con formato sucio?

El endpoint de import bulk usa el mismo validador BeValidE164. Líneas no-parseables se marcan en el reporte de errores del import, no rompen toda la carga. Esto aplica cuando el import exista — no es parte del sprint actual.

¿Dependencias nuevas?

libphonenumber-csharp (NuGet, MIT, port oficial de Google ~5MB) + react-phone-number-input (~30kB gz, ya usa el design system de inputs). Ambos mantenidos activamente, sin issues de seguridad conocidos.

11Resumen de decisiones (tomadas)

Recomendación final

Empezar con Supplier como piloto (es donde los devs notaron el problema). ADR + EF Core migration + seeder update + frontend en una sola PR. Si funciona, replicar a Customer y demás entidades con Phone. Recordar: estamos en pure dev — no hay datos de producción que mover, solo schema changes y seed regen.

Fuentes: ITU-T E.164 · ITU-T E.123 · Microsoft Learn (Dynamics GP — Phone_Number Dexterity type) · Stripe API docs · Shopify Customer API · Square Vendors API · Twilio Phone Validation Best Practices · GitHub catamphetamine/react-phone-number-input · GitHub twcclegg/libphonenumber-csharp.