Skill: zero2ai-hub/skill-dropshipping-fulfillment (version 1.2.3)

--- name: skill-dropshipping-fulfillment description: Automates order fulfillment by pushing WooCommerce orders to CJ Dropshipping. Fetches "Processing" orders, matches line items to CJ variants via a supplier selection map, submits orders to CJ API, updates WooCommerce order status, and logs results. Supports dry-run and single-order modes. metadata: openclaw: requires: { bins: ["node"] } --- # CJ Fulfillment Engine Automates the WooCommerce → CJ Dropshipping order flow. No manual copy-paste. ## What it does 1. Fetches all `processing` orders from WooCommerce 2. Maps line items to CJ variant IDs via `cj-supplier-selection.json` 3. Submits matched items to CJ API as a dropship order 4. Updates WooCommerce order status to `on-hold` (awaiting CJ dispatch) 5. Adds an order note with the CJ order ID 6. Logs all results to `cj-fulfillment-log.json` ## Credentials / paths | File | Contents | |---|---| | `woo-api.json` | `{ url, consumerKey, consumerSecret }` | | `cj-api.json` | `{ apiKey, baseUrl, accessToken, tokenExpiry }` | | `cj-supplier-selection.json` | Array of `{ sku, cjProductId, variantId, ... }` | ## Usage ```bash # Dry run — preview without placing orders node {baseDir}/scripts/fulfill.js --dry-run # Fulfill all processing orders node {baseDir}/scripts/fulfill.js # Fulfill a single WooCommerce order node {baseDir}/scripts/fulfill.js --order 1234 ``` ## cj-supplier-selection.json format Full 6-field schema — one entry per product variant. Matching is SKU-first with fallback to `wooProductId:wooVariationId`. ```json [ { "wooProductId": 77261, "wooVariationId": 77265, "sku": "CJYD2360896-BLACK", "cjProductId": "CJ-PRODUCT-ID", "variantId": "CJ-VARIANT-ID", "productName": "My Product — Black" } ] ``` Generate or rebuild this file from CJ API automatically: ```bash node {baseDir}/scripts/rebuild-mapping.js ``` ## FBA / excluded products Products that should never be fulfilled via CJ (e.g. FBA, in-house). Set via env var: ```bash FBA_PRODUCT_IDS=75927,75808,2382 node fulfill.js ``` These are skipped with a log entry: `"FBA product — manual fulfillment required"`. ## Output - Console: per-order summary with matched/unmatched items and CJ order ID - `cj-fulfillment-log.json`: append-only log with `{ orderId, status, cjOrderId, timestamp }` - `cj-rejection-log.json`: unmatched/skipped items for manual review ## Unmatched items If a line item has no SKU match and no `wooProductId:wooVariationId` match, it's logged to the rejection log and the order is skipped. Fix by running `rebuild-mapping.js` or adding the entry manually. ## Environment overrides ```bash CJ_SELECTION_PATH=/custom/path/selection.json node fulfill.js FULFILL_LOG_PATH=/custom/path/log.json node fulfill.js ```

path: package-lock.json
content: {
  "name": "skill-dropshipping-fulfillment",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "skill-dropshipping-fulfillment",
      "version": "1.0.0",
      "license": "ISC",
      "dependencies": {
        "axios": "^1.13.6"
      }
    },
    "node_modules/asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/axios": {
      "version": "1.13.6",
      "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "follow-redirects": "^1.15.11",
        "form-data": "^4.0.5",
        "proxy-from-env": "^1.1.0"
      }
    },
    "node_modules/call-bind-apply-helpers": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/combined-stream": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "delayed-stream": "~1.0.0"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/delayed-stream": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.1",
        "es-errors": "^1.3.0",
        "gopd": "^1.2.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-errors": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-object-atoms": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-set-tostringtag": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6",
        "has-tostringtag": "^1.0.2",
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/follow-redirects": {
      "version": "1.15.11",
      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "individual",
          "url": "https://github.com/sponsors/RubenVerborgh"
        }
      ],
      "license": "MIT",
      "engines": {
        "node": ">=4.0"
      },
      "peerDependenciesMeta": {
        "debug": {
          "optional": true
        }
      }
    },
    "node_modules/form-data": {
      "version": "4.0.5",
      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "asynckit": "^0.4.0",
        "combined-stream": "^1.0.8",
        "es-set-tostringtag": "^2.1.0",
        "hasown": "^2.0.2",
        "mime-types": "^2.1.12"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/function-bind": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-intrinsic": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "function-bind": "^1.1.2",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "math-intrinsics": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-symbols": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-tostringtag": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "has-symbols": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/hasown": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/mime-db": {
      "version": "1.52.0",
      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/mime-types": {
      "version": "2.1.35",
      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "mime-db": "1.52.0"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/proxy-from-env": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    }
  }
}


