Materiales, insumos y lista de materiales (BOM)

Este dominio cubre cuatro entidades relacionadas que soportan manufactura, costeo y preparación de productos: materiales (insumos con costeo por lotes, útiles en producción), insumos (supplies, consumibles operativos como empaque o etiquetas), lista de materiales (BOM — qué materiales consume un producto al venderse o producirse) y modificadores (opciones de personalización para verticales de comida como Uber Eats, Rappi o DiDi Food).

No existe una entidad HTTP 'bundle'

El único mecanismo de composición producto-de-productos con endpoints propios es la lista de materiales (BOM), orientada a manufactura y consumo de inventario. Un "bundle" o kit de venta (bundleConfig) existe como un sub-objeto dentro del payload del producto (PUT /products/{id}, ver Actualizar un producto) — no tiene rutas /products/{sku}/bundle propias. Si buscas exponer/vender un kit compuesto por varios productos, es bundleConfig, no BOM.

Materiales, insumos, BOM y modificadores se persisten sobre el mismo modelo Product: un material es un Product con productType:'material' y su configuración específica en materialConfig; un insumo tiene productType:'supply' y supplyConfig; la lista de materiales vive en billOfMaterials de cualquier producto; los modificadores viven en modifiersConfig. No son colecciones separadas.

Materiales

Los materiales viven en una colección dedicada, no en materialConfig

A diferencia de lo que sugiere el modelo conceptual (§ arriba), el código real de MaterialsService persiste los materiales en una colección materials dedicada (no en el documento Product con productType:'material' + materialConfig). Por eso el objeto que devuelven estos endpoints es plano: los campos (baseUnit, costing, stockAlerts, etc.) van al nivel raíz del documento, no anidados bajo materialConfig. La tabla de modelo de datos describe el campo embebido que sí existe en el schema de Product, pero no es lo que esta superficie REST retorna.

Lista materiales con paginación y filtros

pagenumber

Página, base 0. Default 0.

limitnumber

Elementos por página. Default 50.

categorystring

Filtra por categoría del material.

statusstring

Filtra por estado del material.

termstring

Búsqueda de texto.

200
{
  "data": [
    {
      "sku": "MAT-TELA-ALGODON",
      "title": { "value": "Tela de algodón" },
      "baseUnit": { "code": "m", "name": "Metro", "precision": 2 },
      "costing": {
        "method": "fifo",
        "currentCost": { "value": 45.5, "currency": "MXN", "perUnit": "m", "calculatedAt": "2026-07-21T12:00:00.000Z" }
      },
      "stockAlerts": { "minStock": 20, "reorderPoint": 30, "reorderQuantity": 100, "alertEnabled": true },
      "lotTrackingEnabled": true,
      "status": "active"
    }
  ],
  "total": 1,
  "page": 0,
  "limit": 50,
  "totalPages": 1,
  "hasMore": false
}

Permiso requerido: products:read

Obtiene un material por SKU

skustringrequired

SKU del material.

404
{ "code": "not-found", "message": "Material not found" }

Permiso requerido: products:read

Crea un material

{
  "sku": "MAT-TELA-ALGODON",
  "title": { "value": "Tela de algodón" },
  "baseUnit": { "code": "m", "name": "Metro", "precision": 2 },
  "costingMethod": "fifo",
  "initialCost": 45.5,
  "currency": "MXN",
  "lotTrackingEnabled": true,
  "stockAlerts": { "minStock": 20, "reorderPoint": 30, "reorderQuantity": 100, "alertEnabled": true }
}
201
{
  "sku": "MAT-TELA-ALGODON",
  "title": { "value": "Tela de algodón" },
  "baseUnit": { "code": "m", "name": "Metro", "precision": 2 },
  "alternativeUnits": [],
  "costing": {
    "method": "fifo",
    "currentCost": { "value": 45.5, "currency": "MXN", "perUnit": "m", "calculatedAt": "2026-07-21T12:00:00.000Z" },
    "costHistory": [{ "cost": 45.5, "currency": "MXN", "date": "2026-07-21T12:00:00.000Z", "quantity": 0 }]
  },
  "price": 45.5,
  "currency": "MXN",
  "expiration": { "expires": false, "expirationPolicy": "none" },
  "suppliers": [],
  "stockAlerts": { "minStock": 20, "reorderPoint": 30, "reorderQuantity": 100, "alertEnabled": true },
  "lotTrackingEnabled": true,
  "tags": [],
  "status": "active"
}
400
{ "code": "duplicate", "message": "Material with SKU MAT-TELA-ALGODON already exists" }

