audit and sync
This commit is contained in:
18
db/migrations/20260119130000_audit_log.sql
Normal file
18
db/migrations/20260119130000_audit_log.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- migrate:up
|
||||||
|
|
||||||
|
create table if not exists audit_log (
|
||||||
|
id bigserial primary key,
|
||||||
|
tenant_id uuid not null references tenants(id) on delete cascade,
|
||||||
|
entity_type text not null, -- 'product' | 'user' | 'order'
|
||||||
|
entity_id text not null, -- woo_product_id, wa_chat_id, etc.
|
||||||
|
action text not null, -- 'create' | 'update' | 'delete' | 'sync_from_woo' | 'push_to_woo'
|
||||||
|
changes jsonb, -- { field: { old, new } }
|
||||||
|
actor text not null default 'system', -- 'system' | 'webhook' | 'ui'
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists audit_log_entity_idx on audit_log(tenant_id, entity_type, entity_id);
|
||||||
|
create index if not exists audit_log_created_idx on audit_log(tenant_id, created_at desc);
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
drop table if exists audit_log;
|
||||||
@@ -73,7 +73,7 @@ class ProductsCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input type="text" id="search" placeholder="Buscar por nombre o SKU..." />
|
<input type="text" id="search" placeholder="Buscar por nombre o SKU..." />
|
||||||
<button id="syncBtn" class="secondary">Sync Woo</button>
|
<button id="syncBtn" class="secondary" title="Reimportar todos los productos desde WooCommerce (solo emergencias)">Resincronizar</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" id="list">
|
<div class="list" id="list">
|
||||||
<div class="loading">Cargando productos...</div>
|
<div class="loading">Cargando productos...</div>
|
||||||
@@ -141,19 +141,37 @@ class ProductsCrud extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncFromWoo() {
|
async syncFromWoo() {
|
||||||
|
// Mostrar confirmación antes de sincronizar
|
||||||
|
const confirmed = confirm(
|
||||||
|
"⚠️ Resincronización de emergencia\n\n" +
|
||||||
|
"Esto reimportará TODOS los productos desde WooCommerce y sobrescribirá los datos locales.\n\n" +
|
||||||
|
"Usar solo si:\n" +
|
||||||
|
"• La plataforma estuvo caída mientras se hacían cambios en Woo\n" +
|
||||||
|
"• Los webhooks no funcionaron correctamente\n" +
|
||||||
|
"• Necesitás una sincronización completa\n\n" +
|
||||||
|
"¿Continuar?"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
const btn = this.shadowRoot.getElementById("syncBtn");
|
const btn = this.shadowRoot.getElementById("syncBtn");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Sincronizando...";
|
btn.textContent = "Sincronizando...";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.syncProducts();
|
const result = await api.syncFromWoo();
|
||||||
|
if (result.ok) {
|
||||||
|
alert(`Sincronización completada: ${result.synced} productos importados`);
|
||||||
|
} else {
|
||||||
|
alert("Error: " + (result.error || "Error desconocido"));
|
||||||
|
}
|
||||||
await this.load();
|
await this.load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error syncing products:", e);
|
console.error("Error syncing products:", e);
|
||||||
alert("Error sincronizando: " + (e.message || e));
|
alert("Error sincronizando: " + (e.message || e));
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Sync Woo";
|
btn.textContent = "Resincronizar";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,4 +179,9 @@ export const api = {
|
|||||||
body: JSON.stringify({ rules }),
|
body: JSON.stringify({ rules }),
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Sync de emergencia desde WooCommerce
|
||||||
|
async syncFromWoo() {
|
||||||
|
return fetch("/products/sync-from-woo", { method: "POST" }).then(r => r.json());
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts, handleUpdateProductUnit, handleBulkUpdateProductUnit, handleUpdateProduct } from "../handlers/products.js";
|
import { handleSearchProducts, handleListProducts, handleGetProduct, handleSyncProducts, handleSyncFromWoo, handleUpdateProductUnit, handleBulkUpdateProductUnit, handleUpdateProduct } from "../handlers/products.js";
|
||||||
|
|
||||||
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
|
export const makeSearchProducts = (tenantIdOrFn) => async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -54,6 +54,20 @@ export const makeSyncProducts = (tenantIdOrFn) => async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync manual de emergencia: reimporta todos los productos de WooCommerce
|
||||||
|
*/
|
||||||
|
export const makeSyncFromWoo = (tenantIdOrFn) => async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
const result = await handleSyncFromWoo({ tenantId });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const makeUpdateProductUnit = (tenantIdOrFn) => async (req, res) => {
|
export const makeUpdateProductUnit = (tenantIdOrFn) => async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
||||||
|
|||||||
@@ -624,3 +624,63 @@ export async function countQtyRulesByProduct({ tenantId }) {
|
|||||||
const { rows } = await pool.query(sql, [tenantId]);
|
const { rows } = await pool.query(sql, [tenantId]);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Audit Log
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserta un registro en el audit log
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.tenantId - UUID del tenant
|
||||||
|
* @param {string} params.entityType - 'product' | 'user' | 'order'
|
||||||
|
* @param {string} params.entityId - ID de la entidad (woo_product_id, wa_chat_id, etc)
|
||||||
|
* @param {string} params.action - 'create' | 'update' | 'delete' | 'sync_from_woo' | 'push_to_woo'
|
||||||
|
* @param {Object} params.changes - { field: { old, new } }
|
||||||
|
* @param {string} params.actor - 'system' | 'webhook' | 'ui'
|
||||||
|
*/
|
||||||
|
export async function insertAuditLog({ tenantId, entityType, entityId, action, changes, actor = 'system' }) {
|
||||||
|
const sql = `
|
||||||
|
insert into audit_log (tenant_id, entity_type, entity_id, action, changes, actor)
|
||||||
|
values ($1, $2, $3, $4, $5::jsonb, $6)
|
||||||
|
returning id, created_at
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(sql, [
|
||||||
|
tenantId,
|
||||||
|
entityType,
|
||||||
|
String(entityId),
|
||||||
|
action,
|
||||||
|
changes ? JSON.stringify(changes) : null,
|
||||||
|
actor
|
||||||
|
]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el historial de cambios de una entidad
|
||||||
|
*/
|
||||||
|
export async function getAuditLog({ tenantId, entityType, entityId, limit = 50 }) {
|
||||||
|
const sql = `
|
||||||
|
select id, entity_type, entity_id, action, changes, actor, created_at
|
||||||
|
from audit_log
|
||||||
|
where tenant_id = $1
|
||||||
|
and ($2::text is null or entity_type = $2)
|
||||||
|
and ($3::text is null or entity_id = $3)
|
||||||
|
order by created_at desc
|
||||||
|
limit $4
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(sql, [tenantId, entityType || null, entityId || null, limit]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el historial de cambios recientes de productos
|
||||||
|
*/
|
||||||
|
export async function getProductAuditLog({ tenantId, wooProductId, limit = 20 }) {
|
||||||
|
return getAuditLog({
|
||||||
|
tenantId,
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: wooProductId ? String(wooProductId) : null,
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { searchSnapshotItems } from "../../shared/wooSnapshot.js";
|
import { searchSnapshotItems, syncFromWoo, pushProductToWoo } from "../../shared/wooSnapshot.js";
|
||||||
import { listProducts, getProductByWooId, updateProductSellUnit, bulkUpdateProductSellUnit, updateProduct } from "../db/repo.js";
|
import { listProducts, getProductByWooId, updateProductSellUnit, bulkUpdateProductSellUnit, updateProduct, insertAuditLog } from "../db/repo.js";
|
||||||
|
|
||||||
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({
|
||||||
@@ -20,9 +20,34 @@ export async function handleGetProduct({ tenantId, wooProductId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function handleSyncProducts({ tenantId }) {
|
export async function handleSyncProducts({ tenantId }) {
|
||||||
// This is a placeholder - actual sync would fetch from Woo API
|
// Placeholder legacy - redirigir a syncFromWoo
|
||||||
// For now, just return success
|
return handleSyncFromWoo({ tenantId });
|
||||||
return { ok: true, message: "Sync triggered (use import script for full sync)" };
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza todos los productos desde WooCommerce (solo para emergencias)
|
||||||
|
*/
|
||||||
|
export async function handleSyncFromWoo({ tenantId }) {
|
||||||
|
console.log("[products] handleSyncFromWoo starting...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncFromWoo({ tenantId });
|
||||||
|
|
||||||
|
// Registrar en audit_log
|
||||||
|
await insertAuditLog({
|
||||||
|
tenantId,
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: 'all',
|
||||||
|
action: 'sync_from_woo',
|
||||||
|
changes: { synced_count: result.synced, ms: result.ms },
|
||||||
|
actor: 'ui'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, synced: result.synced, ms: result.ms };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[products] handleSyncFromWoo error:", err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleUpdateProductUnit({ tenantId, wooProductId, sell_unit }) {
|
export async function handleUpdateProductUnit({ tenantId, wooProductId, sell_unit }) {
|
||||||
@@ -35,8 +60,43 @@ export async function handleBulkUpdateProductUnit({ tenantId, wooProductIds, sel
|
|||||||
return { ok: true, updated_count: wooProductIds.length, sell_unit };
|
return { ok: true, updated_count: wooProductIds.length, sell_unit };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) {
|
||||||
|
// 1. Guardar cambios localmente
|
||||||
await updateProduct({ tenantId, wooProductId, sell_unit, categories });
|
await updateProduct({ tenantId, wooProductId, sell_unit, categories });
|
||||||
return { ok: true, woo_product_id: wooProductId, sell_unit, categories };
|
|
||||||
|
// 2. Auto-push a WooCommerce (best effort - no falla si Woo no responde)
|
||||||
|
let wooPushResult = null;
|
||||||
|
try {
|
||||||
|
wooPushResult = await pushProductToWoo({ tenantId, wooProductId, categories, sellUnit: sell_unit });
|
||||||
|
console.log("[products] handleUpdateProduct: pushed to Woo", { wooProductId, updated: wooPushResult?.updated });
|
||||||
|
} catch (err) {
|
||||||
|
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: {
|
||||||
|
sell_unit: { new: sell_unit },
|
||||||
|
categories: { new: categories },
|
||||||
|
woo_synced: wooPushResult?.updated ?? false
|
||||||
|
},
|
||||||
|
actor: 'ui'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
woo_product_id: wooProductId,
|
||||||
|
sell_unit,
|
||||||
|
categories,
|
||||||
|
woo_synced: wooPushResult?.updated ?? false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { makeListRuns, makeGetRunById } from "../../0-ui/controllers/runs.js";
|
|||||||
import { makeSimSend } from "../controllers/sim.js";
|
import { makeSimSend } from "../controllers/sim.js";
|
||||||
import { makeGetConversationState } from "../../0-ui/controllers/conversationState.js";
|
import { makeGetConversationState } from "../../0-ui/controllers/conversationState.js";
|
||||||
import { makeListMessages } from "../../0-ui/controllers/messages.js";
|
import { makeListMessages } from "../../0-ui/controllers/messages.js";
|
||||||
import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, makeUpdateProductUnit, makeBulkUpdateProductUnit, makeUpdateProduct } from "../../0-ui/controllers/products.js";
|
import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts, makeSyncFromWoo, makeUpdateProductUnit, makeBulkUpdateProductUnit, makeUpdateProduct } from "../../0-ui/controllers/products.js";
|
||||||
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
|
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
|
||||||
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
|
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
|
||||||
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
|
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
|
||||||
@@ -56,6 +56,7 @@ export function createSimulatorRouter({ tenantId }) {
|
|||||||
router.get("/products/search", makeSearchProducts(getTenantId));
|
router.get("/products/search", makeSearchProducts(getTenantId));
|
||||||
router.patch("/products/bulk/unit", makeBulkUpdateProductUnit(getTenantId));
|
router.patch("/products/bulk/unit", makeBulkUpdateProductUnit(getTenantId));
|
||||||
router.post("/products/sync", makeSyncProducts(getTenantId));
|
router.post("/products/sync", makeSyncProducts(getTenantId));
|
||||||
|
router.post("/products/sync-from-woo", makeSyncFromWoo(getTenantId));
|
||||||
router.get("/products/:id", makeGetProduct(getTenantId));
|
router.get("/products/:id", makeGetProduct(getTenantId));
|
||||||
router.patch("/products/:id/unit", makeUpdateProductUnit(getTenantId));
|
router.patch("/products/:id/unit", makeUpdateProductUnit(getTenantId));
|
||||||
router.patch("/products/:id", makeUpdateProduct(getTenantId));
|
router.patch("/products/:id", makeUpdateProduct(getTenantId));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
|
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
|
||||||
import { getTenantByKey } from "../db/repo.js";
|
import { getTenantByKey } from "../db/repo.js";
|
||||||
|
import { insertAuditLog } from "../../0-ui/db/repo.js";
|
||||||
|
|
||||||
function unauthorized(res) {
|
function unauthorized(res) {
|
||||||
res.setHeader("WWW-Authenticate", 'Basic realm="woo-webhook"');
|
res.setHeader("WWW-Authenticate", 'Basic realm="woo-webhook"');
|
||||||
@@ -18,11 +19,25 @@ function checkBasicAuth(req) {
|
|||||||
return { ok: false, reason: "invalid_creds" };
|
return { ok: false, reason: "invalid_creds" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWooId(payload) {
|
function parseWooPayload(payload) {
|
||||||
const id = payload?.id || payload?.data?.id || null;
|
const id = payload?.id || payload?.data?.id || null;
|
||||||
const parentId = payload?.parent_id || payload?.data?.parent_id || null;
|
const parentId = payload?.parent_id || payload?.data?.parent_id || null;
|
||||||
const resource = payload?.resource || payload?.topic || null;
|
const resource = payload?.resource || payload?.topic || null;
|
||||||
return { id: id ? Number(id) : null, parentId: parentId ? Number(parentId) : null, resource };
|
const action = payload?.action || null; // 'created' | 'updated' | 'deleted'
|
||||||
|
|
||||||
|
// Extraer campos relevantes para audit
|
||||||
|
const name = payload?.name || null;
|
||||||
|
const price = payload?.price || null;
|
||||||
|
const stockStatus = payload?.stock_status || null;
|
||||||
|
const stockQty = payload?.stock_quantity ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id ? Number(id) : null,
|
||||||
|
parentId: parentId ? Number(parentId) : null,
|
||||||
|
resource,
|
||||||
|
action,
|
||||||
|
changes: { name, price, stockStatus, stockQty }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeWooProductWebhook() {
|
export function makeWooProductWebhook() {
|
||||||
@@ -30,7 +45,7 @@ export function makeWooProductWebhook() {
|
|||||||
const auth = checkBasicAuth(req);
|
const auth = checkBasicAuth(req);
|
||||||
if (!auth.ok) return unauthorized(res);
|
if (!auth.ok) return unauthorized(res);
|
||||||
|
|
||||||
const { id, parentId, resource } = parseWooId(req.body || {});
|
const { id, parentId, resource, action, changes } = parseWooPayload(req.body || {});
|
||||||
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
|
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
|
||||||
|
|
||||||
// Determinar tenant por query ?tenant_key=...
|
// Determinar tenant por query ?tenant_key=...
|
||||||
@@ -42,17 +57,55 @@ export function makeWooProductWebhook() {
|
|||||||
const parentForVariation =
|
const parentForVariation =
|
||||||
resource && String(resource).includes("variation") ? parentId || null : null;
|
resource && String(resource).includes("variation") ? parentId || null : null;
|
||||||
|
|
||||||
const updated = await refreshProductByWooId({
|
// Determinar acción para audit
|
||||||
|
let auditAction = 'update';
|
||||||
|
if (action === 'created' || resource?.includes('created')) {
|
||||||
|
auditAction = 'create';
|
||||||
|
} else if (action === 'deleted' || resource?.includes('deleted')) {
|
||||||
|
auditAction = 'delete';
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = null;
|
||||||
|
|
||||||
|
// Si es delete, no podemos refresh (el producto ya no existe en Woo)
|
||||||
|
if (auditAction !== 'delete') {
|
||||||
|
try {
|
||||||
|
updated = await refreshProductByWooId({
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
wooId: id,
|
wooId: id,
|
||||||
parentId: parentForVariation,
|
parentId: parentForVariation,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[wooWebhook] Error refreshing product:", err.message);
|
||||||
|
// Si falla el refresh (ej: producto eliminado), registramos igual en audit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar en audit_log
|
||||||
|
try {
|
||||||
|
await insertAuditLog({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: String(id),
|
||||||
|
action: auditAction,
|
||||||
|
changes: {
|
||||||
|
...changes,
|
||||||
|
resource,
|
||||||
|
woo_action: action,
|
||||||
|
refreshed: updated != null
|
||||||
|
},
|
||||||
|
actor: 'webhook'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[wooWebhook] Error inserting audit log:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
ok: true,
|
ok: true,
|
||||||
woo_id: updated?.woo_id || id,
|
woo_id: updated?.woo_id || id,
|
||||||
type: updated?.type || null,
|
type: updated?.type || null,
|
||||||
parent_id: updated?.parent_id || null,
|
parent_id: updated?.parent_id || null,
|
||||||
|
action: auditAction,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,3 +276,159 @@ export async function refreshProductByWooId({ tenantId, wooId, parentId = null }
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Sincronización completa con WooCommerce
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los productos de WooCommerce usando paginación
|
||||||
|
*/
|
||||||
|
export async function fetchAllWooProducts({ tenantId }) {
|
||||||
|
const client = await getWooClient({ tenantId });
|
||||||
|
const allProducts = [];
|
||||||
|
let page = 1;
|
||||||
|
const perPage = 100;
|
||||||
|
|
||||||
|
console.log("[wooSnapshot] fetchAllWooProducts starting...");
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const url = `${client.base}/products?per_page=${perPage}&page=${page}&status=any`;
|
||||||
|
const data = await fetchWoo({
|
||||||
|
url,
|
||||||
|
method: "GET",
|
||||||
|
timeout: client.timeout * 2, // Más tiempo para listados grandes
|
||||||
|
headers: client.authHeader
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
allProducts.push(...data);
|
||||||
|
console.log(`[wooSnapshot] fetchAllWooProducts page ${page}: ${data.length} products (total: ${allProducts.length})`);
|
||||||
|
|
||||||
|
if (data.length < perPage) {
|
||||||
|
break; // Última página
|
||||||
|
}
|
||||||
|
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[wooSnapshot] fetchAllWooProducts completed: ${allProducts.length} products`);
|
||||||
|
return allProducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza todos los productos desde WooCommerce (reemplaza snapshot local)
|
||||||
|
*/
|
||||||
|
export async function syncFromWoo({ tenantId }) {
|
||||||
|
console.log("[wooSnapshot] syncFromWoo starting...");
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
// 1. Obtener todos los productos de Woo
|
||||||
|
const wooProducts = await fetchAllWooProducts({ tenantId });
|
||||||
|
|
||||||
|
if (wooProducts.length === 0) {
|
||||||
|
console.log("[wooSnapshot] syncFromWoo: no products found in Woo");
|
||||||
|
return { ok: true, synced: 0, ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Crear run para tracking
|
||||||
|
const runId = await insertSnapshotRun({ tenantId, source: "sync_from_woo", total: wooProducts.length });
|
||||||
|
|
||||||
|
// 3. Normalizar e insertar productos
|
||||||
|
const normalized = wooProducts.map(normalizeWooProduct);
|
||||||
|
await upsertSnapshotItems({ tenantId, items: normalized, runId });
|
||||||
|
|
||||||
|
// 4. Eliminar productos que ya no existen en Woo
|
||||||
|
await deleteMissingItems({ tenantId, runId });
|
||||||
|
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
console.log(`[wooSnapshot] syncFromWoo completed: ${wooProducts.length} products in ${ms}ms`);
|
||||||
|
|
||||||
|
return { ok: true, synced: wooProducts.length, ms };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushea cambios de un producto a WooCommerce
|
||||||
|
*/
|
||||||
|
export async function pushProductToWoo({ tenantId, wooProductId, categories, sellUnit }) {
|
||||||
|
const client = await getWooClient({ tenantId });
|
||||||
|
const url = `${client.base}/products/${encodeURIComponent(wooProductId)}`;
|
||||||
|
|
||||||
|
// Construir payload de actualización
|
||||||
|
const updatePayload = {};
|
||||||
|
|
||||||
|
// Actualizar categorías si se proporcionaron
|
||||||
|
if (categories && Array.isArray(categories) && categories.length > 0) {
|
||||||
|
// Primero obtener las categorías existentes en Woo para mapear nombres a IDs
|
||||||
|
const existingCats = await fetchWooCategoriesByNames({ tenantId, names: categories });
|
||||||
|
if (existingCats.length > 0) {
|
||||||
|
updatePayload.categories = existingCats.map(c => ({ id: c.id }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar meta de unidad de venta
|
||||||
|
if (sellUnit) {
|
||||||
|
updatePayload.meta_data = [
|
||||||
|
{ key: "_sell_unit_override", value: sellUnit }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo hacer request si hay algo que actualizar
|
||||||
|
if (Object.keys(updatePayload).length === 0) {
|
||||||
|
return { ok: true, woo_product_id: wooProductId, updated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchWoo({
|
||||||
|
url,
|
||||||
|
method: "PUT",
|
||||||
|
body: updatePayload,
|
||||||
|
timeout: client.timeout,
|
||||||
|
headers: client.authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dbg.wooHttp) console.log("[wooSnapshot] pushProductToWoo", { wooProductId, updated: true });
|
||||||
|
|
||||||
|
return { ok: true, woo_product_id: wooProductId, updated: true, data };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[wooSnapshot] pushProductToWoo error:", err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene categorías de WooCommerce por nombres
|
||||||
|
*/
|
||||||
|
async function fetchWooCategoriesByNames({ tenantId, names }) {
|
||||||
|
if (!names || names.length === 0) return [];
|
||||||
|
|
||||||
|
const client = await getWooClient({ tenantId });
|
||||||
|
const allCategories = [];
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
|
// Obtener todas las categorías de Woo (usualmente son pocas)
|
||||||
|
while (true) {
|
||||||
|
const url = `${client.base}/products/categories?per_page=100&page=${page}`;
|
||||||
|
const data = await fetchWoo({
|
||||||
|
url,
|
||||||
|
method: "GET",
|
||||||
|
timeout: client.timeout,
|
||||||
|
headers: client.authHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(data) || data.length === 0) break;
|
||||||
|
allCategories.push(...data);
|
||||||
|
if (data.length < 100) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrar las que coinciden con los nombres buscados
|
||||||
|
const normalizedNames = names.map(n => String(n).toLowerCase().trim());
|
||||||
|
return allCategories.filter(c =>
|
||||||
|
normalizedNames.includes(String(c.name).toLowerCase().trim()) ||
|
||||||
|
normalizedNames.includes(String(c.slug).toLowerCase().trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user