Compare commits
2 Commits
c786e47160
...
bae85033e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bae85033e0 | ||
|
|
423a99ab48 |
@@ -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';
|
||||||
10
db/migrations/20260518110000_drop_reply_templates.sql
Normal file
10
db/migrations/20260518110000_drop_reply_templates.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- Eliminar tabla reply_templates: el código actual no la consulta (cero referencias
|
||||||
|
-- en src/ y public/). Las 45 filas seedeadas son código zombi.
|
||||||
|
DROP INDEX IF EXISTS idx_reply_active;
|
||||||
|
DROP TABLE IF EXISTS reply_templates;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
-- No recreamos. Para restaurar el schema + seed correr en orden:
|
||||||
|
-- 20260501110000_reply_templates.sql (schema + index)
|
||||||
|
-- 20260501130000_seed_piaf_settings_and_replies.sql (45 filas)
|
||||||
@@ -458,6 +458,24 @@ class ProductsCrud extends HTMLElement {
|
|||||||
Define si este producto se vende por peso o por unidad
|
Define si este producto se vende por peso o por unidad
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Unidades aceptadas al pedir</label>
|
||||||
|
<div id="allowedUnitsBox" style="display:flex;gap:14px;align-items:center;">
|
||||||
|
<label style="display:inline-flex;gap:6px;align-items:center;font-size:13px;">
|
||||||
|
<input type="checkbox" id="cbAllowKg" ${this.computeAllowedUI(p, currentUnit).includes("kg") ? "checked" : ""} ${currentUnit === "unit" ? "disabled" : ""}>
|
||||||
|
Permitir pedir por kg
|
||||||
|
</label>
|
||||||
|
<label style="display:inline-flex;gap:6px;align-items:center;font-size:13px;">
|
||||||
|
<input type="checkbox" id="cbAllowUnit" ${this.computeAllowedUI(p, currentUnit).includes("unit") ? "checked" : ""} ${currentUnit === "unit" ? "disabled" : ""}>
|
||||||
|
Permitir pedir por unidad
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;" id="allowedUnitsHint">
|
||||||
|
${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).'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Categorías actuales</label>
|
<label>Categorías actuales</label>
|
||||||
<div id="currentCategories" style="display:flex;flex-wrap:wrap;gap:6px;min-height:30px;margin-bottom:8px;">
|
<div id="currentCategories" style="display:flex;flex-wrap:wrap;gap:6px;min-height:30px;margin-bottom:8px;">
|
||||||
@@ -505,6 +523,39 @@ class ProductsCrud extends HTMLElement {
|
|||||||
// Bind save button
|
// Bind save button
|
||||||
this.shadowRoot.getElementById("saveProduct").onclick = () => this.saveProduct();
|
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
|
// Bind add category button
|
||||||
this.shadowRoot.getElementById("addCatBtn").onclick = () => this.addCategoryToProduct();
|
this.shadowRoot.getElementById("addCatBtn").onclick = () => this.addCategoryToProduct();
|
||||||
|
|
||||||
@@ -563,15 +614,18 @@ class ProductsCrud extends HTMLElement {
|
|||||||
const categories = this.getCurrentProductCategories();
|
const categories = this.getCurrentProductCategories();
|
||||||
const sellUnitSelect = this.shadowRoot.getElementById("sellUnit");
|
const sellUnitSelect = this.shadowRoot.getElementById("sellUnit");
|
||||||
const sell_unit = sellUnitSelect?.value || p.sell_unit || 'kg';
|
const sell_unit = sellUnitSelect?.value || p.sell_unit || 'kg';
|
||||||
|
const allowed_order_units = this.getCurrentAllowedOrderUnits();
|
||||||
|
|
||||||
try {
|
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
|
// Actualizar localmente
|
||||||
p.categories = JSON.stringify(categories.map(name => ({ name })));
|
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);
|
const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
this.items[idx].categories = p.categories;
|
this.items[idx].categories = p.categories;
|
||||||
|
if (allowed_order_units) this.items[idx].allowed_order_units = allowed_order_units;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mostrar feedback breve
|
// Mostrar feedback breve
|
||||||
@@ -604,6 +658,24 @@ class ProductsCrud extends HTMLElement {
|
|||||||
return [...tags].map(t => t.dataset.category);
|
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() {
|
async saveProduct() {
|
||||||
if (this.selectedItems.length !== 1) return;
|
if (this.selectedItems.length !== 1) return;
|
||||||
|
|
||||||
@@ -613,20 +685,23 @@ class ProductsCrud extends HTMLElement {
|
|||||||
|
|
||||||
const sell_unit = sellUnitSelect.value;
|
const sell_unit = sellUnitSelect.value;
|
||||||
const categories = this.getCurrentProductCategories();
|
const categories = this.getCurrentProductCategories();
|
||||||
|
const allowed_order_units = this.getCurrentAllowedOrderUnits();
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Guardando...";
|
btn.textContent = "Guardando...";
|
||||||
|
|
||||||
try {
|
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
|
// Actualizar localmente
|
||||||
p.sell_unit = sell_unit;
|
p.sell_unit = sell_unit;
|
||||||
p.categories = JSON.stringify(categories.map(name => ({ name })));
|
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);
|
const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
this.items[idx].sell_unit = sell_unit;
|
this.items[idx].sell_unit = sell_unit;
|
||||||
this.items[idx].categories = p.categories;
|
this.items[idx].categories = p.categories;
|
||||||
|
if (allowed_order_units) this.items[idx].allowed_order_units = p.allowed_order_units;
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.textContent = "Guardado!";
|
btn.textContent = "Guardado!";
|
||||||
|
|||||||
@@ -116,12 +116,12 @@ export const api = {
|
|||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateProduct(wooProductId, { sell_unit, categories }) {
|
async updateProduct(wooProductId, { sell_unit, categories, allowed_order_units }) {
|
||||||
return fetch(`/products/${encodeURIComponent(wooProductId)}`, {
|
return safeFetch(`/products/${encodeURIComponent(wooProductId)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ sell_unit, categories }),
|
body: JSON.stringify({ sell_unit, categories, allowed_order_units }),
|
||||||
}).then(r => r.json());
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateProductsUnit(wooProductIds, { sell_unit }) {
|
async updateProductsUnit(wooProductIds, { sell_unit }) {
|
||||||
|
|||||||
@@ -111,13 +111,23 @@ export const makeUpdateProduct = (tenantIdOrFn) => async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
const wooProductId = req.params.id;
|
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)) {
|
if (sell_unit && !["kg", "unit"].includes(sell_unit)) {
|
||||||
return res.status(400).json({ ok: false, error: "invalid_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);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export async function listProducts({ tenantId, q = "", limit = 2000, offset = 0
|
|||||||
attributes_normalized,
|
attributes_normalized,
|
||||||
updated_at as refreshed_at,
|
updated_at as refreshed_at,
|
||||||
raw as payload,
|
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
|
from woo_products_snapshot
|
||||||
where tenant_id = $1
|
where tenant_id = $1
|
||||||
and (name ilike $2 or coalesce(slug,'') ilike $2 or coalesce(raw->>'SKU', raw->>'sku', '') ilike $2)
|
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,
|
attributes_normalized,
|
||||||
updated_at as refreshed_at,
|
updated_at as refreshed_at,
|
||||||
raw as payload,
|
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
|
from woo_products_snapshot
|
||||||
where tenant_id = $1
|
where tenant_id = $1
|
||||||
order by name asc
|
order by name asc
|
||||||
@@ -106,7 +108,7 @@ export async function bulkUpdateProductSellUnit({ tenantId, wooProductIds, sell_
|
|||||||
return { updated: result.rowCount };
|
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
|
// Build the update - combine all raw updates into one
|
||||||
let updates = [];
|
let updates = [];
|
||||||
let params = [tenantId, wooProductId];
|
let params = [tenantId, wooProductId];
|
||||||
@@ -133,8 +135,14 @@ export async function updateProduct({ tenantId, wooProductId, sell_unit, categor
|
|||||||
paramIdx++;
|
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
|
// Only add raw update if we modified it
|
||||||
if (sell_unit || categories) {
|
if (sell_unit || categories || allowed_order_units) {
|
||||||
updates.push(`raw = ${rawExpr}`);
|
updates.push(`raw = ${rawExpr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import { searchSnapshotItems, syncFromWoo, pushProductToWoo } from "../../shared/wooSnapshot.js";
|
import { searchSnapshotItems, syncFromWoo, pushProductToWoo } from "../../shared/wooSnapshot.js";
|
||||||
import { listProducts, getProductByWooId, updateProductSellUnit, bulkUpdateProductSellUnit, updateProduct, insertAuditLog } from "../db/repo.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" }) {
|
export async function handleSearchProducts({ tenantId, q = "", limit = "10", forceWoo = "0" }) {
|
||||||
const { items, source } = await searchSnapshotItems({
|
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 }) {
|
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 };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleGetProduct({ tenantId, wooProductId }) {
|
export async function handleGetProduct({ tenantId, wooProductId }) {
|
||||||
return getProductByWooId({ tenantId, wooProductId });
|
const row = await getProductByWooId({ tenantId, wooProductId });
|
||||||
|
return resolveAllowedOrderUnitsForRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleSyncProducts({ tenantId }) {
|
export async function handleSyncProducts({ tenantId }) {
|
||||||
@@ -63,9 +80,23 @@ export async function handleBulkUpdateProductUnit({ tenantId, wooProductIds, sel
|
|||||||
/**
|
/**
|
||||||
* Actualiza un producto localmente y automáticamente lo pushea a WooCommerce
|
* 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
|
// 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)
|
// 2. Auto-push a WooCommerce (best effort - no falla si Woo no responde)
|
||||||
let wooPushResult = null;
|
let wooPushResult = null;
|
||||||
@@ -86,6 +117,7 @@ export async function handleUpdateProduct({ tenantId, wooProductId, sell_unit, c
|
|||||||
changes: {
|
changes: {
|
||||||
sell_unit: { new: sell_unit },
|
sell_unit: { new: sell_unit },
|
||||||
categories: { new: categories },
|
categories: { new: categories },
|
||||||
|
allowed_order_units: { new: effectiveAllowed },
|
||||||
woo_synced: wooPushResult?.updated ?? false
|
woo_synced: wooPushResult?.updated ?? false
|
||||||
},
|
},
|
||||||
actor: 'ui'
|
actor: 'ui'
|
||||||
@@ -96,6 +128,7 @@ export async function handleUpdateProduct({ tenantId, wooProductId, sell_unit, c
|
|||||||
woo_product_id: wooProductId,
|
woo_product_id: wooProductId,
|
||||||
sell_unit,
|
sell_unit,
|
||||||
categories,
|
categories,
|
||||||
|
allowed_order_units: effectiveAllowed,
|
||||||
woo_synced: wooPushResult?.updated ?? false
|
woo_synced: wooPushResult?.updated ?? false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/modules/3-turn-engine/agent/fallbackReplies.js
Normal file
30
src/modules/3-turn-engine/agent/fallbackReplies.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const VARIANTS = {
|
||||||
|
no_understanding: [
|
||||||
|
"Disculpame, no te entendí. ¿Me lo decís de otra forma?",
|
||||||
|
"Perdón, no te seguí. ¿Lo repetís?",
|
||||||
|
"Mmm, no me quedó claro. ¿Cómo era?",
|
||||||
|
],
|
||||||
|
awaiting_human: [
|
||||||
|
"Te paso con alguien que pueda ayudarte.",
|
||||||
|
"Dejame que te derive a un compañero, ya te responde.",
|
||||||
|
"Te conecto con una persona, esperá un toque.",
|
||||||
|
],
|
||||||
|
paused: [
|
||||||
|
"Dale, cuando quieras seguimos.",
|
||||||
|
"Bueno, te espero. Avisame cuando estés.",
|
||||||
|
"Listo, lo dejamos en pausa. Decime cuando seguimos.",
|
||||||
|
],
|
||||||
|
llm_error: [
|
||||||
|
"Disculpame, tuve un problema. ¿Lo intentás de nuevo?",
|
||||||
|
"Uy, se me trabó algo. ¿Lo escribís de nuevo?",
|
||||||
|
"Perdón, algo no anduvo bien. ¿Repetís?",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pickFallbackReply(kind) {
|
||||||
|
const pool = VARIANTS[kind] || VARIANTS.no_understanding;
|
||||||
|
const idx = Math.floor(Math.random() * pool.length);
|
||||||
|
return pool[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __FALLBACK_VARIANTS__ = VARIANTS;
|
||||||
40
src/modules/3-turn-engine/agent/fallbackReplies.test.js
Normal file
40
src/modules/3-turn-engine/agent/fallbackReplies.test.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { pickFallbackReply, __FALLBACK_VARIANTS__ } from "./fallbackReplies.js";
|
||||||
|
|
||||||
|
const KINDS = ["no_understanding", "awaiting_human", "paused", "llm_error"];
|
||||||
|
|
||||||
|
describe("pickFallbackReply", () => {
|
||||||
|
it("cada kind tiene al menos 3 variantes", () => {
|
||||||
|
for (const k of KINDS) {
|
||||||
|
expect(__FALLBACK_VARIANTS__[k].length).toBeGreaterThanOrEqual(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retorna string no vacío para cada kind", () => {
|
||||||
|
for (const k of KINDS) {
|
||||||
|
const out = pickFallbackReply(k);
|
||||||
|
expect(typeof out).toBe("string");
|
||||||
|
expect(out.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retorna una variante del pool correspondiente", () => {
|
||||||
|
for (const k of KINDS) {
|
||||||
|
const pool = __FALLBACK_VARIANTS__[k];
|
||||||
|
const out = pickFallbackReply(k);
|
||||||
|
expect(pool).toContain(out);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kind inexistente cae a no_understanding (no rompe)", () => {
|
||||||
|
const out = pickFallbackReply("ese_kind_no_existe");
|
||||||
|
expect(__FALLBACK_VARIANTS__.no_understanding).toContain(out);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("variación: 100 llamadas cubren al menos 2 variantes distintas por kind", () => {
|
||||||
|
for (const k of KINDS) {
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i < 100; i++) seen.add(pickFallbackReply(k));
|
||||||
|
expect(seen.size).toBeGreaterThanOrEqual(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import { SYSTEM_PROMPT } from "./systemPrompt.js";
|
|||||||
import { TOOL_SCHEMAS } from "./tools/schemas.js";
|
import { TOOL_SCHEMAS } from "./tools/schemas.js";
|
||||||
import { executeToolCall } from "./tools/executor.js";
|
import { executeToolCall } from "./tools/executor.js";
|
||||||
import { getCustomerProfile } from "./customerProfile.js";
|
import { getCustomerProfile } from "./customerProfile.js";
|
||||||
|
import { pickFallbackReply } from "./fallbackReplies.js";
|
||||||
import { debug as dbg } from "../../shared/debug.js";
|
import { debug as dbg } from "../../shared/debug.js";
|
||||||
|
|
||||||
const MAX_TOOL_CALLS = parseInt(process.env.AGENT_MAX_TOOL_CALLS || "10", 10);
|
const MAX_TOOL_CALLS = parseInt(process.env.AGENT_MAX_TOOL_CALLS || "10", 10);
|
||||||
@@ -212,7 +213,7 @@ export async function runTurnAgent({
|
|||||||
if (!calls.length) {
|
if (!calls.length) {
|
||||||
// Sin tool calls → forzar say con fallback y salir
|
// Sin tool calls → forzar say con fallback y salir
|
||||||
audit.no_tool_calls = true;
|
audit.no_tool_calls = true;
|
||||||
ctx.say_text = ctx.say_text || msg.content || "Disculpame, no te entendí. ¿Me lo decís de otra forma?";
|
ctx.say_text = ctx.say_text || msg.content || pickFallbackReply("no_understanding");
|
||||||
turnDone = true;
|
turnDone = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -241,9 +242,13 @@ export async function runTurnAgent({
|
|||||||
if (dbg.llm) console.error("[agent] error", llmError);
|
if (dbg.llm) console.error("[agent] error", llmError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no hay say, fallback determinista
|
// Si no hay say, fallback con variante random
|
||||||
if (!ctx.say_text) {
|
if (!ctx.say_text) {
|
||||||
ctx.say_text = pickFallbackReply(ctx, llmError);
|
const kind = ctx.awaiting_human ? "awaiting_human"
|
||||||
|
: ctx.paused ? "paused"
|
||||||
|
: llmError ? "llm_error"
|
||||||
|
: "no_understanding";
|
||||||
|
ctx.say_text = pickFallbackReply(kind);
|
||||||
audit.fallback_used = true;
|
audit.fallback_used = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,13 +299,6 @@ export async function runTurnAgent({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickFallbackReply(ctx, err) {
|
|
||||||
if (ctx.awaiting_human) return "Te paso con un humano que pueda ayudarte.";
|
|
||||||
if (ctx.paused) return "Dale, cuando quieras seguimos.";
|
|
||||||
if (err) return "Disculpame, tuve un problema. ¿Lo intentás de nuevo?";
|
|
||||||
return "No te seguí, ¿me lo decís de otra forma?";
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectIntent(toolCalls = []) {
|
function detectIntent(toolCalls = []) {
|
||||||
const names = toolCalls.map((c) => c.name);
|
const names = toolCalls.map((c) => c.name);
|
||||||
if (names.includes("confirm_order")) return "confirm_order";
|
if (names.includes("confirm_order")) return "confirm_order";
|
||||||
|
|||||||
@@ -113,5 +113,21 @@ LIMITES TÉCNICOS:
|
|||||||
|
|
||||||
LIMITES OPERATIVOS DEL COMERCIO (NO LOS NEGOCIES):
|
LIMITES OPERATIVOS DEL COMERCIO (NO LOS NEGOCIES):
|
||||||
- Lo que diga el bloque store del working_memory.
|
- 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 { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
||||||
import { createCartItem } from "../../orderModel.js";
|
import { createCartItem } from "../../orderModel.js";
|
||||||
|
import { computeAllowedOrderUnits, isUnitAllowed, normalizeUnitToken } from "../../../shared/sellUnitPolicy.js";
|
||||||
|
|
||||||
export async function addToCartTool(args, ctx) {
|
export async function addToCartTool(args, ctx) {
|
||||||
const { woo_id, qty, unit } = args;
|
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
|
// Crear item de carrito
|
||||||
const newItem = createCartItem({
|
const newItem = createCartItem({
|
||||||
woo_id,
|
woo_id,
|
||||||
qty,
|
qty,
|
||||||
unit,
|
unit: normalizedUnit,
|
||||||
name: found.name,
|
name: found.name,
|
||||||
price: found.price ?? null,
|
price: found.price ?? null,
|
||||||
|
allowed_order_units: allowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Si ya existe el woo_id en el cart, sumamos cantidad
|
// Si ya existe el woo_id en el cart, sumamos cantidad
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const TOOL_SCHEMAS = [
|
|||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: "add_to_cart",
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -46,7 +46,7 @@ export const TOOL_SCHEMAS = [
|
|||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: "set_quantity",
|
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: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { retrieveCandidates } from "../../catalogRetrieval.js";
|
import { retrieveCandidates } from "../../catalogRetrieval.js";
|
||||||
import { searchSnapshotItems } from "../../../shared/wooSnapshot.js";
|
import { searchSnapshotItems } from "../../../shared/wooSnapshot.js";
|
||||||
import { pool } from "../../../shared/db/pool.js";
|
import { pool } from "../../../shared/db/pool.js";
|
||||||
|
import { computeAllowedOrderUnits } from "../../../shared/sellUnitPolicy.js";
|
||||||
|
|
||||||
const MIN_GOOD_SCORE = 0.4;
|
const MIN_GOOD_SCORE = 0.4;
|
||||||
|
|
||||||
@@ -18,6 +19,9 @@ function summarizeCandidate(c, idx) {
|
|||||||
name: c.name,
|
name: c.name,
|
||||||
price: c.price ?? null,
|
price: c.price ?? null,
|
||||||
sell_unit: c.sell_unit || 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,
|
score: typeof c._score === "number" ? Number(c._score.toFixed(2)) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PendingStatus,
|
PendingStatus,
|
||||||
} from "../../orderModel.js";
|
} from "../../orderModel.js";
|
||||||
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
||||||
|
import { computeAllowedOrderUnits, isUnitAllowed, normalizeUnitToken } from "../../../shared/sellUnitPolicy.js";
|
||||||
|
|
||||||
export async function selectCandidateTool(args, ctx) {
|
export async function selectCandidateTool(args, ctx) {
|
||||||
const { pending_id, woo_id } = args;
|
const { pending_id, woo_id } = args;
|
||||||
@@ -33,29 +34,46 @@ export async function selectCandidateTool(args, ctx) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sellsByWeight =
|
const allowed = Array.isArray(found.allowed_order_units) && found.allowed_order_units.length
|
||||||
!found.sell_unit || !["unit", "unidad"].includes(found.sell_unit);
|
? found.allowed_order_units
|
||||||
const displayUnit = found.sell_unit === "unit" ? "unit" : sellsByWeight ? "kg" : "unit";
|
: computeAllowedOrderUnits({ sell_unit: found.sell_unit });
|
||||||
|
|
||||||
const hasRequestedQty =
|
const hasRequestedQty =
|
||||||
pending.requested_qty != null && Number.isFinite(pending.requested_qty) && pending.requested_qty > 0;
|
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 finalQty = hasRequestedQty ? pending.requested_qty : null;
|
||||||
const finalUnit = pending.requested_unit || displayUnit;
|
const isReady = !needsQty && !needsUnitChoice;
|
||||||
const needsQty = sellsByWeight && !hasRequestedQty;
|
|
||||||
|
|
||||||
const updated = updatePendingItem(ctx.order, pending_id, {
|
const updated = updatePendingItem(ctx.order, pending_id, {
|
||||||
selected_woo_id: woo_id,
|
selected_woo_id: woo_id,
|
||||||
selected_name: found.name,
|
selected_name: found.name,
|
||||||
selected_price: found.price ?? null,
|
selected_price: found.price ?? null,
|
||||||
selected_unit: displayUnit,
|
selected_unit: displayUnit,
|
||||||
|
selected_allowed_order_units: allowed,
|
||||||
|
needs_unit_choice: needsUnitChoice,
|
||||||
candidates: [],
|
candidates: [],
|
||||||
qty: needsQty ? null : finalQty,
|
qty: isReady ? finalQty : null,
|
||||||
unit: finalUnit,
|
unit: finalUnit || displayUnit,
|
||||||
status: needsQty ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
status: isReady ? PendingStatus.READY : PendingStatus.NEEDS_QUANTITY,
|
||||||
});
|
});
|
||||||
ctx.order = moveReadyToCart(updated);
|
ctx.order = moveReadyToCart(updated);
|
||||||
|
|
||||||
if (!needsQty) {
|
if (isReady) {
|
||||||
ctx.pending_actions.push({
|
ctx.pending_actions.push({
|
||||||
type: "add_to_cart",
|
type: "add_to_cart",
|
||||||
payload: { woo_id, qty: finalQty, unit: finalUnit },
|
payload: { woo_id, qty: finalQty, unit: finalUnit },
|
||||||
@@ -65,8 +83,15 @@ export async function selectCandidateTool(args, ctx) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
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_quantity: needsQty,
|
||||||
|
needs_unit_choice: needsUnitChoice,
|
||||||
cart_size: (ctx.order.cart || []).length,
|
cart_size: (ctx.order.cart || []).length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js";
|
import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js";
|
||||||
|
import { isUnitAllowed } from "../../../shared/sellUnitPolicy.js";
|
||||||
|
|
||||||
export async function setQuantityTool(args, ctx) {
|
export async function setQuantityTool(args, ctx) {
|
||||||
const { pending_id, qty, unit } = args;
|
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." };
|
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, {
|
const updated = updatePendingItem(ctx.order, pending_id, {
|
||||||
qty,
|
qty,
|
||||||
unit,
|
unit,
|
||||||
|
needs_unit_choice: false,
|
||||||
status: PendingStatus.READY,
|
status: PendingStatus.READY,
|
||||||
});
|
});
|
||||||
ctx.order = moveReadyToCart(updated);
|
ctx.order = moveReadyToCart(updated);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function buildWorkingMemory({
|
|||||||
qty: it.qty,
|
qty: it.qty,
|
||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
price: it.price ?? null,
|
price: it.price ?? null,
|
||||||
|
allowed_order_units: it.allowed_order_units ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pending = (order.pending || []).map((p) => ({
|
const pending = (order.pending || []).map((p) => ({
|
||||||
@@ -66,6 +67,8 @@ export function buildWorkingMemory({
|
|||||||
selected_woo_id: p.selected_woo_id ?? null,
|
selected_woo_id: p.selected_woo_id ?? null,
|
||||||
selected_name: p.selected_name ?? null,
|
selected_name: p.selected_name ?? null,
|
||||||
selected_unit: p.selected_unit ?? 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_qty: p.requested_qty ?? null,
|
||||||
requested_unit: p.requested_unit ?? null,
|
requested_unit: p.requested_unit ?? null,
|
||||||
candidates: (p.candidates || []).slice(0, 8).map((c, i) => ({
|
candidates: (p.candidates || []).slice(0, 8).map((c, i) => ({
|
||||||
@@ -74,6 +77,7 @@ export function buildWorkingMemory({
|
|||||||
name: c.name,
|
name: c.name,
|
||||||
price: c.price ?? null,
|
price: c.price ?? null,
|
||||||
display_unit: c.display_unit ?? null,
|
display_unit: c.display_unit ?? null,
|
||||||
|
allowed_order_units: c.allowed_order_units ?? null,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ export function createEmptyOrder() {
|
|||||||
/**
|
/**
|
||||||
* Crea un item de carrito confirmado
|
* 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 {
|
return {
|
||||||
woo_id,
|
woo_id,
|
||||||
qty: Number(qty) || 1,
|
qty: Number(qty) || 1,
|
||||||
unit: unit || "unit", // "kg" | "g" | "unit"
|
unit: unit || "unit", // "kg" | "g" | "unit"
|
||||||
name,
|
name,
|
||||||
price,
|
price,
|
||||||
|
allowed_order_units,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +56,8 @@ export function createPendingItem({
|
|||||||
selected_name = null,
|
selected_name = null,
|
||||||
selected_price = null,
|
selected_price = null,
|
||||||
selected_unit = null,
|
selected_unit = null,
|
||||||
|
selected_allowed_order_units = null,
|
||||||
|
needs_unit_choice = false,
|
||||||
qty = null,
|
qty = null,
|
||||||
unit = null,
|
unit = null,
|
||||||
status = PendingStatus.NEEDS_TYPE,
|
status = PendingStatus.NEEDS_TYPE,
|
||||||
@@ -64,11 +67,13 @@ export function createPendingItem({
|
|||||||
return {
|
return {
|
||||||
id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
query,
|
query,
|
||||||
candidates, // [{ woo_id, name, price, display_unit }]
|
candidates, // [{ woo_id, name, price, display_unit, allowed_order_units }]
|
||||||
selected_woo_id, // Producto elegido (null si NEEDS_TYPE)
|
selected_woo_id, // Producto elegido (null si NEEDS_TYPE)
|
||||||
selected_name,
|
selected_name,
|
||||||
selected_price,
|
selected_price,
|
||||||
selected_unit, // Unidad del producto seleccionado
|
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)
|
qty, // Cantidad (null si NEEDS_QUANTITY)
|
||||||
unit, // Unidad elegida por usuario
|
unit, // Unidad elegida por usuario
|
||||||
status,
|
status,
|
||||||
@@ -96,6 +101,7 @@ export function moveReadyToCart(order) {
|
|||||||
unit: item.unit || item.selected_unit || "unit",
|
unit: item.unit || item.selected_unit || "unit",
|
||||||
name: item.selected_name,
|
name: item.selected_name,
|
||||||
price: item.selected_price,
|
price: item.selected_price,
|
||||||
|
allowed_order_units: item.selected_allowed_order_units || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
||||||
import { debug as dbg } from "../shared/debug.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";
|
import * as ordersRepo from "./ordersRepo.js";
|
||||||
|
|
||||||
// --- Simple in-memory lock to serialize work per key ---
|
// --- Simple in-memory lock to serialize work per key ---
|
||||||
@@ -129,15 +130,33 @@ function toMoney(value) {
|
|||||||
async function buildLineItems({ tenantId, basket }) {
|
async function buildLineItems({ tenantId, basket }) {
|
||||||
const items = normalizeBasketItems(basket);
|
const items = normalizeBasketItems(basket);
|
||||||
const lineItems = [];
|
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) {
|
for (const it of items) {
|
||||||
const productId = Number(it.product_id);
|
const productId = Number(it.product_id);
|
||||||
const unit = String(it.unit).toLowerCase();
|
const unit = String(it.unit).toLowerCase();
|
||||||
const qty = Number(it.quantity);
|
const qty = Number(it.quantity);
|
||||||
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
|
if (!productId || !Number.isFinite(qty) || qty <= 0) continue;
|
||||||
const pricePerKg = await getWooProductPrice({ tenantId, productId });
|
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") {
|
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({
|
lineItems.push({
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
...(it.variation_id ? { variation_id: it.variation_id } : {}),
|
...(it.variation_id ? { variation_id: it.variation_id } : {}),
|
||||||
@@ -145,6 +164,8 @@ async function buildLineItems({ tenantId, basket }) {
|
|||||||
...(total ? { subtotal: total, total } : {}),
|
...(total ? { subtotal: total, total } : {}),
|
||||||
meta_data: [
|
meta_data: [
|
||||||
{ key: "unit", value: "unit" },
|
{ key: "unit", value: "unit" },
|
||||||
|
{ key: "sell_unit_product", value: productSellUnit || "" },
|
||||||
|
{ key: "unit_mode_mismatch", value: mismatch ? "true" : "false" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -165,6 +186,7 @@ async function buildLineItems({ tenantId, basket }) {
|
|||||||
{ key: "unit", value: "g" },
|
{ key: "unit", value: "g" },
|
||||||
{ key: "weight_g", value: grams },
|
{ key: "weight_g", value: grams },
|
||||||
{ key: "unit_price_per_kg", value: pricePerKg },
|
{ key: "unit_price_per_kg", value: pricePerKg },
|
||||||
|
{ key: "sell_unit_product", value: productSellUnit || "" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/modules/shared/sellUnitPolicy.js
Normal file
23
src/modules/shared/sellUnitPolicy.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
63
src/modules/shared/sellUnitPolicy.test.js
Normal file
63
src/modules/shared/sellUnitPolicy.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { pool } from "./db/pool.js";
|
import { pool } from "./db/pool.js";
|
||||||
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
import { getDecryptedTenantEcommerceConfig } from "../2-identity/db/repo.js";
|
||||||
import { debug as dbg } from "./debug.js";
|
import { debug as dbg } from "./debug.js";
|
||||||
|
import { computeAllowedOrderUnits } from "./sellUnitPolicy.js";
|
||||||
|
|
||||||
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) {
|
async function fetchWoo({ url, method = "GET", body = null, timeout = 20000, headers = {} }) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -180,6 +181,10 @@ function snapshotRowToItem(row) {
|
|||||||
price_html: null,
|
price_html: null,
|
||||||
},
|
},
|
||||||
sell_unit: raw?._sell_unit_override || 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",
|
source: "snapshot",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -274,7 +279,13 @@ export async function upsertSnapshotItems({ tenantId, items, runId = null }) {
|
|||||||
attributes_normalized = excluded.attributes_normalized,
|
attributes_normalized = excluded.attributes_normalized,
|
||||||
date_modified = excluded.date_modified,
|
date_modified = excluded.date_modified,
|
||||||
run_id = excluded.run_id,
|
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()
|
updated_at = now()
|
||||||
`;
|
`;
|
||||||
await pool.query(q, [
|
await pool.query(q, [
|
||||||
|
|||||||
Reference in New Issue
Block a user