Permiso requerido: products:create

Actualiza un material (parcial)

skustringrequired

SKU del material.

{
  "stockAlerts": { "minStock": 25, "reorderPoint": 40, "reorderQuantity": 120, "alertEnabled": true },
  "lotTrackingEnabled": true
}
200
{
  "sku": "MAT-TELA-ALGODON",
  "title": { "value": "Tela de algodón" },
  "baseUnit": { "code": "m", "name": "Metro", "precision": 2 },
  "stockAlerts": { "minStock": 25, "reorderPoint": 40, "reorderQuantity": 120, "alertEnabled": true },
  "lotTrackingEnabled": true,
  "status": "active"
}
404
{ "code": "not-found", "message": "Material not found" }

Permiso requerido: products:update

Elimina un material

skustringrequired

SKU del material.

200
{ "success": true, "message": "Material deleted" }
404
{ "code": "not-found", "message": "Material not found" }

Permiso requerido: products:delete

Inventario de materiales

Stock total del material (todas las ubicaciones)

skustringrequired

SKU del material.

200
{
  "totalQuantity": 340,
  "totalReserved": 15,
  "totalCommitted": 10,
  "totalAvailable": 315,
  "byLocation": [
    { "locationId": "65f2a0b1c4d5e6f7a8b9c0aa", "quantity": 340, "available": 315 }
  ]
}

Permiso requerido: products:read

Stock del material en una ubicación

skustringrequired

SKU del material.

locationIdstringrequired

ID de la ubicación.

200
{
  "tenantId": "65e1f0a1c4d5e6f7a8b9c0bb",
  "materialSku": "MAT-TELA-ALGODON",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 340,
  "unit": "m",
  "reservedQty": 15,
  "committedQty": 10,
  "lotTrackingEnabled": true,
  "lots": [
    { "lotNumber": "L-2026-001", "quantity": 340, "costPerUnit": 45.5, "currency": "MXN", "receivedDate": "2026-06-01T00:00:00.000Z", "status": "available" }
  ],
  "minStock": 20,
  "reorderPoint": 30
}
404
{ "code": "not-found", "message": "Inventory not found for this material at this location" }

Permiso requerido: products:read

Registra o ajusta inventario de un material en una ubicación

skustringrequired

SKU del material.

locationIdstringrequired

Ubicación donde se registra el inventario.

quantitynumberrequired

Cantidad. Debe ser mayor que 0.

unitstring

Unidad de medida.

lotobject

Datos del lote, cuando el material tiene seguimiento por lote (lotTrackingEnabled): { lotNumber, costPerUnit?, currency?, expirationDate? }.

minStocknumber

Stock mínimo de alerta (solo al crear el registro de inventario).

reorderPointnumber

Punto de reorden (solo al crear el registro de inventario).

{
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 100,
  "unit": "m",
  "lot": { "lotNumber": "L-2026-002", "costPerUnit": 46.0, "currency": "MXN" }
}
200
{
  "tenantId": "65e1f0a1c4d5e6f7a8b9c0bb",
  "materialSku": "MAT-TELA-ALGODON",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 440,
  "unit": "m",
  "reservedQty": 15,
  "committedQty": 10,
  "lotTrackingEnabled": true,
  "lots": [
    { "lotNumber": "L-2026-001", "quantity": 340, "costPerUnit": 45.5, "currency": "MXN", "receivedDate": "2026-06-01T00:00:00.000Z", "status": "available" },
    { "lotNumber": "L-2026-002", "quantity": 100, "costPerUnit": 46.0, "currency": "MXN", "receivedDate": "2026-07-21T12:00:00.000Z", "status": "available" }
  ]
}
400
{ "code": "bad-request", "message": "Quantity must be a positive number", "parameter": "quantity" }