path: package.json
content: {
  "name": "skill-dropshipping-fulfillment",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "dependencies": {
    "axios": "^1.13.6"
  }
}


path: scripts/cj-api.js
content: /**
 * CJ Dropshipping API helper
 * Reads ./cj-api.json: { apiKey, baseUrl, accessToken, tokenExpiry }
 */
const axios = require('axios');
const fs = require('fs');

const CJ_API_PATH = process.env.CJ_API_PATH || './cj-api.json';

function readCfg() { return JSON.parse(fs.readFileSync(CJ_API_PATH, 'utf8')); }
function writeCfg(o) { fs.writeFileSync(CJ_API_PATH, JSON.stringify(o, null, 2)); }

async function ensureToken() {
  const cfg = readCfg();
  const now = Date.now();
  const exp = Number(cfg.tokenExpiry || 0);
  if (cfg.accessToken && exp && now < exp - 10 * 60 * 1000) return cfg.accessToken;

  // Refresh
  const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
  const res = await axios.post(`${baseUrl}/authentication/getAccessToken`, { [REDACTED_SECRET] }, {
    headers: { 'Content-Type': 'application/json' }, timeout: 30000,
  });
  if (!res.data?.result) throw new Error(`CJ token refresh failed: ${JSON.stringify(res.data).slice(0, 200)}`);
  const [REDACTED_SECRET];
  cfg.accessToken = token;
  cfg.tokenExpiry = now + 14 * 24 * 3600 * 1000;
  writeCfg(cfg);
  return token;
}

function baseUrl() {
  return (readCfg().baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
}

async function headers() {
  return { 'CJ-Access-Token': await ensureToken(), 'Content-Type': 'application/json' };
}

/**
 * Get variant info by CJ variant ID to confirm stock/price.
 */
async function getVariant(variantId) {
  const res = await axios.get(`${baseUrl()}/product/variant/query`, {
    headers: await headers(),
    params: { vid: variantId },
    timeout: 30000,
  });
  return res.data;
}

/**
 * Create an order on CJ.
 * orderData: { orderNumber, shippingZip, shippingCountry, shippingCountryCode,
 *              shippingProvince, shippingCity, shippingAddress, shippingPhone,
 *              shippingCustomerName, shippingEmail, products: [{ vid, quantity }] }
 */
async function createOrder(orderData) {
  const res = await axios.post(`${baseUrl()}/shopping/order/createOrder`, orderData, {
    headers: await headers(),
    timeout: 60000,
  });
  return res.data;
}

module.exports = { ensureToken, getVariant, createOrder };


path: scripts/fulfill.js
content: #!/usr/bin/env node
/**
 * CJ Fulfillment Engine
 *
 * Fetches WooCommerce "processing" orders, matches line items to CJ variants
 * via cj-supplier-selection.json, and submits orders to CJ Dropshipping.
 *
 * Usage:
 *   node fulfill.js [--dry-run] [--order <woo_order_id>]
 *
 * --dry-run   Preview what would be submitted without actually placing orders
 * --order ID  Process only a specific WooCommerce order ID
 */

const fs = require('fs');
const axios = require('axios');

const WOO_API_PATH = process.env.WOO_API_PATH || '/home/aladdin/woo-api.json';
const CJ_API_PATH = process.env.CJ_API_PATH || '/home/aladdin/cj-api.json';
const SELECTION_PATH = process.env.CJ_SELECTION_PATH || '/home/aladdin/cj-supplier-selection.json';
const LOG_PATH = process.env.FULFILL_LOG_PATH || '/home/aladdin/cj-fulfillment-log.json';
const REJECTION_LOG_PATH = process.env.REJECTION_LOG_PATH || '/home/aladdin/cj-rejection-log.json';

const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');
const ORDER_FILTER = (() => {
  const i = args.indexOf('--order');
  return i >= 0 ? String(args[i + 1]) : null;
})();

// FBA/excluded product IDs — never fulfilled via CJ
// Set via env: FBA_PRODUCT_IDS=75927,75808,2382 or leave empty for none
const FBA_PRODUCT_IDS = new Set(
  (process.env.FBA_PRODUCT_IDS || '').split(',').map(s => parseInt(s.trim())).filter(Boolean)
);

// ── Utility ───────────────────────────────────────────────────────────────

function readJson(p) {
  if (!fs.existsSync(p)) return [];
  return JSON.parse(fs.readFileSync(p, 'utf8'));
}

function appendLog(entry) {
  const log = readJson(LOG_PATH);
  const arr = Array.isArray(log) ? log : [];
  arr.push({ ...entry, timestamp: new Date().toISOString() });
  fs.writeFileSync(LOG_PATH, JSON.stringify(arr, null, 2));
}

function appendRejectionLog(entry) {
  const existing = readJson(REJECTION_LOG_PATH);
  const log = Array.isArray(existing) ? existing : [];
  log.unshift({ ...entry, timestamp: new Date().toISOString() });
  if (log.length > 500) log.splice(500);
  fs.writeFileSync(REJECTION_LOG_PATH, JSON.stringify(log, null, 2));
}

async function withRetry(fn, label, retries = 1) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const isNetwork = err.code && ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND'].includes(err.code);
      if (attempt < retries && isNetwork) {
        console.log(`  ⟳ Retrying ${label} (attempt ${attempt + 2})...`);
        await new Promise(r => setTimeout(r, 2000));
      } else {
        throw err;
      }
    }
  }
}

