Productos: allowed_order_units + fix sync que pisaba overrides
Introduce un campo nuevo allowed_order_units por producto (array kg/unit) que separa "cómo se vende" de "cómo se puede pedir". Productos sell_unit=unit solo aceptan unit (regla dura aplicada por código y handler). Productos sell_unit=kg defaultean a ambos, editable desde la UI con checkboxes. Fixea bug crítico de wooSnapshot.upsertSnapshotItems que pisaba raw entero en cada sync, perdiendo overrides locales (_sell_unit_override, _categories_override y el nuevo _allowed_order_units). El UPDATE ahora mergea preservando los overrides. Las tools del agente (addToCart, setQuantity, selectCandidate) validan unit contra allowed_order_units y devuelven unit_not_allowed para que el LLM re-pregunte. workingMemory expone allowed_order_units y needs_unit_choice. buildLineItems agrega meta_data unit/sell_unit_product/unit_mode_mismatch y omite subtotal/total cuando hay mismatch para que Woo recalcule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.`;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user