Permiso requerido: inventory:update

Transfiere inventario de material entre ubicaciones

materialSkustringrequired

SKU del material a transferir.

fromLocationIdstringrequired

Ubicación origen.

toLocationIdstringrequired

Ubicación destino.

quantitynumberrequired

Cantidad a transferir. Debe ser mayor que 0.

{
  "materialSku": "MAT-TELA-ALGODON",
  "fromLocationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "toLocationId": "65f2a0b1c4d5e6f7a8b9c0cc",
  "quantity": 50
}
200
{ "success": true, "message": "Transfer completed" }
400
{ "code": "transfer-failed", "message": "Insufficient stock at source location. Available: 20" }

Permiso requerido: inventory:transfer

Consumo de materiales

Estos tres endpoints permiten simular y ejecutar el consumo de materiales que un producto necesita al venderse o prepararse — típicamente invocados por el flujo de venta (POS, checkout) antes o durante la confirmación de un pedido.

Verifica disponibilidad de materiales para producir/vender una cantidad de un producto, sin consumir

productSkustringrequired

SKU del producto a evaluar (el que tiene BOM asociado).

locationIdstringrequired

Ubicación donde se verifica el stock de materiales.

quantitynumber

Cantidad del producto a producir/vender. Default 1.

selectedModifiersarray

Modificadores seleccionados, si el producto usa modificadores con consumo asociado: [{ groupId, modifierId, quantity? }].

{
  "productSku": "PROD-CAMISA-M",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 2
}
200
{
  "available": true,
  "calculation": {
    "components": [{ "materialSku": "MAT-TELA-ALGODON", "quantity": 3, "unit": "m", "source": "bom" }],
    "totalMaterials": 1,
    "byMaterial": [{ "materialSku": "MAT-TELA-ALGODON", "totalQuantity": 3, "unit": "m", "sources": [{ "source": "BOM", "quantity": 3 }] }]
  },
  "details": [
    { "materialSku": "MAT-TELA-ALGODON", "required": 3, "available": 315, "sufficient": true }
  ]
}

Permiso requerido: inventory:read

Ejecuta el consumo real de materiales según el BOM del producto

productSkustringrequired

SKU del producto.

locationIdstringrequired

Ubicación de consumo.

quantitynumber

Cantidad del producto. Default 1.

selectedModifiersarray

Modificadores seleccionados.

optionsobject

{ method?: 'fifo'|'lifo', referenceId?, referenceType? } — método de consumo de lotes y referencia (por ejemplo, el pedido que originó el consumo).

{
  "productSku": "PROD-CAMISA-M",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 1,
  "options": { "method": "fifo", "referenceType": "order", "referenceId": "65f3a1b2c4d5e6f7a8b9c0d1" }
}
200
{
  "success": true,
  "consumed": [
    { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.5, "unit": "m", "lotsConsumed": [{ "lotNumber": "L-2026-001", "quantity": 1.5, "costPerUnit": 45.5 }] }
  ],
  "totalCost": 68.25,
  "currency": "MXN",
  "errors": []
}
400
{
  "code": "consumption-failed",
  "message": "Insufficient materials",
  "errors": [{ "materialSku": "MAT-TELA-ALGODON", "required": 5, "available": 2, "error": "Insufficient stock: need 5, have 2" }]
}

Permiso requerido: inventory:update

Estima el costo de materiales que consumiría producir/vender una cantidad de un producto

productSkustringrequired

SKU del producto.

locationIdstringrequired

Ubicación (el costo puede variar por lote disponible en cada ubicación).

quantitynumber

Cantidad del producto. Default 1.

selectedModifiersarray

Modificadores seleccionados.

{
  "productSku": "PROD-CAMISA-M",
  "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
  "quantity": 1
}
200
{
  "totalCost": 68.25,
  "currency": "MXN",
  "breakdown": [
    { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.5, "unit": "m", "costPerUnit": 45.5, "totalCost": 68.25 }
  ]
}

Permiso requerido: inventory:read

