diff --git a/db/migrations/20260518100000_woo_products_allowed_order_units_backfill.sql b/db/migrations/20260518100000_woo_products_allowed_order_units_backfill.sql new file mode 100644 index 0000000..af1a5bc --- /dev/null +++ b/db/migrations/20260518100000_woo_products_allowed_order_units_backfill.sql @@ -0,0 +1,24 @@ +-- migrate:up +-- Backfill _allowed_order_units en raw para productos existentes. +-- Regla: +-- sell_unit_override = 'unit' -> ["unit"] (regla dura) +-- sell_unit_override = 'kg' o null -> ["kg","unit"] (default permisivo) +-- Idempotente: solo aplica si el campo no existe todavía. + +update woo_products_snapshot +set raw = jsonb_set( + coalesce(raw, '{}'::jsonb), + '{_allowed_order_units}', + case + when coalesce(raw->>'_sell_unit_override', 'kg') = 'unit' then '["unit"]'::jsonb + else '["kg","unit"]'::jsonb + end, + true +) +where raw is null + or not (raw ? '_allowed_order_units'); + +-- migrate:down +update woo_products_snapshot +set raw = raw - '_allowed_order_units' +where raw ? '_allowed_order_units'; diff --git a/public/components/products-crud.js b/public/components/products-crud.js index 01581dd..d531c8e 100644 --- a/public/components/products-crud.js +++ b/public/components/products-crud.js @@ -458,6 +458,24 @@ class ProductsCrud extends HTMLElement { Define si este producto se vende por peso o por unidad +
+ +
+ + +
+
+ ${currentUnit === "unit" + ? 'Producto por unidad: solo se acepta pedir por unidad.' + : 'Para productos vendidos por kg, permitir también pedido por unidad (ej: milanesas, hamburguesas).'} +
+
@@ -504,7 +522,40 @@ class ProductsCrud extends HTMLElement { // Bind save button this.shadowRoot.getElementById("saveProduct").onclick = () => this.saveProduct(); - + + // Bind sell_unit select: si cambia a "unit", forzar checks; si pasa a "kg", rehabilitar. + const sellUnitSel = this.shadowRoot.getElementById("sellUnit"); + const cbKg = this.shadowRoot.getElementById("cbAllowKg"); + const cbUnit = this.shadowRoot.getElementById("cbAllowUnit"); + const hintEl = this.shadowRoot.getElementById("allowedUnitsHint"); + sellUnitSel.onchange = () => { + if (sellUnitSel.value === "unit") { + cbKg.checked = false; + cbUnit.checked = true; + cbKg.disabled = true; + cbUnit.disabled = true; + hintEl.textContent = 'Producto por unidad: solo se acepta pedir por unidad.'; + } else { + cbKg.disabled = false; + cbUnit.disabled = false; + // Default permisivo al pasar a kg + if (!cbKg.checked && !cbUnit.checked) { + cbKg.checked = true; + cbUnit.checked = true; + } + hintEl.textContent = 'Para productos vendidos por kg, permitir también pedido por unidad (ej: milanesas, hamburguesas).'; + } + }; + const validateAtLeastOne = (e) => { + if (!cbKg.checked && !cbUnit.checked) { + modal.warn("Tenés que dejar al menos una unidad aceptada."); + // Revertir: marcar el que se acaba de desmarcar + e.target.checked = true; + } + }; + cbKg.addEventListener("change", validateAtLeastOne); + cbUnit.addEventListener("change", validateAtLeastOne); + // Bind add category button this.shadowRoot.getElementById("addCatBtn").onclick = () => this.addCategoryToProduct(); @@ -558,22 +609,25 @@ class ProductsCrud extends HTMLElement { async autoSaveCategories() { if (this.selectedItems.length !== 1) return; - + const p = this.selectedItems[0]; const categories = this.getCurrentProductCategories(); const sellUnitSelect = this.shadowRoot.getElementById("sellUnit"); const sell_unit = sellUnitSelect?.value || p.sell_unit || 'kg'; - + const allowed_order_units = this.getCurrentAllowedOrderUnits(); + try { - await api.updateProduct(p.woo_product_id, { sell_unit, categories }); - + await api.updateProduct(p.woo_product_id, { sell_unit, categories, allowed_order_units }); + // Actualizar localmente p.categories = JSON.stringify(categories.map(name => ({ name }))); + if (allowed_order_units) p.allowed_order_units = allowed_order_units; const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id); if (idx >= 0) { this.items[idx].categories = p.categories; + if (allowed_order_units) this.items[idx].allowed_order_units = allowed_order_units; } - + // Mostrar feedback breve const status = this.shadowRoot.getElementById("saveStatus"); if (status) { @@ -604,31 +658,52 @@ class ProductsCrud extends HTMLElement { return [...tags].map(t => t.dataset.category); } + computeAllowedUI(p, currentUnit) { + if (currentUnit === "unit") return ["unit"]; + if (Array.isArray(p?.allowed_order_units) && p.allowed_order_units.length > 0) { + return p.allowed_order_units; + } + return ["kg", "unit"]; + } + + getCurrentAllowedOrderUnits() { + const cbKg = this.shadowRoot.getElementById("cbAllowKg"); + const cbUnit = this.shadowRoot.getElementById("cbAllowUnit"); + if (!cbKg || !cbUnit) return undefined; + const list = []; + if (cbKg.checked) list.push("kg"); + if (cbUnit.checked) list.push("unit"); + return list.length ? list : ["unit"]; + } + async saveProduct() { if (this.selectedItems.length !== 1) return; - + const p = this.selectedItems[0]; const btn = this.shadowRoot.getElementById("saveProduct"); const sellUnitSelect = this.shadowRoot.getElementById("sellUnit"); - + const sell_unit = sellUnitSelect.value; const categories = this.getCurrentProductCategories(); - + const allowed_order_units = this.getCurrentAllowedOrderUnits(); + btn.disabled = true; btn.textContent = "Guardando..."; - + try { - await api.updateProduct(p.woo_product_id, { sell_unit, categories }); - + await api.updateProduct(p.woo_product_id, { sell_unit, categories, allowed_order_units }); + // Actualizar localmente p.sell_unit = sell_unit; p.categories = JSON.stringify(categories.map(name => ({ name }))); + if (allowed_order_units) p.allowed_order_units = sell_unit === "unit" ? ["unit"] : allowed_order_units; const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id); if (idx >= 0) { this.items[idx].sell_unit = sell_unit; this.items[idx].categories = p.categories; + if (allowed_order_units) this.items[idx].allowed_order_units = p.allowed_order_units; } - + btn.textContent = "Guardado!"; this.renderList(); setTimeout(() => { btn.textContent = "Guardar cambios"; btn.disabled = false; }, 1500); diff --git a/public/lib/api.js b/public/lib/api.js index a39c19f..c0219ea 100644 --- a/public/lib/api.js +++ b/public/lib/api.js @@ -116,12 +116,12 @@ export const api = { }).then(r => r.json()); }, - async updateProduct(wooProductId, { sell_unit, categories }) { - return fetch(`/products/${encodeURIComponent(wooProductId)}`, { + async updateProduct(wooProductId, { sell_unit, categories, allowed_order_units }) { + return safeFetch(`/products/${encodeURIComponent(wooProductId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sell_unit, categories }), - }).then(r => r.json()); + body: JSON.stringify({ sell_unit, categories, allowed_order_units }), + }); }, async updateProductsUnit(wooProductIds, { sell_unit }) { diff --git a/src/modules/0-ui/controllers/products.js b/src/modules/0-ui/controllers/products.js index be7f08d..7a7420a 100644 --- a/src/modules/0-ui/controllers/products.js +++ b/src/modules/0-ui/controllers/products.js @@ -111,13 +111,23 @@ export const makeUpdateProduct = (tenantIdOrFn) => async (req, res) => { try { const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; const wooProductId = req.params.id; - const { sell_unit, categories } = req.body || {}; - + const { sell_unit, categories, allowed_order_units } = req.body || {}; + if (sell_unit && !["kg", "unit"].includes(sell_unit)) { return res.status(400).json({ ok: false, error: "invalid_sell_unit" }); } - - const result = await handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories }); + + if (allowed_order_units !== undefined) { + if (!Array.isArray(allowed_order_units) || allowed_order_units.length === 0) { + return res.status(400).json({ ok: false, error: "invalid_allowed_order_units" }); + } + const valid = allowed_order_units.every((u) => u === "kg" || u === "unit"); + if (!valid) { + return res.status(400).json({ ok: false, error: "invalid_allowed_order_units" }); + } + } + + const result = await handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories, allowed_order_units }); res.json(result); } catch (err) { console.error(err); diff --git a/src/modules/0-ui/db/repo.js b/src/modules/0-ui/db/repo.js index 87b5180..01f25f4 100644 --- a/src/modules/0-ui/db/repo.js +++ b/src/modules/0-ui/db/repo.js @@ -25,7 +25,8 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0 attributes_normalized, updated_at as refreshed_at, raw as payload, - coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit + coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit, + raw->'_allowed_order_units' as allowed_order_units_raw from woo_products_snapshot where tenant_id = $1 and (name ilike $2 or coalesce(slug,'') ilike $2 or coalesce(raw->>'SKU', raw->>'sku', '') ilike $2) @@ -47,7 +48,8 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0 attributes_normalized, updated_at as refreshed_at, raw as payload, - coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit + coalesce(raw->>'_sell_unit_override', 'kg') as sell_unit, + raw->'_allowed_order_units' as allowed_order_units_raw from woo_products_snapshot where tenant_id = $1 order by name asc @@ -106,38 +108,44 @@ export async function bulkUpdateProductSellUnit({ tenantId, wooProductIds, sell_ return { updated: result.rowCount }; } -export async function updateProduct({ tenantId, wooProductId, sell_unit, categories }) { +export async function updateProduct({ tenantId, wooProductId, sell_unit, categories, allowed_order_units }) { // Build the update - combine all raw updates into one let updates = []; let params = [tenantId, wooProductId]; let paramIdx = 3; - + // Build the raw column update by chaining jsonb_set calls let rawExpr = "coalesce(raw, '{}'::jsonb)"; - + if (sell_unit) { rawExpr = `jsonb_set(${rawExpr}, '{_sell_unit_override}', $${paramIdx}::jsonb)`; params.push(JSON.stringify(sell_unit)); paramIdx++; } - + if (categories) { // Update categories column updates.push(`categories = $${paramIdx}::jsonb`); params.push(JSON.stringify(categories.map(name => ({ name })))); paramIdx++; - + // Chain the categories override into raw rawExpr = `jsonb_set(${rawExpr}, '{_categories_override}', $${paramIdx}::jsonb)`; params.push(JSON.stringify(categories)); paramIdx++; } - + + if (allowed_order_units) { + rawExpr = `jsonb_set(${rawExpr}, '{_allowed_order_units}', $${paramIdx}::jsonb)`; + params.push(JSON.stringify(allowed_order_units)); + paramIdx++; + } + // Only add raw update if we modified it - if (sell_unit || categories) { + if (sell_unit || categories || allowed_order_units) { updates.push(`raw = ${rawExpr}`); } - + if (!updates.length) return null; const sql = ` diff --git a/src/modules/0-ui/handlers/products.js b/src/modules/0-ui/handlers/products.js index 8087cb8..d5ec9c9 100644 --- a/src/modules/0-ui/handlers/products.js +++ b/src/modules/0-ui/handlers/products.js @@ -1,5 +1,20 @@ import { searchSnapshotItems, syncFromWoo, pushProductToWoo } from "../../shared/wooSnapshot.js"; import { listProducts, getProductByWooId, updateProductSellUnit, bulkUpdateProductSellUnit, updateProduct, insertAuditLog } from "../db/repo.js"; +import { computeAllowedOrderUnits, normalizeUnitToken } from "../../shared/sellUnitPolicy.js"; + +function resolveAllowedOrderUnitsForRow(row) { + if (!row) return row; + let parsed = row.allowed_order_units_raw; + if (typeof parsed === "string") { + try { parsed = JSON.parse(parsed); } catch { parsed = null; } + } + const allowed = computeAllowedOrderUnits({ + sell_unit: row.sell_unit, + raw_override: parsed, + }); + const { allowed_order_units_raw, ...rest } = row; + return { ...rest, allowed_order_units: allowed }; +} export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) { const { items, source } = await searchSnapshotItems({ @@ -11,12 +26,14 @@ export async function handleSearchProducts({ tenantId, q = "", limit = "10", for } export async function handleListProducts({ tenantId, q = "", limit = 2000, offset = 0 }) { - const items = await listProducts({ tenantId, q, limit, offset }); + const rows = await listProducts({ tenantId, q, limit, offset }); + const items = rows.map(resolveAllowedOrderUnitsForRow); return { items }; } export async function handleGetProduct({ tenantId, wooProductId }) { - return getProductByWooId({ tenantId, wooProductId }); + const row = await getProductByWooId({ tenantId, wooProductId }); + return resolveAllowedOrderUnitsForRow(row); } export async function handleSyncProducts({ tenantId }) { @@ -63,10 +80,24 @@ export async function handleBulkUpdateProductUnit({ tenantId, wooProductIds, sel /** * Actualiza un producto localmente y automáticamente lo pushea a WooCommerce */ -export async function handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories }) { +export async function handleUpdateProduct({ tenantId, wooProductId, sell_unit, categories, allowed_order_units }) { + // Aplicar regla dura: si sell_unit final es "unit", forzar allowed_order_units = ["unit"]. + // Tomamos el sell_unit final (el que viene en el patch o el actual del producto). + let effectiveAllowed = allowed_order_units; + if (allowed_order_units !== undefined) { + let effectiveSellUnit = sell_unit; + if (!effectiveSellUnit) { + const current = await getProductByWooId({ tenantId, wooProductId }); + effectiveSellUnit = current?.sell_unit || null; + } + if (normalizeUnitToken(effectiveSellUnit) === "unit") { + effectiveAllowed = ["unit"]; + } + } + // 1. Guardar cambios localmente - await updateProduct({ tenantId, wooProductId, sell_unit, categories }); - + await updateProduct({ tenantId, wooProductId, sell_unit, categories, allowed_order_units: effectiveAllowed }); + // 2. Auto-push a WooCommerce (best effort - no falla si Woo no responde) let wooPushResult = null; try { @@ -76,26 +107,28 @@ export async function handleUpdateProduct({ tenantId, wooProductId, sell_unit, c console.error("[products] handleUpdateProduct: Woo push failed (continuing anyway)", err.message); // No lanzamos el error - el guardado local fue exitoso } - + // 3. Registrar en audit_log await insertAuditLog({ tenantId, entityType: 'product', entityId: String(wooProductId), action: 'update', - changes: { + changes: { sell_unit: { new: sell_unit }, categories: { new: categories }, + allowed_order_units: { new: effectiveAllowed }, woo_synced: wooPushResult?.updated ?? false }, actor: 'ui' }); - - return { - ok: true, - woo_product_id: wooProductId, - sell_unit, + + return { + ok: true, + woo_product_id: wooProductId, + sell_unit, categories, + allowed_order_units: effectiveAllowed, woo_synced: wooPushResult?.updated ?? false }; } diff --git a/src/modules/3-turn-engine/agent/systemPrompt.js b/src/modules/3-turn-engine/agent/systemPrompt.js index 3df7089..234a7a5 100644 --- a/src/modules/3-turn-engine/agent/systemPrompt.js +++ b/src/modules/3-turn-engine/agent/systemPrompt.js @@ -113,5 +113,21 @@ LIMITES TÉCNICOS: LIMITES OPERATIVOS DEL COMERCIO (NO LOS NEGOCIES): - Lo que diga el bloque store del working_memory. -- Si el cliente pide algo fuera de eso, decí qué es lo que sí podemos.`; +- Si el cliente pide algo fuera de eso, decí qué es lo que sí podemos. + +UNIDADES (cómo se pide vs cómo se vende): +- Cada candidato/pending trae allowed_order_units = ["kg"], ["unit"] o ["kg","unit"]. + Es CÓMO PUEDE PEDIRLO EL CLIENTE, no cómo lo vende el comercio. +- ["unit"] → solo unidad ("3 botellas", "5 huevos"). Si el cliente pide en kg/g + para uno de estos, decile que ese producto solo se vende por unidad y preguntale + cuántas quiere. +- ["kg"] → solo peso ("500g", "1kg"). Si el cliente da una cantidad sin unidad + ("quiero 2"), preguntale el peso. +- ["kg","unit"] (ej: milanesas, hamburguesas, chorizos) → preguntale al cliente + si lo quiere por peso o por unidad. Ejemplo: "¿Las milanesas las querés por + peso (500g, 1kg) o por unidad (cantidad)?". +- Si una tool te devuelve error "unit_not_allowed", NO insistas con la misma + unidad: releé allowed_order_units y pedile al cliente la unidad correcta. +- Cuando el pending tiene needs_unit_choice=true, antes de llamar set_quantity + asegurate de que el cliente declaró la unidad explícitamente.`; diff --git a/src/modules/3-turn-engine/agent/tools/addToCart.js b/src/modules/3-turn-engine/agent/tools/addToCart.js index 1633cc6..b72fd4d 100644 --- a/src/modules/3-turn-engine/agent/tools/addToCart.js +++ b/src/modules/3-turn-engine/agent/tools/addToCart.js @@ -7,6 +7,7 @@ import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js"; import { createCartItem } from "../../orderModel.js"; +import { computeAllowedOrderUnits, isUnitAllowed, normalizeUnitToken } from "../../../shared/sellUnitPolicy.js"; export async function addToCartTool(args, ctx) { const { woo_id, qty, unit } = args; @@ -25,13 +26,31 @@ export async function addToCartTool(args, ctx) { }; } + // Validar unit contra allowed_order_units del producto + const allowed = Array.isArray(found.allowed_order_units) && found.allowed_order_units.length + ? found.allowed_order_units + : computeAllowedOrderUnits({ sell_unit: found.sell_unit }); + if (!isUnitAllowed(unit, allowed)) { + return { + ok: false, + error: "unit_not_allowed", + product: { woo_id, name: found.name }, + allowed_order_units: allowed, + hint: `Para "${found.name}" solo se acepta pedir en: ${allowed.join(", ")}. Preguntale al cliente cómo lo quiere.`, + }; + } + + // Normalizar unit: g se persiste como tal (la conversión a kg ocurre en wooOrders). + const normalizedUnit = unit; + // Crear item de carrito const newItem = createCartItem({ woo_id, qty, - unit, + unit: normalizedUnit, name: found.name, price: found.price ?? null, + allowed_order_units: allowed, }); // Si ya existe el woo_id en el cart, sumamos cantidad diff --git a/src/modules/3-turn-engine/agent/tools/schemas.js b/src/modules/3-turn-engine/agent/tools/schemas.js index 841248b..9ec1d2d 100644 --- a/src/modules/3-turn-engine/agent/tools/schemas.js +++ b/src/modules/3-turn-engine/agent/tools/schemas.js @@ -29,7 +29,7 @@ export const TOOL_SCHEMAS = [ type: "function", function: { name: "add_to_cart", - description: "Agrega un producto resuelto al carrito.", + description: "Agrega un producto resuelto al carrito. Validá la unit contra allowed_order_units del candidato; si no coincide la tool devuelve unit_not_allowed.", parameters: { type: "object", additionalProperties: false, @@ -46,7 +46,7 @@ export const TOOL_SCHEMAS = [ type: "function", function: { name: "set_quantity", - description: "Setea la cantidad de un producto pendiente que ya está resuelto pero faltaba qty/unit.", + description: "Setea la cantidad de un producto pendiente que ya está resuelto pero faltaba qty/unit. Validá la unit contra selected_allowed_order_units del pending; si no coincide la tool devuelve unit_not_allowed.", parameters: { type: "object", additionalProperties: false, diff --git a/src/modules/3-turn-engine/agent/tools/searchCatalog.js b/src/modules/3-turn-engine/agent/tools/searchCatalog.js index 00a6a00..778ffbe 100644 --- a/src/modules/3-turn-engine/agent/tools/searchCatalog.js +++ b/src/modules/3-turn-engine/agent/tools/searchCatalog.js @@ -8,6 +8,7 @@ import { retrieveCandidates } from "../../catalogRetrieval.js"; import { searchSnapshotItems } from "../../../shared/wooSnapshot.js"; import { pool } from "../../../shared/db/pool.js"; +import { computeAllowedOrderUnits } from "../../../shared/sellUnitPolicy.js"; const MIN_GOOD_SCORE = 0.4; @@ -18,6 +19,9 @@ function summarizeCandidate(c, idx) { name: c.name, price: c.price ?? null, sell_unit: c.sell_unit || null, + allowed_order_units: Array.isArray(c.allowed_order_units) && c.allowed_order_units.length + ? c.allowed_order_units + : computeAllowedOrderUnits({ sell_unit: c.sell_unit }), score: typeof c._score === "number" ? Number(c._score.toFixed(2)) : null, }; } diff --git a/src/modules/3-turn-engine/agent/tools/selectCandidate.js b/src/modules/3-turn-engine/agent/tools/selectCandidate.js index 32f0a22..d461cac 100644 --- a/src/modules/3-turn-engine/agent/tools/selectCandidate.js +++ b/src/modules/3-turn-engine/agent/tools/selectCandidate.js @@ -11,6 +11,7 @@ import { PendingStatus, } from "../../orderModel.js"; import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js"; +import { computeAllowedOrderUnits, isUnitAllowed, normalizeUnitToken } from "../../../shared/sellUnitPolicy.js"; export async function selectCandidateTool(args, ctx) { const { pending_id, woo_id } = args; @@ -33,29 +34,46 @@ export async function selectCandidateTool(args, ctx) { }; } - const sellsByWeight = - !found.sell_unit || !["unit", "unidad"].includes(found.sell_unit); - const displayUnit = found.sell_unit === "unit" ? "unit" : sellsByWeight ? "kg" : "unit"; + const allowed = Array.isArray(found.allowed_order_units) && found.allowed_order_units.length + ? found.allowed_order_units + : computeAllowedOrderUnits({ sell_unit: found.sell_unit }); const hasRequestedQty = pending.requested_qty != null && Number.isFinite(pending.requested_qty) && pending.requested_qty > 0; + + // Resolver la unidad: si el usuario declaró una unit válida → usarla. + // Si allowed tiene una sola opción → tomarla. Si allowed = ["kg","unit"] y no hay declaración → ambigua. + let finalUnit = null; + let needsUnitChoice = false; + if (pending.requested_unit && isUnitAllowed(pending.requested_unit, allowed)) { + finalUnit = normalizeUnitToken(pending.requested_unit); + } else if (allowed.length === 1) { + finalUnit = allowed[0]; + } else { + needsUnitChoice = true; + } + + const displayUnit = finalUnit || (allowed.includes("kg") ? "kg" : allowed[0]); + const sellsByWeight = displayUnit === "kg"; + const needsQty = !hasRequestedQty; const finalQty = hasRequestedQty ? pending.requested_qty : null; - const finalUnit = pending.requested_unit || displayUnit; - const needsQty = sellsByWeight && !hasRequestedQty; + const isReady = !needsQty && !needsUnitChoice; const updated = updatePendingItem(ctx.order, pending_id, { selected_woo_id: woo_id, selected_name: found.name, selected_price: found.price ?? null, selected_unit: displayUnit, + selected_allowed_order_units: allowed, + needs_unit_choice: needsUnitChoice, candidates: [], - qty: needsQty ? null : finalQty, - unit: finalUnit, - status: needsQty ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, + qty: isReady ? finalQty : null, + unit: finalUnit || displayUnit, + status: isReady ? PendingStatus.READY : PendingStatus.NEEDS_QUANTITY, }); ctx.order = moveReadyToCart(updated); - if (!needsQty) { + if (isReady) { ctx.pending_actions.push({ type: "add_to_cart", payload: { woo_id, qty: finalQty, unit: finalUnit }, @@ -65,8 +83,15 @@ export async function selectCandidateTool(args, ctx) { return { ok: true, - selected: { woo_id, name: found.name, unit: displayUnit, sells_by_weight: sellsByWeight }, + selected: { + woo_id, + name: found.name, + unit: displayUnit, + sells_by_weight: sellsByWeight, + allowed_order_units: allowed, + }, needs_quantity: needsQty, + needs_unit_choice: needsUnitChoice, cart_size: (ctx.order.cart || []).length, }; } diff --git a/src/modules/3-turn-engine/agent/tools/setQuantity.js b/src/modules/3-turn-engine/agent/tools/setQuantity.js index e8dd681..6c1d221 100644 --- a/src/modules/3-turn-engine/agent/tools/setQuantity.js +++ b/src/modules/3-turn-engine/agent/tools/setQuantity.js @@ -4,6 +4,7 @@ */ import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js"; +import { isUnitAllowed } from "../../../shared/sellUnitPolicy.js"; export async function setQuantityTool(args, ctx) { const { pending_id, qty, unit } = args; @@ -15,9 +16,24 @@ export async function setQuantityTool(args, ctx) { return { ok: false, error: "pending_not_resolved", hint: "Llamá select_candidate primero para resolver el producto." }; } + // Validar unit contra allowed_order_units del producto (cacheado en pending al hacer select). + const allowed = Array.isArray(pending.selected_allowed_order_units) && pending.selected_allowed_order_units.length + ? pending.selected_allowed_order_units + : ["kg", "unit"]; + if (!isUnitAllowed(unit, allowed)) { + return { + ok: false, + error: "unit_not_allowed", + product: { woo_id: pending.selected_woo_id, name: pending.selected_name }, + allowed_order_units: allowed, + hint: `Para "${pending.selected_name}" solo se acepta pedir en: ${allowed.join(", ")}. Preguntale al cliente cómo lo quiere.`, + }; + } + const updated = updatePendingItem(ctx.order, pending_id, { qty, unit, + needs_unit_choice: false, status: PendingStatus.READY, }); ctx.order = moveReadyToCart(updated); diff --git a/src/modules/3-turn-engine/agent/workingMemory.js b/src/modules/3-turn-engine/agent/workingMemory.js index 706826d..edb517a 100644 --- a/src/modules/3-turn-engine/agent/workingMemory.js +++ b/src/modules/3-turn-engine/agent/workingMemory.js @@ -57,6 +57,7 @@ export function buildWorkingMemory({ qty: it.qty, unit: it.unit, price: it.price ?? null, + allowed_order_units: it.allowed_order_units ?? null, })); const pending = (order.pending || []).map((p) => ({ @@ -66,6 +67,8 @@ export function buildWorkingMemory({ selected_woo_id: p.selected_woo_id ?? null, selected_name: p.selected_name ?? null, selected_unit: p.selected_unit ?? null, + selected_allowed_order_units: p.selected_allowed_order_units ?? null, + needs_unit_choice: !!p.needs_unit_choice, requested_qty: p.requested_qty ?? null, requested_unit: p.requested_unit ?? null, candidates: (p.candidates || []).slice(0, 8).map((c, i) => ({ @@ -74,6 +77,7 @@ export function buildWorkingMemory({ name: c.name, price: c.price ?? null, display_unit: c.display_unit ?? null, + allowed_order_units: c.allowed_order_units ?? null, })), })); diff --git a/src/modules/3-turn-engine/orderModel.js b/src/modules/3-turn-engine/orderModel.js index fa0dca2..eae7dc2 100644 --- a/src/modules/3-turn-engine/orderModel.js +++ b/src/modules/3-turn-engine/orderModel.js @@ -34,28 +34,31 @@ export function createEmptyOrder() { /** * Crea un item de carrito confirmado */ -export function createCartItem({ woo_id, qty, unit, name = null, price = null }) { +export function createCartItem({ woo_id, qty, unit, name = null, price = null, allowed_order_units = null }) { return { woo_id, qty: Number(qty) || 1, unit: unit || "unit", // "kg" | "g" | "unit" name, price, + allowed_order_units, }; } /** * Crea un item pendiente de clarificación */ -export function createPendingItem({ +export function createPendingItem({ id, - query, - candidates = [], + query, + candidates = [], selected_woo_id = null, selected_name = null, selected_price = null, selected_unit = null, - qty = null, + selected_allowed_order_units = null, + needs_unit_choice = false, + qty = null, unit = null, status = PendingStatus.NEEDS_TYPE, requested_qty = null, @@ -64,16 +67,18 @@ export function createPendingItem({ return { id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, query, - candidates, // [{ woo_id, name, price, display_unit }] - selected_woo_id, // Producto elegido (null si NEEDS_TYPE) + candidates, // [{ woo_id, name, price, display_unit, allowed_order_units }] + selected_woo_id, // Producto elegido (null si NEEDS_TYPE) selected_name, selected_price, - selected_unit, // Unidad del producto seleccionado - qty, // Cantidad (null si NEEDS_QUANTITY) - unit, // Unidad elegida por usuario + selected_unit, // Unidad del producto seleccionado + selected_allowed_order_units, // Unidades válidas para este producto (cache) + needs_unit_choice, // Si true, el LLM tiene que preguntar la unidad antes de cerrar + qty, // Cantidad (null si NEEDS_QUANTITY) + unit, // Unidad elegida por usuario status, - requested_qty, // Cantidad pedida originalmente por el usuario (para usar después de selección) - requested_unit, // Unidad pedida originalmente por el usuario + requested_qty, // Cantidad pedida originalmente por el usuario (para usar después de selección) + requested_unit, // Unidad pedida originalmente por el usuario }; } @@ -96,6 +101,7 @@ export function moveReadyToCart(order) { unit: item.unit || item.selected_unit || "unit", name: item.selected_name, price: item.selected_price, + allowed_order_units: item.selected_allowed_order_units || null, }); if (existingIdx >= 0) { diff --git a/src/modules/4-woo-orders/wooOrders.js b/src/modules/4-woo-orders/wooOrders.js index d3b8999..ea1b787 100644 --- a/src/modules/4-woo-orders/wooOrders.js +++ b/src/modules/4-woo-orders/wooOrders.js @@ -1,6 +1,7 @@ import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js"; import { debug as dbg } from "../shared/debug.js"; -import { getSnapshotPriceByWooId } from "../shared/wooSnapshot.js"; +import { getSnapshotPriceByWooId, getSnapshotItemsByIds } from "../shared/wooSnapshot.js"; +import { normalizeUnitToken } from "../shared/sellUnitPolicy.js"; import * as ordersRepo from "./ordersRepo.js"; // --- Simple in-memory lock to serialize work per key --- @@ -129,15 +130,33 @@ function toMoney(value) { async function buildLineItems({ tenantId, basket }) { const items = normalizeBasketItems(basket); const lineItems = []; + + // Traer en bulk el sell_unit del snapshot para cada producto del basket. + const productIds = [...new Set(items.map((it) => Number(it.product_id)).filter(Boolean))]; + const sellUnitByWoo = new Map(); + if (productIds.length) { + const lookup = await getSnapshotItemsByIds({ tenantId, wooProductIds: productIds }); + for (const p of lookup?.items || []) { + sellUnitByWoo.set(Number(p.woo_product_id), p.sell_unit || null); + } + } + for (const it of items) { const productId = Number(it.product_id); const unit = String(it.unit).toLowerCase(); const qty = Number(it.quantity); if (!productId || !Number.isFinite(qty) || qty <= 0) continue; const pricePerKg = await getWooProductPrice({ tenantId, productId }); + const productSellUnit = sellUnitByWoo.get(productId); + const productSellUnitNorm = normalizeUnitToken(productSellUnit); + const requestedUnitNorm = normalizeUnitToken(unit); + // Mismatch: cliente pidió "unit" pero el producto se vende por kg (u otro) + const mismatch = requestedUnitNorm === "unit" && productSellUnitNorm !== "unit"; if (unit === "unit") { - const total = pricePerKg != null ? toMoney(pricePerKg * qty) : null; + // Si hay mismatch (producto kg pedido como unidad) → no enviar subtotal/total, + // dejar que Woo calcule con el precio del producto. El comercio resuelve al armar. + const total = !mismatch && pricePerKg != null ? toMoney(pricePerKg * qty) : null; lineItems.push({ product_id: productId, ...(it.variation_id ? { variation_id: it.variation_id } : {}), @@ -145,6 +164,8 @@ async function buildLineItems({ tenantId, basket }) { ...(total ? { subtotal: total, total } : {}), meta_data: [ { key: "unit", value: "unit" }, + { key: "sell_unit_product", value: productSellUnit || "" }, + { key: "unit_mode_mismatch", value: mismatch ? "true" : "false" }, ], }); continue; @@ -165,6 +186,7 @@ async function buildLineItems({ tenantId, basket }) { { key: "unit", value: "g" }, { key: "weight_g", value: grams }, { key: "unit_price_per_kg", value: pricePerKg }, + { key: "sell_unit_product", value: productSellUnit || "" }, ], }); } diff --git a/src/modules/shared/sellUnitPolicy.js b/src/modules/shared/sellUnitPolicy.js new file mode 100644 index 0000000..e3c2f16 --- /dev/null +++ b/src/modules/shared/sellUnitPolicy.js @@ -0,0 +1,23 @@ +export function normalizeUnitToken(unit) { + const s = String(unit || "").trim().toLowerCase(); + if (s === "g" || s === "kg" || s === "gramos" || s === "kilo" || s === "kilos") return "kg"; + if (s === "unit" || s === "unidad" || s === "unidades" || s === "u") return "unit"; + return null; +} + +export function computeAllowedOrderUnits({ sell_unit, raw_override } = {}) { + if (normalizeUnitToken(sell_unit) === "unit") return ["unit"]; + + if (Array.isArray(raw_override) && raw_override.length > 0) { + const filtered = [...new Set(raw_override.map(normalizeUnitToken).filter((u) => u !== null))]; + if (filtered.length > 0) return filtered; + } + return ["kg", "unit"]; +} + +export function isUnitAllowed(unit, allowed) { + const normalized = normalizeUnitToken(unit); + if (normalized === null) return false; + const list = Array.isArray(allowed) ? allowed : []; + return list.some((u) => normalizeUnitToken(u) === normalized); +} diff --git a/src/modules/shared/sellUnitPolicy.test.js b/src/modules/shared/sellUnitPolicy.test.js new file mode 100644 index 0000000..e4f811f --- /dev/null +++ b/src/modules/shared/sellUnitPolicy.test.js @@ -0,0 +1,63 @@ +import { computeAllowedOrderUnits, isUnitAllowed, normalizeUnitToken } from "./sellUnitPolicy.js"; + +describe("normalizeUnitToken", () => { + it("normaliza gramos a kg", () => { + expect(normalizeUnitToken("g")).toBe("kg"); + expect(normalizeUnitToken("gramos")).toBe("kg"); + }); + it("normaliza unidad a unit", () => { + expect(normalizeUnitToken("unidad")).toBe("unit"); + expect(normalizeUnitToken("unidades")).toBe("unit"); + expect(normalizeUnitToken("U")).toBe("unit"); + }); + it("retorna null para tokens desconocidos", () => { + expect(normalizeUnitToken(null)).toBe(null); + expect(normalizeUnitToken("")).toBe(null); + expect(normalizeUnitToken("garlic")).toBe(null); + expect(normalizeUnitToken("pack")).toBe(null); + }); +}); + +describe("computeAllowedOrderUnits", () => { + it("regla dura: sell_unit=unit ignora override y retorna ['unit']", () => { + expect(computeAllowedOrderUnits({ sell_unit: "unit", raw_override: ["kg"] })).toEqual(["unit"]); + expect(computeAllowedOrderUnits({ sell_unit: "unidad", raw_override: ["kg", "unit"] })).toEqual(["unit"]); + }); + it("sell_unit=kg con override válido retorna el override filtrado", () => { + expect(computeAllowedOrderUnits({ sell_unit: "kg", raw_override: ["unit"] })).toEqual(["unit"]); + expect(computeAllowedOrderUnits({ sell_unit: "kg", raw_override: ["kg"] })).toEqual(["kg"]); + }); + it("sell_unit=kg sin override retorna default ['kg','unit']", () => { + expect(computeAllowedOrderUnits({ sell_unit: "kg" })).toEqual(["kg", "unit"]); + expect(computeAllowedOrderUnits({ sell_unit: "kg", raw_override: null })).toEqual(["kg", "unit"]); + expect(computeAllowedOrderUnits({ sell_unit: "kg", raw_override: [] })).toEqual(["kg", "unit"]); + }); + it("sell_unit nulo se trata como kg (default permisivo)", () => { + expect(computeAllowedOrderUnits({ sell_unit: null })).toEqual(["kg", "unit"]); + }); + it("override con tokens inválidos se filtra", () => { + expect(computeAllowedOrderUnits({ sell_unit: "kg", raw_override: ["pack", "kg"] })).toEqual(["kg"]); + expect(computeAllowedOrderUnits({ sell_unit: "kg", raw_override: ["nonsense"] })).toEqual(["kg", "unit"]); + }); +}); + +describe("isUnitAllowed", () => { + it("acepta g cuando kg está permitido", () => { + expect(isUnitAllowed("g", ["kg"])).toBe(true); + expect(isUnitAllowed("gramos", ["kg", "unit"])).toBe(true); + }); + it("rechaza unit cuando solo kg está permitido", () => { + expect(isUnitAllowed("unit", ["kg"])).toBe(false); + }); + it("acepta unit cuando unit está permitido", () => { + expect(isUnitAllowed("unidad", ["unit"])).toBe(true); + expect(isUnitAllowed("unit", ["kg", "unit"])).toBe(true); + }); + it("rechaza kg cuando solo unit está permitido", () => { + expect(isUnitAllowed("kg", ["unit"])).toBe(false); + expect(isUnitAllowed("500g", ["unit"])).toBe(false); + }); + it("lista vacía rechaza todo", () => { + expect(isUnitAllowed("kg", [])).toBe(false); + }); +}); diff --git a/src/modules/shared/wooSnapshot.js b/src/modules/shared/wooSnapshot.js index 79d7520..7e4903c 100644 --- a/src/modules/shared/wooSnapshot.js +++ b/src/modules/shared/wooSnapshot.js @@ -1,6 +1,7 @@ import { pool } from "./db/pool.js"; import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js"; import { debug as dbg } from "./debug.js"; +import { computeAllowedOrderUnits } from "./sellUnitPolicy.js"; async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) { const controller = new AbortController(); @@ -180,6 +181,10 @@ function snapshotRowToItem(row) { price_html: null, }, sell_unit: raw?._sell_unit_override || null, + allowed_order_units: computeAllowedOrderUnits({ + sell_unit: raw?._sell_unit_override, + raw_override: raw?._allowed_order_units, + }), source: "snapshot", }; } @@ -274,7 +279,13 @@ export async function upsertSnapshotItems({ tenantId, items, runId = null }) { attributes_normalized = excluded.attributes_normalized, date_modified = excluded.date_modified, run_id = excluded.run_id, - raw = excluded.raw, + raw = coalesce(woo_products_snapshot.raw, '{}'::jsonb) + || excluded.raw + || jsonb_strip_nulls(jsonb_build_object( + '_sell_unit_override', woo_products_snapshot.raw->'_sell_unit_override', + '_allowed_order_units', woo_products_snapshot.raw->'_allowed_order_units', + '_categories_override', woo_products_snapshot.raw->'_categories_override' + )), updated_at = now() `; await pool.query(q, [