Modelo de datos — Product y ProductVariant
Este artículo describe la forma realmente persistida de un producto: la fuente es el schema de Mongoose (Lib/Services/Products/src/model/schema.ts), no el tipo TypeScript público del paquete @fenicia/core.
El tipo TS público y el schema real DIVERGEN
El paquete @fenicia/core expone un tipo Product más amplio que lo que Mongoose acepta guardar. Si generas tus tipos de cliente a partir de ese paquete, vas a tener campos que el servidor nunca persiste y enums que el servidor rechaza al guardar. Esta página documenta el contrato real; las divergencias se marcan explícitamente en cada fila.
Product — campos principales
| Campo | Tipo | Requerido | Nota |
|---|---|---|---|
id | string | — | Virtual derivado de _id. |
tenantId | string | sí | Multi-tenant, indexado. |
sku | string | sí | Único por {tenantId, sku}; se normaliza a MAYÚSCULAS antes de guardar. |
status | enum | no | Ver divergencia de status abajo. Default 'draft'. |
productType | enum | no | Ver productType vs type abajo. |
upc | string | no | Regex /^\d{12}$/. |
barcode / barcodeType | string / enum | no | |
categoryMap | {domain, value}[] | no | Default []. |
condition | enum | no | Ver divergencia de condition abajo. |
title / description / htmlDescription | {value, translations[]} | title sí, el resto no | Campos i18n. |
brand / model / productKind | string | no | |
satCode | string | no | 8 dígitos, usado para CFDI. |
price | number | sí | min: 0, indexado. |
compareAtPrice | number | no | Validado en pre('save'): si se envía, debe ser mayor que price o el guardado falla. |
priceSchemas | ProductPrice[] | no | Ver sub-shapes. |
cost / costs | number / ProductCost[] | no | Cost Layers. |
inventoryCost / inventoryCostMethod | number / enum | no | Derivado del sistema de lotes; solo lectura. |
category | ProductCategory (objeto único, no arreglo) | no | Ver sub-shapes. |
currency | string ISO-3 | no | Default 'USD'. |
taxable | boolean | sí (tipo) | Default true. |
media | ProductMedia[] | sí (tipo) | Ver sub-shapes. |
variants / options | arrays | no | Ver ProductVariant abajo. |
attributes | ProductAttribute[] | sí (tipo) | |
shipping | ProductShipping (embebido) | no | |
seo | {metaTitle, metaDescription, slug} | no | slug regex /^[a-z0-9-]+$/; metaTitle máx. 60, metaDescription máx. 160. |
tags / bullets | string[] | no | bullets máx. 7 (uso de marketplace). |
bindings | ProductBinding[] | no | Ver divergencia de canal abajo. |
bundleConfig | BundleConfig | no | Solo cuando productType === 'bundle'. Ver Bundle. |
minAlertStock | number | no | Default 5. |
sellIfOutOfStock | boolean | sí (tipo) | Default false. |
embeddings / embeddingMetadata | objetos | no | El schema real tiene text/images/combined (v2.0) que no existen en el tipo TS ProductEmbeddings. |
fitmentConfig | Mixed | no | Sin validación estructural en Mongoose. |
supplyConfig / materialConfig / billOfMaterials / modifiersConfig | objetos | no | Solo aplican según productType. Ver Materials / Supplies / BOM / Modifiers. |
metafields | Metafield[] | no | Namespaces conocidos: mkt_category, mkt_attr, mkt_variant. |
Campos del tipo TS sin persistencia real
Estos campos existen en el tipo público @fenicia/core pero no se guardan en el documento Mongoose — no confíes en ellos si generas tu modelo a partir del tipo TS: inventoryQuantity, categoryString, attributeGroups, warranty, preparationTime, localization.
marketplaceCategories no es un campo estable
marketplaceCategories existe en algunos documentos pero está deliberadamente comentado/deshabilitado en el schema, con un TODO explícito del propio equipo. No lo trates como un campo soportado.
status — 3 valores reales, no 5
El schema Mongoose acepta únicamente:
active | disabled | draft (default: draft)El tipo TS público declara 5 valores: active | disabled | inactive | draft | out_of_stock. inactive y out_of_stock no existen en el enum real — si tu cliente intenta guardar un producto con alguno de esos dos valores, Mongoose rechaza el guardado por validación de enum.
condition — 12 valores reales, no 3
El tipo TS público declara solo 3 valores (el subconjunto exacto no quedó registrado en la auditoría de este artículo). El schema Mongoose real acepta 12: new, new-open-box, new-oem, refurbished, más 4 variantes agrupadas bajo el prefijo used-* y 4 variantes agrupadas bajo el prefijo collectible-* (los sufijos exactos de esos 8 valores no se capturaron en la auditoría — revisa el schema fuente si tu integración depende del valor literal).
productType vs type — tres definiciones que no coinciden
Hay tres lugares distintos en el código que definen "tipo de producto", y no coinciden entre sí:
- La constante
PRODUCT_TYPESde la librería declara 3 valores:simple | variable | bundle. - El tipo TS público
Product.typedeclara 5 valores:simple | variable | bundle | supply | material. - El schema Mongoose real no tiene ningún campo
type. Solo tieneproductType, con un enum de 6 valores que mezcla naturaleza física con estructura de composición:
physical | digital | service | bundle | supply | material'simple' y 'variable' no son persistibles
Ninguno de los dos existe en el documento real. No hay, a nivel de este campo, una forma de marcar explícitamente en runtime "producto con variantes" vs "producto simple" — esa distinción hoy se infiere de si variants[] tiene elementos, no de un campo type/productType. Si documentas o integras contra el modelo de datos, usa productType (el real) y no el tipo TS type.
Tip
ProductVariant también tiene un campo llamado productType, pero es un campo distinto con un dominio distinto: solo 3 valores (physical | digital | service, default physical) — no mezcla bundle/supply/material. No asumas que el productType de un producto y el de su variante comparten el mismo enum solo porque se llaman igual.
ProductVariant — campos principales
| Campo | Tipo | Requerido | Nota |
|---|---|---|---|
id | string | no | Autogenerado (ObjectId().toString()). |
sku | string | sí | Único a nivel producto+tenant; normalizado a MAYÚSCULAS. |
parentSku / productSku | string | no | Se asignan automáticamente al SKU del producto padre en pre('save'). |
imageId | string | no | Referencia a ProductMedia.id. |
barcode / barcodeType / satCode | — | no | Overrides a nivel variante. |
productType | enum physical | digital | service | no | Default physical. Ver nota arriba — no es el mismo dominio que Product.productType. |
title | {value, translations} | sí | |
price | number | sí | min: 0. |
priceSchemas / cost / costs | — | no | Análogo al producto. |
compareAtPrice | number | no | No validado contra price a nivel de variante — la regla pre('save') de "debe ser mayor" solo se aplica a nivel producto. |
position | number | sí | min: 0. |
taxable | boolean | sí | Default true. |
shipping | ProductShipping (override) | no | Si viene vacío, hereda el del producto. |
options | {id, name, value}[] | sí (tipo) | id referencia product.options[i].id. |
bindings | {id?, channelId, handle}[] | sí (tipo) | |
countryOfOrigin | string ISO-2 | no | |
minAlertStock | number | null | no | Hereda el del producto padre, o default 5. |
sellIfOutOfStock | boolean | sí | Default false. |
El stock de una variante no vive en el documento
No hay un campo de stock dentro de ProductVariant. El stock se resuelve mediante un join en vivo contra la colección inventory, separada — consistente con que la proyección por defecto (DEFAULT_PROJECTION) reserve variants.stock solo para el resultado en memoria que ves en la respuesta, no para lo que está persistido en el documento del producto.
Sub-shapes relevantes
- Precio (
ProductPriceSchema):id,name(req),isWholesale: boolean(defaultfalse),content: Mixed(req),currency(req, ISO-3),target: 'customers' | 'client' | 'segment'(req);channels/client/segmentse vuelven requeridos condicionalmente segúntarget. - Costo (
ProductCostSchema):id,name,type: 'money' | 'rate'(req),cost ≥ 0(req),currency(req),reason: enum['tax','delivery-packaging','fees','material','inventory','custom'](req). - Media (
ProductMediaSchema):id,src(regex^https?://, req),name(req),type: 'image' | 'video' | 'audio' | 'document'(defaultimage),size: Mixed,position ≥ 0(req),alt: TranslatedField(req),bindings[]. - Categoría (
ProductCategorySchema):id,name(req),channels[],mappings.claro/mappings.mercadoLibre.{mexico,argentina,colombia},path(req),selectable: boolean(defaulttrue). - SEO:
metaTitle(máx. 60) /metaDescription(máx. 160) /slug(minúsculas, regex/^[a-z0-9-]+$/). - Metafields:
namespace,key(req, indexado),type: enum[string,json,number_integer,number_decimal,date,date_time,url,boolean](req, defaultstring),value: Mixed(req).
Binding — enum de canal inconsistente
ProductBindingSchema: channelId, handle (req), type: enum[mercadolibre,shopify,amazon,walmart,shein,woocommerce,t1,claroshop,linio,coppel] (req), status/desiredStatus: enum['active','inactive','pending','error','paused'], syncStatus: enum['synced','pending','error'], más reservedStock (default 0), overrides[], customValues[] y datos específicos por canal (mercadoLibreData, sheinData, coppelData, t1Channels[]).
Dos bugs de catálogo de canal, no solo uno
El enum de type en este sub-shape tiene 10 valores — le falta liverpool, que sí existe en el catálogo global CHANNEL_TYPES (11 valores). Además usa el literal claroshop (sin guion) mientras el catálogo global usa claro-shop (con guion) para el mismo canal. Son dos strings distintos apuntando al mismo canal — riesgo real de que una comparación literal (===) falle silenciosamente. Si tu integración compara tipos de canal, no asumas que ambos catálogos están sincronizados.
Bundle (bundleConfig)
No es una entidad HTTP — es un sub-shape de datos
bundleConfig no tiene endpoints propios (/products/{sku}/bundle no existe). Se lee y escribe únicamente como parte del payload completo de PUT /products/{id}. El único mecanismo de composición producto-de-productos con endpoints dedicados es BOM — ver Materiales y BOM.
Aplica solo cuando productType === 'bundle': inventoryMode: 'calculated' | 'reserved' (default calculated), components[] (mínimo 1, req): productSku (req), variantMode: 'fixed' | 'selectable' | 'any' (default fixed), quantity ≥ 1 (req, default 1), required (default true).
Materials / Supplies / BOM / Modifiers
Sub-shapes fuera del núcleo Product/ProductVariant pero parte del mismo documento; sus endpoints están en Materiales y BOM.
- Supply (
productType: 'supply'):supplyCategory: enum[packaging,shipping,office,cleaning,tools,labels,other](defaultother),consumptionUnit(req, defaultpza),stockAlerts { minStock, reorderPoint, reorderQuantity (todos ≥ 0, req), alertEmails[], alertEnabled (default true) },typicalUsagePerOrder,shippingAssociation,preferredSupplierId. - Material (
productType: 'material'):baseUnit { code, name, precision ≤ 6 }(req),alternativeUnits[],costing(req, métodosfifo | lifo | average | last_purchase),expiration { expires, shelfLifeDays, expirationPolicy },suppliers[],stockAlerts(req),lotTrackingEnabled. - BOM (
billOfMaterials, aplica a cualquier producto, no gated porproductType):consumptionMode: 'on_sale' | 'on_production' | 'manual',yield { quantity, unit },components[](mínimo 1, req; cada unomaterialSku + quantity + unit, req, consubstitutes[]),wastagePercent ≤ 100. - Modifiers (grupos de modificadores tipo food-delivery — Uber Eats/Rappi/DiDi):
enabled(defaultfalse),groups[]concode,name(req),selectionRule,modifiers[](mínimo 1, req; cada unocode,name,priceAdjustment { type: fixed|percentage|replace, value, currency },channelBindings[]conchannelType: enum['uber-eats','rappi','didi-food','mercado-libre','shopify','other']),conditions { variantSkus[], availableHours { start, end } (regex HH:MM), availableDays[] }.
Los tipos públicos de Material están deshabilitados en las exportaciones del paquete
CreateMaterialInput y tipos afines están comentados en las exportaciones públicas de @fenicia/core, con un TODO explícito ("temporarily disabled... to allow countAvailableProducts fix to deploy"). La funcionalidad existe y funciona en runtime — el problema es que un consumidor TypeScript externo no puede importar esos tipos del paquete publicado.