// ── WooCommerce ───────────────────────────────────────────────────────────

function wooCfg() {
  const cfg = JSON.parse(fs.readFileSync(WOO_API_PATH, 'utf8'));
  const base = cfg.url.replace(/\/$/, '') + '/wp-json/wc/v3';
  const auth = { username: cfg.consumerKey, [REDACTED_SECRET] };
  return { base, auth };
}

async function getOrders(status = 'processing', perPage = 50) {
  const { base, auth } = wooCfg();
  const res = await axios.get(`${base}/orders`, {
    auth,
    params: { status, per_page: perPage, orderby: 'date', order: 'asc' },
    timeout: 30000,
  });
  return res.data;
}

async function updateOrderStatus(orderId, status) {
  const { base, auth } = wooCfg();
  const res = await axios.put(`${base}/orders/${orderId}`, { status }, { auth, timeout: 30000 });
  return res.data;
}

async function addOrderNote(orderId, note, customerNote = false) {
  const { base, auth } = wooCfg();
  const res = await axios.post(`${base}/orders/${orderId}/notes`, {
    note,
    customer_note: customerNote,
  }, { auth, timeout: 30000 });
  return res.data;
}

// ── CJ API ────────────────────────────────────────────────────────────────

function cjCfg() {
  return JSON.parse(fs.readFileSync(CJ_API_PATH, 'utf8'));
}

async function cjEnsureToken() {
  const cfg = cjCfg();
  const now = Date.now();
  const exp = Number(cfg.tokenExpiry || 0);
  if (cfg.accessToken && exp && now < exp - 10 * 60 * 1000) return cfg.accessToken;

  console.log('  🔑 Refreshing CJ access token...');
  const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
  const res = await axios.post(`${baseUrl}/authentication/getAccessToken`, { [REDACTED_SECRET] }, {
    headers: { 'Content-Type': 'application/json' }, timeout: 30000,
  });
  if (!res.data?.result) throw new Error(`CJ token refresh failed: ${JSON.stringify(res.data).slice(0, 200)}`);
  const [REDACTED_SECRET];
  cfg.accessToken = token;
  cfg.tokenExpiry = now + 14 * 24 * 3600 * 1000;
  fs.writeFileSync(CJ_API_PATH, JSON.stringify(cfg, null, 2));
  console.log('  ✅ CJ token refreshed');
  return token;
}

