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, [