Sin validación de producto/BOM inexistente

Los tres endpoints de consumo llaman a MaterialConsumptionService.calculateConsumption, que lanza Product {productSku} not found cuando el producto no existe; el handler solo mapea a 404 los mensajes que contienen la subcadena "not found" — coincide en este caso, así que sí verás 404 not-found. Si el producto existe pero no tiene billOfMaterials ni modifiersConfig con consumo, la operación no falla: simplemente calcula sobre cero componentes (totalMaterials: 0).

Insumos

Los insumos (supplies) son consumibles operativos — empaque, etiquetas, materiales de oficina — con la misma estructura CRUD que los materiales, pero sin costeo por lotes ni seguimiento de vencimiento. Igual que los materiales, SuppliesService persiste en una colección supplies dedicada con forma plana — no en Product.supplyConfig.

Lista insumos con paginación y filtros

pagenumber

Página, base 0.

limitnumber

Elementos por página.

categorystring

Filtra por categoría del insumo.

statusstring

Filtra por estado.

termstring

Búsqueda de texto.

200
{
  "data": [
    {
      "sku": "SUP-CAJA-CHICA",
      "title": { "value": "Caja de empaque chica" },
      "supplyCategory": "packaging",
      "consumptionUnit": "pza",
      "price": 0,
      "currency": "MXN",
      "stockAlerts": { "minStock": 50, "reorderPoint": 100, "reorderQuantity": 500, "alertEnabled": true },
      "status": "active"
    }
  ],
  "total": 1,
  "page": 0,
  "limit": 50,
  "totalPages": 1,
  "hasMore": false
}

Obtiene un insumo por SKU

skustringrequired

SKU del insumo.

404
{ "code": "not-found", "message": "Supply not found" }

Crea un insumo

{
  "sku": "SUP-CAJA-CHICA",
  "title": { "value": "Caja de empaque chica" },
  "supplyCategory": "packaging",
  "consumptionUnit": "pza",
  "price": 8.5,
  "currency": "MXN",
  "stockAlerts": { "minStock": 50, "reorderPoint": 100, "reorderQuantity": 500, "alertEnabled": true }
}
201
{
  "sku": "SUP-CAJA-CHICA",
  "title": { "value": "Caja de empaque chica" },
  "supplyCategory": "packaging",
  "consumptionUnit": "pza",
  "price": 8.5,
  "currency": "MXN",
  "tags": [],
  "status": "active",
  "stockAlerts": { "minStock": 50, "reorderPoint": 100, "reorderQuantity": 500, "alertEnabled": true }
}
400
{ "code": "duplicate", "message": "Supply with SKU SUP-CAJA-CHICA already exists" }

Actualiza un insumo (parcial)

skustringrequired

SKU del insumo.

{ "price": 9.0, "stockAlerts": { "minStock": 60, "reorderPoint": 120, "reorderQuantity": 500 } }
200
{
  "sku": "SUP-CAJA-CHICA",
  "title": { "value": "Caja de empaque chica" },
  "supplyCategory": "packaging",
  "consumptionUnit": "pza",
  "price": 9.0,
  "currency": "MXN",
  "status": "active",
  "stockAlerts": { "minStock": 60, "reorderPoint": 120, "reorderQuantity": 500, "alertEnabled": true }
}
404
{ "code": "not-found", "message": "Supply not found" }

Elimina un insumo

skustringrequired

SKU del insumo.

200
{ "success": true, "message": "Supply deleted" }
404
{ "code": "not-found", "message": "Supply not found" }

Permiso requerido: análogo a materiales — products:read (GET), products:create (POST), products:update (PUT), products:delete (DELETE).

Lista de materiales (BOM)

La BOM (Bill of Materials) define qué materiales consume un producto — ya sea al venderse (on_sale), al producirse (on_production), o solo cuando se dispara manualmente (manual). Vive en el campo billOfMaterials del producto mismo, no en una colección separada.

Obtiene la BOM de un producto

skustringrequired

SKU del producto.

404
{ "code": "not-found", "message": "Product or BOM not found" }

Permiso requerido: products:read