function cjBaseUrl() {
  return (cjCfg().baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
}

async function cjHeaders() {
  return { 'CJ-Access-Token': await cjEnsureToken(), 'Content-Type': 'application/json' };
}

async function createCjOrder(orderData) {
  const res = await axios.post(`${cjBaseUrl()}/shopping/order/createOrder`, orderData, {
    headers: await cjHeaders(),
    timeout: 60000,
  });
  return res.data;
}

// ── Selection / SKU map ───────────────────────────────────────────────────

/**
 * Build lookup maps from cj-supplier-selection.json:
 * - by SKU (uppercase)
 * - by wooProductId + wooVariationId
 * - by wooProductId alone (for simple products with wooVariationId === null)
 */
function buildMaps(selection) {
  const bySkU = {};
  const byWooId = {};  // key: "productId:variationId" or "productId:null"

  for (const item of selection) {
    if (item.sku) bySkU[item.sku.toUpperCase()] = item;
    const key = `${item.wooProductId}:${item.wooVariationId ?? 'null'}`;
    byWooId[key] = item;
  }

  return { bySkU, byWooId };
}

function findMatch(lineItem, maps) {
  // 1. Match by SKU first
  const sku = (lineItem.sku || '').toUpperCase();
  if (sku && maps.bySkU[sku]) return maps.bySkU[sku];

  // 2. Fall back to wooProductId + variationId
  const varId = lineItem.variation_id || null;
  const prodId = lineItem.product_id;
  const key = `${prodId}:${varId || 'null'}`;
  if (maps.byWooId[key]) return maps.byWooId[key];

  // 3. Try product ID with null variation (simple product)
  const simpleKey = `${prodId}:null`;
  if (maps.byWooId[simpleKey]) return maps.byWooId[simpleKey];

  return null;
}

// ── Order mapping ─────────────────────────────────────────────────────────

function mapOrder(order, maps) {
  const shipping = order.shipping || {};
  const billing = order.billing || {};

  const products = [];
  const unmatched = [];
  const fbaItems = [];

  for (const item of order.line_items || []) {
    const prodId = item.product_id;

    // FBA/LUUCCO exclusion
    if (FBA_PRODUCT_IDS.has(prodId)) {
      fbaItems.push({ id: item.id, name: item.name, productId: prodId });
      continue;
    }

    const match = findMatch(item, maps);
    if (!match) {
      unmatched.push({ id: item.id, name: item.name, sku: item.sku, productId: prodId, variationId: item.variation_id });
      continue;
    }

    products.push({
      vid: match.variantId || match.cjProductId,
      quantity: item.quantity,
      productName: item.name,
      matchedSku: match.sku,
    });
  }

  return {
    orderId: order.id,
    orderNumber: `WOO-${order.id}`,
    matched: products,
    unmatched,
    fbaItems,
    shippingName: `${shipping.first_name || ''} ${shipping.last_name || ''}`.trim()
      || `${billing.first_name || ''} ${billing.last_name || ''}`.trim(),
    shippingEmail: billing.email || '',
    shippingPhone: billing.phone || shipping.phone || '',
    shippingAddress: [shipping.address_1, shipping.address_2].filter(Boolean).join(', '),
    shippingCity: shipping.city || '',
    shippingProvince: shipping.state || '',
    shippingZip: shipping.postcode || '',
    shippingCountry: shipping.country || '',
  };
}

// ── Main ──────────────────────────────────────────────────────────────────

async function run() {
  console.log(`\n⚡ CJ Fulfillment Engine — ${DRY_RUN ? 'DRY RUN' : 'LIVE'}\n`);

  // Ensure CJ token is valid before processing
  await cjEnsureToken();

  const selection = readJson(SELECTION_PATH);
  if (!Array.isArray(selection) || !selection.length) {
    console.error(`❌ No products in selection file: ${SELECTION_PATH}`);
    console.error(`   Run: node scripts/rebuild-mapping.js`);
    process.exit(1);
  }
  const maps = buildMaps(selection);
  console.log(`✅ Loaded ${selection.length} entries from selection file`);

  // Fetch processing orders
  let orders = await withRetry(() => getOrders('processing', 50), 'fetch orders');
  if (ORDER_FILTER) {
    orders = orders.filter(o => String(o.id) === ORDER_FILTER);
    if (!orders.length) {
      console.log(`No processing order found with ID: ${ORDER_FILTER}`);
      process.exit(0);
    }
  }

  console.log(`📦 Found ${orders.length} processing order(s)\n`);

  if (!orders.length) {
    appendRejectionLog({
      type: 'no_orders',
      reason: 'zero_processing_orders',
      evaluated: { filter: ORDER_FILTER || 'all' },
      note: 'Engine ran successfully but found no processing orders to fulfill.',
    });
    return;
  }

  let submitted = 0;
  let skipped = 0;
  let failed = 0;

  for (const order of orders) {
    const mapped = mapOrder(order, maps);

    console.log(`\n--- Order #${mapped.orderId} ---`);
    console.log(`  Customer: ${mapped.shippingName} <${mapped.shippingEmail}>`);
    console.log(`  Address: ${mapped.shippingAddress}, ${mapped.shippingCity}, ${mapped.shippingCountry} ${mapped.shippingZip}`);

    // Log FBA items
    if (mapped.fbaItems.length) {
      console.log(`  🏷️  FBA items (manual fulfillment required):`);
      for (const f of mapped.fbaItems) {
        console.log(`       - ${f.name} (product #${f.productId}) — FBA product, manual fulfillment required`);
      }
    }

    console.log(`  Matched CJ items: ${mapped.matched.length}`);
    if (mapped.matched.length) {
      for (const m of mapped.matched) {
        console.log(`       ✓ ${m.productName} × ${m.quantity} (SKU: ${m.matchedSku}, vid: ${m.vid})`);
      }
    }

    if (mapped.unmatched.length) {
      console.log(`  ⚠️  Unmatched items:`);
      for (const u of mapped.unmatched) {
        console.log(`       - ${u.name} (SKU: ${u.sku || 'n/a'}, product #${u.productId})`);
      }
    }

    // Skip if no CJ items to fulfill
    if (!mapped.matched.length) {
      const reason = mapped.fbaItems.length && !mapped.unmatched.length
        ? 'all_items_fba'
        : 'no_matched_cj_items';
      console.log(`  ⏭️  Skipping — ${reason}`);
      skipped++;
      appendLog({ orderId: mapped.orderId, status: 'skipped', reason });
      appendRejectionLog({
        type: 'order_skipped',
        orderId: mapped.orderId,
        reason,
        evaluated: {
          totalLineItems: (order.line_items || []).length,
          fbaItems: mapped.fbaItems.map(f => ({ name: f.name, productId: f.productId })),
          unmatchedItems: mapped.unmatched.map(u => ({ name: u.name, sku: u.sku })),
        },
      });
      continue;
    }

    // Log partial matches
    if (mapped.unmatched.length > 0 || mapped.fbaItems.length > 0) {
      appendRejectionLog({
        type: 'partial_match',
        orderId: mapped.orderId,
        reason: 'some_items_not_cj',
        evaluated: {
          totalLineItems: (order.line_items || []).length,
          matchedCount: mapped.matched.length,
          unmatchedCount: mapped.unmatched.length,
          fbaCount: mapped.fbaItems.length,
        },
      });
    }

    const payload = {
      orderNumber: mapped.orderNumber,
      shippingZip: mapped.shippingZip,
      shippingCountry: mapped.shippingCountry,
      shippingCountryCode: mapped.shippingCountry,
      shippingProvince: mapped.shippingProvince,
      shippingCity: mapped.shippingCity,
      shippingAddress: mapped.shippingAddress,
      shippingPhone: mapped.shippingPhone,
      shippingCustomerName: mapped.shippingName,
      shippingEmail: mapped.shippingEmail,
      products: mapped.matched.map(p => ({ vid: p.vid, quantity: p.quantity })),
    };

    if (DRY_RUN) {
      console.log(`\n  🔍 DRY RUN — would submit payload:`);
      console.log('  ' + JSON.stringify(payload, null, 2).split('\n').join('\n  '));
      submitted++;
      continue;
    }

    try {
      const result = await withRetry(() => createCjOrder(payload), `create CJ order for #${mapped.orderId}`);
      if (result?.result) {
        const cjOrderId = result?.data?.orderId || 'unknown';
        console.log(`  ✅ CJ order created: ${cjOrderId}`);
        await withRetry(() => updateOrderStatus(mapped.orderId, 'on-hold'), 'update woo status');
        await withRetry(() => addOrderNote(mapped.orderId, `✅ CJ order submitted: ${cjOrderId}`), 'add order note');
        appendLog({ orderId: mapped.orderId, status: 'submitted', cjOrderId });
        submitted++;
      } else {
        const errDetail = JSON.stringify(result).slice(0, 300);
        console.error(`  ❌ CJ rejected: ${errDetail}`);
        appendLog({ orderId: mapped.orderId, status: 'failed', error: errDetail });
        appendRejectionLog({
          type: 'cj_api_rejection',
          orderId: mapped.orderId,
          reason: 'cj_api_returned_failure',
          evaluated: { payload, cjResponse: errDetail },
        });
        failed++;
      }
    } catch (err) {
      const errMsg = err.response
        ? `HTTP ${err.response.status}: ${JSON.stringify(err.response.data).slice(0, 200)}`
        : err.message;
      console.error(`  ❌ Error: ${errMsg}`);
      appendLog({ orderId: mapped.orderId, status: 'error', error: errMsg });
      appendRejectionLog({
        type: 'submission_error',
        orderId: mapped.orderId,
        reason: 'exception_thrown',
        evaluated: { errorMessage: errMsg },
      });
      failed++;
    }
  }

  console.log(`\n📊 Summary: ${submitted} submitted, ${skipped} skipped, ${failed} failed\n`);
}

run().catch(e => { console.error(e?.stack || String(e)); process.exit(1); });


path: scripts/rebuild-mapping.js
content: #!/usr/bin/env node
/**
 * Rebuild CJ supplier selection mapping + backfill WooCommerce SKUs
 *
 * Usage:
 *   node scripts/rebuild-mapping.js [--dry-run]
 *
 * What it does:
 *  1. Fetches all published WooCommerce products (excluding LUUCCO/FBA IDs)
 *  2. For each product with _cj_product_id meta, queries CJ for variant list
 *  3. Matches WooCommerce variations to CJ variants by attributes
 *  4. Backfills SKUs on WooCommerce products/variations if missing
 *  5. Writes ./cj-supplier-selection.json with proper mappings
 */

const axios = require('axios');
const fs = require('fs');

const WOO_API_PATH = process.env.WOO_API_PATH || '/home/aladdin/woo-api.json';
const CJ_API_PATH = process.env.CJ_API_PATH || '/home/aladdin/cj-api.json';
const SELECTION_PATH = process.env.CJ_SELECTION_PATH || '/home/aladdin/cj-supplier-selection.json';

const DRY_RUN = process.argv.includes('--dry-run');

// FBA/excluded product IDs — customize via env FBA_PRODUCT_IDS=id1,id2,...
const LUUCCO_IDS = new Set(
  (process.env.FBA_PRODUCT_IDS || '').split(',').map(s => parseInt(s.trim())).filter(Boolean)
);

function readJson(p) {
  if (!fs.existsSync(p)) return null;
  return JSON.parse(fs.readFileSync(p, 'utf8'));
}

// ── WooCommerce helpers ────────────────────────────────────────────────────

function wooCreds() {
  const cfg = readJson(WOO_API_PATH);
  const base = cfg.url.replace(/\/$/, '') + '/wp-json/wc/v3';
  const auth = { username: cfg.consumerKey, [REDACTED_SECRET] };
  return { base, auth };
}

async function wooGet(path, params = {}) {
  const { base, auth } = wooCreds();
  const res = await axios.get(`${base}${path}`, { auth, params, timeout: 30000 });
  return res.data;
}

async function wooPut(path, data) {
  const { base, auth } = wooCreds();
  const res = await axios.put(`${base}${path}`, data, { auth, timeout: 30000 });
  return res.data;
}

async function getAllPublishedProducts() {
  let page = 1;
  const all = [];
  while (true) {
    const batch = await wooGet('/products', { status: 'publish', per_page: 100, page });
    if (!batch.length) break;
    all.push(...batch);
    if (batch.length < 100) break;
    page++;
  }
  return all;
}

async function getVariations(productId) {
  let page = 1;
  const all = [];
  while (true) {
    const batch = await wooGet(`/products/${productId}/variations`, { per_page: 100, page });
    if (!batch.length) break;
    all.push(...batch);
    if (batch.length < 100) break;
    page++;
  }
  return all;
}

function getMeta(product, key) {
  const m = (product.meta_data || []).find(x => x.key === key);
  return m ? m.value : null;
}

// ── CJ helpers ────────────────────────────────────────────────────────────

async function cjEnsureToken() {
  const cfg = readJson(CJ_API_PATH);
  const now = Date.now();
  const exp = Number(cfg.tokenExpiry || 0);
  if (cfg.accessToken && exp && now < exp - 10 * 60 * 1000) return cfg.accessToken;

  const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
  const res = await axios.post(`${baseUrl}/authentication/getAccessToken`, { [REDACTED_SECRET] }, {
    headers: { 'Content-Type': 'application/json' }, timeout: 30000,
  });
  if (!res.data?.result) throw new Error(`CJ token refresh failed: ${JSON.stringify(res.data).slice(0, 200)}`);
  const [REDACTED_SECRET];
  cfg.accessToken = token;
  cfg.tokenExpiry = now + 14 * 24 * 3600 * 1000;
  fs.writeFileSync(CJ_API_PATH, JSON.stringify(cfg, null, 2));
  return token;
}

async function cjGet(path, params = {}) {
  const cfg = readJson(CJ_API_PATH);
  const baseUrl = (cfg.baseUrl || 'https://developers.cjdropshipping.com/api2.0/v1').replace(/\/$/, '');
  const token = await cjEnsureToken();
  const res = await axios.get(`${baseUrl}${path}`, {
    headers: { 'CJ-Access-Token': token, 'Content-Type': 'application/json' },
    params,
    timeout: 30000,
  });
  return res.data;
}

async function getCjProductVariants(cjProductId) {
  try {
    const res = await cjGet('/product/variant/query', { pid: cjProductId });
    if (res.result && Array.isArray(res.data)) return res.data;
    // fallback: try as vid
    return [];
  } catch (e) {
    console.error(`  ⚠️  CJ variant query failed for ${cjProductId}: ${e.message}`);
    return [];
  }
}

async function getCjProductDetail(cjProductId) {
  try {
    const res = await cjGet('/product/query', { pid: cjProductId });
    if (res.result && res.data) return res.data;
    return null;
  } catch (e) {
    console.error(`  ⚠️  CJ product detail failed for ${cjProductId}: ${e.message}`);
    return null;
  }
}

// ── Attribute matching ────────────────────────────────────────────────────

/**
 * Given a WooCommerce variation (has attributes array) and a list of CJ variants,
 * try to find the best match by attribute values (case-insensitive).
 */
function matchVariantByAttributes(wooVariation, cjVariants) {
  if (!cjVariants.length) return null;
  if (cjVariants.length === 1) return cjVariants[0];

  const wooAttrs = (wooVariation.attributes || []).map(a => a.option?.toLowerCase?.() || '');

  let best = null;
  let bestScore = -1;
  for (const v of cjVariants) {
    // CJ variant name typically looks like "Red,L" or "Blue"
    const vName = (v.variantKey || v.variantNameEn || v.sku || '').toLowerCase();
    let score = 0;
    for (const wa of wooAttrs) {
      if (wa && vName.includes(wa)) score++;
    }
    if (score > bestScore) {
      bestScore = score;
      best = v;
    }
  }
  return best;
}

// ── Main ──────────────────────────────────────────────────────────────────

async function main() {
  console.log(`\n🔧 CJ Mapping Rebuild — ${DRY_RUN ? 'DRY RUN' : 'LIVE'}\n`);

  // Ensure CJ token works
  console.log('🔑 Verifying CJ token...');
  await cjEnsureToken();
  console.log('✅ CJ token OK\n');

  console.log('📥 Fetching published WooCommerce products...');
  const allProducts = await getAllPublishedProducts();
  console.log(`   Found ${allProducts.length} published products\n`);

  const cjProducts = allProducts.filter(p => {
    if (LUUCCO_IDS.has(p.id)) return false;
    const cjId = getMeta(p, '_cj_product_id');
    return !!cjId;
  });

  const luuccoCount = allProducts.filter(p => LUUCCO_IDS.has(p.id)).length;
  const noCjMeta = allProducts.filter(p => !LUUCCO_IDS.has(p.id) && !getMeta(p, '_cj_product_id')).length;
  console.log(`📊 Breakdown:`);
  console.log(`   ${cjProducts.length} CJ dropship products`);
  console.log(`   ${luuccoCount} LUUCCO/FBA (skipped)`);
  console.log(`   ${noCjMeta} no CJ meta (skipped)\n`);

  const selectionEntries = [];
  let skuBackfillCount = 0;

  for (const product of cjProducts) {
    const cjProductId = getMeta(product, '_cj_product_id');
    console.log(`\n🛍️  Product #${product.id}: ${product.name.slice(0, 60)}`);
    console.log(`   CJ Product ID: ${cjProductId}`);

    // Fetch CJ product detail + variants
    const cjDetail = await getCjProductDetail(cjProductId);
    const cjVariants = await getCjProductVariants(cjProductId);

    console.log(`   CJ variants found: ${cjVariants.length}`);

    if (product.type === 'simple') {
      // Simple product — use first/only variant or product-level SKU
      const cjVariant = cjVariants[0] || null;
      const sku = cjVariant?.sku || cjDetail?.productSku || getMeta(product, '_cj_sku') || '';

      // Backfill WooCommerce SKU if missing
      if (!product.sku && sku && !DRY_RUN) {
        console.log(`   📝 Backfilling SKU: ${sku}`);
        await wooPut(`/products/${product.id}`, { sku });
        skuBackfillCount++;
      } else if (!product.sku && sku) {
        console.log(`   📝 [DRY RUN] Would set SKU: ${sku}`);
        skuBackfillCount++;
      }

      selectionEntries.push({
        wooProductId: product.id,
        wooVariationId: null,
        sku: sku || product.sku || '',
        cjProductId,
        variantId: cjVariant?.vid || cjVariant?.variantId || '',
        productName: product.name,
      });

    } else if (product.type === 'variable') {
      // Variable product — fetch each variation and match to CJ variant
      const variations = await getVariations(product.id);
      console.log(`   Woo variations: ${variations.length}`);

      for (const variation of variations) {
        const matched = matchVariantByAttributes(variation, cjVariants);
        const sku = matched?.sku || variation.sku || '';

        if (!variation.sku && sku && !DRY_RUN) {
          console.log(`   📝 Variation #${variation.id} — backfilling SKU: ${sku}`);
          await wooPut(`/products/${product.id}/variations/${variation.id}`, { sku });
          skuBackfillCount++;
        } else if (!variation.sku && sku) {
          const attrStr = (variation.attributes || []).map(a => a.option).join('/');
          console.log(`   📝 [DRY RUN] Variation #${variation.id} (${attrStr}) — would set SKU: ${sku}`);
          skuBackfillCount++;
        }

        const attrStr = (variation.attributes || []).map(a => a.option).join(' / ');
        selectionEntries.push({
          wooProductId: product.id,
          wooVariationId: variation.id,
          sku: sku,
          cjProductId,
          variantId: matched?.vid || matched?.variantId || '',
          productName: `${product.name} — ${attrStr}`,
        });
      }
    }

    // Small delay to avoid rate limits
    await new Promise(r => setTimeout(r, 200));
  }

  console.log(`\n\n✅ Mapping complete!`);
  console.log(`   Total selection entries: ${selectionEntries.length}`);
  console.log(`   SKUs to backfill: ${skuBackfillCount}`);

  if (!DRY_RUN) {
    fs.writeFileSync(SELECTION_PATH, JSON.stringify(selectionEntries, null, 2));
    console.log(`\n💾 Written to ${SELECTION_PATH}`);
  } else {
    console.log(`\n[DRY RUN] Would write ${selectionEntries.length} entries to ${SELECTION_PATH}`);
    console.log('\nSample entries:');
    console.log(JSON.stringify(selectionEntries.slice(0, 3), null, 2));
  }

  return selectionEntries;
}

main().catch(e => { console.error(e?.stack || String(e)); process.exit(1); });


path: scripts/woo-api.js
content: /**
 * WooCommerce API helper
 * Reads ./woo-api.json: { url, consumerKey, consumerSecret }
 */
const axios = require('axios');
const fs = require('fs');

const WOO_API_PATH = process.env.WOO_API_PATH || './woo-api.json';

function getCfg() {
  return JSON.parse(fs.readFileSync(WOO_API_PATH, 'utf8'));
}

function client() {
  const { url, consumerKey, consumerSecret } = getCfg();
  const base = url.replace(/\/$/, '') + '/wp-json/wc/v3';
  const auth = { username: consumerKey, [REDACTED_SECRET] };
  return { base, auth };
}

async function getOrders(status = 'processing', perPage = 50) {
  const { base, auth } = client();
  const res = await axios.get(`${base}/orders`, {
    auth,
    params: { status, per_page: perPage, orderby: 'date', order: 'asc' },
    timeout: 30000,
  });
  return res.data;
}

async function updateOrderStatus(orderId, status, note = '') {
  const { base, auth } = client();
  const res = await axios.put(`${base}/orders/${orderId}`, {
    status,
    ...(note ? { customer_note: note } : {}),
  }, { auth, timeout: 30000 });
  return res.data;
}

async function addOrderNote(orderId, note, customerNote = false) {
  const { base, auth } = client();
  const res = await axios.post(`${base}/orders/${orderId}/notes`, {
    note,
    customer_note: customerNote,
  }, { auth, timeout: 30000 });
  return res.data;
}

module.exports = { getOrders, updateOrderStatus, addOrderNote };


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)