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

CampoTipoRequeridoNota
idstringVirtual derivado de _id.
tenantIdstringMulti-tenant, indexado.
skustringÚnico por {tenantId, sku}; se normaliza a MAYÚSCULAS antes de guardar.
statusenumnoVer divergencia de status abajo. Default 'draft'.
productTypeenumnoVer productType vs type abajo.
upcstringnoRegex /^\d{12}$/.
barcode / barcodeTypestring / enumno
categoryMap{domain, value}[]noDefault [].
conditionenumnoVer divergencia de condition abajo.
title / description / htmlDescription{value, translations[]}title sí, el resto noCampos i18n.
brand / model / productKindstringno
satCodestringno8 dígitos, usado para CFDI.
pricenumbermin: 0, indexado.
compareAtPricenumbernoValidado en pre('save'): si se envía, debe ser mayor que price o el guardado falla.
priceSchemasProductPrice[]noVer sub-shapes.
cost / costsnumber / ProductCost[]noCost Layers.
inventoryCost / inventoryCostMethodnumber / enumnoDerivado del sistema de lotes; solo lectura.
categoryProductCategory (objeto único, no arreglo)noVer sub-shapes.
currencystring ISO-3noDefault 'USD'.
taxablebooleansí (tipo)Default true.
mediaProductMedia[]sí (tipo)Ver sub-shapes.
variants / optionsarraysnoVer ProductVariant abajo.
attributesProductAttribute[]sí (tipo)
shippingProductShipping (embebido)no
seo{metaTitle, metaDescription, slug}noslug regex /^[a-z0-9-]+$/; metaTitle máx. 60, metaDescription máx. 160.
tags / bulletsstring[]nobullets máx. 7 (uso de marketplace).
bindingsProductBinding[]noVer divergencia de canal abajo.
bundleConfigBundleConfignoSolo cuando productType === 'bundle'. Ver Bundle.
minAlertStocknumbernoDefault 5.
sellIfOutOfStockbooleansí (tipo)Default false.
embeddings / embeddingMetadataobjetosnoEl schema real tiene text/images/combined (v2.0) que no existen en el tipo TS ProductEmbeddings.
fitmentConfigMixednoSin validación estructural en Mongoose.
supplyConfig / materialConfig / billOfMaterials / modifiersConfigobjetosnoSolo aplican según productType. Ver Materials / Supplies / BOM / Modifiers.
metafieldsMetafield[]noNamespaces 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í:

  1. La constante PRODUCT_TYPES de la librería declara 3 valores: simple | variable | bundle.
  2. El tipo TS público Product.type declara 5 valores: simple | variable | bundle | supply | material.
  3. El schema Mongoose real no tiene ningún campo type. Solo tiene productType, 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

CampoTipoRequeridoNota
idstringnoAutogenerado (ObjectId().toString()).
skustringÚnico a nivel producto+tenant; normalizado a MAYÚSCULAS.
parentSku / productSkustringnoSe asignan automáticamente al SKU del producto padre en pre('save').
imageIdstringnoReferencia a ProductMedia.id.
barcode / barcodeType / satCodenoOverrides a nivel variante.
productTypeenum physical | digital | servicenoDefault physical. Ver nota arriba — no es el mismo dominio que Product.productType.
title{value, translations}
pricenumbermin: 0.
priceSchemas / cost / costsnoAnálogo al producto.
compareAtPricenumbernoNo validado contra price a nivel de variante — la regla pre('save') de "debe ser mayor" solo se aplica a nivel producto.
positionnumbermin: 0.
taxablebooleanDefault true.
shippingProductShipping (override)noSi 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)
countryOfOriginstring ISO-2no
minAlertStocknumber | nullnoHereda el del producto padre, o default 5.
sellIfOutOfStockbooleanDefault 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 (default false), content: Mixed (req), currency (req, ISO-3), target: 'customers' | 'client' | 'segment' (req); channels/client/segment se vuelven requeridos condicionalmente según target.
  • 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' (default image), 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 (default true).
  • 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, default string), 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] (default other), consumptionUnit (req, default pza), 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étodos fifo | lifo | average | last_purchase), expiration { expires, shelfLifeDays, expirationPolicy }, suppliers[], stockAlerts (req), lotTrackingEnabled.
  • BOM (billOfMaterials, aplica a cualquier producto, no gated por productType): consumptionMode: 'on_sale' | 'on_production' | 'manual', yield { quantity, unit }, components[] (mínimo 1, req; cada uno materialSku + quantity + unit, req, con substitutes[]), wastagePercent ≤ 100.
  • Modifiers (grupos de modificadores tipo food-delivery — Uber Eats/Rappi/DiDi): enabled (default false), groups[] con code, name (req), selectionRule, modifiers[] (mínimo 1, req; cada uno code, name, priceAdjustment { type: fixed|percentage|replace, value, currency }, channelBindings[] con channelType: 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.

Siguientes pasos