Asigna una BOM a un producto

skustringrequired

SKU del producto.

consumptionModestringrequired

'on_sale' | 'on_production' | 'manual'.

yieldobjectrequired

{ quantity, unit } — rendimiento de la receta/ensamble.

componentsarrayrequired

Al menos un componente: { materialSku, quantity, unit, required?(def true), sequence?, substitutes?[{materialSku,conversionFactor}], notes? }.

wastagePercentnumber

Porcentaje de merma esperado.

preparationNotesobject

Notas de preparación, campo traducido { value }.

prepTimeMinutesnumber

Tiempo estimado de preparación en minutos.

{
  "consumptionMode": "on_sale",
  "yield": { "quantity": 1, "unit": "pza" },
  "components": [
    { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.5, "unit": "m", "required": true },
    { "materialSku": "SUP-ETIQUETA", "quantity": 1, "unit": "pza", "required": true }
  ],
  "wastagePercent": 5
}
200
{
  "sku": "PROD-CAMISA-M",
  "billOfMaterials": {
    "consumptionMode": "on_sale",
    "yield": { "quantity": 1, "unit": "pza" },
    "components": [
      { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.5, "unit": "m", "required": true, "sequence": 0 },
      { "materialSku": "SUP-ETIQUETA", "quantity": 1, "unit": "pza", "required": true, "sequence": 1 }
    ],
    "wastagePercent": 5,
    "calculatedCost": { "value": 71.66, "currency": "MXN", "calculatedAt": "2026-07-21T12:00:00.000Z" }
  }
}

Permiso requerido: products:update

200, no 201 — y sin ApiResponse de error documentado

A pesar de ser la operación que crea la BOM, assignBOM responde 200 (no 201). Si algún materialSku de components no existe, BOMService lanza BOM validation failed: Material {sku} not found, pero el handler no tiene una rama que mapee ese mensaje a 400 — cae directo al catch genérico y responde 500 internal-error, no un 400 de validación.

Actualiza la BOM de un producto (parcial)

skustringrequired

SKU del producto.

{ "wastagePercent": 8, "components": [{ "materialSku": "MAT-TELA-ALGODON", "quantity": 1.6, "unit": "m" }] }
200
{
  "sku": "PROD-CAMISA-M",
  "billOfMaterials": {
    "consumptionMode": "on_sale",
    "yield": { "quantity": 1, "unit": "pza" },
    "components": [
      { "materialSku": "MAT-TELA-ALGODON", "quantity": 1.6, "unit": "m", "required": true, "sequence": 0 }
    ],
    "wastagePercent": 8,
    "calculatedCost": { "value": 78.62, "currency": "MXN", "calculatedAt": "2026-07-21T12:05:00.000Z" }
  }
}
404
{ "code": "not-found", "message": "Product not found" }

Permiso requerido: products:update

Elimina la BOM de un producto

skustringrequired

SKU del producto.

200
{ "success": true, "message": "BOM removed" }
404
{ "code": "not-found", "message": "Product not found" }

Permiso requerido: products:delete

Calcula el consumo de materiales de la BOM para una cantidad producida/vendida del producto

skustringrequired

SKU del producto.

quantitynumberrequired

Cantidad producida/vendida (se multiplica por cada componente de la BOM).

referenceIdstring

ID de referencia (por ejemplo, el pedido).

referenceTypestring

'order' | 'production' | 'sample' | 'waste'.

allowSubstitutesboolean

Permitir sustitutos definidos en la BOM.

notesstring

Notas libres.

{ "quantity": 2, "referenceId": "65f3a1b2c4d5e6f7a8b9c0d1", "referenceType": "order" }
200
{
  "success": true,
  "quantityProduced": 2,
  "unit": "pza",
  "componentsConsumed": [
    { "materialSku": "MAT-TELA-ALGODON", "quantity": 3, "unit": "m", "costPerUnit": 45.5, "totalCost": 136.5, "substituted": false }
  ],
  "totalCost": 143.33,
  "currency": "MXN",
  "referenceId": "65f3a1b2c4d5e6f7a8b9c0d1",
  "referenceType": "order",
  "consumedAt": "2026-07-21T12:10:00.000Z"
}
500
{ "code": "internal-error", "message": "Failed to consume BOM" }

Permiso requerido: products:update

No deduce inventario real ni valida stock insuficiente

BOMService.consumeComponents calcula el costo y las cantidades como si se consumieran (usando el costo vigente de cada material), pero no descuenta inventario real — el propio código fuente lo marca explícito: "In production, this would actually consume from inventory using MaterialsService.consumeMaterial()". Por eso siempre responde success:true, incluso sin stock suficiente; no existe hoy un 400 insufficient-stock alcanzable en este endpoint. Además, si el producto no tiene BOM asignada, el servicio lanza Product {sku} has no BOM — ese mensaje no contiene la subcadena "not found" que el handler busca para mapear a 404, así que en ese caso verás 500 internal-error, no 404.

Modificadores

Los modificadores implementan opciones de personalización estilo food-delivery (Uber Eats, Rappi, DiDi Food) — por ejemplo "sin cebolla" o "extra queso" — con ajuste de precio y, opcionalmente, consumo de materiales.

Obtiene la configuración de modificadores de un producto

skustringrequired

SKU del producto.

200
{
  "enabled": true,
  "groups": [
    {
      "id": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10",
      "code": "group-0",
      "name": { "value": "Extras" },
      "selectionRule": { "minSelections": 0, "maxSelections": 3, "exactlyOne": false, "allowDuplicates": false },
      "modifiers": [
        {
          "id": "9d1e4a2c-5b7f-4e10-8a3c-1f2b3c4d5e6f",
          "code": "extra-queso",
          "name": { "value": "Extra queso" },
          "type": "addon",
          "priceAdjustment": { "type": "fixed", "value": 15, "currency": "MXN" },
          "available": true,
          "isDefault": false,
          "position": 0
        }
      ],
      "position": 0,
      "active": true
    }
  ]
}
404
{ "code": "not-found", "message": "Product or modifiers not found" }

Permiso requerido: products:read

Configura los grupos de modificadores de un producto (reemplaza la configuración completa)

skustringrequired

SKU del producto.

enabledbooleanrequired

Si los modificadores están activos para el producto.

groupsarrayrequired

Grupos de modificadores: { name, displayName?, selectionRule:{minSelections,maxSelections,exactlyOne,allowDuplicates,maxPerModifier?}, modifiers:[{name,type,sku?,priceAdjustment?,inventoryConsumption?:{materialSku,quantity,unit},isDefault?,isAvailable?,sortOrder?,channelBindings?}], sortOrder?, channelBindings? }.

{
  "enabled": true,
  "groups": [
    {
      "name": { "value": "Extras" },
      "selectionRule": { "minSelections": 0, "maxSelections": 3, "exactlyOne": false, "allowDuplicates": false },
      "modifiers": [
        {
          "name": { "value": "Extra queso" },
          "type": "addon",
          "sku": "extra-queso",
          "priceAdjustment": { "type": "fixed", "value": 15, "currency": "MXN" },
          "inventoryConsumption": { "materialSku": "SUP-QUESO-REBANADA", "quantity": 1, "unit": "pza" }
        }
      ]
    }
  ]
}
200
{
  "sku": "PROD-HAMBURGUESA",
  "modifiersConfig": {
    "enabled": true,
    "groups": [
      {
        "id": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10",
        "code": "group-0",
        "name": { "value": "Extras" },
        "selectionRule": { "minSelections": 0, "maxSelections": 3, "exactlyOne": false, "allowDuplicates": false },
        "modifiers": [
          {
            "id": "9d1e4a2c-5b7f-4e10-8a3c-1f2b3c4d5e6f",
            "code": "extra-queso",
            "name": { "value": "Extra queso" },
            "type": "addon",
            "priceAdjustment": { "type": "fixed", "value": 15, "currency": "MXN" },
            "inventoryConsumption": [{ "sku": "SUP-QUESO-REBANADA", "quantity": 1, "unit": "pza" }],
            "available": true,
            "isDefault": false,
            "position": 0
          }
        ],
        "position": 0,
        "active": true
      }
    ]
  }
}
404
{ "code": "not-found", "message": "Product not found" }

Permiso requerido: products:update

materialSku de entrada se guarda como sku

inventoryConsumption en el request usa el campo materialSku (objeto único). Al guardarse, ModifiersService.buildModifier lo convierte a un arreglo con el campo sku (no materialSku) — es la forma que verás en la respuesta y en GET /products/{sku}/modifiers. No asumas que el nombre de campo se conserva entre request y response.

Valida una selección de modificadores contra las reglas configuradas (por ejemplo, min/max de un grupo) y calcula el ajuste de precio y el consumo de materiales

skustringrequired

SKU del producto.

selectionsarrayrequired

[{ groupId, modifierIds:string[], quantities?: Record<modifierId, number> }] — groupId/modifierId son los `id` (UUID) generados por el servidor, no el `code`.

{
  "selections": [
    { "groupId": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10", "modifierIds": ["9d1e4a2c-5b7f-4e10-8a3c-1f2b3c4d5e6f"] }
  ]
}
200
{
  "valid": true,
  "errors": [],
  "selectedModifiers": [
    {
      "groupId": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10",
      "groupName": "Extras",
      "modifierId": "9d1e4a2c-5b7f-4e10-8a3c-1f2b3c4d5e6f",
      "modifierName": "Extra queso",
      "quantity": 1,
      "priceAdjustment": 15,
      "inventoryConsumption": { "materialSku": "SUP-QUESO-REBANADA", "quantity": 1, "unit": "pza" }
    }
  ],
  "totalPriceAdjustment": 15,
  "materialsToConsume": [{ "materialSku": "SUP-QUESO-REBANADA", "quantity": 1, "unit": "pza" }]
}

Permiso requerido: products:read

Actualiza un grupo de modificadores específico

skustringrequired

SKU del producto.

groupIdstringrequired

`id` (UUID) del grupo — NO el `code`. El servidor busca por `group.id === groupId`; usar el `code` no encuentra el grupo (404).

nameobject

Campo traducido { value }.

displayNameobject

Se guarda como `description` del grupo.

selectionRuleobject

{ minSelections, maxSelections, exactlyOne, allowDuplicates, maxPerModifier? }.

sortOrdernumber

Se guarda como `position` del grupo.

channelBindingsarray

[{ channelId, externalGroupId?, isEnabled }].

{ "selectionRule": { "minSelections": 1, "maxSelections": 2, "exactlyOne": false, "allowDuplicates": false } }
200
{
  "sku": "PROD-HAMBURGUESA",
  "modifiersConfig": {
    "enabled": true,
    "groups": [
      {
        "id": "6b2f6f2a-1a3e-4c9a-9c2e-8f6a2b1d4e10",
        "code": "group-0",
        "name": { "value": "Extras" },
        "selectionRule": { "minSelections": 1, "maxSelections": 2, "exactlyOne": false, "allowDuplicates": false },
        "modifiers": [],
        "position": 0,
        "active": true
      }
    ]
  }
}
404
{ "code": "not-found", "message": "Product or group not found" }

Permiso requerido: products:update

Desactiva los modificadores del producto (enabled:false) — NO borra los grupos configurados

skustringrequired

SKU del producto.

200
{ "success": true, "message": "Modifiers disabled" }
404
{ "code": "not-found", "message": "Product not found" }

Permiso requerido: products:delete

No elimina la configuración, solo la desactiva

Pese a su nombre y descripción, este endpoint invoca ModifiersService.toggleModifiers(tenantId, sku, false): pone modifiersConfig.enabled = false pero conserva groups[] intacto. Si luego vuelves a activar (POST /products/{sku}/modifiers con enabled:true), los grupos previos siguen ahí. Para borrar grupos de verdad usa DELETE /products/{sku}/modifiers/{groupId} por cada uno.

Elimina un grupo de modificadores específico

skustringrequired

SKU del producto.

groupIdstringrequired

`id` (UUID) del grupo — NO el `code`.

200
{ "success": true, "message": "Modifier group removed" }
404
{ "code": "not-found", "message": "Product or group not found" }

Permiso requerido: products:delete

groupId inexistente no da 404 — es un no-op silencioso

ModifiersService.removeModifierGroup solo valida que el producto exista y tenga modifiersConfig; si el groupId enviado no coincide con ningún grupo, hace filter() sobre el arreglo (que no quita nada) y aun así responde 200 {success:true, message:"Modifier group removed"} — no hay forma de distinguir "grupo borrado" de "groupId que nunca existió" desde la respuesta.

Modelo de datos

Material (materialConfig, productType:'material')

CampoTipoRequeridoNota
baseUnit{code, name, precision}precision máximo 6 decimales.
alternativeUnitsarrayNoUnidades alternativas convertibles a baseUnit.
costingobjetoMétodo: fifo, lifo, average o last_purchase.
expiration{expires, shelfLifeDays, expirationPolicy}NoPara materiales perecederos.
suppliersarrayNoProveedores del material.
stockAlerts{minStock, reorderPoint, reorderQuantity, alertEmails, alertEnabled}
lotTrackingEnabledbooleanNoHabilita seguimiento por lote (usado por consumption/consume con options.method).

Insumo (supplyConfig, productType:'supply')

CampoTipoRequeridoNota
supplyCategoryenumNopackaging, shipping, office, cleaning, tools, labels, other. Default other.
consumptionUnitstringDefault pza.
stockAlertsobjetoMismo shape que en materiales.
typicalUsagePerOrdernúmeroNo
shippingAssociationNo
preferredSupplierIdstringNo

Lista de materiales (billOfMaterials, cualquier producto)

CampoTipoRequeridoNota
consumptionModeenumNoon_sale, on_production o manual.
yield{quantity, unit}NoRendimiento de la receta/ensamble.
componentsarrayAl menos un componente: materialSku, quantity, unit requeridos; substitutes[] opcional.
wastagePercentnúmeroNoMáximo 100.

Modificadores (modifiersConfig)

CampoTipoRequeridoNota
enabledbooleanNoDefault false.
groupsarrayNoCada grupo: code, name requeridos, más selectionRule y modifiers[].
modifiers[].priceAdjustment{type, value, currency}Sí (por modificador)type: fixed, percentage o replace.
modifiers[].channelBindings[].channelTypeenumNouber-eats, rappi, didi-food, mercado-libre, shopify, other.
conditions{variantSkus, availableHours, availableDays}NoavailableHours en formato HH:MM.

Bundle (bundleConfig, solo productType/type de bundle) — recordatorio

bundleConfig no tiene endpoints propios. Se gestiona dentro de PUT /products/{id} (ver Actualizar un producto). Estructura: inventoryMode (calculated o reserved, default calculated) y components[] con al menos un elemento — productSku requerido, variantMode (fixed/selectable/any, default fixed), quantity (mínimo 1, default 1), required (default true).

Ejemplo — verificar y consumir materiales al vender

curl -X POST https://api.fenicia.io/products/materials/consumption/consume \
  -H "Authorization: Bearer fkapi_tu_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "productSku": "PROD-CAMISA-M",
    "locationId": "65f2a0b1c4d5e6f7a8b9c0aa",
    "quantity": 1,
    "options": { "method": "fifo", "referenceType": "order", "referenceId": "65f3a1b2c4d5e6f7a8b9c0d1" }
  }'

Errores

CódigoStatusDescripción
validation-error400Campo inválido o faltante en el body.
duplicate-sku409Ya existe un material/insumo/producto con ese SKU en tu tenant.
not-found404No existe el material, insumo, producto o registro de inventario solicitado.
insufficient-stock400No hay suficiente stock del material para completar el consumo de la BOM.
consumption-failed400El consumo de materiales falló; revisa errors[] para el detalle por material.
transfer-failed400La transferencia de inventario de material falló (por ejemplo, stock insuficiente en origen).
auth:invalid_token401La API key es inválida o fue revocada.
auth:permission_denied403La API key no tiene el permiso requerido para esta operación.

Consulta el catálogo completo de errores para el resto de los códigos posibles.

Siguientes pasos