diff --git a/sg-commerce-pro/CHANGELOG.md b/sg-commerce-pro/CHANGELOG.md new file mode 100644 index 0000000..5f6f0e6 --- /dev/null +++ b/sg-commerce-pro/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +## 4.0.0 — Amazon Seller Suite + +### Added +- **Amazon Suite** admin app: a single-page app with Dashboard, Profit & P&L, + Orders, Inventory & Restock, PPC Manager, Keywords, Listing Optimizer, + Hijackers & Buy Box, Review Requests, Reimbursements, Traffic & Returns, + Product Research, Reports Center, Alerts and Settings. +- REST API `sg-commerce/v1/suite/*` (45 routes, `manage_options` only). +- 24 new `sg_suite_*` tables, versioned separately (`sg_suite_schema_version`). +- Data pipelines: + - Orders v0 (incremental, lazy item fetch). + - Finances v0 financial events (windowed and resumable per region). + - Reports API pipeline: request → poll → download → gunzip → ingest. Covers + all-orders, sales & traffic, ledger, reimbursements, returns, inventory + planning, seller feedback and Search Query Performance. + - Ads API v3: campaigns, keywords, targets, and spCampaigns / spTargeting / + spSearchTerm reports. +- Engines: + - Profit engine: settled + estimated fees, refunds, account fees, ads, COGS + with sellable-return add-back, expenses, month-end forecast. + - Restock planner: OOS-aware velocity, case pack / MOQ, stock-out gap before + inbound. + - PPC bid optimizer, search-term harvester / negator and budget pacing, with + review, apply, dismiss and auto-apply. + - Listing auditor: 100-point score, content-change detection, AI rewrite. + - Listing monitor: hijackers, Buy Box, competitor price drops, price/BSR + history. + - Review requester (Solicitations API). + - Reimbursement auditor: lost/damaged inventory, refund-no-return, fee + overcharge, with case-message generator. + - Product research: catalog search, BSR sales estimate, fee estimates, + profitability calculator. +- Marketplace-local day bucketing (US = Pacific), matching Seller Central. +- Email delivery for suite alerts (configurable levels, throttled). +- Deterministic demo account for the 7 SEVEN catalog, removable exactly. +- Tests: `tests/unit.php` (79 assertions) and + `tests/integration-smoke.php` (all REST routes, secret handling, demo + cleanup). + +### Changed +- Amazon Ads API secrets are added to the encrypted secret store. +- Rate-limiter buckets added for reports documents, order items, finances, + listings, catalog search, fee estimates and solicitations. + +### Fixed +- Uninstall now also drops `sg_inbound_plans`, `sg_product_ean` and every + `sg_suite_*` table. It also deletes every `sg_commerce_*` option (carton + templates and the sandbox flag were previously left behind), all suite + options and transients, and Action Scheduler jobs. + +## 3.3.1 +- Baseline imported (SP-API connection, sync, repricer, AI copy, analytics, + automation, MCF, inbound plans, PII tools, settlements, headless API). diff --git a/sg-commerce-pro/README.md b/sg-commerce-pro/README.md new file mode 100644 index 0000000..cb98ce2 --- /dev/null +++ b/sg-commerce-pro/README.md @@ -0,0 +1,158 @@ +# Seven Gum Commerce Pro — with Amazon Seller Suite (v4.0) + +A WordPress plugin that turns **sevengum.com** into the command center for the +7 SEVEN Amazon business. v4.0 adds the **Amazon Seller Suite**, a full +single-page app inside wp-admin (**Amazon Suite** menu) that covers what sellers +usually pay several monthly subscriptions for. It runs on Amazon's official +SP-API and Ads API and needs no external SaaS. + +| Suite module | Replaces / matches | Data source (official APIs only) | +|---|---|---| +| **Dashboard**: Today / Yesterday / MTD / Last month, month-end forecast, action center | Sellerboard dashboard | Orders, Finances, Ads, Reports | +| **Profit & P&L**: net profit per day / SKU / order, fee breakdown, waterfall, COGS editor, custom expenses | Sellerboard, Shopkeeper | Finances v0 events, Orders v0, all-orders report, Ads reports | +| **Orders**: every order with its own profit, settled vs. estimated fees | Sellerboard orders | Orders v0 (near real time) + flat-file orders report | +| **Inventory & Restock**: velocity forecast, days of cover, stock-out date, order-by date, PO qty (case pack / MOQ), lost-sales estimate, gap-before-inbound warning, aged inventory & storage | SoStocked, Forecastly, RestockPro | FBA inventory, order items, FBA inventory planning report | +| **PPC Manager**: bid optimisation to target ACoS, pause bleeders, search-term harvesting (exact + ASIN), negative keywords, budget pacing, change log, auto-apply | Helium 10 Adtomic, Perpetua, PPC Entourage | Ads API v3 (Sponsored Products + Reporting v3) | +| **Keywords**: share-of-search tracker (impression / click / purchase share, 8-week trend), keyword research, all Brand Analytics queries per ASIN | Helium 10 Keyword Tracker / Cerebro | Brand Analytics Search Query Performance, Ads keyword recommendations, your search-term report | +| **Listing Optimizer**: 100-point grade (title policy 2025, bullets, description, backend bytes, images, Amazon issues, suppression, keyword coverage) + AI rewrite in brand voice | Helium 10 Listing Analyzer / Scribbles | Listings Items API 2021-08-01 + your AI provider (Ollama) | +| **Hijackers & Buy Box**: new-seller (hijacker) alerts, Buy Box lost/suppressed alerts, competitor price-drop alerts, Keepa-style price & BSR history | Seller Snap alerts, Keepa | Product Pricing v0 `getItemOffers` | +| **Review Requests**: automatic Amazon "Request a Review" 5–30 days after delivery, skipping refunded/returned orders, daily cap; seller-feedback monitor | FeedbackWhiz, Jungle Scout review automation | Solicitations API, seller feedback report | +| **Reimbursements**: lost & damaged FBA inventory, refunded-but-never-returned orders, FBA fee overcharges, each with a ready-to-paste case message | GETIDA, Refunds Manager | Inventory ledger, reimbursements, returns reports, Finances | +| **Traffic & Returns**: sessions, page views, unit-session %, Buy Box % per day and ASIN; return reasons & dispositions | Business Reports+ | Sales & Traffic report, customer returns report | +| **Product Research**: niche explorer (BSR-based sales estimate, page-1 revenue, offers, opportunity score), exact FBA fees for any ASIN, profitability calculator (break-even ACoS, max CPC, ROI) | Jungle Scout, Helium 10 Black Box, FBA calculator | Catalog Items 2022-04-01, Product Pricing, Product Fees | +| **Reports Center**, **Alerts** (+ email), **Settings**, **Demo mode** | | | + +--- + +## Install + +1. Upload the plugin zip in **Plugins → Add New → Upload**, then activate. + Requirements: WordPress 6.4+, PHP 8.1+ with `openssl`, `curl`, `json`, `mbstring`. +2. Open **Amazon Suite** in wp-admin. Before you connect anything, you can click + **Load demo account** to explore every screen with a realistic 90-day + sample account for the 19 SKUs. Demo rows are flagged and removed exactly by + **Clear demo data**; real data is never touched. + +### Hostinger / shared hosting: use a real cron + +WP-Cron only runs when someone visits the site. In hPanel → **Advanced → Cron +Jobs**, add (every 5 minutes): + +``` +wget -q -O - https://sevengum.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1 +``` + +and add `define( 'DISABLE_WP_CRON', true );` to `wp-config.php`. All jobs are +batched to fit shared-hosting time limits (report polling, item fetches and +monitor checks are capped per tick and resume on the next one). + +--- + +## Connect Amazon (SP-API) + +1. **Seller Central → Apps and Services → Develop Apps** (or the Solution + Provider Portal) → register as a *private developer* and create an app client. +2. Request these **roles** so every suite module works: + - Product Listing (listings, catalog) + - Pricing (Buy Box / offers, competitive pricing, fee estimates) + - Inventory and Order Tracking (orders, FBA inventory) + - Amazon Fulfillment (FBA reports, inbound, MCF) + - Finance and Accounting (financial events) + - Buyer Solicitation (review requests) + - Brand Analytics (Search Query Performance; needs Brand Registry) + - Selling Partner Insights (seller feedback) +3. **Authorize** the app for your own account to get the **refresh token**. +4. In wp-admin → **Seven Gum → Amazon Connect**, enter the LWA Client ID, Client + Secret and Refresh Token, choose the primary marketplace (US) and the enabled + marketplaces (e.g. US + AE), save, and click **Run connection test**. +5. In **Amazon Suite → Settings**, enter your **Seller ID (Merchant Token)**: + Seller Central → Settings → Account Info → *Merchant Token*. Listing audits + and hijacker detection need it. +6. Click **Settings → Run now → Backfill history**. This requests the orders and + traffic reports for the backfill window (default 60 days) and resets the + finance cursor. Data then fills in over the next cron ticks. + +## Connect the Amazon Ads API (PPC) + +1. Apply for Amazon Ads API access (advertising.amazon.com → API), using a Login + with Amazon security profile. +2. Generate a refresh token with scope `advertising::campaign_management`. +3. In **Amazon Suite → Settings → Amazon Ads API**: choose the region, enter the + refresh token, plus the client ID and secret if they differ from your SP-API + app. Click **Test & list profiles**, then **Use** on your seller profile + (e.g. US). +4. Turn on **Sync ads daily**. Set a target ACoS. Optionally set a harvest + destination campaign and ad group (your exact-match campaign). +5. The optimizer plans changes daily. Review them in **PPC → Optimizer** and + apply the ones you want. Turn on **Auto-apply** only after you trust the + rules; it pushes bid changes and negatives only. + +--- + +## How the numbers are computed + +- **Profit basis.** Sales, units and promotions are counted on the order date, + in the marketplace's own timezone (US = Pacific), the same as Seller Central. + Amazon fees are the settled amounts for those orders. For orders that are not + settled yet, fees are estimated from each SKU's trailing 90-day fee per unit. + Refunds, storage, subscription, reimbursements and adjustments are counted on + their posted date. Ads spend comes from the Ads API (or from invoices if the + Ads API is not connected). COGS is units × landed cost, with cost added back + for returns restocked as sellable. Monthly expenses are pro-rated per day. + Taxes are pass-through. +- **Restock.** Velocity = 0.5·v7 + 0.3·v30 + 0.2·v90, with out-of-stock days + excluded. Reorder point = velocity × (lead time + safety days). Order quantity + covers lead time plus target cover, rounded up to the case pack and at least + the MOQ. +- **Bid rules.** With orders: move the bid toward (sales ÷ clicks) × target ACoS, + at most ±max-step per run. No orders after N clicks: cut the bid, or pause once + spend reaches 2× break-even CPA. Low impressions: raise by half a step. Bids + are always clamped to [min, max]. + +### Honest limitations + +- **BSR → monthly sales is an estimate (about ±40%).** No tool has real unit + data for other sellers' ASINs. +- **Amazon has no official organic-rank API.** The keyword tracker uses Brand + Analytics share of impressions, clicks and purchases, which is the compliant + equivalent. +- **Search Query Performance requires Brand Registry.** +- **Review requests use Amazon's own template only.** That is the only + ToS-compliant automation. + +--- + +## Security & privacy + +- Every secret (LWA, Ads, AI keys) is encrypted at rest with AES-256-GCM and + never returned to the browser or written to logs. The settings API only + exposes "is set" flags. +- Every admin page and REST route requires `manage_options`. REST uses the + `wp_rest` nonce. +- Buyer PII is never requested (no RDT) and never stored. PII columns in flat + files (buyer email, name, address) are skipped at ingest. Only ship country is + kept. +- **Uninstall leaves zero traces**: all `sg_*` / `sg_suite_*` tables, all + `sg_commerce_*` / `sg_suite_*` options and transients, encrypted secrets, + cron events and Action Scheduler jobs are removed (multisite-safe). + +## Tests + +```bash +php tests/unit.php # 79 pure-logic tests, no WordPress needed +wp eval-file wp-content/plugins/sg-commerce-pro/tests/integration-smoke.php + # seeds demo, hits every REST route, checks secrets & cleanup +``` + +--- + +## راهنمای سریع (فارسی) + +۱. افزونه را نصب و فعال کنید. منوی جدید **Amazon Suite** در پیشخوان وردپرس اضافه می‌شود. +۲. برای دیدن همه‌ی بخش‌ها قبل از اتصال، روی **Load demo account** بزنید. داده‌های نمونه‌ی ۹۰ روزه برای ۱۹ محصول ساخته می‌شود و با **Clear demo data** کامل پاک می‌شود. +۳. اتصال آمازون: در **Seven Gum → Amazon Connect** مقادیر Client ID، Client Secret و Refresh Token را وارد کنید و تست اتصال را بزنید. +۴. در **Amazon Suite → Settings** شناسه‌ی فروشنده (Merchant Token) را وارد کنید و سپس **Backfill history** را بزنید. +۵. برای مدیریت تبلیغات (PPC)، اطلاعات Amazon Ads API را در همان تنظیمات وارد کنید، **Test & list profiles** را بزنید و پروفایل US را انتخاب کنید. +۶. روی هاستینگر حتماً Cron واقعی تنظیم کنید (دستور بالا). + +بخش‌ها: سود خالص واقعی و صورت سود و زیان، سفارش‌ها، برنامه‌ریزی موجودی و سفارش مجدد، مدیریت تبلیغات، ردیابی کلمات کلیدی، بهینه‌سازی لیستینگ با هوش مصنوعی، هشدار هایجکر و Buy Box، درخواست خودکار نظر، پیدا کردن غرامت‌های آمازون، ترافیک و مرجوعی‌ها، تحقیق محصول و ماشین‌حساب سود. diff --git a/sg-commerce-pro/admin/views/ai.php b/sg-commerce-pro/admin/views/ai.php new file mode 100644 index 0000000..30bbb15 --- /dev/null +++ b/sg-commerce-pro/admin/views/ai.php @@ -0,0 +1,121 @@ +get( SettingsRepository::class ); +$prompts = $container->get( PromptRegistry::class ); + +$active_provider = (string) $settings->get( 'ai_provider', 'ollama' ); +$ollama_url = (string) $settings->get( 'ollama_url', 'http://127.0.0.1:11434' ); +$ollama_model = (string) $settings->get( 'ollama_model', 'llama3.1:8b' ); +$temperature = (float) $settings->get( 'ai_temperature', 0.7 ); +$brand_voice = (string) $settings->get( 'ai_brand_voice', 'modern, clean, playful but confident' ); +?> +
+
+

AI

+

+
+ + + +
+
+

+ + + + + + + +

+ +

+ + +

+ + llama3.1:8b · gemma2:9b-instruct-q4_K_M · qwen2.5:7b +

+ + + + +

+ +

+ + + +
+ +
+
+ +
+

+

+ +
+ + +
+ +
+
+ +
+ +

+
    +
  1. 1
    Install: curl -fsSL https://ollama.com/install.sh | sh
  2. +
  3. 2
    Pull a model: ollama pull llama3.1:8b
  4. +
  5. 3
    Verify: curl http://127.0.0.1:11434/api/tags
  6. +
  7. 4
  8. +
+
+
+ +
+

+

+ +

+
brand_system() ); ?>
+ +

+
describe_product( array(
+	'sku' => '{sku}', 'asin' => '{asin}', 'product_name' => '{name}', 'market' => '{market}'
+) ) ); ?>
+ +

+
translate( '{english_text}', '{target_language}' ) ); ?>
+
+
diff --git a/sg-commerce-pro/admin/views/amazon.php b/sg-commerce-pro/admin/views/amazon.php new file mode 100644 index 0000000..331e3df --- /dev/null +++ b/sg-commerce-pro/admin/views/amazon.php @@ -0,0 +1,140 @@ +get( SettingsRepository::class ); +$marketplaces = $container->get( Marketplaces::class ); +$client = $container->get( AmazonClient::class ); + +$markets = $marketplaces->all(); +$enabled_codes = $marketplaces->enabled_codes(); +$primary = $marketplaces->primary(); +$has_creds = $client->has_credentials(); +?> +
+
+

Amazon Connect

+

+
+ + + +
+
+

+

+ + + + + + + + + + + + + + +
+ +
+ $m ) : ?> + + +
+
+ +
+ +
+
+ +
+

+

+ + +

+ +
+
Client ID saved
+
Client Secret encrypted
+
Refresh Token encrypted
+
+ +
+ + +
+ +
+
+ +
+ +

+

+ 'Client Secret', 'amazon_refresh_token' => 'Refresh Token', 'amazon_seller_id' => 'Seller ID' ) as $secret_key => $label ) : ?> +
+ + + + + +
+ + + +
+ +

+
    +
  1. 1
  2. +
  3. 2
  4. +
  5. 3
  6. +
  7. 4
  8. +
+
+
+
diff --git a/sg-commerce-pro/admin/views/analytics.php b/sg-commerce-pro/admin/views/analytics.php new file mode 100644 index 0000000..6cc8f45 --- /dev/null +++ b/sg-commerce-pro/admin/views/analytics.php @@ -0,0 +1,165 @@ +get( ProductRepository::class ); +$aggregator = $container->get( SalesAggregator::class ); + +$kpis = $aggregator->headline_kpis(); +$by_mkt = $products->counts_by_market(); +$trend = $aggregator->buybox_trend( 14 ); +$at_risk = $aggregator->at_risk( 20 ); + +// Build chart data. +$trend_labels = array(); +$trend_won = array(); +$trend_lost = array(); +foreach ( $trend as $row ) { + $trend_labels[] = (string) $row['day']; + $trend_won[] = (int) $row['won']; + $trend_lost[] = (int) $row['lost']; +} +?> +
+
+

+

+
+ + + +
+
+

+

%

+

+
+
+

+

%

+

+
+
+

+

+

+
+
+

+

+

+
+
+ +
+
+

+ +

+ + + + +
+ +
+

+ +

+ + + + + $count ) : ?> + + + + + + + +
+ +
+
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + + + +
+ ' . $qty . '' : (string) $qty; + ?> + + Won' + : 'Lost'; ?> +
+ +
+
diff --git a/sg-commerce-pro/admin/views/api-keys.php b/sg-commerce-pro/admin/views/api-keys.php new file mode 100644 index 0000000..7ddc69d --- /dev/null +++ b/sg-commerce-pro/admin/views/api-keys.php @@ -0,0 +1,117 @@ +get( SettingsRepository::class ); +$keys = (array) $settings->get( 'api_keys', array() ); + +$new_key = get_transient( 'sg_new_api_key_' . get_current_user_id() ); +if ( $new_key ) { + delete_transient( 'sg_new_api_key_' . get_current_user_id() ); +} +?> +
+
+

Headless API Keys

+

+
+ + + + +
+

✓

+

+
+

+ + Authorization: Bearer +

+
+ + +
+

+ + + + + + +
+ +
+
+ +
+

()

+ +

+ + + + + + + + + + $k ) : if ( ! is_array( $k ) ) continue; ?> + + + + + + + + +
+
+ + + + +
+
+ +
+ +
+

+ +

+
+ +

+ + +

+
curl '' \
+  -H 'Authorization: Bearer sgc_YOUR_KEY_HERE'
+ +

+ +

+
+
diff --git a/sg-commerce-pro/admin/views/automation.php b/sg-commerce-pro/admin/views/automation.php new file mode 100644 index 0000000..68d3947 --- /dev/null +++ b/sg-commerce-pro/admin/views/automation.php @@ -0,0 +1,104 @@ +get_results( "SELECT * FROM {$wpdb->prefix}sg_rules ORDER BY id ASC", ARRAY_A ); +$alerts = AlertDispatcher::recent( 50 ); +$unread = AlertDispatcher::unread_count(); +?> +
+
+

+

+
+ + + +
+

()

+ +

+ + + + + + + + + + + + + + + + + + + + + + +
+ Active' + : 'Off'; ?> + +
+ + + + +
+
+ +
+ +
+

+ + 0 ) : ?> + + +

+ + 0 ) : ?> +
+ + + +
+ + + +

+ + + +
+
diff --git a/sg-commerce-pro/admin/views/catalog-lookup.php b/sg-commerce-pro/admin/views/catalog-lookup.php new file mode 100644 index 0000000..774aec1 --- /dev/null +++ b/sg-commerce-pro/admin/views/catalog-lookup.php @@ -0,0 +1,174 @@ +get( Marketplaces::class ); +$lookup = $container->get( CatalogLookup::class ); + +global $wpdb; +$mapped = $wpdb->get_results( + "SELECT * FROM {$wpdb->prefix}sg_product_ean ORDER BY sku ASC, market ASC", + ARRAY_A +); + +$search_ean = isset( $_POST['ean'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['ean'] ) ) : ''; +$search_market = isset( $_POST['market'] ) ? strtoupper( sanitize_text_field( wp_unslash( (string) $_POST['market'] ) ) ) : 'US'; +$search_results = null; +$search_error = ''; + +if ( '' !== $search_ean && isset( $_POST['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( (string) $_POST['_wpnonce'] ) ), 'sg_ean_lookup' ) ) { + try { + if ( ! CatalogLookup::validate_ean13( $search_ean ) ) { + $search_error = __( 'Invalid EAN-13 — checksum digit does not match.', 'sg-commerce' ); + } else { + $preflight = $lookup->preflight( $search_ean, $search_market ); + $search_results = $preflight; + } + } catch ( \Throwable $e ) { + $search_error = $e->getMessage(); + } +} +?> +
+
+

Catalog Lookup

+

+
+ + + +
+

+ + +
+ + + +
+ + +

+ ✓ EAN checksum valid. +

+ +

+ ✗ EAN checksum invalid. +

+ +
+ + +
+

+

+
+ +
+

+ + +
+

+ ⚠
+ +

+
+ +
+ ✓ +

+ +

+
+ +
+ ⚠ +

+
+ + + +
+
+
+ +
+ +
+
+
+ + +
+
+
+ +
+ + +
+

()

+ +
+

+
+ + + + + + + + + + + + + 'sg-chip-ok', 'absent' => 'sg-chip-warn', 'ambiguous' => 'sg-chip-warn', + default => 'sg-chip-muted', + }; + $listing_chip = match ( (string) $m['listing_status'] ) { + 'published' => 'sg-chip-ok', 'draft' => 'sg-chip-muted', + 'error' => 'sg-chip-err', default => 'sg-chip-muted', + }; + ?> + + + + + + + + + + + +
SKUEAN-13ASIN
+ +
+ +
+
diff --git a/sg-commerce-pro/admin/views/comparisons.php b/sg-commerce-pro/admin/views/comparisons.php new file mode 100644 index 0000000..c6206db --- /dev/null +++ b/sg-commerce-pro/admin/views/comparisons.php @@ -0,0 +1,125 @@ +get( Marketplaces::class ); + +global $wpdb; +$sets = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}sg_comparisons ORDER BY updated_at DESC", ARRAY_A ); +?> +
+
+

+

+
+ + + +
+

+ + + + +
+ + + + +
+ + +
+ +
+
+ +
+

()

+ + +
+

[sg_compare slug="..."].

+
+ + + + + + + + + + + + + + + + + + + + + + +
' . esc_html( $set['market'] ) . '' : 'auto'; ?> + +
+ + + + +
+
+ +
+ +
+

+

+
[sg_compare skus="SG-COOKIE-01,SG-MINT-01,SG-CINN-01"]
+[sg_compare skus="..." market="DE" features="image,name,price,stock,cta"]
+
+
+ + diff --git a/sg-commerce-pro/admin/views/dashboard.php b/sg-commerce-pro/admin/views/dashboard.php new file mode 100644 index 0000000..a74988b --- /dev/null +++ b/sg-commerce-pro/admin/views/dashboard.php @@ -0,0 +1,186 @@ +get( AmazonClient::class ); +$marketplaces = $container->get( Marketplaces::class ); +$products = $container->get( ProductRepository::class ); +$kpis = $container->get( SalesAggregator::class )->headline_kpis(); + +$has_amazon = $amazon->has_credentials(); +$total = (int) $kpis['products_total']; + +global $wpdb; +$described = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT product_id) FROM {$wpdb->prefix}sg_descriptions WHERE language='en'" ); + +$step1 = $has_amazon; +$step2 = $total > 0; +$step3 = $described > 0; +$all_done = $step1 && $step2 && $step3; + +$last_sync = $products->last_sync_at(); +$last_sync_human = $last_sync ? human_time_diff( strtotime( (string) $last_sync ), time() ) . ' ago' : 'never'; + +$at_risk = $container->get( SalesAggregator::class )->at_risk( 5 ); +$recent_alerts = AlertDispatcher::recent( 5 ); +?> +
+
+
+

Seven Gum Commerce

+

Enterprise Amazon SP-API + AI commerce engine. Inventory, pricing, copywriting, repricing & analytics — all in one place.

+
+
+ v + enabled_codes() ) ); ?> markets +
+
+ + + + +
+

+
    +
  1. + +
    +

    +

    + + + + + + +
    +
  2. + +
  3. + +
    +

    +

    + + + + + + + + +
    +
  4. + +
  5. + +
    +

    +

    + + + + + + + +
    +
  6. +
+
+ + + +
+
+

+

+

+
+
+

+

+

+
+
+

+

+

+
+
+

+

+

+
+
+ + +
+
+

+

+ +

+ + + + + + + + + + + + + +
SKUMarketStockBuy Box
+ + + + + + + + + + + +
+ +
+ +
+

+

+ +

+ +
    + +
  • + + · ago + +

    + +
  • + +
+ + +
+
+
diff --git a/sg-commerce-pro/admin/views/fulfillment.php b/sg-commerce-pro/admin/views/fulfillment.php new file mode 100644 index 0000000..5d9214c --- /dev/null +++ b/sg-commerce-pro/admin/views/fulfillment.php @@ -0,0 +1,215 @@ +get( SettingsRepository::class ); +$marketplaces = $container->get( Marketplaces::class ); +$repo = $container->get( MCFOrderRepository::class ); +$orders = $repo->recent( 50 ); +$counts = $repo->count_by_status(); + +$mcf_auto_wc = (bool) $settings->get( 'mcf_auto_wc', false ); +$default_market = (string) $settings->get( 'mcf_default_market', 'US' ); +$default_speed = (string) $settings->get( 'mcf_default_speed', 'Standard' ); +$wc_active = class_exists( 'WooCommerce' ); +?> +
+
+

Multi-Channel Fulfillment

+

+
+ + + +
+
+

+

+

+
+
+

+

+

+
+
+

+

+

+
+
+

+

+

+
+
+ +
+
+

+ + + + + +

+ +

+ + + + + +
+ +
+
+ +
+

+ +
    +
  1. 1
  2. +
  3. 2
  4. +
  5. 3
  6. +
  7. 4
  8. +
+ +
+

+

+
do_action('sg_commerce_fulfill', [
+  'displayable_order_id' => 'WEB-12345',
+  'market' => 'US',
+  'customer' => ['name' => 'Jane', 'email' => '...', 'phone' => '...'],
+  'address' => [
+    'line1' => '123 Main St',
+    'city' => 'Austin',
+    'state_region' => 'TX',
+    'postal_code' => '78701',
+    'country_code' => 'US',
+  ],
+  'items' => [['sku' => 'SG-COOKIE-01', 'quantity' => 2]],
+  'shipping_speed' => 'Standard',
+]);
+ +
+ + +
+ +
+
+
+
+ +
+

+ + +
+

+
+ +
+ + + + + + + + + + + + + 'sg-chip-ok', + 'shipped' => 'sg-chip-ok', + 'processing'=> 'sg-chip-warn', + 'submitted' => 'sg-chip-warn', + 'failed' => 'sg-chip-err', + 'cancelled' => 'sg-chip-err', + default => 'sg-chip-muted', + }; + ?> + + + + + + + + + + + + +
+
+
+
+ +
+
+ + +
+ +
+ + + +
+ + + — + +
+ +
+ + + + +
+ +
+
+ +
+
diff --git a/sg-commerce-pro/admin/views/inbound-plans.php b/sg-commerce-pro/admin/views/inbound-plans.php new file mode 100644 index 0000000..90df634 --- /dev/null +++ b/sg-commerce-pro/admin/views/inbound-plans.php @@ -0,0 +1,240 @@ +get( Marketplaces::class ); +$box_service = $container->get( BoxContentService::class ); + +global $wpdb; +$plans = $wpdb->get_results( + "SELECT * FROM {$wpdb->prefix}sg_inbound_plans ORDER BY updated_at DESC LIMIT 50", + ARRAY_A +); + +$templates = $box_service->get_templates(); +?> +
+
+

Inbound Shipments

+

+
+ + + +
+

✓

+

+ +

+
+ +
+
+

+ +
+
+
sg-master-24pack
+ +
+
+ +
+
+ +
+
kg
+ +
+
kg
+ +
+
+ +
+
+
+ +

+ +

+ +

+
    +
  • + + ✓ +
  • +
  • + + ✓ +
  • +
  • + ✓ +
  • +
+ +
+ +
+

+
    +
  1. 1
    Create plan (items + source address)
  2. +
  3. 2
    Poll plan status until READY
  4. +
  5. 3
    Generate packing options
  6. +
  7. 4
    Confirm packing option + push box content
  8. +
  9. 5
    Generate placement options (warehouse choice)
  10. +
  11. 6
    Confirm placement — operator review recommended
  12. +
  13. 7
    Generate transport options (carrier, cost)
  14. +
  15. 8
    Confirm transport option
  16. +
  17. 9
    Confirm plan + print labels
  18. +
+

+ +

+
+
+ +
+

+
+ + + +
+ + +
+ +

+
+ + +
+ +
+ + + + +
+ +

+
+ + + + +
+ +
+ +
+
+
+ +
+

()

+ +

+ + + + + + + + + + + + + 'sg-chip-ok', + 'draft' => 'sg-chip-muted', + 'error' => 'sg-chip-err', + default => 'sg-chip-warn', + }; + ?> + + + + + + + + + + + +
+ +
+
+ + diff --git a/sg-commerce-pro/admin/views/injection.php b/sg-commerce-pro/admin/views/injection.php new file mode 100644 index 0000000..3800af8 --- /dev/null +++ b/sg-commerce-pro/admin/views/injection.php @@ -0,0 +1,163 @@ +get( Marketplaces::class ); +$settings = $container->get( SettingsRepository::class ); +$enabled = (bool) $settings->get( 'injection_enabled', true ); + +global $wpdb; +$rules = $wpdb->get_results( + "SELECT * FROM {$wpdb->prefix}sg_injection_rules ORDER BY priority ASC, id ASC", + ARRAY_A +); +?> +
+
+

Auto-Injection

+

+
+ + + +
+ + + +

+ +

+ +

+
+ +
+
+ +
+

+ + + + +
+ + + + +
+ +
+ + + +
+ +
+ +
+
+ +
+

()

+ + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ On' + : 'Off'; ?> + + + + regex + + ' . esc_html( $rule['market'] ) . '' : 'auto'; ?> +
+ + + + +
+
+ + + + +
+
+ +
+ +
+

+

+
+

+
+
diff --git a/sg-commerce-pro/admin/views/logs.php b/sg-commerce-pro/admin/views/logs.php new file mode 100644 index 0000000..da480c2 --- /dev/null +++ b/sg-commerce-pro/admin/views/logs.php @@ -0,0 +1,65 @@ +get( Logger::class ); +$lines = $logger->tail( 200 ); +$log_dir = $logger->get_log_dir(); +?> +
+
+

+

+
+ + + +
+ +

+ +

+ + + +

+ + + + + + + + + + 'sg-chip-err', + 'WARNING' => 'sg-chip-warn', + 'INFO', 'NOTICE' => 'sg-chip-ok', + default => 'sg-chip-muted', + }; + ?> + + + + + + + + +
+ +
+ +
+
diff --git a/sg-commerce-pro/admin/views/pii.php b/sg-commerce-pro/admin/views/pii.php new file mode 100644 index 0000000..20f1394 --- /dev/null +++ b/sg-commerce-pro/admin/views/pii.php @@ -0,0 +1,151 @@ +get( SettingsRepository::class ); +$pii = $container->get( PIIManager::class ); + +$retention = (int) $settings->get( 'pii_retention_days', 30 ); +$pending = $pii->count_pending_anonymization(); + +global $wpdb; +$total_orders = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}sg_mcf_orders" ); +$anonymized = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}sg_mcf_orders WHERE customer_name = %s", + '[anonymized]' +) ); + +$recent_audit = $wpdb->get_results( + "SELECT * FROM {$wpdb->prefix}sg_audit WHERE action LIKE 'pii_%' ORDER BY created_at DESC LIMIT 10", + ARRAY_A +); +?> +
+
+

PII Compliance

+

+
+ + + +
+
+

+

+

+
+
+

+

+

+
+
+

+

+

+
+
+

+

+

+
+
+ +
+
+

+ + + + + +

+ +

+ +

+
    +
  • ✗
  • +
  • ✗
  • +
  • ✗
  • +
  • ✗
  • +
  • ✓
  • +
  • ✓
  • +
+ +
+ +
+
+ +
+

+ +

+

+ +

+
+ + + +
+ +
+ +

+

+ +

+
+ + + +
+ +
+
+
+
+ +
+

+ +

+ + + + + + + + + + + + + + + + +
+ +
+
diff --git a/sg-commerce-pro/admin/views/products.php b/sg-commerce-pro/admin/views/products.php new file mode 100644 index 0000000..54494dd --- /dev/null +++ b/sg-commerce-pro/admin/views/products.php @@ -0,0 +1,258 @@ +get( ProductRepository::class ); +$marketplaces = $container->get( Marketplaces::class ); +$copywriter = $container->get( Copywriter::class ); +$has_amazon = $container->get( AmazonClient::class )->has_credentials(); + +$markets = $marketplaces->all(); +$enabled = $marketplaces->enabled_codes(); + +$search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['s'] ) ) : ''; +$market_f = isset( $_GET['market'] ) ? strtoupper( sanitize_text_field( wp_unslash( (string) $_GET['market'] ) ) ) : ''; +$in_stock = ! empty( $_GET['in_stock'] ); +$page_num = max( 1, (int) ( $_GET['paged'] ?? 1 ) ); +$per_page = 25; + +$result = $products->paginate( array( + 'search' => $search, + 'market' => $market_f, + 'in_stock' => $in_stock, + 'page' => $page_num, + 'per_page' => $per_page, + 'orderby' => 'updated_at', + 'order' => 'DESC', +) ); + +$base_url = admin_url( 'admin.php?page=sg-commerce-products' ); +?> +
+
+

+

+
+ + + + +
+ + +
+ +

+ + + + + +

+
+ + +
+ + +
+ + + + + + + + + + + +
+ + +
+ +
+

+
+ +
+ + + + + + + + + + + + + + + + descriptions_for( (int) $r['id'] ); + $has_en = isset( $descs['en'] ); + ?> + + + + + + + + + + + + + + + + +
+ 0 && $qty > 5 ) : ?> + + 0 ) : ?> + + + 0 + + + + +
+ + + + + +
+
+ $d ) : ?> + + + — + +
+ + + + +
+
+ + + + + +
+
+ + + + +
+
+ $d ) : ?> +
:
+ +
+
+ + + 1 ) : ?> +
+ $search, + 'market' => $market_f, + 'in_stock' => $in_stock ? '1' : null, + ) ); + $args = array( + 'base' => add_query_arg( array_merge( $query_args, array( 'paged' => '%#%' ) ), $base_url ), + 'format' => '', + 'current' => $page_num, + 'total' => $result['pages'], + 'prev_text' => '←', + 'next_text' => '→', + ); + echo paginate_links( $args ); // phpcs:ignore + ?> +
+ + +
+ + +
+

+

+ + +
+ + + + +
+ + +
+
+
diff --git a/sg-commerce-pro/admin/views/repricer.php b/sg-commerce-pro/admin/views/repricer.php new file mode 100644 index 0000000..c213f82 --- /dev/null +++ b/sg-commerce-pro/admin/views/repricer.php @@ -0,0 +1,88 @@ +get( SettingsRepository::class ); + +$enabled = (bool) $settings->get( 'repricer_enabled', false ); +$strategy = (string) $settings->get( 'repricer_strategy', 'match_buybox' ); +$dry_run = (bool) $settings->get( 'repricer_dry_run', true ); +$cents_below = (float) $settings->get( 'repricer_cents_below_buybox', 0.01 ); +$min_margin = (float) $settings->get( 'repricer_min_margin_pct', 15.0 ); +?> +
+
+

+

+
+ + + +
+
+

+ + + + + + + + + + + + + + +
+ +
+
+ +
+

+

+ +
+ + +
+ +
+
+ +
+

+
    +
  1. +
  2. +
  3. +
  4. +
+
+
+
diff --git a/sg-commerce-pro/admin/views/settings.php b/sg-commerce-pro/admin/views/settings.php new file mode 100644 index 0000000..ed9b42f --- /dev/null +++ b/sg-commerce-pro/admin/views/settings.php @@ -0,0 +1,154 @@ +get( SettingsRepository::class ); + +$log_level = (string) $settings->get( 'log_level', 'info' ); +$log_retention = (int) $settings->get( 'log_retention_days', 30 ); +$pages_per_run = (int) $settings->get( 'sync_pages_per_run', 5 ); +$referral_pct = (float) $settings->get( 'referral_fee_pct', 15.0 ); +$fba_fee = (float) $settings->get( 'fba_pick_pack_fee', 3.00 ); +?> +
+
+

+

+
+ + + +
+ + + +

+
+ + +
+ +

+ +

+ +

+ +

+

+
+ + +
+ +
+ +
+
+ + +
+

+

+ + +

+ + + +

+ + + + +
+ +
+
+ + +
+

+

+ +

+ + get( 'sqs_enabled', false ); + $sqs_region = (string) $settings->get( 'sqs_region', 'us-east-1' ); + $sqs_queue = (string) $settings->get( 'sqs_queue_url', '' ); + $sqs_ak = (string) $settings->get( 'sqs_access_key', '' ); + $sqs_has_sk = '' !== (string) $settings->get( 'sqs_secret_key_enc', '' ); + ?> + + + + + + +
+ + +
+ +
+ + +
+ +

+ +

+ +
+ +
+
+ +
+

+

+ + +
+ +
+
+
diff --git a/sg-commerce-pro/admin/views/settlements.php b/sg-commerce-pro/admin/views/settlements.php new file mode 100644 index 0000000..d6c139a --- /dev/null +++ b/sg-commerce-pro/admin/views/settlements.php @@ -0,0 +1,193 @@ +prefix . 'sg_settlements'; + +$totals_by_type = $wpdb->get_results( + "SELECT event_type, COUNT(*) cnt, SUM(amount) total, currency + FROM {$table} + GROUP BY event_type, currency + ORDER BY ABS(SUM(amount)) DESC", + ARRAY_A +); + +$net = (float) $wpdb->get_var( "SELECT COALESCE(SUM(amount), 0) FROM {$table}" ); +$event_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); + +$top_skus = $wpdb->get_results( + "SELECT sku, + SUM(CASE WHEN event_type = 'sale' THEN amount ELSE 0 END) revenue, + SUM(CASE WHEN event_type IN ('fba_fee','fba_storage','referral_fee','advertising') THEN amount ELSE 0 END) fees, + SUM(amount) net + FROM {$table} + WHERE sku != '' + GROUP BY sku + ORDER BY revenue DESC + LIMIT 20", + ARRAY_A +); + +$recent = $wpdb->get_results( + "SELECT * FROM {$table} ORDER BY event_date DESC, id DESC LIMIT 50", + ARRAY_A +); + +$settlement_ids = $wpdb->get_col( "SELECT DISTINCT settlement_id FROM {$table} WHERE settlement_id != '' ORDER BY imported_at DESC LIMIT 10" ); +?> +
+
+

+

+
+ + + +
+
+

+

+
+
+

+

+ +

+

+
+
+

+

+
+
+

+

+
+
+ +
+

+ + + + +

+

+ + GET_V2_SETTLEMENT_REPORT_DATA_FLAT_FILE_V2 +

+ +
+ + +
+ +
+ +
+
+ +
+
+

+ +

+ + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
diff --git a/sg-commerce-pro/admin/views/suite.php b/sg-commerce-pro/admin/views/suite.php new file mode 100644 index 0000000..68736e5 --- /dev/null +++ b/sg-commerce-pro/admin/views/suite.php @@ -0,0 +1,25 @@ + +
+

+
+
+ + +
+
+ +
diff --git a/sg-commerce-pro/admin/views/theme-bridge.php b/sg-commerce-pro/admin/views/theme-bridge.php new file mode 100644 index 0000000..1c71d7d --- /dev/null +++ b/sg-commerce-pro/admin/views/theme-bridge.php @@ -0,0 +1,210 @@ +get( Marketplaces::class ); +$products = $container->get( ProductRepository::class ); + +$theme_slug = get_stylesheet(); +$theme_active = ( 'sevengum' === $theme_slug || 'sevengum' === get_template() ); + +$theme_posts = get_posts( array( + 'post_type' => 'products', + 'post_status' => array( 'publish', 'draft' ), + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', +) ); + +$mapped = 0; +$unmapped = 0; +$rows = array(); +foreach ( (array) $theme_posts as $p ) { + $sku = (string) get_post_meta( $p->ID, ThemeBridge::META_SKU, true ); + $market = (string) get_post_meta( $p->ID, ThemeBridge::META_MARKET, true ) ?: $marketplaces->primary(); + $last = (string) get_post_meta( $p->ID, ThemeBridge::META_LAST_SYNC, true ); + $stock = (int) get_post_meta( $p->ID, ThemeBridge::META_STOCK_CACHE, true ); + $price = (string) get_post_meta( $p->ID, '_sevengum_price', true ); + $theme_mod = (string) get_theme_mod( 'sg_amazon_' . $p->post_name, '' ); + $amazon_row = '' !== $sku ? $products->find_by_sku( $market, $sku ) : null; + + if ( '' !== $sku ) $mapped++; else $unmapped++; + + $rows[] = array( + 'post' => $p, + 'sku' => $sku, + 'market' => $market, + 'last' => $last, + 'stock' => $stock, + 'price' => $price, + 'theme_mod' => $theme_mod, + 'row' => $amazon_row, + ); +} +?> +
+
+

Theme Bridge

+

+
+ + + + +
+

+

+ ' . esc_html( $theme_slug ) . '' + ); ?> +

+
+ + +
+
+

+

+

+
+
+

+

+

+
+
+

+

+

+
+
+

+

+

+ ✓ ' . esc_html__( 'compatible', 'sg-commerce' ) . '' + : '⚠ ' . esc_html__( 'incompatible', 'sg-commerce' ) . ''; ?> +

+
+
+ +
+
+

+
    +
  1. +
  2. +
  3. +
      +
    • Buy Box price → _sevengum_price post meta
    • +
    • Amazon URL → theme_mod['sg_amazon_{slug}']
    • +
    • Stock count → _sg_amazon_stock post meta
    • +
    +
  4. +
  5. +
+
+ +
+

+

+
[sg_theme_price slug="cookie"]
+[sg_theme_buy slug="cookie" label="Buy Cookie Gum"]
+

+ +

+ +
+ +
+ + +
+ +
+
+
+
+ +
+

()

+ + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ post_title ); ?> + post_status ); ?> + post_name ); ?> + + + + + +
⚠ + + +
+ + — + 5 ) : ?> + + 0 ) : ?> + + + 0 + + + —'; ?> + + + ✓ + + — + + + +
+
+ +
+
diff --git a/sg-commerce-pro/admin/views/widgets.php b/sg-commerce-pro/admin/views/widgets.php new file mode 100644 index 0000000..977ebb2 --- /dev/null +++ b/sg-commerce-pro/admin/views/widgets.php @@ -0,0 +1,101 @@ +get( ProductRepository::class ); +$result = $products->paginate( array( 'page' => 1, 'per_page' => 200 ) ); +?> +
+
+

+

+
+ + + +
+

+

+
[sg_product sku="SG-COOKIE-01" market="US"]
+ +

+ +
+ +
+

()

+ + +
+

+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ +
+

+

+
GET 
+
+
+ + diff --git a/sg-commerce-pro/assets/css/admin.css b/sg-commerce-pro/assets/css/admin.css new file mode 100644 index 0000000..5a7f4ac --- /dev/null +++ b/sg-commerce-pro/assets/css/admin.css @@ -0,0 +1,568 @@ +/* + * Seven Gum Commerce Pro — admin styles. + * Light theme, high-contrast, no invisible headings. + */ + +:root { + --sg-bg: #f3f4f6; + --sg-surface: #ffffff; + --sg-ink: #111827; + --sg-ink-soft: #374151; + --sg-ink-muted: #6b7280; + --sg-ink-faint: #9ca3af; + --sg-line: #e5e7eb; + --sg-line-strong: #d1d5db; + --sg-accent: #0a0a0a; + --sg-accent-hover: #262626; + --sg-mint: #059669; + --sg-mint-bg: #d1fae5; + --sg-amber: #b45309; + --sg-amber-bg: #fef3c7; + --sg-danger: #b91c1c; + --sg-danger-bg: #fee2e2; + --sg-radius: 12px; +} + +.sg-admin { + max-width: 1320px; + margin: 20px 20px 40px 0; + color: var(--sg-ink); + font-size: 14px; + line-height: 1.55; +} +.sg-admin * { box-sizing: border-box; } + +/* HERO */ +.sg-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + padding: 28px 0 24px; + border-bottom: 1px solid var(--sg-line); + margin-bottom: 24px; + flex-wrap: wrap; +} +.sg-hero h1 { + margin: 0 0 6px; + font-size: 32px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--sg-ink); + line-height: 1.1; +} +.sg-hero h1 span { + font-weight: 400; + color: var(--sg-ink-muted); +} +.sg-hero-meta { display: flex; gap: 8px; } +.sg-subtitle { + margin: 0; + color: var(--sg-ink-muted); + font-size: 14.5px; + max-width: 720px; +} + +/* CARD */ +.sg-card { + background: var(--sg-surface); + border: 1px solid var(--sg-line); + border-radius: var(--sg-radius); + padding: 24px 28px; + margin-bottom: 20px; + box-shadow: 0 1px 2px rgba(0,0,0,0.03); +} +.sg-card h2 { + margin: 0 0 6px; + font-size: 20px; + font-weight: 600; + color: var(--sg-ink); + letter-spacing: -0.01em; +} +.sg-card h3 { + margin: 16px 0 8px; + font-size: 15px; + font-weight: 600; + color: var(--sg-ink); +} +.sg-card p { margin: 0 0 14px; color: var(--sg-ink-soft); } +.sg-card hr { + border: 0; + border-top: 1px solid var(--sg-line); + margin: 22px 0; +} +.sg-muted { color: var(--sg-ink-muted); } + +/* GRIDS */ +.sg-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; +} +@media (max-width: 960px) { .sg-grid-2 { grid-template-columns: 1fr; } } + +.sg-grid-4 { + display: grid; + grid-template-columns: 1fr 2fr 2fr 2fr; + gap: 14px; + align-items: end; + margin-bottom: 14px; +} +@media (max-width: 800px) { .sg-grid-4 { grid-template-columns: 1fr 1fr; } } + +/* FORMS */ +.sg-admin label { + display: block; + margin: 14px 0 0; + font-size: 13px; + font-weight: 600; + color: var(--sg-ink-soft); +} +.sg-admin label > input, +.sg-admin label > select, +.sg-admin label > textarea { + display: block; + width: 100%; + margin-top: 6px; + padding: 10px 12px; + font-size: 14px; + font-family: inherit; + color: var(--sg-ink); + background: #fff; + border: 1px solid var(--sg-line-strong); + border-radius: 8px; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.03); + transition: border-color 0.15s, box-shadow 0.15s; + font-weight: 400; +} +.sg-admin label > input:focus, +.sg-admin label > select:focus, +.sg-admin label > textarea:focus { + outline: none; + border-color: var(--sg-accent); + box-shadow: 0 0 0 3px rgba(10,10,10,0.1); +} +.sg-admin label.sg-inline { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; +} +.sg-admin label.sg-inline > select, +.sg-admin label.sg-inline > input { + width: auto; margin: 0; padding: 6px 10px; +} +.sg-admin label.sg-inline > input[type="checkbox"] { padding: 0; } + +.sg-fieldset { border: 0; padding: 0; margin-top: 20px; } +.sg-fieldset legend { + font-size: 13px; + font-weight: 600; + color: var(--sg-ink-soft); + margin-bottom: 10px; + padding: 0; +} +.sg-market-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); + gap: 8px; +} +.sg-check { + display: flex !important; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--sg-surface); + border: 1px solid var(--sg-line); + border-radius: 8px; + margin: 0 !important; + cursor: pointer; + font-weight: 400 !important; + transition: border-color 0.15s; +} +.sg-check:hover { border-color: var(--sg-line-strong); } +.sg-check input[type="checkbox"] { margin: 0; } +.sg-check span { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-weight: 600; + font-size: 12px; + color: var(--sg-ink); +} +.sg-check em { + font-style: normal; + color: var(--sg-ink-muted); + font-size: 11.5px; +} + +.sg-actions { + margin-top: 22px; + display: flex; + gap: 10px; + justify-content: flex-end; +} + +/* BUTTONS */ +.sg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 9px 16px; + border: 1px solid var(--sg-line-strong); + background: #fff; + color: var(--sg-ink); + border-radius: 8px; + font-size: 13.5px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + font-family: inherit; +} +.sg-btn:hover { background: #f9fafb; border-color: var(--sg-line-strong); color: var(--sg-ink); } +.sg-btn-primary { + background: var(--sg-accent); + color: #fff; + border-color: var(--sg-accent); +} +.sg-btn-primary:hover { background: var(--sg-accent-hover); color: #fff; border-color: var(--sg-accent-hover); } +.sg-btn-primary:focus { box-shadow: 0 0 0 3px rgba(10,10,10,0.2); } +.sg-btn-sm { padding: 5px 10px; font-size: 12px; } +.sg-btn[disabled], .sg-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.sg-btn-icon { + background: transparent; + border: 0; + color: var(--sg-ink-faint); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 4px 8px; + border-radius: 4px; +} +.sg-btn-icon:hover { color: var(--sg-danger); background: var(--sg-danger-bg); } + +.sg-link { + color: var(--sg-ink-soft); + font-size: 12px; + text-decoration: underline; + margin-left: 10px; +} + +.sg-select-sm { + padding: 4px 8px; + font-size: 12px; + border: 1px solid var(--sg-line-strong); + border-radius: 6px; + background: #fff; + color: var(--sg-ink); +} + +/* CHIPS */ +.sg-chip { + display: inline-flex; + align-items: center; + padding: 3px 9px; + background: #f3f4f6; + border: 1px solid var(--sg-line); + border-radius: 999px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 11px; + font-weight: 600; + color: var(--sg-ink-soft); + letter-spacing: 0.02em; + white-space: nowrap; +} +.sg-chip-ok { background: var(--sg-mint-bg); border-color: #a7f3d0; color: var(--sg-mint); } +.sg-chip-warn { background: var(--sg-amber-bg); border-color: #fde68a; color: var(--sg-amber); } +.sg-chip-err { background: var(--sg-danger-bg); border-color: #fecaca; color: var(--sg-danger); } +.sg-chip-muted { background: #f3f4f6; border-color: var(--sg-line); color: var(--sg-ink-muted); } + +.sg-dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + background: var(--sg-ink-faint); + margin-right: 6px; +} +.sg-dot-ok { background: var(--sg-mint); box-shadow: 0 0 0 2px rgba(5,150,105,0.15); } + +/* ONBOARDING */ +.sg-onboarding { + background: #fff; + border: 1px solid var(--sg-line); + border-radius: var(--sg-radius); + padding: 28px; + margin-bottom: 24px; +} +.sg-onboarding > h2 { + margin: 0 0 16px; + font-size: 20px; + font-weight: 600; +} +.sg-steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 12px; +} +.sg-steps li { + display: grid; + grid-template-columns: 44px 1fr; + gap: 16px; + align-items: start; + padding: 18px; + background: #fafafa; + border: 1px solid var(--sg-line); + border-radius: 10px; + transition: border-color 0.2s, background 0.2s; +} +.sg-steps li.active { + border-color: var(--sg-accent); + background: #fff; + box-shadow: 0 0 0 3px rgba(10,10,10,0.04); +} +.sg-steps li.done { opacity: 0.75; } +.sg-steps li.pending { opacity: 0.55; } +.sg-step-num { + width: 36px; height: 36px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: #fff; + border: 1.5px solid var(--sg-line-strong); + color: var(--sg-ink-muted); + font-size: 15px; + font-weight: 700; + line-height: 1; +} +.sg-steps li.active .sg-step-num { border-color: var(--sg-accent); color: var(--sg-accent); } +.sg-steps li.done .sg-step-num { background: var(--sg-mint); border-color: var(--sg-mint); color: #fff; } +.sg-steps li > div h3 { margin: 0 0 4px; font-size: 15px; font-weight: 600; } +.sg-steps li > div p { margin: 0 0 10px; color: var(--sg-ink-muted); font-size: 13px; } +.sg-steps-compact li { padding: 10px 0; background: transparent; border: 0; border-bottom: 1px dashed var(--sg-line); } +.sg-steps-compact li:last-child { border-bottom: 0; } +.sg-steps-compact .sg-step-num { width: 26px; height: 26px; font-size: 12px; } +.sg-steps-compact li > div { padding-top: 2px; } +.sg-steps-compact li > div * { font-size: 13px; } + +/* STATS */ +.sg-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; +} +@media (max-width: 900px) { .sg-stats { grid-template-columns: 1fr 1fr; } } +.sg-stat { + background: var(--sg-surface); + border: 1px solid var(--sg-line); + border-radius: var(--sg-radius); + padding: 20px 24px; +} +.sg-stat-label { + margin: 0 0 6px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--sg-ink-muted); + font-weight: 600; +} +.sg-stat-value { + margin: 0; + font-size: 32px; + font-weight: 700; + line-height: 1.1; + color: var(--sg-ink); + letter-spacing: -0.02em; +} +.sg-stat-value-text { font-size: 16px; font-weight: 500; } +.sg-stat-meta { + margin: 6px 0 0; + font-size: 12px; + color: var(--sg-ink-muted); +} + +/* TABLES */ +.sg-table-wrap { overflow-x: auto; margin: 10px -4px 0; } +.sg-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} +.sg-table th { + text-align: left; + padding: 10px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--sg-ink-muted); + border-bottom: 1px solid var(--sg-line); + background: #fafafa; +} +.sg-table td { + padding: 12px; + border-bottom: 1px solid var(--sg-line); + vertical-align: middle; + color: var(--sg-ink); +} +.sg-table tr:last-child td { border-bottom: 0; } +.sg-table tbody tr:hover td { background: #fafafa; } +.sg-mono { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12.5px; +} +.sg-row-actions { white-space: nowrap; } +.sg-row-actions form { margin-right: 4px; } +.sg-row-actions .sg-select-sm { margin-right: 2px; } +.sg-desc-row td { + background: #fafafa; + font-size: 12.5px; + color: var(--sg-ink-soft); + padding: 10px 20px; +} +.sg-desc-row div { margin-bottom: 4px; } +.sg-desc-row strong { color: var(--sg-ink); font-weight: 600; } + +/* SYNC + FILTER BAR */ +.sg-syncbar, .sg-filterbar { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} +.sg-syncbar > div:first-child { flex: 1; min-width: 200px; } + +/* PAGINATION */ +.sg-pagination { + margin: 16px 0 0; + text-align: center; + display: flex; + justify-content: center; + gap: 4px; +} +.sg-pagination .page-numbers { + display: inline-block; + padding: 6px 12px; + border: 1px solid var(--sg-line); + border-radius: 6px; + font-size: 13px; + color: var(--sg-ink); + text-decoration: none; + background: #fff; +} +.sg-pagination .page-numbers.current { + background: var(--sg-accent); color: #fff; border-color: var(--sg-accent); +} +.sg-pagination .page-numbers:hover { background: #f9fafb; } +.sg-pagination .page-numbers.current:hover { background: var(--sg-accent); } + +/* EMPTY STATE */ +.sg-empty { + padding: 40px 20px; + text-align: center; + border: 1px dashed var(--sg-line-strong); + border-radius: 10px; + color: var(--sg-ink-muted); + background: #fafafa; +} +.sg-empty p { margin: 0; } + +/* CREDENTIALS SUMMARY */ +.sg-creds-summary { + display: grid; + gap: 6px; + padding: 14px 16px; + background: var(--sg-mint-bg); + border: 1px solid #a7f3d0; + border-radius: 8px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12.5px; + color: var(--sg-mint); + margin-bottom: 16px; +} + +/* CODE */ +.sg-code { + display: block; + padding: 14px 16px; + background: #1f2937; + color: #e5e7eb; + border-radius: 8px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12.5px; + line-height: 1.6; + overflow-x: auto; + white-space: pre; + margin: 10px 0 18px; + user-select: all; +} +.sg-code-inline { + display: inline-block; + padding: 6px 10px; + background: #f3f4f6; + color: var(--sg-ink); + border: 1px solid var(--sg-line); + border-radius: 6px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; + width: 100%; + user-select: all; + word-break: break-all; +} + +.sg-attr-list { + margin: 8px 0 0; + padding: 0; + list-style: none; +} +.sg-attr-list li { + padding: 8px 0; + border-bottom: 1px dashed var(--sg-line); + font-size: 13.5px; + color: var(--sg-ink-soft); +} +.sg-attr-list li:last-child { border-bottom: 0; } +.sg-attr-list code { + background: #f3f4f6; + padding: 2px 7px; + border-radius: 4px; + font-size: 12px; + margin-right: 4px; + color: var(--sg-ink); +} + +/* ALERTS FEED */ +.sg-alerts { + list-style: none; + margin: 0; + padding: 0; +} +.sg-alert { + padding: 12px 14px; + border-left: 3px solid var(--sg-line-strong); + background: #fafafa; + border-radius: 6px; + margin-bottom: 8px; +} +.sg-alert--info { border-left-color: var(--sg-mint); } +.sg-alert--warning { border-left-color: var(--sg-amber); background: #fffbeb; } +.sg-alert--error { border-left-color: var(--sg-danger); background: #fef2f2; } +.sg-alert--critical { border-left-color: var(--sg-danger); background: #fef2f2; } +.sg-alert.is-read { opacity: 0.6; } +.sg-alert p { margin: 6px 0 0; font-size: 13px; color: var(--sg-ink-soft); } + +/* HORIZONTAL BAR (for analytics) */ +.sg-bar { + width: 100%; + height: 8px; + background: #f3f4f6; + border-radius: 999px; + overflow: hidden; +} +.sg-bar-fill { + height: 100%; + background: var(--sg-accent); + border-radius: 999px; +} diff --git a/sg-commerce-pro/assets/css/compare.css b/sg-commerce-pro/assets/css/compare.css new file mode 100644 index 0000000..b1609de --- /dev/null +++ b/sg-commerce-pro/assets/css/compare.css @@ -0,0 +1,212 @@ +/* + * Seven Gum Compare — mobile-first comparison table. + * + * Strategy: emit BOTH the desktop and the mobile .sg-compare__cards + * in the HTML. Hide one or the other via media query. Keeps JS out of the + * layout entirely — pure CSS. + * + * Breakpoint: 768px. + */ +.sg-compare { + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: #1a1a1a; + margin: 28px 0; + box-sizing: border-box; +} +.sg-compare *, .sg-compare *::before, .sg-compare *::after { box-sizing: border-box; } + +.sg-compare__title { + font-size: 22px; + font-weight: 700; + margin: 0 0 16px; + color: #0a0a0a; + letter-spacing: -0.01em; +} + +.sg-compare--error { + padding: 14px 18px; + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + border-radius: 8px; + font-size: 14px; +} + +/* By default, show cards, hide table. Desktop media query inverts this. */ +.sg-compare__table-wrap { display: none; } +.sg-compare__cards { display: grid; gap: 16px; } + +/* ---------------- MOBILE CARDS ---------------- */ +.sg-compare__card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 14px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +.sg-compare__card-image { + margin: -4px 0 14px; + text-align: center; +} +.sg-compare__card-image img { + max-width: 140px; + height: auto; + border-radius: 10px; +} +.sg-compare__card-title { + margin: 0 0 12px; + font-size: 18px; + font-weight: 700; + color: #0a0a0a; + line-height: 1.25; +} +.sg-compare__dl { + margin: 0 0 16px; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} +.sg-compare__dl dt { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #6b7280; + margin: 0 0 2px; +} +.sg-compare__dl dd { + margin: 0 0 0; + font-size: 14px; + color: #1a1a1a; + line-height: 1.45; +} +.sg-compare__cta { + display: block; + padding: 12px 16px; + background: #0a0a0a; + color: #fff !important; + text-decoration: none; + text-align: center; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + transition: background 0.2s ease; +} +.sg-compare__cta:hover { background: #262626; color: #fff !important; } + +/* ---------------- DESKTOP TABLE ---------------- */ +@media (min-width: 768px) { + .sg-compare__table-wrap { + display: block; + overflow-x: auto; + border-radius: 14px; + border: 1px solid #e5e7eb; + background: #fff; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); + } + .sg-compare__cards { display: none; } + + .sg-compare__table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + } + .sg-compare__table th, + .sg-compare__table td { + padding: 14px 16px; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid #f3f4f6; + } + .sg-compare__table tr:last-child td, + .sg-compare__table tr:last-child th { border-bottom: 0; } + .sg-compare__table thead th { + background: #fafafa; + border-bottom: 1px solid #e5e7eb; + font-weight: 600; + } + .sg-compare__table tbody th { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #6b7280; + font-weight: 700; + background: #fafafa; + width: 140px; + } + .sg-compare__col-head { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + } + .sg-compare__col-head img { + max-width: 80px; + height: auto; + border-radius: 8px; + } + .sg-compare__col-head strong { + font-size: 14px; + font-weight: 700; + color: #0a0a0a; + line-height: 1.3; + } +} + +/* Shared — works in both layouts */ +.sg-compare__price { + font-size: 16px; + font-weight: 700; + color: #0a0a0a; +} +.sg-compare__price small { + font-size: 11px; + font-weight: 500; + color: #6b7280; + margin-left: 2px; +} +.sg-compare__badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} +.sg-compare__badge--ok { background: rgba(22, 163, 74, 0.12); color: #16a34a; } +.sg-compare__badge--out { background: rgba(220, 38, 38, 0.12); color: #dc2626; } + +.sg-compare__cta-small { + display: inline-block; + padding: 6px 12px; + background: #0a0a0a; + color: #fff !important; + text-decoration: none; + border-radius: 6px; + font-size: 12.5px; + font-weight: 600; +} +.sg-compare__cta-small:hover { background: #262626; color: #fff !important; } + +/* Injected link styles (from ContentInjector) */ +.sg-injected-link, +.sg-injected-tooltip { + color: #0a0a0a; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; + text-decoration-color: rgba(10,10,10,0.35); +} +.sg-injected-link:hover, +.sg-injected-tooltip:hover { + text-decoration-style: solid; + text-decoration-color: #0a0a0a; +} +.sg-injected-widget { + margin: 12px 0; +} diff --git a/sg-commerce-pro/assets/css/suite.css b/sg-commerce-pro/assets/css/suite.css new file mode 100644 index 0000000..4787b96 --- /dev/null +++ b/sg-commerce-pro/assets/css/suite.css @@ -0,0 +1,240 @@ +/* + * Amazon Seller Suite — app styles. Everything is scoped under .sgs so the + * rest of wp-admin is untouched. + */ + +.sgs-wrap { margin: 0 20px 0 0; } +.sgs { + --ink: #0f172a; + --ink-2: #334155; + --muted: #64748b; + --faint: #94a3b8; + --line: #e2e8f0; + --line-2: #cbd5e1; + --bg: #f1f5f9; + --card: #ffffff; + --accent: #0f172a; + --blue: #2563eb; + --blue-bg: #dbeafe; + --green: #059669; + --green-bg: #d1fae5; + --amber: #b45309; + --amber-bg: #fef3c7; + --red: #dc2626; + --red-bg: #fee2e2; + --violet: #7c3aed; + --violet-bg: #ede9fe; + --radius: 12px; + --shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 1px 3px rgba(15, 23, 42, .06); + color: var(--ink); + font-size: 13.5px; + line-height: 1.5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-variant-numeric: tabular-nums; + max-width: 1480px; + padding-bottom: 60px; +} +.sgs *, .sgs *::before, .sgs *::after { box-sizing: border-box; } +.sgs a { color: var(--blue); text-decoration: none; } +.sgs a:hover { text-decoration: underline; } +.sgs h2, .sgs h3, .sgs h4 { color: var(--ink); margin: 0; letter-spacing: -.01em; } +.sgs h2 { font-size: 18px; font-weight: 650; } +.sgs h3 { font-size: 14.5px; font-weight: 650; } +.sgs p { margin: 0; } +.sgs code { background: var(--bg); padding: 1px 5px; border-radius: 4px; font-size: 12px; } + +.sgs-boot { display: flex; gap: 10px; align-items: center; padding: 60px; color: var(--muted); } +.sgs-spinner { width: 18px; height: 18px; border: 2px solid var(--line-2); border-top-color: var(--ink); border-radius: 50%; animation: sgs-spin .7s linear infinite; display: inline-block; } +@keyframes sgs-spin { to { transform: rotate(360deg); } } + +/* Header */ +.sgs-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 0 12px; flex-wrap: wrap; } +.sgs-brand { display: flex; align-items: center; gap: 12px; } +.sgs-logo { width: 36px; height: 36px; border-radius: 10px; background: var(--ink); color: #fff; display: grid; place-items: center; font-weight: 800; font-size: 17px; } +.sgs-brand h1 { font-size: 20px; font-weight: 700; margin: 0; padding: 0; line-height: 1.2; color: var(--ink); } +.sgs-brand small { display: block; color: var(--muted); font-size: 12px; } +.sgs-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.sgs-pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: var(--bg); color: var(--ink-2); white-space: nowrap; } +.sgs-pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); } +.sgs-pill.ok .dot { background: var(--green); } +.sgs-pill.warn .dot { background: #f59e0b; } +.sgs-pill.bad .dot { background: var(--red); } + +/* Nav */ +.sgs-nav { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 18px; overflow-x: auto; scrollbar-width: thin; } +.sgs-nav a { padding: 10px 12px; color: var(--muted); font-weight: 600; font-size: 13px; border-bottom: 2px solid transparent; white-space: nowrap; text-decoration: none !important; } +.sgs-nav a:hover { color: var(--ink); } +.sgs-nav a.on { color: var(--ink); border-bottom-color: var(--ink); } +.sgs-nav a .badge { background: var(--red); color: #fff; border-radius: 999px; padding: 0 6px; font-size: 10.5px; margin-left: 4px; } + +/* Banners */ +.sgs-banner { display: flex; gap: 12px; align-items: center; justify-content: space-between; padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px; flex-wrap: wrap; } +.sgs-banner.demo { background: var(--violet-bg); color: #4c1d95; } +.sgs-banner.info { background: var(--blue-bg); color: #1e3a8a; } +.sgs-banner.warn { background: var(--amber-bg); color: #78350f; } +.sgs-banner.bad { background: var(--red-bg); color: #7f1d1d; } + +/* Layout */ +.sgs-page-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } +.sgs-page-head p { color: var(--muted); margin-top: 2px; max-width: 760px; } +.sgs-grid { display: grid; gap: 14px; } +.sgs-grid.k5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.sgs-grid.k4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.sgs-grid.k3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.sgs-grid.k2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.sgs-grid.k21 { grid-template-columns: 2fr 1fr; } +.sgs-grid.k12 { grid-template-columns: 1fr 2fr; } +@media (max-width: 1280px) { .sgs-grid.k5 { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 1100px) { .sgs-grid.k4, .sgs-grid.k3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .sgs-grid.k21, .sgs-grid.k12, .sgs-grid.k2 { grid-template-columns: 1fr; } } +@media (max-width: 700px) { .sgs-grid.k5, .sgs-grid.k4, .sgs-grid.k3 { grid-template-columns: 1fr; } } +.sgs-stack > * + * { margin-top: 14px; } + +.sgs-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; min-width: 0; } +.sgs-card.flush { padding: 0; } +.sgs-card.flush > .sgs-card-head { padding: 14px 18px 10px; } +.sgs-card-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } +.sgs-card-head p { color: var(--muted); font-size: 12.5px; } + +/* KPI tiles */ +.sgs-kpi { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); min-width: 0; } +.sgs-kpi .lbl { color: var(--muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } +.sgs-kpi .val { font-size: 22px; font-weight: 700; margin-top: 4px; letter-spacing: -.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sgs-kpi .sub { color: var(--muted); font-size: 12px; margin-top: 2px; } +.sgs-kpi.tone-green .val { color: var(--green); } +.sgs-kpi.tone-red .val { color: var(--red); } +.sgs-kpi.tone-amber .val { color: var(--amber); } +.sgs-period { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); } +.sgs-period .head { display: flex; justify-content: space-between; align-items: baseline; } +.sgs-period .head b { font-size: 13px; } +.sgs-period .head span { color: var(--muted); font-size: 11.5px; } +.sgs-period .big { font-size: 22px; font-weight: 700; margin: 6px 0 2px; letter-spacing: -.02em; } +.sgs-period dl { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; margin: 8px 0 0; font-size: 12.5px; } +.sgs-period dt { color: var(--muted); } +.sgs-period dd { margin: 0; text-align: right; font-weight: 600; } +.sgs-period.hl { background: var(--ink); color: #fff; border-color: var(--ink); } +.sgs-period.hl .head span, .sgs-period.hl dt { color: #cbd5e1; } +.sgs-period.hl .head b { color: #fff; } +.delta { font-size: 11.5px; font-weight: 700; padding: 1px 6px; border-radius: 999px; margin-left: 6px; vertical-align: middle; } +.delta.up { background: var(--green-bg); color: var(--green); } +.delta.down { background: var(--red-bg); color: var(--red); } + +/* Buttons & forms */ +.sgs .btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line-2); background: #fff; color: var(--ink); padding: 6px 12px; border-radius: 8px; font-weight: 600; font-size: 12.5px; cursor: pointer; line-height: 1.4; white-space: nowrap; } +.sgs .btn:hover { border-color: var(--ink-2); } +.sgs .btn.primary { background: var(--ink); border-color: var(--ink); color: #fff; } +.sgs .btn.primary:hover { background: #1e293b; } +.sgs .btn.green { background: var(--green); border-color: var(--green); color: #fff; } +.sgs .btn.danger { color: var(--red); border-color: #fecaca; } +.sgs .btn.ghost { border-color: transparent; background: transparent; } +.sgs .btn.sm { padding: 3px 8px; font-size: 12px; } +.sgs .btn[disabled] { opacity: .5; cursor: not-allowed; } +.sgs .btns { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.sgs input[type=text], .sgs input[type=number], .sgs input[type=date], .sgs input[type=email], .sgs input[type=password], .sgs select, .sgs textarea { + border: 1px solid var(--line-2); border-radius: 8px; padding: 5px 9px; font-size: 13px; background: #fff; color: var(--ink); min-height: 32px; max-width: 100%; +} +.sgs input:focus, .sgs select:focus, .sgs textarea:focus { outline: 2px solid var(--blue-bg); border-color: var(--blue); box-shadow: none; } +.sgs textarea { width: 100%; min-height: 80px; font-family: inherit; } +.sgs .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.sgs .field label { font-size: 12px; font-weight: 600; color: var(--ink-2); } +.sgs .field small { color: var(--muted); font-size: 11.5px; } +.sgs .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px 16px; } +.sgs .check { display: flex; gap: 8px; align-items: center; font-weight: 600; font-size: 12.5px; } +.sgs .seg { display: inline-flex; border: 1px solid var(--line-2); border-radius: 8px; overflow: hidden; background: #fff; } +.sgs .seg button { border: 0; background: transparent; padding: 5px 10px; font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; border-right: 1px solid var(--line); } +.sgs .seg button:last-child { border-right: 0; } +.sgs .seg button.on { background: var(--ink); color: #fff; } + +/* Tables */ +.sgs-table-tools { display: flex; gap: 8px; align-items: center; justify-content: space-between; padding: 0 18px 10px; flex-wrap: wrap; } +.sgs-table-tools input[type=text] { min-width: 220px; } +.sgs-table-wrap { overflow-x: auto; } +.sgs table.t { width: 100%; border-collapse: collapse; font-size: 12.8px; } +.sgs table.t th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; padding: 8px 10px; border-bottom: 1px solid var(--line); background: #f8fafc; white-space: nowrap; position: sticky; top: 0; cursor: pointer; user-select: none; } +.sgs table.t th.r, .sgs table.t td.r { text-align: right; } +.sgs table.t th.c, .sgs table.t td.c { text-align: center; } +.sgs table.t th .arrow { color: var(--ink); margin-left: 3px; } +.sgs table.t td { padding: 8px 10px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; } +.sgs table.t tr:hover td { background: #f8fafc; } +.sgs table.t tr.click { cursor: pointer; } +.sgs table.t tfoot td { font-weight: 700; background: #f8fafc; border-top: 1px solid var(--line); } +.sgs table.t .title { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } +.sgs table.t .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } +.sgs .pos { color: var(--green); } +.sgs .neg { color: var(--red); } +.sgs .muted { color: var(--muted); } +.sgs .small { font-size: 12px; } +.sgs-pager { display: flex; gap: 6px; justify-content: flex-end; align-items: center; padding: 10px 18px; color: var(--muted); font-size: 12px; } +.sgs-empty { padding: 36px 18px; text-align: center; color: var(--muted); } +.sgs-empty b { display: block; color: var(--ink); font-size: 14px; margin-bottom: 4px; } + +/* Badges */ +.sgs .b { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; white-space: nowrap; background: var(--bg); color: var(--ink-2); } +.sgs .b.green { background: var(--green-bg); color: #065f46; } +.sgs .b.red { background: var(--red-bg); color: #991b1b; } +.sgs .b.amber { background: var(--amber-bg); color: #92400e; } +.sgs .b.blue { background: var(--blue-bg); color: #1e40af; } +.sgs .b.violet { background: var(--violet-bg); color: #5b21b6; } +.sgs .b.dark { background: var(--ink); color: #fff; } +.sgs .grade { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; font-weight: 800; color: #fff; } +.sgs .grade.A { background: var(--green); } .sgs .grade.B { background: #16a34a; } .sgs .grade.C { background: #d97706; } .sgs .grade.D { background: #ea580c; } .sgs .grade.F { background: var(--red); } + +/* Bars & meters */ +.sgs .meter { height: 6px; background: var(--bg); border-radius: 999px; overflow: hidden; min-width: 60px; } +.sgs .meter i { display: block; height: 100%; background: var(--ink); border-radius: 999px; } +.sgs .meter.green i { background: var(--green); } .sgs .meter.amber i { background: #f59e0b; } .sgs .meter.red i { background: var(--red); } +.sgs .wf { display: grid; grid-template-columns: 130px 1fr 110px; gap: 6px 10px; align-items: center; font-size: 12.8px; } +.sgs .wf .bar { height: 14px; border-radius: 4px; background: var(--bg); position: relative; } +.sgs .wf .bar i { position: absolute; top: 0; bottom: 0; border-radius: 4px; } +.sgs .wf .v { text-align: right; font-weight: 600; } +.sgs .wf .total { font-weight: 800; } + +/* Charts */ +.sgs-chart { position: relative; width: 100%; } +.sgs-chart svg { display: block; width: 100%; overflow: visible; } +.sgs-chart .axis text { fill: var(--faint); font-size: 10.5px; } +.sgs-chart .grid line { stroke: #eef2f7; } +.sgs-chart .tip { position: absolute; pointer-events: none; background: var(--ink); color: #fff; padding: 7px 10px; border-radius: 8px; font-size: 12px; box-shadow: 0 6px 16px rgba(0,0,0,.18); white-space: nowrap; z-index: 5; transform: translate(-50%, -110%); } +.sgs-chart .tip b { display: block; margin-bottom: 2px; } +.sgs-chart .tip i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; } +.sgs-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--muted); } +.sgs-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; } +.sgs .spark { display: inline-block; vertical-align: middle; } + +/* Lists */ +.sgs .list { list-style: none; margin: 0; padding: 0; } +.sgs .list li { display: flex; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid #f1f5f9; align-items: center; } +.sgs .list li:last-child { border-bottom: 0; } +.sgs .action-card { display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; cursor: pointer; text-decoration: none !important; color: var(--ink) !important; } +.sgs .action-card:hover { border-color: var(--ink-2); } +.sgs .action-card .ic { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; font-size: 16px; flex: 0 0 34px; } +.sgs .action-card b { display: block; font-size: 13px; } +.sgs .action-card span { color: var(--muted); font-size: 12px; } +.sgs .ic.red { background: var(--red-bg); } .sgs .ic.amber { background: var(--amber-bg); } .sgs .ic.green { background: var(--green-bg); } .sgs .ic.blue { background: var(--blue-bg); } .sgs .ic.violet { background: var(--violet-bg); } + +/* Drawer & modal */ +.sgs-drawer-bg { position: fixed; inset: 0; background: rgba(15, 23, 42, .35); z-index: 99990; } +.sgs-drawer { position: fixed; top: 32px; right: 0; bottom: 0; width: min(760px, 96vw); background: #fff; z-index: 99991; box-shadow: -10px 0 30px rgba(0,0,0,.15); display: flex; flex-direction: column; } +.sgs-drawer header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--line); gap: 10px; } +.sgs-drawer .body { overflow-y: auto; padding: 18px 20px 40px; flex: 1; } +.sgs-drawer .body > * + * { margin-top: 14px; } +@media (max-width: 782px) { .sgs-drawer { top: 46px; } } + +/* Toast */ +.sgs-toasts { position: fixed; right: 20px; bottom: 20px; z-index: 99999; display: flex; flex-direction: column; gap: 8px; } +.sgs-toast { background: var(--ink); color: #fff; padding: 10px 14px; border-radius: 10px; box-shadow: 0 8px 20px rgba(0,0,0,.2); font-size: 13px; max-width: 420px; animation: sgs-in .2s ease; } +.sgs-toast.bad { background: var(--red); } +.sgs-toast.ok { background: var(--green); } +@keyframes sgs-in { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } } + +/* Findings */ +.sgs .finding { display: flex; gap: 8px; padding: 7px 0; border-bottom: 1px dashed var(--line); font-size: 12.8px; } +.sgs .finding:last-child { border-bottom: 0; } +.sgs .pre { white-space: pre-wrap; background: #f8fafc; border: 1px solid var(--line); border-radius: 8px; padding: 12px; font-size: 12.5px; font-family: inherit; } +.sgs .kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 12px; font-size: 12.8px; } +.sgs .kv dt { color: var(--muted); } +.sgs .kv dd { margin: 0; word-break: break-word; } +.sgs .skeleton { background: linear-gradient(90deg, #eef2f7 25%, #f8fafc 50%, #eef2f7 75%); background-size: 200% 100%; animation: sgs-sk 1.2s infinite; border-radius: 10px; min-height: 90px; } +@keyframes sgs-sk { to { background-position: -200% 0; } } +.sgs .help { color: var(--muted); font-size: 12px; } +.sgs .divider { height: 1px; background: var(--line); margin: 14px 0; } +.sgs img.thumb { width: 40px; height: 40px; object-fit: contain; border-radius: 6px; background: #fff; border: 1px solid var(--line); } diff --git a/sg-commerce-pro/assets/css/widget.css b/sg-commerce-pro/assets/css/widget.css new file mode 100644 index 0000000..f2c389c --- /dev/null +++ b/sg-commerce-pro/assets/css/widget.css @@ -0,0 +1,112 @@ +/* + * Seven Gum Commerce — frontend widget styles. + * Self-contained, theme-agnostic. + */ +.sg-widget { + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + box-sizing: border-box; + color: #1a1a1a; + max-width: 420px; + margin: 24px 0; +} +.sg-widget *, .sg-widget *::before, .sg-widget *::after { box-sizing: border-box; } + +.sg-widget--card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 20px; + box-shadow: 0 4px 18px rgba(0,0,0,0.06); + display: grid; + grid-template-columns: 110px 1fr; + gap: 18px; + align-items: center; +} +.sg-widget--default { background: #fff; padding: 16px 0; } +.sg-widget--compact { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + font-size: 14px; +} +.sg-widget--compact .sg-widget__image, +.sg-widget--compact .sg-widget__desc { display: none; } + +.sg-widget__image img { + width: 100%; + height: auto; + border-radius: 10px; + display: block; +} + +.sg-widget__name { + margin: 0 0 6px; + font-size: 17px; + font-weight: 600; + line-height: 1.3; + color: #0a0a0a; +} +.sg-widget__desc { + margin: 0 0 12px; + font-size: 13.5px; + line-height: 1.5; + color: #4b5563; +} +.sg-widget__meta { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.sg-widget__price { + font-size: 18px; + font-weight: 700; + color: #0a0a0a; +} +.sg-widget__price small { + font-size: 11px; + font-weight: 500; + color: #6b7280; + margin-left: 2px; +} +.sg-widget__stock { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 3px 9px; + border-radius: 999px; +} +.sg-widget__stock--ok { background: rgba(22, 163, 74, 0.1); color: #16a34a; } +.sg-widget__stock--out { background: rgba(220, 38, 38, 0.1); color: #dc2626; } + +.sg-widget__cta { + display: inline-block; + padding: 10px 18px; + background: #0a0a0a; + color: #fff !important; + text-decoration: none; + border-radius: 8px; + font-size: 13.5px; + font-weight: 600; + transition: background 0.2s ease; +} +.sg-widget__cta:hover { background: #262626; color: #fff !important; } + +.sg-widget--error { + padding: 12px 16px; + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + border-radius: 8px; + font-size: 13px; +} + +@media (max-width: 480px) { + .sg-widget--card { grid-template-columns: 1fr; } +} diff --git a/sg-commerce-pro/assets/js/admin.js b/sg-commerce-pro/assets/js/admin.js new file mode 100644 index 0000000..1b3cc76 --- /dev/null +++ b/sg-commerce-pro/assets/js/admin.js @@ -0,0 +1,11 @@ +/* Seven Gum Commerce — admin JS enhancements (minimal). */ +(function() { + // Auto-dismiss flash notices after 6 seconds. + setTimeout(function() { + var notices = document.querySelectorAll('.notice.is-dismissible'); + notices.forEach(function(n) { n.style.transition = 'opacity 0.4s'; n.style.opacity = '0'; }); + setTimeout(function() { + notices.forEach(function(n) { n.style.display = 'none'; }); + }, 400); + }, 6000); +})(); diff --git a/sg-commerce-pro/assets/js/suite.js b/sg-commerce-pro/assets/js/suite.js new file mode 100644 index 0000000..d27d386 --- /dev/null +++ b/sg-commerce-pro/assets/js/suite.js @@ -0,0 +1,1516 @@ +/*! + * Amazon Seller Suite — wp-admin single-page app. + * Vanilla JS (no build step), talks to /wp-json/sg-commerce/v1/suite/*. + */ +(function () { + 'use strict'; + + var CFG = window.SGSuite || {}; + var root = document.getElementById('sgs-app'); + if (!root) { return; } + + /* ================================================================== */ + /* State */ + /* ================================================================== */ + + var LS_KEY = 'sgsuite.v1'; + var saved = {}; + try { saved = JSON.parse(window.localStorage.getItem(LS_KEY) || '{}') || {}; } catch (e) { saved = {}; } + + var state = { + market: saved.market || CFG.primary || 'US', + preset: saved.preset || '30', + from: null, + to: null, + demo: !!CFG.demo, + connected: !!CFG.connected, + ads: !!CFG.ads, + unread: 0 + }; + + function persist() { + try { window.localStorage.setItem(LS_KEY, JSON.stringify({ market: state.market, preset: state.preset })); } catch (e) { /* private mode */ } + } + + function marketInfo() { + var m = (CFG.markets || []).filter(function (x) { return x.code === state.market; })[0]; + return m || { code: state.market, currency: 'USD', tld: 'com' }; + } + + /* ================================================================== */ + /* Utilities */ + /* ================================================================== */ + + function h(tag, attrs) { + var el = document.createElement(tag); + if (attrs) { + Object.keys(attrs).forEach(function (k) { + var v = attrs[k]; + if (v === null || v === undefined || v === false) { return; } + if (k === 'class') { el.className = v; } + else if (k === 'style' && typeof v === 'object') { Object.assign(el.style, v); } + else if (k.indexOf('on') === 0 && typeof v === 'function') { el.addEventListener(k.slice(2), v); } + else if (v === true) { el.setAttribute(k, ''); } + else { el.setAttribute(k, v); } + }); + } + for (var i = 2; i < arguments.length; i++) { add(el, arguments[i]); } + return el; + } + function add(el, c) { + if (c === null || c === undefined || c === false) { return; } + if (Array.isArray(c)) { c.forEach(function (x) { add(el, x); }); return; } + if (typeof c !== 'object') { el.appendChild(document.createTextNode(String(c))); return; } // text, never markup + if (c instanceof Node) { el.appendChild(c); } + } + /** Return the element of `allowed` equal to `value` (a fresh, trusted string), else `fallback`. */ + function pick(allowed, value, fallback) { + for (var i = 0; i < allowed.length; i++) { + if (allowed[i] === value) { return allowed[i]; } + } + return fallback; + } + function clear(el) { while (el.firstChild) { el.removeChild(el.firstChild); } return el; } + + function num(v) { var n = parseFloat(v); return isNaN(n) ? 0 : n; } + function money(v, digits) { + if (v === null || v === undefined || v === '') { return '—'; } + if (typeof digits !== 'number') { digits = undefined; } // table fmt() passes the row as 2nd arg + var cur = marketInfo().currency || 'USD'; + try { + return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur, minimumFractionDigits: digits === undefined ? 2 : digits, maximumFractionDigits: digits === undefined ? 2 : digits }).format(num(v)); + } catch (e) { return cur + ' ' + num(v).toFixed(2); } + } + function money0(v) { return money(v, 0); } + function int(v) { if (v === null || v === undefined || v === '') { return '—'; } return Math.round(num(v)).toLocaleString(); } + function pct(v, d) { if (v === null || v === undefined || v === '') { return '—'; } return num(v).toFixed(d === undefined ? 1 : d) + '%'; } + function signed(v, fn) { var s = (fn || money)(v); return num(v) > 0 ? '+' + s : s; } + function tone(v) { return num(v) > 0 ? 'pos' : (num(v) < 0 ? 'neg' : ''); } + function shortDate(d) { if (!d) { return '—'; } var x = new Date(String(d).replace(' ', 'T') + (String(d).length <= 10 ? 'T00:00:00' : '')); return isNaN(x) ? d : x.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } + function dateTime(d) { if (!d) { return '—'; } var x = new Date(String(d).replace(' ', 'T') + 'Z'); return isNaN(x) ? d : x.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } + function ymd(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } + function addDays(s, n) { var d = new Date(s + 'T00:00:00'); d.setDate(d.getDate() + n); return ymd(d); } + function amazonUrl(asin) { return 'https://www.amazon.' + (marketInfo().tld || 'com') + '/dp/' + encodeURIComponent(asin); } + function delta(cur, prev, invert) { + cur = num(cur); prev = num(prev); + if (!prev) { return null; } + var d = (cur - prev) / Math.abs(prev) * 100; + var good = invert ? d < 0 : d > 0; + return h('span', { class: 'delta ' + (good ? 'up' : 'down') }, (d > 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1) + '%'); + } + + function rangeParams() { + var today = ymd(new Date()); + if (state.preset === 'custom' && state.from && state.to) { return { from: state.from, to: state.to }; } + if (state.preset === 'mtd') { return { from: today.slice(0, 8) + '01', to: today }; } + if (state.preset === 'lm') { + var d = new Date(); d.setDate(1); d.setDate(0); + return { from: ymd(d).slice(0, 8) + '01', to: ymd(d) }; + } + if (state.preset === 'ytd') { return { from: today.slice(0, 5) + '01-01', to: today }; } + var n = parseInt(state.preset, 10) || 30; + return { from: addDays(today, -(n - 1)), to: today }; + } + + /* ================================================================== */ + /* API */ + /* ================================================================== */ + + function api(path, opts) { + opts = opts || {}; + var url = String(CFG.root || '').replace(/\/$/, '') + path; + var q = Object.assign({ market: state.market }, opts.query || {}); + var qs = Object.keys(q).filter(function (k) { return q[k] !== undefined && q[k] !== null && q[k] !== ''; }).map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(q[k]); }).join('&'); + url += (url.indexOf('?') >= 0 ? '&' : '?') + qs; + var init = { method: opts.method || 'GET', credentials: 'same-origin', headers: { 'X-WP-Nonce': CFG.nonce, 'Accept': 'application/json' } }; + if (opts.body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(opts.body); } + return fetch(url, init).then(function (res) { + return res.text().then(function (txt) { + var data = null; + try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = null; } + if (!res.ok) { + var msg = (data && (data.message || data.code)) || ('HTTP ' + res.status); + throw new Error(msg); + } + return data; + }); + }); + } + + /* ================================================================== */ + /* UI components */ + /* ================================================================== */ + + var toasts = h('div', { class: 'sgs-toasts' }); + document.body.appendChild(toasts); + function toast(msg, kind) { + var t = h('div', { class: 'sgs-toast ' + (kind || '') }); + t.textContent = String(msg); // message text is never parsed as markup + toasts.appendChild(t); + setTimeout(function () { t.remove(); }, kind === 'bad' ? 7000 : 3500); + } + + function btn(label, onClick, cls, attrs) { + var b = h('button', Object.assign({ type: 'button', class: 'btn ' + (cls || '') }, attrs || {}), label); + b.addEventListener('click', function (e) { + if (b.disabled) { return; } + var r = onClick(e, b); + if (r && typeof r.then === 'function') { + var old = b.textContent; + b.disabled = true; b.textContent = '…'; + r.then(function () { b.disabled = false; b.textContent = old; }, function (err) { b.disabled = false; b.textContent = old; toast(err.message || String(err), 'bad'); }); + } + }); + return b; + } + + function card(title, body, opts) { + opts = opts || {}; + return h('div', { class: 'sgs-card' + (opts.flush ? ' flush' : '') }, + (title || opts.actions) ? h('div', { class: 'sgs-card-head' }, h('div', null, title ? h('h3', null, title) : null, opts.sub ? h('p', null, opts.sub) : null), opts.actions || null) : null, + body); + } + + function kpi(label, value, sub, cls) { + return h('div', { class: 'sgs-kpi ' + (cls || '') }, h('div', { class: 'lbl' }, label), h('div', { class: 'val', title: typeof value === 'string' ? value : '' }, value), sub ? h('div', { class: 'sub' }, sub) : null); + } + + function badge(text, color) { return h('span', { class: 'b ' + (color || '') }, text); } + + function empty(title, text, action) { + return h('div', { class: 'sgs-empty' }, h('b', null, title), text ? h('div', null, text) : null, action ? h('div', { style: { marginTop: '12px' } }, action) : null); + } + + function meter(p, color) { + p = Math.max(0, Math.min(100, num(p))); + return h('div', { class: 'meter ' + (color || '') }, h('i', { style: { width: p + '%' } })); + } + + function seg(options, value, onChange) { + var wrap = h('div', { class: 'seg' }); + options.forEach(function (o) { + wrap.appendChild(h('button', { type: 'button', class: o[0] === value ? 'on' : '', onclick: function () { onChange(o[0]); } }, o[1])); + }); + return wrap; + } + + function field(label, input, help) { + return h('div', { class: 'field' }, h('label', null, label), input, help ? h('small', null, help) : null); + } + + function drawer(title, content, actions) { + var bg = h('div', { class: 'sgs-drawer-bg' }); + var body = h('div', { class: 'body' }, content); + var close = function () { bg.remove(); panel.remove(); document.removeEventListener('keydown', onKey); }; + var onKey = function (e) { if (e.key === 'Escape') { close(); } }; + var panel = h('div', { class: 'sgs-drawer sgs', role: 'dialog', 'aria-label': title }, + h('header', null, h('h2', null, title), h('div', { class: 'btns' }, actions || null, btn('Close', close, 'ghost'))), + body); + bg.addEventListener('click', close); + document.addEventListener('keydown', onKey); + document.body.appendChild(bg); + document.body.appendChild(panel); + return { close: close, body: body }; + } + + function copy(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).then(function () { toast('Copied to clipboard', 'ok'); }); + } + var ta = h('textarea', null, text); document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); toast('Copied to clipboard', 'ok'); } catch (e) { /* ignore */ } + ta.remove(); + return Promise.resolve(); + } + + function downloadCsv(name, columns, rows) { + var cols = columns.filter(function (c) { return c.key && c.csv !== false; }); + var lines = [cols.map(function (c) { return '"' + String(c.label).replace(/"/g, '""') + '"'; }).join(',')]; + rows.forEach(function (r) { + lines.push(cols.map(function (c) { + var v = c.csvValue ? c.csvValue(r) : r[c.key]; + if (v === null || v === undefined) { v = ''; } + return '"' + String(v).replace(/"/g, '""') + '"'; + }).join(',')); + }); + var blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8' }); + var a = h('a', { href: URL.createObjectURL(blob), download: name + '-' + ymd(new Date()) + '.csv' }); + document.body.appendChild(a); a.click(); a.remove(); + } + + /** + * Sortable, searchable, paginated table with CSV export. + * columns: [{key, label, align:'r'|'c', fmt(v,row), render(row)->Node, sort:false, sum:true}] + */ + function table(opts) { + var cols = opts.columns; + var all = (opts.rows || []).slice(); + var sortKey = opts.sort || null; + var sortDir = opts.dir || -1; + var page = 0; + var per = opts.per || 50; + var query = ''; + var selected = {}; + + var wrap = h('div', { class: 'sgs-card flush' }); + var tbody = h('tbody'); + var tfoot = h('tfoot'); + var pager = h('div', { class: 'sgs-pager' }); + var thead = h('thead'); + var search = opts.search === false ? null : h('input', { type: 'text', placeholder: opts.placeholder || 'Search…', oninput: function (e) { query = e.target.value.toLowerCase(); page = 0; draw(); } }); + + var tools = h('div', { class: 'sgs-table-tools' }, + h('div', { class: 'btns' }, search, opts.tools || null), + h('div', { class: 'btns' }, opts.selectable ? opts.bulk(function () { return all.filter(function (r) { return selected[r[opts.selectable]]; }); }) : null, + opts.csv ? btn('Export CSV', function () { downloadCsv(opts.csv, cols, filtered()); }, 'sm') : null)); + + if (opts.title) { + wrap.appendChild(h('div', { class: 'sgs-card-head' }, h('div', null, h('h3', null, opts.title), opts.sub ? h('p', null, opts.sub) : null), opts.actions || null)); + } + wrap.appendChild(tools); + wrap.appendChild(h('div', { class: 'sgs-table-wrap' }, h('table', { class: 't' }, thead, tbody, tfoot))); + wrap.appendChild(pager); + + function filtered() { + var rows = all; + if (query) { + rows = rows.filter(function (r) { + return cols.some(function (c) { var v = r[c.key]; return v !== null && v !== undefined && String(v).toLowerCase().indexOf(query) >= 0; }); + }); + } + if (sortKey) { + rows = rows.slice().sort(function (a, b) { + var x = a[sortKey], y = b[sortKey]; + var nx = parseFloat(x), ny = parseFloat(y); + if (!isNaN(nx) && !isNaN(ny) && String(nx) !== 'NaN') { return (nx - ny) * sortDir; } + if (x === null || x === undefined) { return 1; } + if (y === null || y === undefined) { return -1; } + return String(x).localeCompare(String(y)) * sortDir; + }); + } + return rows; + } + + function head() { + clear(thead); + var tr = h('tr'); + if (opts.selectable) { + var cb = h('input', { type: 'checkbox', onchange: function (e) { filtered().forEach(function (r) { selected[r[opts.selectable]] = e.target.checked; }); draw(); } }); + tr.appendChild(h('th', { style: { width: '28px' } }, cb)); + } + cols.forEach(function (c) { + var th = h('th', { class: c.align || '', title: c.title || '' }, c.label, sortKey === c.key ? h('span', { class: 'arrow' }, sortDir > 0 ? '▲' : '▼') : null); + if (c.sort !== false && c.key) { + th.addEventListener('click', function () { if (sortKey === c.key) { sortDir = -sortDir; } else { sortKey = c.key; sortDir = -1; } draw(); }); + } + tr.appendChild(th); + }); + thead.appendChild(tr); + } + + function draw() { + head(); + clear(tbody); clear(tfoot); clear(pager); + var rows = filtered(); + if (!rows.length) { + tbody.appendChild(h('tr', null, h('td', { colspan: cols.length + (opts.selectable ? 1 : 0) }, opts.empty || empty('Nothing here yet', 'No rows match.')))); + return; + } + var pages = Math.ceil(rows.length / per); + page = Math.min(page, pages - 1); + rows.slice(page * per, page * per + per).forEach(function (r) { + var tr = h('tr', { class: opts.onRow ? 'click' : '' }); + if (opts.selectable) { + var id = r[opts.selectable]; + tr.appendChild(h('td', { onclick: function (e) { e.stopPropagation(); } }, h('input', { type: 'checkbox', checked: !!selected[id], onchange: function (e) { selected[id] = e.target.checked; } }))); + } + cols.forEach(function (c) { + var content = c.render ? c.render(r) : (c.fmt ? c.fmt(r[c.key], r) : (r[c.key] === null || r[c.key] === undefined || r[c.key] === '' ? '—' : r[c.key])); + var cls = (c.align || '') + (c.tone ? ' ' + tone(r[c.key]) : '') + (c.mono ? ' mono' : ''); + tr.appendChild(h('td', { class: cls }, content)); + }); + if (opts.onRow) { tr.addEventListener('click', function () { opts.onRow(r); }); } + tbody.appendChild(tr); + }); + if (cols.some(function (c) { return c.sum; })) { + var ft = h('tr'); + if (opts.selectable) { ft.appendChild(h('td')); } + cols.forEach(function (c, i) { + if (c.sum) { + var s = rows.reduce(function (a, r) { return a + num(r[c.key]); }, 0); + ft.appendChild(h('td', { class: (c.align || '') + (c.tone ? ' ' + tone(s) : '') }, c.fmt ? c.fmt(s, {}) : s)); + } else { + ft.appendChild(h('td', { class: c.align || '' }, i === 0 ? 'Total (' + rows.length + ')' : '')); + } + }); + tfoot.appendChild(ft); + } + if (pages > 1) { + pager.appendChild(h('span', null, (page * per + 1) + '–' + Math.min(rows.length, page * per + per) + ' of ' + rows.length)); + pager.appendChild(btn('‹', function () { page = Math.max(0, page - 1); draw(); }, 'sm', { disabled: page === 0 })); + pager.appendChild(btn('›', function () { page = Math.min(pages - 1, page + 1); draw(); }, 'sm', { disabled: page >= pages - 1 })); + } else { + pager.appendChild(h('span', null, rows.length + ' rows')); + } + } + + draw(); + wrap.setRows = function (rows) { all = rows.slice(); selected = {}; draw(); }; + wrap.clearSelection = function () { selected = {}; draw(); }; + return wrap; + } + + /* ------------------------------------------------------------------ */ + /* SVG charts */ + /* ------------------------------------------------------------------ */ + + var NS = 'http://www.w3.org/2000/svg'; + function s(tag, attrs) { + var el = document.createElementNS(NS, tag); + Object.keys(attrs || {}).forEach(function (k) { el.setAttribute(k, attrs[k]); }); + for (var i = 2; i < arguments.length; i++) { if (arguments[i]) { el.appendChild(arguments[i]); } } + return el; + } + + function niceMax(v) { + if (v <= 0) { return 1; } + var p = Math.pow(10, Math.floor(Math.log10(v))); + var n = v / p; + return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * p; + } + + /** + * chart({labels, series:[{label, values, type:'bar'|'line'|'area', color, axis:'left'|'right', fmt}], height, fmt}) + */ + function chart(o) { + var W = 900, H = o.height || 240, pl = 56, pr = o.series.some(function (x) { return x.axis === 'right'; }) ? 56 : 14, pt = 10, pb = 26; + var iw = W - pl - pr, ih = H - pt - pb; + var labels = o.labels || []; + var n = Math.max(1, labels.length); + function range(axis) { + var vals = []; + o.series.filter(function (x) { return (x.axis || 'left') === axis; }).forEach(function (x) { x.values.forEach(function (v) { if (v !== null && v !== undefined) { vals.push(num(v)); } }); }); + var max = Math.max.apply(null, vals.concat([0])), min = Math.min.apply(null, vals.concat([0])); + var top = niceMax(Math.max(max, Math.abs(min) * 0.2)); + var bottom = min < 0 ? -niceMax(Math.abs(min)) : 0; + return [bottom, top]; + } + var L = range('left'), R = range('right'); + function y(v, axis) { var r = axis === 'right' ? R : L; return pt + ih - (num(v) - r[0]) / (r[1] - r[0] || 1) * ih; } + function x(i) { return pl + (i + 0.5) * (iw / n); } + + var svg = s('svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', height: H, role: 'img', 'aria-label': o.title || 'chart' }); + var grid = s('g', { class: 'grid' }); + var axis = s('g', { class: 'axis' }); + for (var g = 0; g <= 4; g++) { + var vy = L[0] + (L[1] - L[0]) * g / 4; + var yy = y(vy, 'left'); + grid.appendChild(s('line', { x1: pl, x2: W - pr, y1: yy, y2: yy })); + var t = s('text', { x: pl - 8, y: yy + 3, 'text-anchor': 'end' }); t.textContent = (o.fmt || money0)(vy); axis.appendChild(t); + if (pr > 20) { + var vr = R[0] + (R[1] - R[0]) * g / 4; + var tr = s('text', { x: W - pr + 8, y: y(vr, 'right') + 3, 'text-anchor': 'start' }); + tr.textContent = (o.series.filter(function (x) { return x.axis === 'right'; })[0].fmt || int)(vr); axis.appendChild(tr); + } + } + var step = Math.max(1, Math.ceil(n / 10)); + labels.forEach(function (l, i) { + if (i % step !== 0 && i !== n - 1) { return; } + var t = s('text', { x: x(i), y: H - 6, 'text-anchor': 'middle' }); t.textContent = o.labelFmt ? o.labelFmt(l) : shortDate(l); axis.appendChild(t); + }); + svg.appendChild(grid); svg.appendChild(axis); + if (L[0] < 0) { svg.appendChild(s('line', { x1: pl, x2: W - pr, y1: y(0, 'left'), y2: y(0, 'left'), stroke: '#cbd5e1' })); } + + var bars = o.series.filter(function (x) { return x.type === 'bar'; }); + var bw = Math.max(2, (iw / n) * 0.72 / Math.max(1, bars.length)); + bars.forEach(function (ser, si) { + ser.values.forEach(function (v, i) { + if (v === null || v === undefined) { return; } + var y0 = y(0, ser.axis), y1 = y(v, ser.axis); + var xx = x(i) - (bw * bars.length) / 2 + si * bw; + svg.appendChild(s('rect', { x: xx, y: Math.min(y0, y1), width: bw - 1, height: Math.max(1, Math.abs(y1 - y0)), rx: 2, fill: ser.color, opacity: 0.9 })); + }); + }); + o.series.filter(function (x) { return x.type !== 'bar'; }).forEach(function (ser) { + var d = ''; + ser.values.forEach(function (v, i) { if (v === null || v === undefined) { return; } d += (d ? 'L' : 'M') + x(i).toFixed(1) + ',' + y(v, ser.axis).toFixed(1); }); + if (!d) { return; } + if (ser.type === 'area') { + svg.appendChild(s('path', { d: d + 'L' + x(n - 1) + ',' + y(0, ser.axis) + 'L' + x(0) + ',' + y(0, ser.axis) + 'Z', fill: ser.color, opacity: 0.1 })); + } + svg.appendChild(s('path', { d: d, fill: 'none', stroke: ser.color, 'stroke-width': 2.2, 'stroke-linejoin': 'round', 'vector-effect': 'non-scaling-stroke' })); + }); + + var wrap = h('div', { class: 'sgs-chart' }); + var tip = h('div', { class: 'tip', style: { display: 'none' } }); + var cursor = s('line', { y1: pt, y2: pt + ih, stroke: '#94a3b8', 'stroke-dasharray': '3 3', opacity: 0 }); + svg.appendChild(cursor); + svg.addEventListener('mousemove', function (e) { + var rect = svg.getBoundingClientRect(); + var px = (e.clientX - rect.left) / rect.width * W; + var i = Math.max(0, Math.min(n - 1, Math.floor((px - pl) / (iw / n)))); + cursor.setAttribute('x1', x(i)); cursor.setAttribute('x2', x(i)); cursor.setAttribute('opacity', 1); + clear(tip); + tip.appendChild(h('b', null, o.labelFmt ? o.labelFmt(labels[i]) : shortDate(labels[i]))); + o.series.forEach(function (ser) { + tip.appendChild(h('div', null, h('i', { style: { background: ser.color } }), ser.label + ': ', (ser.fmt || o.fmt || money)(ser.values[i]))); + }); + tip.style.display = 'block'; + tip.style.left = (x(i) / W * 100) + '%'; + tip.style.top = ((e.clientY - rect.top)) + 'px'; + }); + svg.addEventListener('mouseleave', function () { tip.style.display = 'none'; cursor.setAttribute('opacity', 0); }); + wrap.appendChild(svg); wrap.appendChild(tip); + var legend = h('div', { class: 'sgs-legend' }); + o.series.forEach(function (ser) { legend.appendChild(h('span', null, h('i', { style: { background: ser.color } }), ser.label)); }); + return h('div', null, legend, h('div', { style: { height: '8px' } }), wrap); + } + + function sparkline(values, color, w, hgt) { + w = w || 90; hgt = hgt || 24; + var vals = values.map(num); + var max = Math.max.apply(null, vals.concat([0.0001])), min = Math.min.apply(null, vals.concat([0])); + var d = vals.map(function (v, i) { return (i ? 'L' : 'M') + (i / Math.max(1, vals.length - 1) * w).toFixed(1) + ',' + (hgt - 2 - (v - min) / (max - min || 1) * (hgt - 4)).toFixed(1); }).join(''); + return s('svg', { width: w, height: hgt, class: 'spark' }, s('path', { d: d, fill: 'none', stroke: color || '#2563eb', 'stroke-width': 1.6 })); + } + + var C = { sales: '#2563eb', profit: '#059669', ads: '#f59e0b', refunds: '#dc2626', fees: '#94a3b8', units: '#7c3aed', grey: '#cbd5e1' }; + + /* ================================================================== */ + /* Shell */ + /* ================================================================== */ + + var ROUTES = [ + ['', 'Dashboard'], ['profit', 'Profit'], ['orders', 'Orders'], ['restock', 'Inventory'], ['ppc', 'PPC'], + ['keywords', 'Keywords'], ['listings', 'Listings'], ['monitor', 'Hijackers & Buy Box'], ['reviews', 'Reviews'], + ['reimbursements', 'Reimbursements'], ['analytics', 'Traffic & Returns'], ['research', 'Research'], + ['reports', 'Reports'], ['alerts', 'Alerts'], ['settings', 'Settings'] + ]; + + var headerSlot = h('div'); + var nav = h('nav', { class: 'sgs-nav', 'aria-label': 'Amazon Suite sections' }); + var bannerSlot = h('div'); + var main = h('main'); + + function header() { + var sel = h('select', { 'aria-label': 'Marketplace', onchange: function (e) { state.market = e.target.value; persist(); route(); } }); + (CFG.markets || [{ code: state.market }]).forEach(function (m) { sel.appendChild(h('option', { value: m.code, selected: m.code === state.market }, m.code + (m.currency ? ' · ' + m.currency : ''))); }); + return h('div', { class: 'sgs-top' }, + h('div', { class: 'sgs-brand' }, h('div', { class: 'sgs-logo' }, '7'), h('div', null, h('h1', null, 'Amazon Seller Suite'), h('small', null, 'Profit · PPC · Inventory · Reviews · Reimbursements · Protection'))), + h('div', { class: 'sgs-controls' }, + h('span', { class: 'sgs-pill ' + (state.connected ? 'ok' : 'bad') }, h('span', { class: 'dot' }), state.connected ? 'SP-API connected' : 'SP-API not connected'), + h('span', { class: 'sgs-pill ' + (state.ads ? 'ok' : 'warn') }, h('span', { class: 'dot' }), state.ads ? 'Ads API connected' : 'Ads API off'), + state.demo ? h('span', { class: 'sgs-pill' }, '🧪 Demo data') : null, + sel)); + } + + function rangeBar(onChange) { + var r = rangeParams(); + var from = h('input', { type: 'date', value: r.from, onchange: function () { state.preset = 'custom'; state.from = from.value; state.to = to.value; persist(); onChange(); } }); + var to = h('input', { type: 'date', value: r.to, onchange: function () { state.preset = 'custom'; state.from = from.value; state.to = to.value; persist(); onChange(); } }); + return h('div', { class: 'btns' }, + seg([['7', '7D'], ['14', '14D'], ['30', '30D'], ['mtd', 'MTD'], ['lm', 'Last month'], ['90', '90D'], ['ytd', 'YTD']], state.preset, function (v) { state.preset = v; persist(); onChange(); }), + from, h('span', { class: 'muted' }, '→'), to); + } + + function pageHead(title, sub, actions) { + return h('div', { class: 'sgs-page-head' }, h('div', null, h('h2', null, title), sub ? h('p', null, sub) : null), actions ? h('div', { class: 'btns' }, actions) : null); + } + + function banners() { + clear(bannerSlot); + if (state.demo) { + bannerSlot.appendChild(h('div', { class: 'sgs-banner demo' }, + h('span', null, h('b', null, 'Demo mode. '), 'You are looking at a realistic sample account for the 7 SEVEN catalog. Clear it any time; your real data is never touched.'), + btn('Clear demo data', function () { return api('/demo', { method: 'DELETE' }).then(function () { state.demo = false; toast('Demo data removed', 'ok'); route(); }); }, 'sm'))); + } else if (!state.connected) { + bannerSlot.appendChild(h('div', { class: 'sgs-banner warn' }, + h('span', null, h('b', null, 'Amazon is not connected yet. '), 'Add your LWA credentials under Seven Gum → Amazon Connect, or explore with a demo account first.'), + h('div', { class: 'btns' }, + h('a', { class: 'btn sm', href: CFG.coreUrl }, 'Connect Amazon'), + btn('Load demo account', function () { return seedDemo(); }, 'sm primary')))); + } + } + + function seedDemo() { + toast('Building the demo account (≈20–40 s)…'); + return api('/demo', { method: 'POST' }).then(function () { state.demo = true; toast('Demo account ready', 'ok'); route(); }); + } + + function drawNav(active) { + clear(nav); + ROUTES.forEach(function (r) { + nav.appendChild(h('a', { href: '#/' + r[0], class: r[0] === active ? 'on' : '' }, r[1], r[0] === 'alerts' && state.unread ? h('span', { class: 'badge' }, state.unread) : null)); + }); + } + + function loading() { + return h('div', { class: 'sgs-stack' }, h('div', { class: 'sgs-grid k4' }, [1, 2, 3, 4].map(function () { return h('div', { class: 'skeleton' }); })), h('div', { class: 'skeleton', style: { minHeight: '260px' } })); + } + + function failure(err) { + return h('div', { class: 'sgs-banner bad' }, h('span', null, h('b', null, 'Could not load this view. '), err.message || String(err)), btn('Retry', route, 'sm')); + } + + var token = 0; + function route() { + var hash = (window.location.hash || '').replace(/^#\/?/, ''); + var name = hash.split('?')[0]; + var page = pick(Object.keys(PAGES), name, ''); + var render = PAGES[page]; + drawNav(page); + clear(headerSlot).appendChild(header()); + banners(); + clear(main).appendChild(loading()); + var my = ++token; + Promise.resolve().then(function () { return render(); }).then(function (node) { + if (my !== token) { return; } + // Pages may refresh connection/demo/unread state — redraw the chrome for the CURRENT route only. + drawNav(page); + clear(headerSlot).appendChild(header()); + banners(); + clear(main).appendChild(node); + }).catch(function (err) { + if (my !== token) { return; } + clear(main).appendChild(failure(err)); + }); + } + + /* ================================================================== */ + /* Pages */ + /* ================================================================== */ + + var PAGES = {}; + + /* ---------- Dashboard ---------- */ + PAGES[''] = function () { + return api('/overview').then(function (d) { + state.unread = d.unread || 0; + state.connected = d.connected; state.ads = d.ads_ready; state.demo = d.demo; + var P = d.periods; + function period(key, title, hl) { + var t = P[key]; + return h('div', { class: 'sgs-period' + (hl ? ' hl' : '') }, + h('div', { class: 'head' }, h('b', null, title), h('span', null, t.from === t.to ? shortDate(t.from) : shortDate(t.from) + ' – ' + shortDate(t.to))), + h('div', { class: 'big' }, money(t.sales)), + h('dl', null, + h('dt', null, 'Orders / units'), h('dd', null, int(t.orders) + ' / ' + int(t.units)), + h('dt', null, 'Ad spend'), h('dd', null, money(-t.ads)), + h('dt', null, 'Amazon fees'), h('dd', null, money(-t.amazon_fees)), + h('dt', null, 'Net profit'), h('dd', { class: hl ? '' : tone(t.net_profit) }, money(t.net_profit)), + h('dt', null, 'Margin'), h('dd', null, pct(t.margin_pct)))); + } + var F = P.forecast; + var kpis = h('div', { class: 'sgs-grid k5' }, + period('today', 'Today', true), period('yesterday', 'Yesterday'), period('mtd', 'Month to date'), + h('div', { class: 'sgs-period' }, + h('div', { class: 'head' }, h('b', null, 'Forecast this month'), h('span', null, F.basis || '')), + h('div', { class: 'big' }, money(F.sales)), + h('dl', null, h('dt', null, 'Units'), h('dd', null, int(F.units)), h('dt', null, 'Net profit'), h('dd', { class: tone(F.net_profit) }, money(F.net_profit)))), + period('last_month', 'Last month')); + + var series = d.series || []; + var trend = card('Sales & net profit — last 30 days', chart({ + labels: series.map(function (r) { return r.date; }), + series: [ + { label: 'Sales', values: series.map(function (r) { return r.sales; }), type: 'bar', color: C.sales }, + { label: 'Net profit', values: series.map(function (r) { return r.net_profit; }), type: 'line', color: C.profit }, + { label: 'Ad spend', values: series.map(function (r) { return r.ads; }), type: 'line', color: C.ads } + ] + })); + + var T = d.totals_30; + var unit = h('div', { class: 'sgs-grid k4' }, + kpi('30-day net profit', money(T.net_profit), 'Margin ' + pct(T.margin_pct) + ' · ROI ' + pct(T.roi_pct), num(T.net_profit) >= 0 ? 'tone-green' : 'tone-red'), + kpi('TACoS', pct(T.tacos_pct), 'ACoS ' + pct(T.acos_pct) + ' · spend ' + money(-T.ads)), + kpi('Conversion rate', pct(d.conversion), int(d.sessions) + ' sessions'), + kpi('Refund rate', pct(T.refund_rate_pct), money(-T.refunds) + ' refunded')); + + var actions = h('div', { class: 'sgs-grid k3' }); + function act(icon, color, title, sub, href) { actions.appendChild(h('a', { class: 'action-card', href: href }, h('div', { class: 'ic ' + color }, icon), h('div', null, h('b', null, title), h('span', null, sub)))); } + var R = d.restock; + act('📦', R.reorder || R.out_of_stock ? 'red' : 'green', R.out_of_stock + ' out of stock · ' + R.reorder + ' to reorder', R.lost_sales_per_day > 0 ? 'Losing ≈' + money(R.lost_sales_per_day) + '/day in sales' : 'Suggested PO: ' + int(R.order_units) + ' units (' + money0(R.order_value) + ')', '#/restock'); + act('🛡️', d.buybox.hijacked ? 'red' : 'green', d.buybox.hijacked ? d.buybox.hijacked + ' listing(s) with other sellers' : 'No hijackers detected', 'Buy Box held on ' + d.buybox.winning + '/' + d.buybox.asins + ' ASINs (' + pct(d.buybox.pct) + ')', '#/monitor'); + act('💸', d.reimburse.open ? 'amber' : 'green', money(d.reimburse.value) + ' recoverable', d.reimburse.open + ' open reimbursement case(s) ready to file', '#/reimbursements'); + act('🎯', 'blue', 'PPC: ' + money(d.ads.wasted_spend) + ' wasted spend', 'Search terms with clicks but no orders (30 days) · ROAS ' + (d.ads.roas || '—'), '#/ppc'); + act('📝', d.listings.avg_score !== null && d.listings.avg_score < 80 ? 'amber' : 'green', 'Listing score ' + (d.listings.avg_score === null ? '—' : d.listings.avg_score + '/100'), d.listings.audited + ' listing(s) audited', '#/listings'); + act('⭐', 'violet', int(d.reviews.sent) + ' review requests sent', '30 days · ' + int(d.reviews.ineligible) + ' ineligible', '#/reviews'); + + var top = table({ + title: 'Top products — 30 days', search: false, per: 8, sort: 'sales', + columns: [ + { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), h('span', { class: 'title muted small' }, r.title)); } }, + { key: 'units', label: 'Units', align: 'r', fmt: int }, + { key: 'sales', label: 'Sales', align: 'r', fmt: money }, + { key: 'net_profit', label: 'Net profit', align: 'r', fmt: money, tone: true }, + { key: 'margin_pct', label: 'Margin', align: 'r', fmt: function (v) { return pct(v); } } + ], + rows: d.top_skus + }); + var alerts = card('Latest alerts', d.alerts && d.alerts.length ? h('ul', { class: 'list' }, d.alerts.map(function (a) { + return h('li', null, h('div', null, h('b', null, a.title), h('div', { class: 'small muted' }, dateTime(a.created_at))), badge(a.level, a.level === 'critical' ? 'red' : (a.level === 'warning' ? 'amber' : 'blue'))); + })) : empty('All quiet', 'No alerts yet.'), { actions: h('a', { href: '#/alerts', class: 'btn sm' }, 'All alerts') }); + + var missing = d.missing_costs && d.missing_costs.length ? h('div', { class: 'sgs-banner warn' }, h('span', null, h('b', null, d.missing_costs.length + ' SKU(s) have no product cost — '), 'profit is overstated until you add COGS.'), h('a', { class: 'btn sm', href: '#/profit?tab=costs' }, 'Add costs')) : null; + + return h('div', { class: 'sgs-stack' }, + pageHead('Dashboard', 'Live net profit after every Amazon fee, refund, ad dollar and unit cost — plus everything that needs your attention today.', [btn('Refresh', route, 'sm')]), + missing, kpis, h('div', { class: 'sgs-grid k21' }, trend, alerts), unit, actions, top); + }); + }; + + /* ---------- Profit ---------- */ + PAGES.profit = function () { + var tab = (window.location.hash.split('tab=')[1] || 'pnl'); + var wrap = h('div', { class: 'sgs-stack' }); + var body = h('div'); + function setTab(t) { tab = t; history.replaceState(null, '', '#/profit?tab=' + t); draw(); } + function draw() { + clear(wrap); + wrap.appendChild(pageHead('Profit & P&L', 'Sellerboard-grade profit: settled fees by order, estimated fees for unsettled orders, refunds, storage, ads, COGS and your own expenses.', [seg([['pnl', 'P&L'], ['costs', 'Product costs (COGS)'], ['expenses', 'Expenses']], tab, setTab)])); + wrap.appendChild(body); + clear(body).appendChild(loading()); + (tab === 'costs' ? costsView() : tab === 'expenses' ? expensesView() : pnlView()).then(function (n) { clear(body).appendChild(n); }, function (e) { clear(body).appendChild(failure(e)); }); + } + function pnlView() { + return api('/profit', { query: rangeParams() }).then(function (d) { + var T = d.totals, P = d.previous || {}; + var k = h('div', { class: 'sgs-grid k5' }, + kpi('Sales', h('span', null, money(T.sales), delta(T.sales, P.sales)), int(T.orders) + ' orders · ' + int(T.units) + ' units'), + kpi('Net profit', h('span', null, money(T.net_profit), delta(T.net_profit, P.net_profit)), 'Margin ' + pct(T.margin_pct), num(T.net_profit) >= 0 ? 'tone-green' : 'tone-red'), + kpi('ROI', pct(T.roi_pct), 'on ' + money(-T.cogs) + ' COGS'), + kpi('Amazon fees', money(-(T.amazon_fees + T.account_fees)), pct(T.sales ? -(T.amazon_fees + T.account_fees) / T.sales * 100 : null) + ' of sales' + (T.fees_estimated ? ' · ' + money(-T.fees_estimated) + ' estimated' : '')), + kpi('Ad spend', money(-T.ads), 'TACoS ' + pct(T.tacos_pct) + ' · ACoS ' + pct(T.acos_pct))); + + var max = Math.max.apply(null, d.waterfall.map(function (w) { return Math.abs(num(w.value)); }).concat([1])); + var wf = h('div', { class: 'wf' }); + d.waterfall.forEach(function (w) { + var v = num(w.value); + wf.appendChild(h('div', null, w.label)); + wf.appendChild(h('div', { class: 'bar' }, h('i', { style: { left: '0', width: (Math.abs(v) / max * 100) + '%', background: v >= 0 ? C.profit : C.refunds, opacity: 0.8 } }))); + wf.appendChild(h('div', { class: 'v ' + tone(v) }, money(v))); + }); + wf.appendChild(h('div', { class: 'total' }, 'Net profit')); + wf.appendChild(h('div', { class: 'bar' }, h('i', { style: { width: (Math.abs(num(T.net_profit)) / max * 100) + '%', background: C.sales } }))); + wf.appendChild(h('div', { class: 'v total ' + tone(T.net_profit) }, money(T.net_profit))); + + var fees = h('ul', { class: 'list' }, d.fee_breakdown.slice(0, 12).map(function (f) { return h('li', null, h('span', null, f.type), h('b', { class: tone(f.amount) }, money(f.amount))); })); + + var series = d.series; + var ch = card('Daily trend', chart({ + labels: series.map(function (r) { return r.date; }), + series: [ + { label: 'Sales', values: series.map(function (r) { return r.sales; }), type: 'bar', color: C.sales }, + { label: 'Net profit', values: series.map(function (r) { return r.net_profit; }), type: 'line', color: C.profit }, + { label: 'Fees', values: series.map(function (r) { return r.fees; }), type: 'line', color: C.fees }, + { label: 'Ads', values: series.map(function (r) { return r.ads; }), type: 'line', color: C.ads } + ] + }), { actions: rangeBar(draw) }); + + var skus = table({ + title: 'Profit by product', sub: 'Ad spend is allocated to SKUs by sales share. Click a row to edit its cost.', csv: 'profit-by-sku', sort: 'sales', + columns: [ + { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), !r.has_cost ? h('span', null, ' ', badge('no cost', 'amber')) : null, h('span', { class: 'title muted small' }, r.title)); } }, + { key: 'units', label: 'Units', align: 'r', fmt: int, sum: true }, + { key: 'sales', label: 'Sales', align: 'r', fmt: money, sum: true }, + { key: 'refunds', label: 'Refunds', align: 'r', fmt: money, sum: true }, + { key: 'amazon_fees', label: 'Amazon fees', align: 'r', fmt: money, sum: true }, + { key: 'ads', label: 'Ads (alloc.)', align: 'r', fmt: money, sum: true }, + { key: 'cogs', label: 'COGS', align: 'r', fmt: money, sum: true }, + { key: 'net_profit', label: 'Net profit', align: 'r', fmt: money, tone: true, sum: true }, + { key: 'margin_pct', label: 'Margin', align: 'r', fmt: function (v) { return pct(v); } }, + { key: 'roi_pct', label: 'ROI', align: 'r', fmt: function (v) { return pct(v); } } + ], + rows: d.skus, + onRow: function () { setTab('costs'); } + }); + return h('div', { class: 'sgs-stack' }, k, ch, + h('div', { class: 'sgs-grid k2' }, card('Profit waterfall', wf, { sub: shortDate(d.from) + ' – ' + shortDate(d.to) }), card('Fee breakdown', fees, { sub: 'Every Amazon fee type, settled + estimated' })), + skus); + }); + } + function costsView() { + return api('/costs').then(function (d) { + var rows = d.rows; + function inp(r, k, step) { + return h('input', { type: 'number', step: step || '0.01', min: '0', value: r[k] || '', style: { width: '90px' }, onchange: function (e) { r[k] = e.target.value; r._dirty = true; } }); + } + var t = table({ + title: 'Product costs (landed COGS)', sub: 'Landed cost = unit + inbound freight + prep + other. Lead time, MOQ and case pack drive the restock planner.', csv: 'product-costs', per: 100, sort: 'sku', dir: 1, + columns: [ + { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), h('span', { class: 'title muted small' }, r.title)); } }, + { key: 'unit_cost', label: 'Unit', align: 'r', render: function (r) { return inp(r, 'unit_cost'); } }, + { key: 'inbound_per_unit', label: 'Inbound/unit', align: 'r', render: function (r) { return inp(r, 'inbound_per_unit'); } }, + { key: 'prep_per_unit', label: 'Prep/unit', align: 'r', render: function (r) { return inp(r, 'prep_per_unit'); } }, + { key: 'other_per_unit', label: 'Other/unit', align: 'r', render: function (r) { return inp(r, 'other_per_unit'); } }, + { key: 'lead_time_days', label: 'Lead days', align: 'r', render: function (r) { return inp(r, 'lead_time_days', '1'); } }, + { key: 'moq', label: 'MOQ', align: 'r', render: function (r) { return inp(r, 'moq', '1'); } }, + { key: 'case_pack', label: 'Case pack', align: 'r', render: function (r) { return inp(r, 'case_pack', '1'); } }, + { key: 'landed', label: 'Landed', align: 'r', fmt: money }, + { key: 'source', label: 'Source', render: function (r) { return badge(r.source, r.source === 'none' ? 'amber' : 'green'); } } + ], + rows: rows, + actions: btn('Save changes', function () { + var dirty = rows.filter(function (r) { return r._dirty; }); + if (!dirty.length) { toast('No changes'); return Promise.resolve(); } + return api('/costs', { method: 'POST', body: { rows: dirty } }).then(function (res) { toast(res.saved + ' cost(s) saved', 'ok'); draw(); }); + }, 'primary') + }); + return t; + }); + } + function expensesView() { + return api('/expenses').then(function (d) { + var label = h('input', { type: 'text', placeholder: 'e.g. Photography, VA, software' }); + var amount = h('input', { type: 'number', step: '0.01', min: '0', placeholder: '0.00' }); + var rec = h('select', null, h('option', { value: 'monthly' }, 'Monthly'), h('option', { value: 'once' }, 'One-off')); + var start = h('input', { type: 'date', value: ymd(new Date()) }); + var cat = h('select', null, ['software', 'staff', 'marketing', 'samples', 'logistics', 'other'].map(function (c) { return h('option', { value: c }, c); })); + var form = card('Add expense', h('div', { class: 'form-grid' }, + field('Label', label), field('Amount', amount), field('Recurrence', rec), field('Category', cat), field('Start date', start), + h('div', { class: 'field' }, h('label', null, ' '), btn('Add expense', function () { + return api('/expenses', { method: 'POST', body: { label: label.value, amount: amount.value, recurrence: rec.value, start_date: start.value, category: cat.value } }).then(function () { toast('Expense added', 'ok'); draw(); }); + }, 'primary'))), { sub: 'Monthly expenses are pro-rated per day in every P&L range.' }); + var t = table({ + title: 'Expenses', csv: 'expenses', columns: [ + { key: 'label', label: 'Label' }, { key: 'category', label: 'Category' }, + { key: 'amount', label: 'Amount', align: 'r', fmt: money }, { key: 'recurrence', label: 'Recurrence' }, + { key: 'start_date', label: 'Start' }, { key: 'end_date', label: 'End' }, + { key: 'id', label: '', sort: false, render: function (r) { return btn('Delete', function () { return api('/expenses/' + r.id, { method: 'DELETE' }).then(draw); }, 'sm danger'); } } + ], rows: d.rows, empty: empty('No expenses yet', 'Add software subscriptions, staff, photography, samples…') + }); + return h('div', { class: 'sgs-stack' }, form, t); + }); + } + draw(); + return wrap; + }; + + /* ---------- Orders ---------- */ + PAGES.orders = function () { + var wrap = h('div', { class: 'sgs-stack' }); + var q = h('input', { type: 'text', placeholder: 'Order ID, SKU or ASIN…' }); + var status = h('select', null, ['', 'Pending', 'Unshipped', 'Shipped', 'Canceled'].map(function (s) { return h('option', { value: s }, s || 'All statuses'); })); + var slot = h('div'); + function load() { + clear(slot).appendChild(loading()); + api('/orders', { query: Object.assign(rangeParams(), { q: q.value, status: status.value }) }).then(function (d) { + clear(slot).appendChild(table({ + title: d.orders.length + ' orders', sub: 'Per-order profit. Fees are settled amounts when available, otherwise estimated from the SKU’s recent fee history.', csv: 'orders', search: false, sort: 'purchase_date', + columns: [ + { key: 'purchase_date', label: 'Date', fmt: function (v) { return dateTime(v); } }, + { key: 'amazon_order_id', label: 'Order', mono: true }, + { key: 'status', label: 'Status', render: function (r) { return badge(r.status, { Shipped: 'green', Canceled: 'red', Pending: 'amber', Unshipped: 'blue' }[r.status] || ''); } }, + { key: 'items', label: 'Items', sort: false, csvValue: function (r) { return r.items.map(function (i) { return i.qty + 'x ' + i.sku; }).join('; '); }, render: function (r) { return h('div', { class: 'small' }, r.items.map(function (i) { return h('div', null, i.qty + '× ', h('b', null, i.sku)); })); } }, + { key: 'units', label: 'Units', align: 'r', fmt: int, sum: true }, + { key: 'sales', label: 'Sales', align: 'r', fmt: money, sum: true }, + { key: 'fees', label: 'Fees', align: 'r', sum: true, fmt: money, render: function (r) { return h('span', null, money(r.fees), r.fees_estimated ? h('span', { class: 'muted small', title: 'Estimated — not settled yet' }, ' est.') : null); } }, + { key: 'refund', label: 'Refund', align: 'r', fmt: money, sum: true }, + { key: 'cogs', label: 'COGS', align: 'r', fmt: money, sum: true }, + { key: 'profit', label: 'Profit', align: 'r', fmt: money, tone: true, sum: true }, + { key: 'fulfillment_channel', label: 'Ch.', fmt: function (v) { return v === 'AFN' ? 'FBA' : 'FBM'; } } + ], + rows: d.orders, empty: empty('No orders in this range', state.connected ? 'Orders sync every 15 minutes.' : 'Connect Amazon or load the demo account.') + })); + }, function (e) { clear(slot).appendChild(failure(e)); }); + } + wrap.appendChild(pageHead('Orders', 'Every order with its own profit — near real time from the Orders API, backfilled from the all-orders report.', [q, status, btn('Search', load, 'primary sm'), rangeBar(load)])); + wrap.appendChild(slot); + q.addEventListener('keydown', function (e) { if (e.key === 'Enter') { load(); } }); + load(); + return wrap; + }; + + /* ---------- Restock ---------- */ + var STATUS = { + out_of_stock: ['Out of stock', 'red'], critical: ['Critical', 'red'], stockout_risk: ['Gap before inbound', 'red'], reorder_now: ['Reorder now', 'amber'], + reorder_soon: ['Reorder soon', 'blue'], healthy: ['Healthy', 'green'], overstock: ['Overstock', 'violet'], no_sales: ['No sales', ''] + }; + PAGES.restock = function () { + return Promise.all([api('/restock'), api('/inventory-health')]).then(function (res) { + var d = res[0], ih = res[1], S = d.summary; + var k = h('div', { class: 'sgs-grid k5' }, + kpi('SKUs tracked', int(S.skus)), + kpi('Need reorder', int(S.reorder), 'critical + reorder now', S.reorder ? 'tone-amber' : ''), + kpi('Out of stock', int(S.out_of_stock), S.lost_sales_per_day > 0 ? '≈' + money(S.lost_sales_per_day) + '/day lost' : 'none losing sales', S.out_of_stock ? 'tone-red' : 'tone-green'), + kpi('Suggested PO', int(S.order_units) + ' units', money(S.order_value) + ' at landed cost'), + kpi('Overstock', int(S.overstock), '> overstock threshold days of cover')); + var t = table({ + title: 'Restock planner', sub: 'Sorted by urgency. Velocity blends 7/30/90-day sales (stock-out days excluded). Order qty covers lead time + target cover, rounded to case pack and MOQ.', csv: 'restock-plan', per: 100, + columns: [ + { key: 'status', label: 'Status', render: function (r) { var s = STATUS[r.status] || [r.status, '']; return badge(s[0], s[1]); }, csvValue: function (r) { return r.status; } }, + { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), h('span', { class: 'title muted small' }, r.title)); } }, + { key: 'fulfillable', label: 'Available', align: 'r', fmt: int }, + { key: 'inbound', label: 'Inbound', align: 'r', fmt: int }, + { key: 'velocity', label: 'Units/day', align: 'r', fmt: function (v) { return num(v).toFixed(1); } }, + { key: 'trend_pct', label: '7d vs 30d', align: 'r', render: function (r) { return r.trend_pct === null ? '—' : h('span', { class: tone(r.trend_pct) }, signed(r.trend_pct, function (v) { return pct(v, 0); })); } }, + { key: 'days_of_cover', label: 'Days of cover', align: 'r', render: function (r) { return r.days_of_cover === null ? '—' : h('div', null, int(r.days_of_cover), meter(r.days_of_cover / 1.2, r.days_of_cover < r.lead_time ? 'red' : (r.days_of_cover < r.lead_time + 30 ? 'amber' : 'green'))); } }, + { key: 'stockout_date', label: 'Stock-out', fmt: shortDate }, + { key: 'reorder_by', label: 'Order by', render: function (r) { return r.reorder_by ? h('span', { class: r.reorder_by <= ymd(new Date()) ? 'neg' : '' }, shortDate(r.reorder_by)) : '—'; } }, + { key: 'lead_time', label: 'Lead', align: 'r', fmt: function (v) { return v + 'd'; } }, + { key: 'order_qty', label: 'Order qty', align: 'r', render: function (r) { return r.order_qty ? h('b', null, int(r.order_qty)) : '—'; }, sum: true, fmt: int }, + { key: 'order_value', label: 'PO value', align: 'r', fmt: money, sum: true } + ], + rows: d.rows, empty: empty('No inventory yet', 'Run a product sync from Seven Gum → Products, or load the demo account.') + }); + var aged = table({ + title: 'Inventory age & storage (FBA inventory planning report)', sub: ih.snapshot ? 'Snapshot ' + ih.snapshot : 'Requested daily via the Reports API.', csv: 'inventory-age', + columns: [ + { key: 'sku', label: 'SKU' }, { key: 'available', label: 'Available', align: 'r', fmt: int }, + { key: 'age_0_90', label: '0–90d', align: 'r', fmt: int }, { key: 'age_91_180', label: '91–180d', align: 'r', fmt: int }, + { key: 'age_181_270', label: '181–270d', align: 'r', fmt: int }, { key: 'age_271_365', label: '271–365d', align: 'r', fmt: int }, + { key: 'age_365_plus', label: '365d+', align: 'r', fmt: int }, + { key: 'days_of_supply', label: 'Days supply', align: 'r', fmt: int }, + { key: 'est_storage_cost', label: 'Storage next mo.', align: 'r', fmt: money, sum: true }, + { key: 'recommended_action', label: 'Amazon recommends' } + ], rows: ih.rows + }); + return h('div', { class: 'sgs-stack' }, pageHead('Inventory & Restock', 'Never stock out, never over-pay storage: demand forecasting, reorder dates, PO quantities and aged-inventory risk.', [h('a', { class: 'btn sm', href: '#/profit?tab=costs' }, 'Edit lead times & case packs')]), k, t, aged); + }); + }; + + /* ---------- PPC ---------- */ + PAGES.ppc = function () { + var tab = (window.location.hash.split('tab=')[1] || 'optimizer'); + var wrap = h('div', { class: 'sgs-stack' }); + var body = h('div', { class: 'sgs-stack' }); + function setTab(t) { tab = t; history.replaceState(null, '', '#/ppc?tab=' + t); draw(); } + function draw() { + clear(wrap); + clear(body).appendChild(loading()); + wrap.appendChild(pageHead('PPC Manager', 'Sponsored Products automation: rule-based bid optimisation to your target ACoS, search-term harvesting, negative keywords and budget pacing — always reviewable before it touches Amazon.', [rangeBar(draw)])); + api('/ads/overview', { query: rangeParams() }).then(function (o) { + var T = o.totals; + var k = h('div', { class: 'sgs-grid k5' }, + kpi('Spend', money(T.cost), int(T.clicks) + ' clicks · CPC ' + money(T.cpc)), + kpi('Ad sales', money(T.sales), int(T.orders) + ' orders · CVR ' + pct(T.cvr)), + kpi('ACoS', pct(T.acos), 'ROAS ' + (T.roas || '—'), num(T.acos) > 40 ? 'tone-red' : 'tone-green'), + kpi('CTR', pct(T.ctr, 2), int(T.impressions) + ' impressions'), + kpi('Wasted spend', money(T.wasted_spend), 'search terms with 0 orders', T.wasted_spend > 0 ? 'tone-amber' : '')); + var ch = card('Spend vs ad sales', chart({ + labels: o.series.map(function (r) { return r.date; }), + series: [ + { label: 'Ad sales', values: o.series.map(function (r) { return r.sales; }), type: 'bar', color: C.sales }, + { label: 'Spend', values: o.series.map(function (r) { return r.cost; }), type: 'bar', color: C.ads }, + { label: 'ACoS', values: o.series.map(function (r) { return r.acos; }), type: 'line', color: C.refunds, axis: 'right', fmt: function (v) { return pct(v); } } + ], height: 220 + })); + var info = !o.ready && !state.demo ? h('div', { class: 'sgs-banner info' }, h('span', null, h('b', null, 'Amazon Ads API not connected. '), 'Add your Ads API credentials and pick a profile in Settings to sync campaigns and apply changes.'), h('a', { class: 'btn sm', href: '#/settings' }, 'Connect Ads API')) : null; + clear(wrap); + wrap.appendChild(pageHead('PPC Manager', 'Sponsored Products automation: rule-based bid optimisation to your target ACoS, search-term harvesting, negative keywords and budget pacing — always reviewable before it touches Amazon.', [rangeBar(draw)])); + add(wrap, [info, k, ch, h('div', null, seg([['optimizer', 'Optimizer' + (o.planned ? ' (' + o.planned + ')' : '')], ['campaigns', 'Campaigns'], ['targets', 'Keywords & targets'], ['terms', 'Search terms'], ['history', 'Change log']], tab, setTab)), body]); + var views = { optimizer: optimizer, campaigns: campaigns, targets: targets, terms: terms, history: historyView }; + var view = views[pick(Object.keys(views), tab, 'optimizer')]; + view().then(function (n) { clear(body).appendChild(n); }, function (e) { clear(body).appendChild(failure(e)); }); + }, function (e) { clear(wrap).appendChild(failure(e)); }); + } + function kcols() { + return [ + { key: 'impressions', label: 'Impr.', align: 'r', fmt: int, sum: true }, + { key: 'clicks', label: 'Clicks', align: 'r', fmt: int, sum: true }, + { key: 'ctr', label: 'CTR', align: 'r', fmt: function (v) { return pct(v, 2); } }, + { key: 'cpc', label: 'CPC', align: 'r', fmt: money }, + { key: 'cost', label: 'Spend', align: 'r', fmt: money, sum: true }, + { key: 'sales', label: 'Sales', align: 'r', fmt: money, sum: true }, + { key: 'orders', label: 'Orders', align: 'r', fmt: int, sum: true }, + { key: 'cvr', label: 'CVR', align: 'r', fmt: function (v) { return pct(v); } }, + { key: 'acos', label: 'ACoS', align: 'r', render: function (r) { return r.acos === null ? '—' : h('span', { class: num(r.acos) > 40 ? 'neg' : (num(r.acos) < 25 ? 'pos' : '') }, pct(r.acos)); } } + ]; + } + function optimizer() { + return api('/ads/actions').then(function (d) { + var KIND = { bid: ['Bid', 'blue'], pause: ['Pause', 'red'], negative: ['Negative', 'red'], harvest: ['Harvest → exact', 'green'], harvest_asin: ['Harvest ASIN', 'green'], budget: ['Budget', 'violet'] }; + var t = table({ + title: d.planned.length + ' recommended changes', sub: 'Nothing is pushed until you apply it. Bids move toward (revenue per click × target ACoS) and never jump more than the max step.', csv: 'ppc-plan', selectable: 'id', sort: 'kind', dir: 1, per: 100, + bulk: function (getSel) { + return h('div', { class: 'btns' }, + btn('Apply selected', function () { var ids = getSel().map(function (r) { return r.id; }); if (!ids.length) { toast('Select rows first'); return Promise.resolve(); } return api('/ads/apply', { method: 'POST', body: { ids: ids } }).then(function (r) { toast(r.applied + ' applied' + (r.failed ? ', ' + r.failed + ' failed' : ''), r.failed ? 'bad' : 'ok'); draw(); }); }, 'green sm'), + btn('Dismiss selected', function () { var ids = getSel().map(function (r) { return r.id; }); if (!ids.length) { return Promise.resolve(); } return api('/ads/dismiss', { method: 'POST', body: { ids: ids } }).then(function () { draw(); }); }, 'sm')); + }, + columns: [ + { key: 'kind', label: 'Action', render: function (r) { var k = KIND[r.kind] || [r.kind, '']; return badge(k[0], k[1]); } }, + { key: 'label', label: 'Keyword / target / campaign', render: function (r) { return h('b', null, r.label); } }, + { key: 'old_value', label: 'Now', align: 'r' }, + { key: 'new_value', label: 'Proposed', align: 'r', render: function (r) { var up = num(r.new_value) > num(r.old_value); return h('b', { class: r.kind === 'bid' || r.kind === 'budget' ? (up ? 'pos' : 'neg') : '' }, r.new_value); } }, + { key: 'reason', label: 'Why', render: function (r) { return h('span', { class: 'small muted' }, r.reason); } } + ], + rows: d.planned, + actions: btn('Re-run optimizer', function () { return api('/ads/optimize', { method: 'POST' }).then(function (r) { toast(r.bids + ' bid, ' + r.terms + ' search-term and ' + r.budgets + ' budget changes planned', 'ok'); draw(); }); }, 'primary'), + empty: empty('No changes recommended', 'Run the optimizer after your ads data syncs (daily).') + }); + return t; + }); + } + function campaigns() { + return api('/ads/campaigns', { query: rangeParams() }).then(function (d) { + return table({ title: 'Campaigns', csv: 'campaigns', sort: 'cost', columns: [ + { key: 'name', label: 'Campaign', render: function (r) { return h('div', null, h('b', null, r.name), h('div', { class: 'small muted' }, r.targeting_type + ' · ' + (r.bidding_strategy || '').replace(/_/g, ' ').toLowerCase())); } }, + { key: 'state', label: 'State', render: function (r) { return badge(r.state, r.state === 'ENABLED' ? 'green' : ''); } }, + { key: 'daily_budget', label: 'Budget/day', align: 'r', fmt: money } + ].concat(kcols()), rows: d.rows, onRow: function (r) { targetsDrawer(r); } }); + }); + } + function targetsDrawer(c) { + var dr = drawer(c.name, loading()); + api('/ads/targets', { query: Object.assign(rangeParams(), { campaign: c.campaign_id }) }).then(function (d) { + clear(dr.body).appendChild(table({ title: 'Keywords & targets', search: false, sort: 'cost', columns: [ + { key: 'keyword_text', label: 'Keyword / target', render: function (r) { return h('div', null, h('b', null, r.keyword_text), ' ', badge(r.match_type)); } }, + { key: 'bid', label: 'Bid', align: 'r', fmt: money } + ].concat(kcols()), rows: d.rows })); + }); + } + function targets() { + return api('/ads/targets', { query: rangeParams() }).then(function (d) { + return table({ title: 'Keywords & product targets', csv: 'ppc-targets', sort: 'cost', columns: [ + { key: 'keyword_text', label: 'Keyword / target', render: function (r) { return h('div', null, h('b', null, r.keyword_text), ' ', badge(r.match_type), h('div', { class: 'small muted' }, r.campaign)); } }, + { key: 'state', label: 'State', render: function (r) { return badge(r.state, r.state === 'ENABLED' ? 'green' : ''); } }, + { key: 'bid', label: 'Bid', align: 'r', fmt: money } + ].concat(kcols()), rows: d.rows }); + }); + } + function terms() { + return api('/ads/search-terms', { query: rangeParams() }).then(function (d) { + return table({ title: 'Customer search terms', sub: 'What shoppers actually typed. Zero-order terms with many clicks are negative candidates; converting terms are harvest candidates.', csv: 'search-terms', sort: 'cost', columns: [ + { key: 'search_term', label: 'Search term', render: function (r) { return h('div', null, h('b', null, r.search_term), h('div', { class: 'small muted' }, 'via ' + r.keyword_text + ' (' + r.match_type + ')')); } } + ].concat(kcols()), rows: d.rows }); + }); + } + function historyView() { + return api('/ads/actions').then(function (d) { + return table({ title: 'Change log', csv: 'ppc-change-log', sort: 'applied_at', columns: [ + { key: 'applied_at', label: 'When', fmt: function (v, r) { return dateTime(v || r.created_at); } }, + { key: 'kind', label: 'Action' }, { key: 'label', label: 'Entity' }, + { key: 'old_value', label: 'From', align: 'r' }, { key: 'new_value', label: 'To', align: 'r' }, + { key: 'status', label: 'Status', render: function (r) { return badge(r.status, { applied: 'green', failed: 'red', dismissed: '' }[r.status]); } }, + { key: 'error', label: 'Error', render: function (r) { return h('span', { class: 'small neg' }, r.error || ''); } } + ], rows: d.history }); + }); + } + draw(); + return wrap; + }; + + /* ---------- Keywords ---------- */ + PAGES.keywords = function () { + return api('/keywords').then(function (d) { + var asinSel = h('select', null, d.asins.map(function (a) { return h('option', { value: a }, a); })); + var kwInput = h('textarea', { placeholder: 'One keyword per line', rows: 3 }); + var prio = h('select', null, h('option', { value: 1 }, 'High'), h('option', { value: 2, selected: true }, 'Medium'), h('option', { value: 3 }, 'Low')); + var addCard = card('Track keywords', h('div', { class: 'form-grid' }, + field('ASIN', asinSel), field('Priority', prio), h('div', { class: 'field', style: { gridColumn: 'span 2' } }, h('label', null, 'Keywords'), kwInput), + h('div', { class: 'field' }, h('label', null, ' '), btn('Add', function () { + return api('/keywords', { method: 'POST', body: { asin: asinSel.value, keywords: kwInput.value, priority: prio.value } }).then(function (r) { toast(r.added + ' keyword(s) tracked', 'ok'); route(); }); + }, 'primary'))), { sub: 'Share of search comes from Brand Analytics Search Query Performance (weekly, brand-registered sellers).' }); + + var tracked = table({ + title: 'Tracked keywords — share of search', sub: d.rows[0] && d.rows[0].week ? 'Week of ' + shortDate(d.rows[0].week) : 'Waiting for the first weekly SQP report.', csv: 'tracked-keywords', sort: 'volume', + columns: [ + { key: 'keyword', label: 'Keyword', render: function (r) { return h('div', null, h('b', null, r.keyword), ' ', badge(['', 'High', 'Med', 'Low'][r.priority] || '', r.priority == 1 ? 'dark' : '')); } }, + { key: 'asin', label: 'ASIN', mono: true, render: function (r) { return h('a', { href: amazonUrl(r.asin), target: '_blank', rel: 'noopener' }, r.asin); } }, + { key: 'volume', label: 'Search volume / wk', align: 'r', fmt: int }, + { key: 'impr_share', label: 'Impr. share', align: 'r', fmt: function (v) { return pct(v, 2); } }, + { key: 'click_share', label: 'Click share', align: 'r', fmt: function (v) { return pct(v, 2); } }, + { key: 'purchase_share', label: 'Purchase share', align: 'r', render: function (r) { return h('span', null, pct(r.purchase_share, 2), r.purchase_share_prev !== null ? delta(r.purchase_share, r.purchase_share_prev) : null); } }, + { key: 'ctr', label: 'CTR', align: 'r', fmt: function (v) { return pct(v, 2); } }, + { key: 'cvr', label: 'CVR', align: 'r', fmt: function (v) { return pct(v, 1); } }, + { key: 'trend', label: '8-wk impr. share', sort: false, csv: false, render: function (r) { return r.trend && r.trend.length ? sparkline(r.trend, C.sales) : '—'; } }, + { key: 'id', label: '', sort: false, csv: false, render: function (r) { return btn('×', function () { return api('/keywords/' + r.id, { method: 'DELETE' }).then(route); }, 'sm ghost', { title: 'Stop tracking' }); } } + ], rows: d.rows, empty: empty('No keywords tracked', 'Add keywords above or discover them below.') + }); + + var resAsin = h('input', { type: 'text', placeholder: 'ASIN(s), comma separated', value: d.asins.slice(0, 3).join(', ') }); + var resSlot = h('div'); + var research = card('Keyword research', h('div', { class: 'sgs-stack' }, + h('div', { class: 'btns' }, resAsin, btn('Find keywords', function () { + clear(resSlot).appendChild(loading()); + return api('/keywords/research', { method: 'POST', body: { asins: resAsin.value.split(/[\s,]+/).filter(Boolean) } }).then(function (r) { + clear(resSlot).appendChild(table({ search: true, csv: 'keyword-ideas', sort: 'orders', columns: [ + { key: 'keyword', label: 'Keyword', render: function (k) { return h('b', null, k.keyword); } }, + { key: 'source', label: 'Source', render: function (k) { return badge({ amazon_ads: 'Amazon Ads', your_ads: 'Your search terms', both: 'Both' }[k.source] || k.source, k.source === 'both' ? 'green' : 'blue'); } }, + { key: 'suggested_bid', label: 'Suggested bid', align: 'r', fmt: money }, + { key: 'impression_rank', label: 'Impr. rank', align: 'r' }, + { key: 'orders', label: 'Your orders', align: 'r', fmt: int }, + { key: 'acos', label: 'Your ACoS', align: 'r', fmt: function (v) { return pct(v); } }, + { key: 'keyword', label: '', sort: false, csv: false, render: function (k) { return btn('Track', function () { return api('/keywords', { method: 'POST', body: { asin: (resAsin.value.split(/[\s,]+/)[0] || asinSel.value), keywords: [k.keyword] } }).then(function () { toast('Tracking “' + k.keyword + '”', 'ok'); }); }, 'sm'); } } + ], rows: r.rows, empty: empty('No ideas yet', 'Connect the Ads API for Amazon keyword recommendations; converting search terms appear once ads data syncs.') })); + }); + }, 'primary')), resSlot), { sub: 'Amazon Ads keyword recommendations for your ASINs + search terms that already convert in your campaigns.' }); + + var qAsin = h('select', null, d.asins.map(function (a) { return h('option', { value: a }, a); })); + var qSlot = h('div'); + var sqp = card('All search queries for an ASIN (Brand Analytics)', h('div', { class: 'sgs-stack' }, h('div', { class: 'btns' }, qAsin, btn('Load', function () { + return api('/keywords/queries', { query: { asin: qAsin.value } }).then(function (r) { + clear(qSlot).appendChild(table({ search: true, csv: 'sqp-queries', sort: 'query_volume', columns: [ + { key: 'keyword', label: 'Query', render: function (k) { return h('b', null, k.keyword); } }, + { key: 'query_volume', label: 'Volume', align: 'r', fmt: int }, + { key: 'impr_share', label: 'Impr. share', align: 'r', fmt: function (v) { return pct(v, 2); } }, + { key: 'click_share', label: 'Click share', align: 'r', fmt: function (v) { return pct(v, 2); } }, + { key: 'purchase_share', label: 'Purchase share', align: 'r', fmt: function (v) { return pct(v, 2); } }, + { key: 'purchases_asin', label: 'Your purchases', align: 'r', fmt: int } + ], rows: r.rows })); + }); + }, 'sm')), qSlot)); + + return h('div', { class: 'sgs-stack' }, pageHead('Keywords', 'Track share of search per keyword, discover new keywords and see every query shoppers use to find your ASINs.'), addCard, tracked, research, sqp); + }); + }; + + /* ---------- Listings ---------- */ + PAGES.listings = function () { + return api('/listings').then(function (d) { + var avg = d.rows.length ? Math.round(d.rows.reduce(function (a, r) { return a + num(r.score); }, 0) / d.rows.length) : null; + var sup = d.rows.filter(function (r) { return r.status === 'suppressed'; }).length; + var k = h('div', { class: 'sgs-grid k4' }, + kpi('Average listing score', avg === null ? '—' : avg + '/100', d.rows.length + ' listings audited', avg !== null && avg < 75 ? 'tone-amber' : 'tone-green'), + kpi('Suppressed / inactive', int(sup), 'not buyable right now', sup ? 'tone-red' : 'tone-green'), + kpi('Errors to fix', int(d.rows.reduce(function (a, r) { return a + r.findings.filter(function (f) { return f.severity === 'error'; }).length; }, 0)), 'policy & suppression risks'), + kpi('Below grade B', int(d.rows.filter(function (r) { return r.score < 80; }).length), 'conversion upside')); + var warn = !d.seller_id_set && !state.demo ? h('div', { class: 'sgs-banner warn' }, h('span', null, h('b', null, 'Seller ID needed. '), 'Listing audits read your listings through the Listings Items API, which needs your Merchant Token.'), h('a', { class: 'btn sm', href: '#/settings' }, 'Add Seller ID')) : null; + var t = table({ + title: 'Listing quality', sub: 'Title, bullets, description, backend keywords, images, Amazon issues and tracked-keyword coverage. Click a row for the full audit and an AI rewrite.', csv: 'listing-audit', sort: 'score', dir: 1, + columns: [ + { key: 'score', label: 'Score', render: function (r) { var g = r.score >= 90 ? 'A' : r.score >= 80 ? 'B' : r.score >= 65 ? 'C' : r.score >= 50 ? 'D' : 'F'; return h('div', { class: 'btns' }, h('span', { class: 'grade ' + g }, g), h('b', null, r.score)); } }, + { key: 'sku', label: 'SKU', render: function (r) { return h('div', null, h('b', null, r.sku), h('span', { class: 'title muted small' }, (r.snapshot && r.snapshot.title) || '')); } }, + { key: 'status', label: 'Status', render: function (r) { return badge(r.status, r.status === 'active' ? 'green' : (r.status === 'suppressed' ? 'red' : '')); } }, + { key: 'errors', label: 'Errors', align: 'r', render: function (r) { var n = r.findings.filter(function (f) { return f.severity === 'error'; }).length; return n ? h('b', { class: 'neg' }, n) : '0'; } }, + { key: 'warnings', label: 'Warnings', align: 'r', render: function (r) { return String(r.findings.filter(function (f) { return f.severity === 'warning'; }).length); } }, + { key: 'audited_at', label: 'Audited', fmt: function (v) { return dateTime(v); } } + ], + rows: d.rows, onRow: listingDrawer, + actions: btn('Audit all listings', function () { return api('/listings/audit', { method: 'POST', body: {} }).then(function (r) { toast(r.audited + ' listing(s) audited', 'ok'); route(); }); }, 'primary'), + empty: empty('No audits yet', 'Click “Audit all listings”.') + }); + return h('div', { class: 'sgs-stack' }, pageHead('Listing Optimizer', 'Grade every listing against Amazon’s style guide and the 2025 title policy, catch suppressions, and rewrite copy with AI in your brand voice.'), warn, k, t); + }); + }; + + function findingsList(findings) { + var SEV = { error: ['Error', 'red'], warning: ['Warning', 'amber'], info: ['Tip', 'blue'] }; + return h('div', null, findings.length ? findings.map(function (f) { var s = SEV[f.severity] || [f.severity, '']; return h('div', { class: 'finding' }, badge(s[0], s[1]), h('span', { class: 'muted small', style: { minWidth: '80px' } }, f.section), h('span', null, f.message)); }) : empty('Perfect', 'No findings.')); + } + + function listingDrawer(r) { + var c = r.snapshot || {}; + var aiSlot = h('div'); + var content = h('div', { class: 'sgs-stack' }, + h('div', { class: 'sgs-grid k3' }, kpi('Score', r.score + '/100'), kpi('Status', r.status), kpi('Images', (c.images || []).length)), + card('Findings', findingsList(r.findings)), + card('Current content', h('dl', { class: 'kv' }, + h('dt', null, 'Title (' + (c.title || '').length + ' chars)'), h('dd', null, c.title || '—'), + h('dt', null, 'Bullets'), h('dd', null, (c.bullets || []).length ? h('ol', { style: { margin: 0, paddingLeft: '18px' } }, (c.bullets || []).map(function (b) { return h('li', null, b); })) : '—'), + h('dt', null, 'Backend terms'), h('dd', null, c.backend || '—'), + h('dt', null, 'Description'), h('dd', { class: 'small' }, (c.description || '—').slice(0, 600)))), + card('AI rewrite', aiSlot, { sub: 'Uses your AI provider (Seven Gum → AI) with your tracked keywords. Review before publishing — nothing is pushed automatically.' })); + aiSlot.appendChild(btn('Generate optimized copy', function () { + clear(aiSlot).appendChild(loading()); + return api('/listings/ai', { method: 'POST', body: { sku: r.sku } }).then(function (x) { + var dft = x.draft; + var text = 'TITLE:\n' + dft.title + '\n\nBULLETS:\n- ' + dft.bullets.join('\n- ') + '\n\nBACKEND SEARCH TERMS:\n' + dft.backend; + clear(aiSlot).appendChild(h('div', { class: 'sgs-stack' }, + h('div', { class: 'btns' }, badge('New score ' + x.score.score + '/100', x.score.score >= r.score ? 'green' : 'amber'), h('span', { class: 'muted small' }, x.provider + ' · ' + x.model), btn('Copy', function () { return copy(text); }, 'sm')), + h('div', { class: 'pre' }, text), findingsList(x.score.findings))); + }, function (e) { clear(aiSlot).appendChild(failure(e)); }); + }, 'primary')); + drawer(r.sku, content, [ + r.asin ? h('a', { class: 'btn sm', href: amazonUrl(r.asin), target: '_blank', rel: 'noopener' }, 'View on Amazon') : null, + btn('Re-audit', function () { return api('/listings/audit', { method: 'POST', body: { sku: r.sku } }).then(function () { toast('Audited', 'ok'); route(); }); }, 'sm') + ]); + } + + /* ---------- Monitor ---------- */ + PAGES.monitor = function () { + return api('/monitor').then(function (d) { + var mine = d.rows.filter(function (r) { return +r.is_mine; }); + var won = mine.filter(function (r) { return +r.buybox_is_mine; }).length; + var k = h('div', { class: 'sgs-grid k4' }, + kpi('Buy Box share', mine.length ? pct(won / mine.length * 100) : '—', won + ' of ' + mine.length + ' ASINs', won === mine.length ? 'tone-green' : 'tone-amber'), + kpi('Hijackers / other sellers', int(d.hijacks.length), 'on your listings right now', d.hijacks.length ? 'tone-red' : 'tone-green'), + kpi('ASINs watched', int(d.watching), 'yours + competitors'), + kpi('Competitors', int(d.rows.length - mine.length), 'add more in Settings')); + var hij = d.hijacks.length ? table({ title: '⚠️ Sellers on your listings', search: false, columns: [ + { key: 'asin', label: 'ASIN', mono: true, render: function (r) { return h('a', { href: amazonUrl(r.asin), target: '_blank', rel: 'noopener' }, r.asin); } }, + { key: 'seller_id', label: 'Seller', mono: true, render: function (r) { return h('a', { href: 'https://www.amazon.' + marketInfo().tld + '/sp?seller=' + encodeURIComponent(r.seller_id), target: '_blank', rel: 'noopener' }, r.seller_id); } }, + { key: 'is_fba', label: 'Fulfilment', render: function (r) { return +r.is_fba ? 'FBA' : 'FBM'; } }, + { key: 'last_price', label: 'Price', align: 'r', fmt: money }, + { key: 'first_seen', label: 'First seen', fmt: function (v) { return dateTime(v); } } + ], rows: d.hijacks }) : null; + var t = table({ + title: 'Listing watch', sub: 'Buy Box owner, price, offers and BSR from the Product Pricing API every 15 minutes (rotating batches). Click for price/BSR history.', csv: 'listing-watch', sort: 'is_mine', + columns: [ + { key: 'asin', label: 'ASIN', mono: true, render: function (r) { return h('div', null, h('b', null, r.asin), ' ', +r.is_mine ? badge('yours', 'dark') : badge('competitor')); } }, + { key: 'buybox_is_mine', label: 'Buy Box', render: function (r) { return +r.is_mine ? (+r.buybox_is_mine ? badge('Winning', 'green') : badge(r.buybox_seller ? 'Lost' : 'Suppressed', 'red')) : (r.buybox_seller || '—'); } }, + { key: 'buybox_price', label: 'Buy Box price', align: 'r', fmt: money }, + { key: 'my_price', label: 'Your price', align: 'r', fmt: money }, + { key: 'lowest_price', label: 'Lowest', align: 'r', fmt: money }, + { key: 'offer_count', label: 'Offers', align: 'r', fmt: int }, + { key: 'other_sellers', label: 'Other sellers', align: 'r', render: function (r) { return +r.is_mine && +r.other_sellers ? h('b', { class: 'neg' }, r.other_sellers) : String(r.other_sellers || 0); } }, + { key: 'bsr', label: 'BSR', align: 'r', fmt: int }, + { key: 'captured_at', label: 'Checked', fmt: function (v) { return dateTime(v); } } + ], + rows: d.rows, onRow: monitorDrawer, + actions: btn('Check now', function () { return api('/monitor/run', { method: 'POST' }).then(function (r) { toast(r.checked + ' ASIN(s) checked, ' + r.alerts + ' alert(s)', 'ok'); route(); }); }, 'primary'), + empty: empty('Nothing watched yet', 'Your ASINs appear after the first product sync.') + }); + return h('div', { class: 'sgs-stack' }, pageHead('Hijackers & Buy Box', 'Instant alerts when a new seller jumps on your listing, when you lose the Buy Box, or when a competitor slashes price — with Keepa-style history.'), k, hij, t); + }); + }; + + function monitorDrawer(r) { + var dr = drawer(r.asin + (+r.is_mine ? ' (yours)' : ''), loading(), [h('a', { class: 'btn sm', href: amazonUrl(r.asin), target: '_blank', rel: 'noopener' }, 'View on Amazon')]); + api('/monitor/history', { query: { asin: r.asin, days: 90 } }).then(function (d) { + var hst = d.history; + clear(dr.body).appendChild(h('div', { class: 'sgs-stack' }, + card('Price history', chart({ labels: hst.map(function (x) { return x.captured_at; }), series: [ + { label: 'Buy Box', values: hst.map(function (x) { return x.buybox_price; }), type: 'line', color: C.sales }, + { label: 'Lowest', values: hst.map(function (x) { return x.lowest_price; }), type: 'line', color: C.refunds }, + { label: 'Your price', values: hst.map(function (x) { return x.my_price; }), type: 'line', color: C.profit } + ], height: 200, fmt: money })), + card('Best Sellers Rank', chart({ labels: hst.map(function (x) { return x.captured_at; }), series: [ + { label: 'BSR (lower is better)', values: hst.map(function (x) { return x.bsr; }), type: 'area', color: C.units, fmt: int } + ], height: 170, fmt: int })), + table({ title: 'Sellers seen on this listing', search: false, columns: [ + { key: 'seller_id', label: 'Seller', mono: true, render: function (x) { return h('span', null, x.seller_id, ' ', +x.is_me ? badge('you', 'dark') : null); } }, + { key: 'is_fba', label: 'FBA', render: function (x) { return +x.is_fba ? 'FBA' : 'FBM'; } }, + { key: 'last_price', label: 'Price', align: 'r', fmt: money }, + { key: 'active', label: 'Active', render: function (x) { return +x.active ? badge('active', 'green') : badge('gone'); } }, + { key: 'first_seen', label: 'First seen', fmt: function (v) { return dateTime(v); } }, + { key: 'last_seen', label: 'Last seen', fmt: function (v) { return dateTime(v); } } + ], rows: d.sellers }))); + }, function (e) { clear(dr.body).appendChild(failure(e)); }); + } + + /* ---------- Reviews ---------- */ + PAGES.reviews = function () { + return Promise.all([api('/reviews'), api('/feedback')]).then(function (res) { + var d = res[0], fb = res[1]; + var off = !d.enabled ? h('div', { class: 'sgs-banner info' }, h('span', null, h('b', null, 'Automatic review requests are off. '), 'Turn them on in Settings — the suite then sends Amazon’s own “Request a Review” for every eligible order (5–30 days after delivery), skipping refunded and returned orders.'), h('a', { class: 'btn sm', href: '#/settings' }, 'Enable')) : null; + var k = h('div', { class: 'sgs-grid k4' }, + kpi('Requests sent (30d)', int(d.by_status.sent), 'via Solicitations API', 'tone-green'), + kpi('Waiting for window', int(d.pending), 'eligible orders queued'), + kpi('Ineligible', int(d.by_status.ineligible), 'outside window / already requested'), + kpi('Seller feedback', fb.avg === null ? '—' : fb.avg + '★', fb.count + ' ratings · ' + pct(fb.negative_pct) + ' negative', num(fb.negative_pct) > 5 ? 'tone-red' : '')); + var ch = card('Requests sent per day', chart({ labels: d.daily.map(function (x) { return x.d; }), series: [{ label: 'Sent', values: d.daily.map(function (x) { return x.sent; }), type: 'bar', color: C.units, fmt: int }], height: 180, fmt: int })); + var t = table({ title: 'Recent requests', csv: 'review-requests', sort: 'attempted_at', columns: [ + { key: 'attempted_at', label: 'When', fmt: function (v) { return dateTime(v); } }, + { key: 'amazon_order_id', label: 'Order', mono: true }, + { key: 'status', label: 'Result', render: function (r) { return badge(r.status, { sent: 'green', ineligible: '', error: 'red' }[r.status]); } }, + { key: 'message', label: 'Note', render: function (r) { return h('span', { class: 'small muted' }, r.message); } } + ], rows: d.recent, actions: btn('Send eligible now', function () { return api('/reviews/run', { method: 'POST' }).then(function (r) { toast(r.sent + ' sent, ' + r.ineligible + ' ineligible', 'ok'); route(); }); }, 'primary') }); + var f = table({ title: 'Seller feedback', csv: 'seller-feedback', sort: 'feedback_date', columns: [ + { key: 'feedback_date', label: 'Date', fmt: shortDate }, + { key: 'rating', label: 'Rating', render: function (r) { return h('span', { class: +r.rating <= 2 ? 'neg' : '' }, '★'.repeat(+r.rating) + '☆'.repeat(5 - +r.rating)); } }, + { key: 'comments', label: 'Comment' }, { key: 'amazon_order_id', label: 'Order', mono: true } + ], rows: fb.rows }); + return h('div', { class: 'sgs-stack' }, pageHead('Review Requests', 'Fully Amazon-compliant review automation — the same request as the Seller Central button, sent at the best moment for every order.'), off, k, ch, t, f); + }); + }; + + /* ---------- Reimbursements ---------- */ + PAGES.reimbursements = function () { + var status = (window.location.hash.split('status=')[1] || 'open'); + return api('/reimbursements', { query: { status: status } }).then(function (d) { + var KIND = { lost_warehouse: ['Lost in warehouse', 'red'], damaged_warehouse: ['Damaged in warehouse', 'amber'], refund_no_return: ['Refunded, never returned', 'violet'], fee_overcharge: ['FBA fee overcharge', 'blue'] }; + var open = d.rows.filter(function (r) { return r.status === 'open'; }); + var k = h('div', { class: 'sgs-grid k4' }, + kpi('Recoverable (open)', money(open.reduce(function (a, r) { return a + num(r.est_amount); }, 0)), open.length + ' case(s) ready to file', 'tone-green'), + kpi('Reimbursed by Amazon (12 mo)', money(d.summary.reimbursed_12m), 'from the reimbursements report'), + kpi('Lost / damaged units', int(d.rows.filter(function (r) { return r.kind.indexOf('warehouse') >= 0; }).reduce(function (a, r) { return a + num(r.qty); }, 0)), 'in this view'), + kpi('Refund-no-return orders', int(d.rows.filter(function (r) { return r.kind === 'refund_no_return'; }).length), '> 45 days old')); + var t = table({ + title: 'Claims', sub: 'Each case comes with a ready-to-paste Seller Central message. Click a row → copy → open a case → mark as filed.', csv: 'reimbursement-claims', sort: 'est_amount', + tools: seg([['open', 'Open'], ['filed', 'Filed'], ['reimbursed', 'Reimbursed'], ['dismissed', 'Dismissed'], ['all', 'All']], status, function (v) { window.location.hash = '#/reimbursements?status=' + v; }), + columns: [ + { key: 'kind', label: 'Type', render: function (r) { var x = KIND[r.kind] || [r.kind, '']; return badge(x[0], x[1]); } }, + { key: 'sku', label: 'SKU / order', render: function (r) { return h('div', null, h('b', null, r.sku || '—'), h('div', { class: 'small muted mono' }, r.reference_id || r.fnsku)); } }, + { key: 'qty', label: 'Units', align: 'r', fmt: int, sum: true }, + { key: 'est_amount', label: 'Est. value', align: 'r', fmt: money, sum: true }, + { key: 'event_date', label: 'Event', fmt: shortDate }, + { key: 'status', label: 'Status', render: function (r) { return badge(r.status, { open: 'amber', filed: 'blue', reimbursed: 'green', dismissed: '' }[r.status]); } }, + { key: 'amazon_case_id', label: 'Case ID', mono: true } + ], + rows: d.rows, onRow: caseDrawer, + actions: btn('Scan now', function () { return api('/reimbursements/scan', { method: 'POST' }).then(function (r) { toast(r.new + ' new case(s), ' + money(r.value) + ' total', 'ok'); route(); }); }, 'primary'), + empty: empty('Nothing to claim', 'The auditor runs daily over your inventory ledger, refunds and fees.') + }); + var recent = table({ title: 'Reimbursements received', csv: 'reimbursements', columns: [ + { key: 'approval_date', label: 'Date', fmt: shortDate }, { key: 'reason', label: 'Reason' }, { key: 'sku', label: 'SKU' }, + { key: 'qty_cash', label: 'Units (cash)', align: 'r', fmt: int }, { key: 'qty_inventory', label: 'Units (inventory)', align: 'r', fmt: int }, + { key: 'amount', label: 'Amount', align: 'r', fmt: money, sum: true }, { key: 'case_id', label: 'Case', mono: true } + ], rows: d.recent }); + return h('div', { class: 'sgs-stack' }, pageHead('Reimbursements', 'Find money Amazon owes you: lost and damaged FBA inventory, refunds that were never returned, and FBA fee overcharges.'), k, t, recent); + }); + }; + + function caseDrawer(r) { + var caseId = h('input', { type: 'text', value: r.amazon_case_id || '', placeholder: 'Seller Central case ID' }); + function set(status) { return api('/reimbursements/' + r.id, { method: 'POST', body: { status: status, amazon_case_id: caseId.value } }).then(function () { toast('Case updated', 'ok'); dr.close(); route(); }); } + var ev = r.evidence || {}; + var dr = drawer('Claim · ' + (r.sku || r.reference_id), h('div', { class: 'sgs-stack' }, + h('dl', { class: 'kv' }, Object.keys(ev).map(function (k) { return [h('dt', null, k.replace(/_/g, ' ')), h('dd', null, String(ev[k]))]; })), + card('Message for Seller Support', h('div', { class: 'sgs-stack' }, h('div', { class: 'pre' }, r.notes || ''), h('div', { class: 'btns' }, btn('Copy message', function () { return copy(r.notes || ''); }, 'primary'), h('a', { class: 'btn', href: 'https://sellercentral.amazon.' + (marketInfo().tld || 'com') + '/help/hub/support', target: '_blank', rel: 'noopener' }, 'Open Seller Support')))), + card('Track', h('div', { class: 'btns' }, caseId, btn('Mark filed', function () { return set('filed'); }), btn('Reimbursed', function () { return set('reimbursed'); }, 'green'), btn('Dismiss', function () { return set('dismissed'); }, 'danger')))) + ); + } + + /* ---------- Analytics: traffic, returns ---------- */ + PAGES.analytics = function () { + var wrap = h('div', { class: 'sgs-stack' }); + function draw() { + clear(wrap).appendChild(loading()); + Promise.all([api('/traffic', { query: rangeParams() }), api('/returns', { query: rangeParams() })]).then(function (res) { + var t = res[0], r = res[1]; + var S = t.series; + var sess = S.reduce(function (a, x) { return a + num(x.sessions); }, 0), units = S.reduce(function (a, x) { return a + num(x.units); }, 0); + var bb = S.length ? S.reduce(function (a, x) { return a + num(x.buy_box_pct); }, 0) / S.length : null; + var retUnits = r.by_reason.reduce(function (a, x) { return a + num(x.units); }, 0); + clear(wrap); + add(wrap, [ + pageHead('Traffic & Returns', 'Business Reports (sessions, page views, conversion, Buy Box %) per day and per ASIN, plus why customers return your products.', [rangeBar(draw)]), + h('div', { class: 'sgs-grid k4' }, kpi('Sessions', int(sess)), kpi('Unit session %', sess ? pct(units / sess * 100, 2) : '—', int(units) + ' units'), kpi('Avg Buy Box %', pct(bb)), kpi('Returned units', int(retUnits), r.by_sku.length + ' SKUs')), + card('Sessions & conversion', chart({ labels: S.map(function (x) { return x.date; }), series: [ + { label: 'Sessions', values: S.map(function (x) { return x.sessions; }), type: 'bar', color: C.grey, fmt: int }, + { label: 'Unit session %', values: S.map(function (x) { return num(x.sessions) ? num(x.units) / num(x.sessions) * 100 : null; }), type: 'line', color: C.profit, axis: 'right', fmt: function (v) { return pct(v, 1); } } + ], fmt: int, height: 220 })), + table({ title: 'By ASIN', csv: 'traffic-by-asin', sort: 'sessions', columns: [ + { key: 'asin', label: 'ASIN', mono: true, render: function (x) { return h('div', null, h('b', null, x.asin), h('div', { class: 'small muted' }, x.sku)); } }, + { key: 'sessions', label: 'Sessions', align: 'r', fmt: int, sum: true }, { key: 'page_views', label: 'Page views', align: 'r', fmt: int, sum: true }, + { key: 'units', label: 'Units', align: 'r', fmt: int, sum: true }, { key: 'cvr', label: 'Unit session %', align: 'r', fmt: function (v) { return pct(v, 2); } }, + { key: 'buy_box_pct', label: 'Buy Box %', align: 'r', render: function (x) { return h('span', { class: num(x.buy_box_pct) < 90 ? 'neg' : '' }, pct(x.buy_box_pct)); } }, + { key: 'sales', label: 'Ordered sales', align: 'r', fmt: money, sum: true } + ], rows: t.asins }), + h('div', { class: 'sgs-grid k2' }, + card('Return reasons', h('ul', { class: 'list' }, r.by_reason.map(function (x) { return h('li', null, h('span', null, x.reason.replace(/_/g, ' ').toLowerCase()), h('div', { class: 'btns', style: { minWidth: '160px' } }, meter(num(x.units) / Math.max(1, retUnits) * 100, 'amber'), h('b', null, int(x.units)))); }))), + table({ title: 'Returns by SKU', search: false, columns: [{ key: 'sku', label: 'SKU' }, { key: 'units', label: 'Returned', align: 'r', fmt: int }, { key: 'sellable', label: 'Back to stock', align: 'r', fmt: int }], rows: r.by_sku })), + table({ title: 'Return log', csv: 'returns', columns: [ + { key: 'return_date', label: 'Date', fmt: shortDate }, { key: 'amazon_order_id', label: 'Order', mono: true }, { key: 'sku', label: 'SKU' }, + { key: 'qty', label: 'Qty', align: 'r' }, { key: 'reason', label: 'Reason' }, { key: 'disposition', label: 'Disposition', render: function (x) { return badge(x.disposition, x.disposition === 'SELLABLE' ? 'green' : 'red'); } }, + { key: 'comments', label: 'Customer comment' } + ], rows: r.rows }) + ]); + }, function (e) { clear(wrap).appendChild(failure(e)); }); + } + draw(); + return wrap; + }; + + /* ---------- Research ---------- */ + PAGES.research = function () { + var kw = h('input', { type: 'text', placeholder: 'e.g. sugar free gum', style: { minWidth: '320px' } }); + var slot = h('div'); + function search() { + if (!kw.value.trim()) { return Promise.resolve(); } + clear(slot).appendChild(loading()); + return api('/research/search', { method: 'POST', body: { keywords: kw.value } }).then(function (d) { + var S = d.summary; + clear(slot).appendChild(h('div', { class: 'sgs-stack' }, + h('div', { class: 'sgs-grid k5' }, + kpi('Opportunity score', S.opportunity === null ? '—' : S.opportunity + '/100', 'demand vs competition vs price', num(S.opportunity) >= 60 ? 'tone-green' : (num(S.opportunity) >= 40 ? 'tone-amber' : 'tone-red')), + kpi('Avg price', money(S.avg_price)), kpi('Avg monthly units', int(S.avg_units), 'BSR estimate ±40%'), + kpi('Page-1 revenue / mo', money0(S.total_revenue)), kpi('Top-3 share', pct(S.top3_share_pct), 'revenue concentration')), + table({ title: 'Results', csv: 'research-' + kw.value.replace(/\W+/g, '-'), sort: 'est_revenue', columns: [ + { key: 'image', label: '', sort: false, csv: false, render: function (r) { return r.image ? h('img', { class: 'thumb', src: r.image, alt: '', loading: 'lazy' }) : ''; } }, + { key: 'title', label: 'Product', render: function (r) { return h('div', null, h('a', { href: amazonUrl(r.asin), target: '_blank', rel: 'noopener', class: 'title' }, r.title), h('div', { class: 'small muted' }, r.asin + ' · ' + (r.brand || '—') + ' · ' + (r.category || ''))); } }, + { key: 'price', label: 'Price', align: 'r', fmt: money }, + { key: 'bsr', label: 'BSR', align: 'r', fmt: int }, + { key: 'est_units', label: 'Est. units/mo', align: 'r', fmt: int }, + { key: 'est_revenue', label: 'Est. revenue/mo', align: 'r', fmt: money0 }, + { key: 'offers', label: 'Offers', align: 'r', fmt: int }, + { key: 'asin', label: '', sort: false, csv: false, render: function (r) { return btn('Fees', function () { return feeLookup(r.asin, r.price); }, 'sm'); } } + ], rows: d.items }))); + }, function (e) { clear(slot).appendChild(failure(e)); }); + } + kw.addEventListener('keydown', function (e) { if (e.key === 'Enter') { search(); } }); + + var f = {}; + ['price', 'unit_cost', 'inbound', 'referral_pct', 'fba_fee', 'storage_per_unit', 'tacos_pct', 'return_pct', 'cvr_pct'].forEach(function (k) { f[k] = h('input', { type: 'number', step: '0.01', min: '0' }); }); + f.price.value = '16.99'; f.unit_cost.value = '4.20'; f.inbound.value = '0.50'; f.referral_pct.value = '15'; f.fba_fee.value = '4.02'; f.storage_per_unit.value = '0.10'; f.tacos_pct.value = '10'; f.return_pct.value = '2'; f.cvr_pct.value = '12'; + var out = h('div'); + function calc() { + var body = {}; Object.keys(f).forEach(function (k) { body[k] = f[k].value; }); + return api('/research/calc', { method: 'POST', body: body }).then(function (r) { + clear(out).appendChild(h('div', { class: 'sgs-grid k4' }, + kpi('Profit / unit', money(r.profit_per_unit), 'Margin ' + pct(r.margin_pct), num(r.profit_per_unit) >= 0 ? 'tone-green' : 'tone-red'), + kpi('ROI', pct(r.roi_pct), 'on ' + money(r.cogs) + ' landed cost'), + kpi('Break-even ACoS', pct(r.breakeven_acos), 'Max CPC ' + money(r.max_cpc) + ' at your CVR'), + kpi('Break-even price', money(r.breakeven_price), 'Amazon fees ' + money(r.amazon_fees)))); + }); + } + var calcCard = card('FBA profitability calculator', h('div', { class: 'sgs-stack' }, h('div', { class: 'form-grid' }, + field('Sale price', f.price), field('Unit cost', f.unit_cost), field('Inbound / unit', f.inbound), field('Referral %', f.referral_pct, 'Grocery: 8% ≤ $15, 15% above'), + field('FBA fee', f.fba_fee, 'Use “Fees” on a result for Amazon’s exact estimate'), field('Storage / unit', f.storage_per_unit), field('TACoS %', f.tacos_pct), field('Return rate %', f.return_pct), field('Ad conversion %', f.cvr_pct)), + h('div', { class: 'btns' }, btn('Calculate', calc, 'primary')), out)); + + function feeLookup(asin, price) { + return api('/research/fees', { method: 'POST', body: { asin: asin, price: price || f.price.value } }).then(function (r) { + f.fba_fee.value = num(r.fba).toFixed(2); + if (r.price) { f.price.value = r.price; f.referral_pct.value = r.price ? (num(r.referral) / num(r.price) * 100).toFixed(1) : f.referral_pct.value; } + toast('Fees for ' + asin + ': ' + money(r.total) + ' (referral ' + money(r.referral) + ', FBA ' + money(r.fba) + ')', 'ok'); + return calc(); + }); + } + var asinFee = h('input', { type: 'text', placeholder: 'ASIN' }); + var feeCard = card('Exact Amazon fees for any ASIN', h('div', { class: 'btns' }, asinFee, btn('Get fees', function () { return feeLookup(asinFee.value.trim().toUpperCase(), f.price.value); })), { sub: 'Product Fees API — fills the calculator.' }); + + return h('div', { class: 'sgs-stack' }, + pageHead('Product Research', 'Size up any niche from the official catalog: BSR-based sales estimates, page-1 revenue, competition, and exact FBA fees — then model your profit.'), + card('Niche explorer', h('div', { class: 'sgs-stack' }, h('div', { class: 'btns' }, kw, btn('Search Amazon', search, 'primary')), slot)), + h('div', { class: 'sgs-grid k21' }, calcCard, feeCard)); + }; + + /* ---------- Reports ---------- */ + PAGES.reports = function () { + return api('/reports').then(function (d) { + var cat = table({ title: 'Report catalog', search: false, columns: [ + { key: 'label', label: 'Report', render: function (r) { return h('div', null, h('b', null, r.label), h('div', { class: 'small muted mono' }, r.type)); } }, + { key: 'schedule', label: 'Auto', render: function (r) { return badge(r.schedule, r.schedule === 'daily' ? 'green' : 'blue'); } }, + { key: 'window', label: 'Window', render: function (r) { return r.window ? r.window + ' days' : 'snapshot'; } }, + { key: 'type', label: '', sort: false, render: function (r) { return btn('Request now', function () { return api('/reports', { method: 'POST', body: { type: r.type } }).then(function () { toast('Requested ' + r.label, 'ok'); route(); }); }, 'sm'); } } + ], rows: d.catalog }); + var STATUS_C = { completed: 'green', requested: 'blue', processing: 'blue', failed: 'red', empty: '' }; + var rows = table({ title: 'Recent report runs', csv: 'report-runs', sort: 'requested_at', columns: [ + { key: 'requested_at', label: 'Requested', fmt: function (v) { return dateTime(v); } }, + { key: 'report_type', label: 'Type', render: function (r) { return h('span', { class: 'small mono' }, r.report_type); } }, + { key: 'source', label: 'API', render: function (r) { return badge(r.source === 'ads' ? 'Ads' : 'SP'); } }, + { key: 'market', label: 'Mkt' }, + { key: 'data_start', label: 'Range', render: function (r) { return r.data_start ? shortDate(r.data_start) + ' – ' + shortDate(r.data_end) : '—'; } }, + { key: 'status', label: 'Status', render: function (r) { return badge(r.status, STATUS_C[r.status]); } }, + { key: 'rows_ingested', label: 'Rows', align: 'r', fmt: int }, + { key: 'error', label: 'Error', render: function (r) { return h('span', { class: 'small neg' }, r.error || ''); } } + ], rows: d.rows, actions: btn('Poll now', function () { return api('/reports/poll', { method: 'POST' }).then(function (r) { toast(r.completed + ' completed, ' + r.rows + ' rows ingested', 'ok'); route(); }); }, 'primary') }); + return h('div', { class: 'sgs-stack' }, pageHead('Reports Center', 'Every Amazon report the suite uses — requested on schedule, polled, downloaded, decompressed and ingested automatically.'), cat, rows, jobsCard(d.jobs)); + }); + }; + + function jobsCard(jobs) { + var keys = Object.keys(jobs || {}); + return card('Background jobs', keys.length ? h('ul', { class: 'list' }, keys.map(function (k) { + var j = jobs[k]; + return h('li', null, h('span', null, h('b', null, k), ' ', h('span', { class: 'muted small' }, new Date(j.at * 1000).toLocaleString())), j.ok ? badge('ok', 'green') : h('span', { class: 'small neg', title: j.error }, '✕ ' + (j.error || '').slice(0, 90))); + })) : empty('No runs yet', 'Jobs run on WP-Cron every 15 minutes, hourly and daily.')); + } + + /* ---------- Alerts ---------- */ + PAGES.alerts = function () { + return api('/alerts').then(function (d) { + state.unread = d.unread; + return h('div', { class: 'sgs-stack' }, pageHead('Alerts', 'Hijackers, Buy Box losses, listing changes, negative feedback, competitor price drops and more. Critical alerts can also go to email (Settings).', [btn('Mark all read', function () { return api('/alerts', { method: 'POST' }).then(route); })]), + table({ title: d.rows.length + ' alerts', csv: 'alerts', sort: 'created_at', columns: [ + { key: 'created_at', label: 'When', fmt: function (v) { return dateTime(v); } }, + { key: 'level', label: 'Level', render: function (r) { return badge(r.level, r.level === 'critical' ? 'red' : (r.level === 'warning' ? 'amber' : 'blue')); } }, + { key: 'category', label: 'Category' }, + { key: 'title', label: 'Alert', render: function (r) { return h('div', null, h('b', { style: { fontWeight: +r.is_read ? 400 : 700 } }, r.title), r.message ? h('div', { class: 'small muted' }, r.message) : null); } } + ], rows: d.rows, empty: empty('No alerts', 'You will see hijacker, Buy Box, feedback and listing-change alerts here.') })); + }); + }; + + /* ---------- Settings ---------- */ + PAGES.settings = function () { + return api('/settings').then(function (d) { + var S = d.settings; + var inputs = {}; + function inp(key, type, attrs) { + var el; + if (type === 'bool') { el = h('input', { type: 'checkbox', checked: !!S[key] }); } + else if (type === 'select') { el = h('select', null, attrs.options.map(function (o) { return h('option', { value: o[0], selected: String(S[key]) === String(o[0]) }, o[1]); })); } + else { el = h('input', Object.assign({ type: type || 'text', value: S[key] === null || S[key] === undefined ? '' : S[key] }, attrs || {})); } + inputs[key] = el; + return el; + } + function chk(key, label) { return h('label', { class: 'check' }, inp(key, 'bool'), label); } + function secret(key, label, help) { + var el = h('input', { type: 'password', autocomplete: 'off', placeholder: S['has_' + key] ? '•••••••• saved — leave blank to keep' : '' }); + inputs[key] = el; + return field(label, el, help); + } + var sellerId = h('input', { type: 'text', placeholder: S.seller_id_set ? '•••••• saved — leave blank to keep' : 'e.g. A1B2C3D4E5F6G7' }); + inputs.amazon_seller_id = sellerId; + + function collect() { + var out = {}; + Object.keys(inputs).forEach(function (k) { + var el = inputs[k]; + out[k] = el.type === 'checkbox' ? el.checked : el.value; + }); + return out; + } + function save() { return api('/settings', { method: 'POST', body: collect() }).then(function () { toast('Settings saved', 'ok'); }); } + + var profileSlot = h('div'); + var g = function (title, sub, children) { return card(title, h('div', { class: 'form-grid' }, children), { sub: sub }); }; + + var sync = g('Data sync', 'Orders every 15 min, finances hourly, reports daily/weekly. First run backfills history.', [ + chk('suite_sync_enabled', 'Background sync enabled'), + field('Backfill days', inp('suite_backfill_days', 'number', { min: 7, max: 730 })), + field('Order items per tick', inp('suite_order_items_per_run', 'number', { min: 5, max: 100 }), 'Orders API item calls per 15 min'), + field('Seller ID (Merchant Token)', sellerId, 'Seller Central → Settings → Account Info → Merchant Token. Needed for listing audits & hijacker detection.') + ]); + var profit = g('Profit', null, [ + field('Default unit cost', inp('suite_default_unit_cost', 'number', { step: '0.01' }), 'Used when a SKU has no cost'), + chk('suite_include_tax', 'Count collected tax as revenue (not recommended)') + ]); + var restock = g('Restock planner', null, [ + field('Default lead time (days)', inp('suite_lead_time_days', 'number')), field('Safety stock (days)', inp('suite_safety_days', 'number')), + field('Target cover after arrival (days)', inp('suite_target_cover_days', 'number')), field('Overstock threshold (days)', inp('suite_overstock_days', 'number')) + ]); + var ads = g('Amazon Ads API (PPC)', 'Create an LWA security profile approved for the Advertising API, then generate a refresh token with scope advertising::campaign_management. Leave client ID/secret blank to reuse the SP-API app.', [ + chk('suite_ads_enabled', 'Sync ads daily'), + field('Region', inp('suite_ads_region', 'select', { options: [['na', 'North America'], ['eu', 'Europe / Middle East'], ['fe', 'Far East']] })), + secret('ads_client_id', 'Ads client ID'), secret('ads_client_secret', 'Ads client secret'), secret('ads_refresh_token', 'Ads refresh token'), + field('Profile ID', inp('suite_ads_profile_id'), 'Use “Test & list profiles”'), + h('div', { class: 'field' }, h('label', null, ' '), btn('Test & list profiles', function () { + return api('/ads/test', { method: 'POST', body: collect() }).then(function (r) { + clear(profileSlot).appendChild(h('ul', { class: 'list' }, r.profiles.map(function (p) { + return h('li', null, h('span', null, h('b', null, p.countryCode + ' · ' + p.name), ' ', h('span', { class: 'muted mono small' }, p.profileId + ' · ' + p.type + ' · ' + p.currency)), btn('Use', function () { inputs.suite_ads_profile_id.value = p.profileId; return save(); }, 'sm')); + }))); + toast('Ads API connected — ' + r.profiles.length + ' profile(s)', 'ok'); + }); + })), + h('div', { style: { gridColumn: '1 / -1' } }, profileSlot) + ]); + var opt = g('PPC optimizer rules', 'Plans are reviewed in PPC → Optimizer. Auto-apply pushes only bid changes and negatives.', [ + field('Target ACoS %', inp('suite_ads_target_acos', 'number', { step: '0.5' })), field('Lookback days', inp('suite_ads_lookback_days', 'number')), + field('Min clicks before cutting', inp('suite_ads_min_clicks', 'number')), field('Max bid step %', inp('suite_ads_max_step_pct', 'number')), + field('Min bid', inp('suite_ads_min_bid', 'number', { step: '0.01' })), field('Max bid', inp('suite_ads_max_bid', 'number', { step: '0.01' })), + field('Negate after clicks (0 orders)', inp('suite_ads_neg_min_clicks', 'number')), field('Harvest after orders', inp('suite_ads_harvest_min_orders', 'number')), + field('Harvest → campaign ID', inp('suite_ads_harvest_campaign')), field('Harvest → ad group ID', inp('suite_ads_harvest_ad_group')), + chk('suite_ads_raise_low_impr', 'Raise bids on low-impression keywords'), chk('suite_ads_auto_apply', 'Auto-apply bids & negatives daily') + ]); + var reviews = g('Review requests', 'Uses Amazon’s Solicitations API — the compliant “Request a Review” button.', [ + chk('suite_reviews_enabled', 'Send review requests automatically'), + field('Days after delivery', inp('suite_reviews_delay_days', 'number', { min: 5, max: 29 })), field('Daily cap', inp('suite_reviews_daily_cap', 'number')), + chk('suite_reviews_skip_refunded', 'Skip refunded / returned orders') + ]); + var mon = g('Monitoring & alerts', null, [ + chk('suite_monitor_enabled', 'Monitor Buy Box, hijackers & competitors'), + field('Competitor price-drop alert %', inp('suite_monitor_price_drop_pct', 'number')), + field('Extra competitor ASINs', inp('suite_monitor_extra_asins', 'text', { placeholder: 'B0…, B0…' })), + field('Alert email', inp('suite_alert_email', 'email', { placeholder: 'defaults to site admin email' })), + field('Email these levels', inp('suite_alert_email_levels', 'select', { options: [['critical', 'Critical only'], ['critical,warning', 'Critical + warning'], ['critical,warning,info', 'Everything'], ['none', 'Never']] })) + ]); + var nr = d.next_runs || {}; + var ops = card('Run now', h('div', { class: 'sgs-stack' }, + h('div', { class: 'btns' }, + [['orders', 'Sync orders'], ['finance', 'Sync finances'], ['reports', 'Request daily reports'], ['weekly', 'Request weekly reports'], ['poll', 'Poll reports'], ['backfill', 'Backfill history']].map(function (j) { + return btn(j[1], function () { return api('/sync', { method: 'POST', body: { job: j[0] } }).then(function (r) { toast(j[1] + ': ' + JSON.stringify(r.result).slice(0, 120), 'ok'); }); }, 'sm'); + })), + h('div', { class: 'small muted' }, 'Next runs — tick: ' + (nr.sg_suite_tick ? dateTime(nr.sg_suite_tick) : 'not scheduled') + ' · hourly: ' + (nr.sg_suite_hourly ? dateTime(nr.sg_suite_hourly) : 'not scheduled') + ' · daily: ' + (nr.sg_suite_daily ? dateTime(nr.sg_suite_daily) : 'not scheduled')), + h('div', { class: 'divider' }), + h('div', { class: 'btns' }, state.demo ? btn('Remove demo data', function () { return api('/demo', { method: 'DELETE' }).then(function () { state.demo = false; route(); }); }, 'danger') : btn('Load demo account', seedDemo)) + ), { sub: d.connected ? 'SP-API connected.' : 'SP-API not connected — set credentials in Seven Gum → Amazon Connect.' }); + + return h('div', { class: 'sgs-stack' }, + pageHead('Suite Settings', 'Everything is configurable; secrets are encrypted with AES-256-GCM and never sent back to the browser.', [btn('Save settings', save, 'primary')]), + h('div', { class: 'sgs-grid k2' }, sync, profit), ads, opt, h('div', { class: 'sgs-grid k2' }, restock, reviews), mon, ops, jobsCard(d.jobs), + h('div', { class: 'btns' }, btn('Save settings', save, 'primary'))); + }); + }; + + /* ================================================================== */ + /* Boot */ + /* ================================================================== */ + + clear(root); + add(root, [headerSlot, nav, bannerSlot, main]); + window.addEventListener('hashchange', route); + route(); +})(); diff --git a/sg-commerce-pro/assets/js/widget.js b/sg-commerce-pro/assets/js/widget.js new file mode 100644 index 0000000..3b193c5 --- /dev/null +++ b/sg-commerce-pro/assets/js/widget.js @@ -0,0 +1,54 @@ +/* + * Seven Gum Commerce — widget live refresh. + * Polls REST endpoint every 60s, updates price + stock chip in place. + */ +(function () { + if (!window.SG_COMMERCE || !window.SG_COMMERCE.rest) return; + var REST = window.SG_COMMERCE.rest; + var REFRESH_MS = 60 * 1000; + + function refresh(el) { + var sku = el.dataset.sku; + var market = el.dataset.market; + if (!sku || !market) return; + + fetch(REST + 'product/' + encodeURIComponent(market) + '/' + encodeURIComponent(sku), { + headers: { 'Accept': 'application/json' } + }) + .then(function(r) { return r.ok ? r.json() : null; }) + .then(function(data) { + if (!data) return; + var priceEl = el.querySelector('[data-role="price"]'); + if (priceEl && data.price !== null && typeof data.price !== 'undefined') { + var small = priceEl.querySelector('small'); + priceEl.textContent = Number(data.price).toFixed(2) + ' '; + if (small) priceEl.appendChild(small); + else { + var s = document.createElement('small'); + s.textContent = data.currency || ''; + priceEl.appendChild(s); + } + } + var stockEl = el.querySelector('[data-role="stock"]'); + if (stockEl) { + stockEl.textContent = data.in_stock ? 'In stock' : 'Out of stock'; + stockEl.classList.toggle('sg-widget__stock--ok', !!data.in_stock); + stockEl.classList.toggle('sg-widget__stock--out', !data.in_stock); + } + }) + .catch(function() { /* silent */ }); + } + + function init() { + var widgets = document.querySelectorAll('.sg-widget[data-sku][data-market]'); + widgets.forEach(function(w) { + setInterval(function() { refresh(w); }, REFRESH_MS); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/sg-commerce-pro/sg-commerce.php b/sg-commerce-pro/sg-commerce.php new file mode 100644 index 0000000..1ddc5aa --- /dev/null +++ b/sg-commerce-pro/sg-commerce.php @@ -0,0 +1,128 @@ +boot(); + } catch ( \Throwable $e ) { + error_log( '[SG Commerce] Boot failure: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() ); + add_action( 'admin_notices', static function () use ( $e ): void { + if ( ! current_user_can( 'manage_options' ) ) return; + printf( + '

Seven Gum Commerce: %s

', + esc_html( $e->getMessage() ) + ); + } ); + } +}, 10 ); diff --git a/sg-commerce-pro/src/AI/AIModule.php b/sg-commerce-pro/src/AI/AIModule.php new file mode 100644 index 0000000..a09ae8c --- /dev/null +++ b/sg-commerce-pro/src/AI/AIModule.php @@ -0,0 +1,79 @@ +container; + + // Bind the active provider as ProviderInterface. + $c->singleton( ProviderInterface::class, function ( Container $c ): ProviderInterface { + $active = (string) $c->get( SettingsRepository::class )->get( 'ai_provider', 'ollama' ); + return match ( $active ) { + // Future providers: openai, anthropic + 'ollama' => $c->get( OllamaProvider::class ), + default => $c->get( OllamaProvider::class ), + }; + } ); + + // Copywriter — depends on the provider above. + $c->singleton( Copywriter::class, static fn( Container $c ) => + new Copywriter( + $c->get( ProviderInterface::class ), + $c->get( PromptRegistry::class ), + $c->get( ProductRepository::class ), + $c->get( Marketplaces::class ), + $c->get( Logger::class ) + ) + ); + + // Async generation queue handler. + add_action( 'sg_commerce_ai_describe', function ( $args ): void { + if ( ! is_array( $args ) || empty( $args['product_id'] ) ) return; + try { + $this->container->get( Copywriter::class )->describe( (int) $args['product_id'] ); + } catch ( \Throwable $e ) { + $this->container->get( Logger::class )->error( 'queued describe failed: ' . $e->getMessage() ); + } + } ); + + add_action( 'sg_commerce_ai_translate', function ( $args ): void { + if ( ! is_array( $args ) || empty( $args['product_id'] ) || empty( $args['market'] ) ) return; + try { + $this->container->get( Copywriter::class )->translate( + (int) $args['product_id'], + (string) $args['market'] + ); + } catch ( \Throwable $e ) { + $this->container->get( Logger::class )->error( 'queued translate failed: ' . $e->getMessage() ); + } + } ); + } +} diff --git a/sg-commerce-pro/src/AI/Copywriter.php b/sg-commerce-pro/src/AI/Copywriter.php new file mode 100644 index 0000000..c8b3bd6 --- /dev/null +++ b/sg-commerce-pro/src/AI/Copywriter.php @@ -0,0 +1,158 @@ +products->find( $product_id ); + if ( null === $product ) { + throw new \RuntimeException( "Product #{$product_id} not found." ); + } + + $prompt = $this->prompts->describe_product( $product ); + $result = $this->provider->generate( $prompt, array( + 'system' => $this->prompts->brand_system(), + 'temperature' => 0.8, + 'max_tokens' => 220, + ) ); + + $this->save_description( $product_id, 'en', $result ); + $this->logger->info( 'Described product', array( + 'product_id' => $product_id, + 'tokens' => $result['tokens'], + 'duration' => $result['duration_ms'], + ) ); + return $result['text']; + } + + /** + * Translate the English description into the language of $target_market. + * If no English description exists, generates one first. + */ + public function translate( int $product_id, string $target_market ): string { + $mp = $this->marketplaces->get( $target_market ); + if ( null === $mp ) { + throw new \RuntimeException( "Unknown marketplace: {$target_market}" ); + } + $lang_code = (string) $mp['language']; + + $english = $this->find_description( $product_id, 'en' ); + if ( null === $english ) { + // Auto-generate the English source first. + $english = $this->describe( $product_id ); + } + + if ( 'en' === $lang_code ) { + // Target market is English-speaking; English copy is the answer. + $this->save_description( $product_id, 'en', array( + 'text' => $english, + 'provider' => $this->provider->id(), + 'model' => '-', + 'tokens' => 0, + 'duration_ms' => 0, + ) ); + return $english; + } + + $prompt = $this->prompts->translate( $english, $this->prompts->language_name( $lang_code ) ); + $result = $this->provider->generate( $prompt, array( + 'system' => $this->prompts->brand_system(), + 'temperature' => 0.4, + 'max_tokens' => 260, + ) ); + + $this->save_description( $product_id, $lang_code, $result ); + $this->logger->info( 'Translated product', array( + 'product_id' => $product_id, + 'language' => $lang_code, + 'tokens' => $result['tokens'], + ) ); + return $result['text']; + } + + /** Look up the stored description for a product+language. */ + public function find_description( int $product_id, string $language ): ?string { + global $wpdb; + $row = $wpdb->get_var( $wpdb->prepare( + "SELECT content FROM {$wpdb->prefix}sg_descriptions WHERE product_id = %d AND language = %s LIMIT 1", + $product_id, + $language + ) ); + return is_string( $row ) ? $row : null; + } + + /** All stored descriptions for a product, keyed by language code. */ + public function descriptions_for( int $product_id ): array { + global $wpdb; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT language, content, provider, model, generated_at + FROM {$wpdb->prefix}sg_descriptions + WHERE product_id = %d + ORDER BY language ASC", + $product_id + ), ARRAY_A ); + $out = array(); + foreach ( (array) $rows as $r ) { + $out[ (string) $r['language'] ] = $r; + } + return $out; + } + + private function save_description( int $product_id, string $language, array $result ): void { + global $wpdb; + $wpdb->query( $wpdb->prepare( + "INSERT INTO {$wpdb->prefix}sg_descriptions + (product_id, language, content, provider, model, temperature, tokens, generation_ms, generated_at) + VALUES (%d, %s, %s, %s, %s, %f, %d, %d, %s) + ON DUPLICATE KEY UPDATE + content = VALUES(content), + provider = VALUES(provider), + model = VALUES(model), + temperature = VALUES(temperature), + tokens = VALUES(tokens), + generation_ms = VALUES(generation_ms), + generated_at = VALUES(generated_at)", + $product_id, + $language, + (string) $result['text'], + (string) ( $result['provider'] ?? '' ), + (string) ( $result['model'] ?? '' ), + 0.0, + (int) ( $result['tokens'] ?? 0 ), + (int) ( $result['duration_ms'] ?? 0 ), + current_time( 'mysql', true ) + ) ); + } +} diff --git a/sg-commerce-pro/src/AI/PromptRegistry.php b/sg-commerce-pro/src/AI/PromptRegistry.php new file mode 100644 index 0000000..251ae4b --- /dev/null +++ b/sg-commerce-pro/src/AI/PromptRegistry.php @@ -0,0 +1,98 @@ +settings->get( 'ai_brand_voice', 'modern, clean, playful but confident' ); + return "You are a senior copywriter for Seven Gum, a premium sugar-free chewing gum brand sold internationally.\n" + . "Product line: nine flavors including Cookie, Mint, Cinnamon, fruit varieties.\n" + . "Brand voice: {$voice}. Never cheesy or salesy.\n" + . "Focus on sensory experience (taste, texture, freshness) and lifestyle (on-the-go, post-meal, after coffee)."; + } + + public function describe_product( array $product ): string { + $sku = (string) ( $product['sku'] ?? '' ); + $asin = (string) ( $product['asin'] ?? '' ); + $name = (string) ( $product['product_name'] ?? '' ); + $market = (string) ( $product['market'] ?? '' ); + + return "Write a product description for this Amazon listing:\n" + . " - SKU: {$sku}\n" + . ( '' !== $asin ? " - ASIN: {$asin}\n" : '' ) + . " - Product name: " . ( '' !== $name ? $name : '(unknown — infer from SKU if possible)' ) . "\n" + . " - Market: {$market}\n" + . "\n" + . "Requirements:\n" + . " - 60-80 words\n" + . " - Lead with the flavor experience\n" + . " - Mention 'sugar-free' once, naturally\n" + . " - End with a short, confident call-to-action\n" + . " - Output ONLY the description. No headings, no markdown, no preamble."; + } + + public function translate( string $english, string $target_language_name ): string { + return "Translate this product description from English to {$target_language_name}.\n" + . "Keep the same brand voice (modern, clean, confident) and the same approximate length.\n" + . "Localize idioms — never word-for-word.\n" + . "Output ONLY the translation. No preamble, no notes.\n" + . "\n" + . "English source:\n" + . $english; + } + + public function competitor_summary( array $competitor_data ): string { + $asin = (string) ( $competitor_data['asin'] ?? '' ); + $name = (string) ( $competitor_data['product_name'] ?? '' ); + $price = (string) ( $competitor_data['last_price'] ?? '' ); + $seller = (string) ( $competitor_data['seller_name'] ?? '' ); + + return "Summarize this competing Amazon listing in 2-3 sentences for a sales briefing:\n" + . " - ASIN: {$asin}\n" + . " - Product: {$name}\n" + . " - Seller: {$seller}\n" + . " - Price: {$price}\n" + . "\n" + . "Focus on positioning differences vs Seven Gum (premium sugar-free gum). " + . "Output ONLY the summary, no headings."; + } + + /** Map ISO language codes to readable names for the translate prompt. */ + public function language_name( string $code ): string { + return match ( $code ) { + 'en' => 'English', + 'de' => 'German', + 'fr' => 'French', + 'es' => 'Spanish', + 'it' => 'Italian', + 'nl' => 'Dutch', + 'sv' => 'Swedish', + 'pl' => 'Polish', + 'tr' => 'Turkish', + 'fa' => 'Persian (Farsi)', + 'ar' => 'Arabic', + 'pt' => 'Portuguese', + 'ja' => 'Japanese', + default => $code, + }; + } +} diff --git a/sg-commerce-pro/src/AI/ProviderInterface.php b/sg-commerce-pro/src/AI/ProviderInterface.php new file mode 100644 index 0000000..7ca5dd1 --- /dev/null +++ b/sg-commerce-pro/src/AI/ProviderInterface.php @@ -0,0 +1,62 @@ + + */ + public function list_models(): array; +} diff --git a/sg-commerce-pro/src/AI/Providers/OllamaProvider.php b/sg-commerce-pro/src/AI/Providers/OllamaProvider.php new file mode 100644 index 0000000..3c8ee0b --- /dev/null +++ b/sg-commerce-pro/src/AI/Providers/OllamaProvider.php @@ -0,0 +1,177 @@ +default_model() ); + $temperature = (float) ( $options['temperature'] ?? (float) $this->settings->get( 'ai_temperature', 0.7 ) ); + $system = isset( $options['system'] ) ? (string) $options['system'] : null; + $max_tokens = isset( $options['max_tokens'] ) ? (int) $options['max_tokens'] : null; + + // Cache lookup. + $cache_key = $this->cache_key( $prompt, $model, $temperature, $system ); + $cached = $this->cache->get( $cache_key ); + if ( is_array( $cached ) && isset( $cached['text'] ) ) { + $this->logger->debug( 'Ollama cache hit', array( 'model' => $model ) ); + return $cached; + } + + $body = array( + 'model' => $model, + 'prompt' => $prompt, + 'stream' => false, + 'options' => array( + 'temperature' => $temperature, + ), + 'keep_alive' => '5m', + ); + if ( null !== $system ) { + $body['system'] = $system; + } + if ( null !== $max_tokens && $max_tokens > 0 ) { + $body['options']['num_predict'] = $max_tokens; + } + + $start = microtime( true ); + $response = wp_remote_post( + $this->base_url() . '/api/generate', + array( + 'timeout' => 180, + 'headers' => $this->headers(), + 'body' => wp_json_encode( $body ), + ) + ); + + if ( is_wp_error( $response ) ) { + $msg = $response->get_error_message(); + $this->logger->error( 'Ollama unreachable: ' . $msg ); + throw new \RuntimeException( 'Ollama unreachable at ' . $this->base_url() . ': ' . $msg ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $raw = (string) wp_remote_retrieve_body( $response ); + $json = json_decode( $raw, true ); + + if ( 200 !== $code ) { + $this->logger->error( "Ollama HTTP {$code}", array( 'body' => substr( $raw, 0, 300 ) ) ); + // Detect "model not found" specifically — most common failure. + if ( str_contains( $raw, 'not found' ) || str_contains( $raw, 'model' ) ) { + throw new \RuntimeException( + "Ollama: model '{$model}' is not installed. Run: ollama pull {$model}" + ); + } + throw new \RuntimeException( "Ollama HTTP {$code}: " . substr( $raw, 0, 200 ) ); + } + if ( ! is_array( $json ) || ! isset( $json['response'] ) ) { + throw new \RuntimeException( 'Ollama returned malformed response.' ); + } + + $result = array( + 'text' => trim( (string) $json['response'] ), + 'provider' => $this->id(), + 'model' => $model, + 'tokens' => (int) ( $json['eval_count'] ?? 0 ), + 'duration_ms' => (int) ( ( microtime( true ) - $start ) * 1000 ), + ); + + $this->cache->set( $cache_key, $result, 6 * HOUR_IN_SECONDS ); + return $result; + } + + public function is_reachable(): bool { + try { + $this->list_models(); + return true; + } catch ( \Throwable ) { + return false; + } + } + + public function list_models(): array { + $response = wp_remote_get( + $this->base_url() . '/api/tags', + array( 'timeout' => 15, 'headers' => $this->headers() ) + ); + if ( is_wp_error( $response ) ) { + throw new \RuntimeException( 'Ollama unreachable: ' . $response->get_error_message() ); + } + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( 200 !== $code ) { + throw new \RuntimeException( "Ollama HTTP {$code} on /api/tags" ); + } + $json = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + $out = array(); + if ( is_array( $json ) && is_array( $json['models'] ?? null ) ) { + foreach ( $json['models'] as $m ) { + if ( isset( $m['name'] ) ) { + $out[] = array( + 'id' => (string) $m['name'], + 'name' => (string) $m['name'], + 'size' => (int) ( $m['size'] ?? 0 ), + ); + } + } + } + return $out; + } + + private function base_url(): string { + return rtrim( (string) $this->settings->get( 'ollama_url', 'http://127.0.0.1:11434' ), '/' ); + } + + private function default_model(): string { + return (string) $this->settings->get( 'ollama_model', 'llama3.1:8b' ); + } + + private function headers(): array { + $h = array( 'Content-Type' => 'application/json' ); + $token = (string) $this->settings->get( 'ollama_auth_token', '' ); + if ( '' !== $token ) { + $h['Authorization'] = 'Bearer ' . $token; + } + return $h; + } + + private function cache_key( string $prompt, string $model, float $temp, ?string $system ): string { + return 'ai/ollama/' . substr( hash( 'sha256', $prompt . '|' . $model . '|' . $temp . '|' . ( $system ?? '' ) ), 0, 32 ); + } +} diff --git a/sg-commerce-pro/src/API/HeadlessAPI.php b/sg-commerce-pro/src/API/HeadlessAPI.php new file mode 100644 index 0000000..51d0f67 --- /dev/null +++ b/sg-commerce-pro/src/API/HeadlessAPI.php @@ -0,0 +1,297 @@ +` header where the key is + * a PAT minted from Settings → API Keys. Falls back to WP application + * passwords for users who already use those. + * + * Rate limit: 60 requests/minute per API key (lightweight transient bucket). + * + * All endpoints return JSON. Each endpoint documents its schema via + * register_rest_route args + rest_field_schema so any OpenAPI tool can + * pull specs automatically. + * + * @package SevenGum\Commerce\API + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\API; + +use SevenGum\Commerce\Analytics\SalesAggregator; +use SevenGum\Commerce\Core\Container; +use SevenGum\Commerce\Core\Module; +use SevenGum\Commerce\Database\Repositories\ProductRepository; +use SevenGum\Commerce\Database\Repositories\SettingsRepository; +use SevenGum\Commerce\Fulfillment\MCFOrderRepository; +use SevenGum\Commerce\Logging\Logger; + +defined( 'ABSPATH' ) || exit; + +final class HeadlessAPI implements Module { + + public const NAMESPACE = 'sg-commerce/v2'; + + public function __construct( private Container $container ) {} + + public function id(): string { + return 'headless_api'; + } + + public function register(): void { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + public function register_routes(): void { + $ns = self::NAMESPACE; + + // GET /products — paginated, filtered + register_rest_route( $ns, '/products', array( + 'methods' => 'GET', + 'permission_callback' => array( $this, 'auth' ), + 'callback' => array( $this, 'list_products' ), + 'args' => array( + 'market' => array( 'type' => 'string' ), + 'search' => array( 'type' => 'string' ), + 'in_stock' => array( 'type' => 'boolean' ), + 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1 ), + 'per_page' => array( 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100 ), + ), + ) ); + + register_rest_route( $ns, '/products/(?P\d+)', array( + 'methods' => 'GET', + 'permission_callback' => array( $this, 'auth' ), + 'callback' => array( $this, 'get_product' ), + ) ); + + // GET /analytics/kpis + register_rest_route( $ns, '/analytics/kpis', array( + 'methods' => 'GET', + 'permission_callback' => array( $this, 'auth' ), + 'callback' => array( $this, 'kpis' ), + ) ); + + // GET /analytics/buybox-trend + register_rest_route( $ns, '/analytics/buybox-trend', array( + 'methods' => 'GET', + 'permission_callback' => array( $this, 'auth' ), + 'callback' => array( $this, 'buybox_trend' ), + 'args' => array( + 'days' => array( 'type' => 'integer', 'default' => 14, 'minimum' => 1, 'maximum' => 90 ), + ), + ) ); + + // GET /mcf/orders + register_rest_route( $ns, '/mcf/orders', array( + 'methods' => 'GET', + 'permission_callback' => array( $this, 'auth' ), + 'callback' => array( $this, 'list_mcf_orders' ), + 'args' => array( + 'limit' => array( 'type' => 'integer', 'default' => 50, 'maximum' => 200 ), + ), + ) ); + + // POST /mcf/orders + register_rest_route( $ns, '/mcf/orders', array( + 'methods' => 'POST', + 'permission_callback' => array( $this, 'auth' ), + 'callback' => array( $this, 'create_mcf_order' ), + ) ); + + // GET /meta + register_rest_route( $ns, '/meta', array( + 'methods' => 'GET', + 'permission_callback' => array( $this, 'auth' ), + 'callback' => array( $this, 'meta' ), + ) ); + + // GET /openapi.json + register_rest_route( $ns, '/openapi', array( + 'methods' => 'GET', + 'permission_callback' => '__return_true', + 'callback' => array( $this, 'openapi_spec' ), + ) ); + } + + /* -------------------- Auth -------------------- */ + + /** + * Accepts: + * - Authorization: Bearer where is a PAT we issued + * - WP application password (automatic via cookie/basic auth) + * - manage_options capability (logged-in admin) + */ + public function auth( \WP_REST_Request $request ): bool|\WP_Error { + if ( current_user_can( 'manage_options' ) ) { + return true; + } + $auth = (string) $request->get_header( 'authorization' ); + if ( '' === $auth ) { + return new \WP_Error( 'unauthorized', 'API key required', array( 'status' => 401 ) ); + } + if ( ! preg_match( '/^Bearer\s+(.+)$/i', $auth, $m ) ) { + return new \WP_Error( 'unauthorized', 'Invalid Authorization header', array( 'status' => 401 ) ); + } + $provided_key = trim( $m[1] ); + + $settings = $this->container->get( SettingsRepository::class ); + $keys = (array) $settings->get( 'api_keys', array() ); + foreach ( $keys as $key_record ) { + if ( ! is_array( $key_record ) ) continue; + $stored_hash = (string) ( $key_record['hash'] ?? '' ); + if ( '' === $stored_hash ) continue; + if ( hash_equals( $stored_hash, hash( 'sha256', $provided_key ) ) ) { + // Rate limit: 60 req/min per key. + if ( ! $this->rate_check( $stored_hash ) ) { + return new \WP_Error( 'rate_limited', 'Rate limit exceeded', array( 'status' => 429 ) ); + } + return true; + } + } + return new \WP_Error( 'unauthorized', 'Invalid API key', array( 'status' => 401 ) ); + } + + private function rate_check( string $key_hash ): bool { + $bucket = 'sg_api_rate_' . substr( $key_hash, 0, 16 ); + $count = (int) get_transient( $bucket ); + if ( $count >= 60 ) return false; + set_transient( $bucket, $count + 1, 60 ); + return true; + } + + /* -------------------- Handlers -------------------- */ + + public function list_products( \WP_REST_Request $req ): \WP_REST_Response { + $result = $this->container->get( ProductRepository::class )->paginate( array( + 'market' => strtoupper( (string) $req->get_param( 'market' ) ), + 'search' => (string) $req->get_param( 'search' ), + 'in_stock' => (bool) $req->get_param( 'in_stock' ), + 'page' => (int) $req->get_param( 'page' ), + 'per_page' => (int) $req->get_param( 'per_page' ), + ) ); + return rest_ensure_response( $result ); + } + + public function get_product( \WP_REST_Request $req ): \WP_REST_Response|\WP_Error { + $id = (int) $req->get_param( 'id' ); + $row = $this->container->get( ProductRepository::class )->find( $id ); + if ( null === $row ) { + return new \WP_Error( 'not_found', 'Product not found', array( 'status' => 404 ) ); + } + return rest_ensure_response( $row ); + } + + public function kpis(): \WP_REST_Response { + return rest_ensure_response( + $this->container->get( SalesAggregator::class )->headline_kpis() + ); + } + + public function buybox_trend( \WP_REST_Request $req ): \WP_REST_Response { + $days = (int) $req->get_param( 'days' ); + return rest_ensure_response( + $this->container->get( SalesAggregator::class )->buybox_trend( $days ) + ); + } + + public function list_mcf_orders( \WP_REST_Request $req ): \WP_REST_Response { + $limit = max( 1, min( 200, (int) $req->get_param( 'limit' ) ) ); + return rest_ensure_response( + $this->container->get( MCFOrderRepository::class )->recent( $limit ) + ); + } + + public function create_mcf_order( \WP_REST_Request $req ): \WP_REST_Response|\WP_Error { + $payload = (array) $req->get_json_params(); + $result = apply_filters( 'sg_commerce_fulfill_result', + do_action( 'sg_commerce_fulfill', $payload ), + $payload + ); + // The action returns void — we query the repository by displayable_order_id + // if caller passed one, to give them back the row. + $display_id = (string) ( $payload['displayable_order_id'] ?? '' ); + if ( '' !== $display_id ) { + global $wpdb; + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}sg_mcf_orders WHERE displayable_order_id = %s ORDER BY id DESC LIMIT 1", + $display_id + ), ARRAY_A ); + if ( $row ) return rest_ensure_response( $row ); + } + return rest_ensure_response( array( 'submitted' => true ) ); + } + + public function meta(): \WP_REST_Response { + $mp = $this->container->get( \SevenGum\Commerce\Amazon\Marketplaces::class ); + return rest_ensure_response( array( + 'plugin_version' => SG_COMMERCE_VERSION, + 'db_version' => SG_COMMERCE_DB_VERSION, + 'primary_market' => $mp->primary(), + 'enabled_markets'=> $mp->enabled_codes(), + 'api_version' => '2.0', + ) ); + } + + public function openapi_spec(): \WP_REST_Response { + return rest_ensure_response( array( + 'openapi' => '3.0.0', + 'info' => array( + 'title' => 'Seven Gum Commerce API', + 'version' => '2.0', + 'description' => 'Headless REST API for product catalog, analytics, MCF fulfillment, and more.', + ), + 'servers' => array( array( 'url' => rest_url( self::NAMESPACE ) ) ), + 'paths' => array( + '/products' => array( 'get' => array( 'summary' => 'List products' ) ), + '/products/{id}' => array( 'get' => array( 'summary' => 'Get one product' ) ), + '/analytics/kpis' => array( 'get' => array( 'summary' => 'Headline KPIs' ) ), + '/analytics/buybox-trend' => array( 'get' => array( 'summary' => 'Buy Box won/lost per day' ) ), + '/mcf/orders' => array( + 'get' => array( 'summary' => 'List MCF orders' ), + 'post' => array( 'summary' => 'Create an MCF order' ), + ), + '/meta' => array( 'get' => array( 'summary' => 'Plugin + market metadata' ) ), + ), + 'components' => array( + 'securitySchemes' => array( + 'bearer' => array( 'type' => 'http', 'scheme' => 'bearer' ), + ), + ), + 'security' => array( array( 'bearer' => array() ) ), + ) ); + } + + /* -------------------- Key management helpers -------------------- */ + + /** + * Mint a new API key. Returns the plaintext key ONCE — only hash is stored. + */ + public static function mint_key( Container $container, string $label ): string { + $settings = $container->get( SettingsRepository::class ); + $plaintext = 'sgc_' . bin2hex( random_bytes( 24 ) ); + $keys = (array) $settings->get( 'api_keys', array() ); + $keys[] = array( + 'label' => $label, + 'hash' => hash( 'sha256', $plaintext ), + 'created_at' => gmdate( 'c' ), + ); + $settings->set( 'api_keys', $keys ); + return $plaintext; + } + + public static function revoke_key( Container $container, int $index ): bool { + $settings = $container->get( SettingsRepository::class ); + $keys = (array) $settings->get( 'api_keys', array() ); + if ( ! isset( $keys[ $index ] ) ) return false; + unset( $keys[ $index ] ); + $settings->set( 'api_keys', array_values( $keys ) ); + return true; + } +} diff --git a/sg-commerce-pro/src/Admin/AdminModule.php b/sg-commerce-pro/src/Admin/AdminModule.php new file mode 100644 index 0000000..6561352 --- /dev/null +++ b/sg-commerce-pro/src/Admin/AdminModule.php @@ -0,0 +1,938 @@ + 'handle_save_amazon', + 'sg_test_amazon' => 'handle_test_amazon', + 'sg_sync_amazon' => 'handle_sync_amazon', + 'sg_save_ai' => 'handle_save_ai', + 'sg_test_ai' => 'handle_test_ai', + 'sg_describe_product' => 'handle_describe_product', + 'sg_translate_product' => 'handle_translate_product', + 'sg_add_product' => 'handle_add_product', + 'sg_delete_product' => 'handle_delete_product', + 'sg_update_cost' => 'handle_update_cost', + 'sg_save_settings' => 'handle_save_settings', + 'sg_save_repricer' => 'handle_save_repricer', + 'sg_run_repricer' => 'handle_run_repricer', + 'sg_clear_secret' => 'handle_clear_secret', + 'sg_mark_alerts_read' => 'handle_mark_alerts_read', + 'sg_toggle_rule' => 'handle_toggle_rule', + // MCF + 'sg_save_mcf' => 'handle_save_mcf', + 'sg_cancel_mcf' => 'handle_cancel_mcf', + 'sg_refresh_mcf' => 'handle_refresh_mcf', + // Injection + 'sg_add_injection_rule' => 'handle_add_injection_rule', + 'sg_toggle_injection' => 'handle_toggle_injection', + 'sg_delete_injection' => 'handle_delete_injection', + 'sg_save_injection_settings' => 'handle_save_injection_settings', + // Comparisons + 'sg_add_comparison' => 'handle_add_comparison', + 'sg_delete_comparison' => 'handle_delete_comparison', + // v3.2: PII + 'sg_pii_purge_now' => 'handle_pii_purge_now', + 'sg_pii_save_settings' => 'handle_pii_save_settings', + 'sg_pii_erase_subject' => 'handle_pii_erase_subject', + // v3.2: Settlements + 'sg_settlement_upload' => 'handle_settlement_upload', + // v3.2: API keys + 'sg_mint_api_key' => 'handle_mint_api_key', + 'sg_revoke_api_key' => 'handle_revoke_api_key', + // v3.2: Sandbox + SQS + 'sg_toggle_sandbox' => 'handle_toggle_sandbox', + 'sg_save_sqs' => 'handle_save_sqs', + 'sg_test_sqs' => 'handle_test_sqs', + // v3.2: Theme bridge + 'sg_theme_resync' => 'handle_theme_resync', + // v3.3: Inbound plans + 'sg_inbound_create' => 'handle_inbound_create', + ); + foreach ( $handlers as $action => $method ) { + add_action( "admin_post_{$action}", array( $this, $method ) ); + } + } + + /* -------------------------------------------------- */ + /* Menu */ + /* -------------------------------------------------- */ + + public function register_menu(): void { + $unread = AlertDispatcher::unread_count(); + $badge = $unread > 0 ? sprintf( ' %d', $unread, $unread ) : ''; + + add_menu_page( + 'Seven Gum Commerce', + 'Seven Gum' . $badge, + self::CAP, + self::MENU, + array( $this, 'render_dashboard' ), + 'dashicons-cart', + 56 + ); + + $pages = array( + array( '', 'Dashboard', 'render_dashboard' ), + array( '-products', 'Products', 'render_products' ), + array( '-amazon', 'Amazon Connect', 'render_amazon' ), + array( '-ai', 'AI (Ollama)', 'render_ai' ), + array( '-analytics', 'Analytics', 'render_analytics' ), + array( '-repricer', 'Repricer', 'render_repricer' ), + array( '-automation', 'Automation', 'render_automation' ), + array( '-fulfillment', 'Fulfillment', 'render_fulfillment' ), + array( '-inbound', 'Inbound Plans', 'render_inbound_plans' ), + array( '-catalog', 'Catalog Lookup', 'render_catalog_lookup' ), + array( '-injection', 'Auto-Injection', 'render_injection' ), + array( '-comparisons', 'Comparisons', 'render_comparisons' ), + array( '-widgets', 'Widgets', 'render_widgets' ), + array( '-theme-bridge','Theme Bridge', 'render_theme_bridge' ), + array( '-pii', 'PII Compliance', 'render_pii' ), + array( '-settlements', 'Settlements', 'render_settlements' ), + array( '-api-keys', 'API Keys', 'render_api_keys' ), + array( '-logs', 'Logs', 'render_logs' ), + array( '-settings', 'Settings', 'render_settings' ), + ); + foreach ( $pages as $p ) { + add_submenu_page( self::MENU, $p[1], $p[1], self::CAP, self::MENU . $p[0], array( $this, $p[2] ) ); + } + } + + public function enqueue_assets( string $hook ): void { + if ( ! str_contains( $hook, self::MENU ) ) { + return; + } + wp_enqueue_style( 'sg-commerce-admin', SG_COMMERCE_URL . 'assets/css/admin.css', array(), SG_COMMERCE_VERSION ); + wp_enqueue_script( 'sg-commerce-admin', SG_COMMERCE_URL . 'assets/js/admin.js', array( 'jquery' ), SG_COMMERCE_VERSION, true ); + } + + /* -------------------------------------------------- */ + /* Views — each delegates to a partial */ + /* -------------------------------------------------- */ + + public function render_dashboard(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/dashboard.php'; + } + public function render_products(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/products.php'; + } + public function render_amazon(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/amazon.php'; + } + public function render_ai(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/ai.php'; + } + public function render_analytics(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/analytics.php'; + } + public function render_repricer(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/repricer.php'; + } + public function render_automation(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/automation.php'; + } + public function render_widgets(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/widgets.php'; + } + public function render_fulfillment(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/fulfillment.php'; + } + public function render_injection(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/injection.php'; + } + public function render_comparisons(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/comparisons.php'; + } + public function render_theme_bridge(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/theme-bridge.php'; + } + public function render_pii(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/pii.php'; + } + public function render_settlements(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/settlements.php'; + } + public function render_api_keys(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/api-keys.php'; + } + public function render_inbound_plans(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/inbound-plans.php'; + } + public function render_catalog_lookup(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/catalog-lookup.php'; + } + public function render_logs(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/logs.php'; + } + public function render_settings(): void { + $container = $this->container; + require SG_COMMERCE_DIR . 'admin/views/settings.php'; + } + + /* -------------------------------------------------- */ + /* Handlers */ + /* -------------------------------------------------- */ + + public function handle_save_amazon(): void { + $this->verify( 'sg_save_amazon' ); + $settings = $this->container->get( SettingsRepository::class ); + + if ( ! empty( $_POST['amazon_client_id'] ) ) { + $settings->set( 'amazon_client_id', sanitize_text_field( wp_unslash( (string) $_POST['amazon_client_id'] ) ) ); + } + if ( ! empty( $_POST['amazon_client_secret'] ) ) { + $settings->set( 'amazon_client_secret', wp_unslash( (string) $_POST['amazon_client_secret'] ) ); + } + if ( ! empty( $_POST['amazon_refresh_token'] ) ) { + $settings->set( 'amazon_refresh_token', wp_unslash( (string) $_POST['amazon_refresh_token'] ) ); + } + if ( isset( $_POST['amazon_seller_id'] ) ) { + $settings->set( 'amazon_seller_id', sanitize_text_field( wp_unslash( (string) $_POST['amazon_seller_id'] ) ) ); + } + $primary = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['amazon_primary_market'] ?? 'US' ) ) ) ); + $markets = isset( $_POST['amazon_enabled_markets'] ) && is_array( $_POST['amazon_enabled_markets'] ) + ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['amazon_enabled_markets'] ) ) + : array( 'US' ); + + $settings->update_many( array( + 'amazon_primary_market' => $primary, + 'amazon_enabled_markets' => array_values( array_unique( $markets ) ), + ) ); + + $this->container->get( \SevenGum\Commerce\Amazon\LWATokenManager::class )->clear_cache(); + $this->flash( 'success', __( 'Amazon credentials saved.', 'sg-commerce' ) ); + $this->redirect( 'amazon' ); + } + + public function handle_test_amazon(): void { + $this->verify( 'sg_test_amazon' ); + try { + $result = $this->container->get( AmazonClient::class )->test_connection(); + $this->flash( 'success', sprintf( + __( 'Amazon accepted us. Seller account participates in %d marketplace(s).', 'sg-commerce' ), + (int) $result['participations'] + ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'amazon' ); + } + + public function handle_sync_amazon(): void { + $this->verify( 'sg_sync_amazon' ); + $market = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? '' ) ) ) ); + try { + $orchestrator = $this->container->get( SyncOrchestrator::class ); + if ( '' === $market ) { + $result = $orchestrator->sync_all(); + $msg = sprintf( + __( 'Synced %d products across %d markets.', 'sg-commerce' ), + (int) $result['total'], + count( $result['by_market'] ) + ); + if ( ! empty( $result['errors'] ) ) { + $msg .= ' ' . __( 'Errors:', 'sg-commerce' ) . ' ' . implode( '; ', array_map( + static fn( $m, $e ) => "{$m}: {$e}", + array_keys( $result['errors'] ), + array_values( $result['errors'] ) + ) ); + } + } else { + $summary = $orchestrator->sync_market( $market ); + $msg = sprintf( __( 'Synced %d products from %s.', 'sg-commerce' ), (int) $summary['rows'], $market ); + } + $this->flash( 'success', $msg ); + } catch ( \Throwable $e ) { + $this->flash( 'error', __( 'Sync failed:', 'sg-commerce' ) . ' ' . $e->getMessage() ); + } + $this->redirect( 'products' ); + } + + public function handle_save_ai(): void { + $this->verify( 'sg_save_ai' ); + $settings = $this->container->get( SettingsRepository::class ); + + $settings->update_many( array( + 'ai_provider' => sanitize_text_field( wp_unslash( (string) ( $_POST['ai_provider'] ?? 'ollama' ) ) ), + 'ollama_url' => esc_url_raw( wp_unslash( (string) ( $_POST['ollama_url'] ?? 'http://127.0.0.1:11434' ) ) ), + 'ollama_model' => sanitize_text_field( wp_unslash( (string) ( $_POST['ollama_model'] ?? 'llama3.1:8b' ) ) ), + 'ai_temperature' => max( 0.0, min( 2.0, (float) ( $_POST['ai_temperature'] ?? 0.7 ) ) ), + 'ai_brand_voice' => sanitize_textarea_field( wp_unslash( (string) ( $_POST['ai_brand_voice'] ?? '' ) ) ), + ) ); + + if ( ! empty( $_POST['ollama_auth_token'] ) ) { + $settings->set( 'ollama_auth_token', wp_unslash( (string) $_POST['ollama_auth_token'] ) ); + } + + $this->flash( 'success', __( 'AI settings saved.', 'sg-commerce' ) ); + $this->redirect( 'ai' ); + } + + public function handle_test_ai(): void { + $this->verify( 'sg_test_ai' ); + try { + $models = $this->container->get( OllamaProvider::class )->list_models(); + $names = array_map( static fn( $m ) => $m['name'], $models ); + $this->flash( 'success', sprintf( + __( 'Ollama is reachable. %d model(s) installed: %s', 'sg-commerce' ), + count( $names ), + '' !== implode( ', ', $names ) ? implode( ', ', $names ) : '(none — run: ollama pull llama3.1:8b)' + ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'ai' ); + } + + public function handle_describe_product(): void { + $this->verify( 'sg_describe_product' ); + $id = (int) ( $_POST['id'] ?? 0 ); + try { + $this->container->get( Copywriter::class )->describe( $id ); + $this->flash( 'success', sprintf( __( 'Generated English description for product #%d.', 'sg-commerce' ), $id ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', __( 'AI failed:', 'sg-commerce' ) . ' ' . $e->getMessage() ); + } + $this->redirect( 'products' ); + } + + public function handle_translate_product(): void { + $this->verify( 'sg_translate_product' ); + $id = (int) ( $_POST['id'] ?? 0 ); + $market = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['target_market'] ?? '' ) ) ) ); + try { + $this->container->get( Copywriter::class )->translate( $id, $market ); + $this->flash( 'success', sprintf( __( 'Translated product #%d for market %s.', 'sg-commerce' ), $id, $market ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', __( 'Translation failed:', 'sg-commerce' ) . ' ' . $e->getMessage() ); + } + $this->redirect( 'products' ); + } + + public function handle_add_product(): void { + $this->verify( 'sg_add_product' ); + $sku = strtoupper( preg_replace( '/[^A-Z0-9_\-\.]/i', '', wp_unslash( (string) ( $_POST['sku'] ?? '' ) ) ) ); + if ( '' === $sku ) { + $this->flash( 'error', __( 'SKU is required.', 'sg-commerce' ) ); + $this->redirect( 'products' ); + return; + } + try { + $this->container->get( ProductRepository::class )->add_manual( array( + 'market' => sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? 'US' ) ) ), + 'sku' => $sku, + 'asin' => preg_replace( '/[^A-Z0-9]/', '', wp_unslash( (string) ( $_POST['asin'] ?? '' ) ) ), + 'product_name' => sanitize_text_field( wp_unslash( (string) ( $_POST['product_name'] ?? '' ) ) ), + 'image_url' => esc_url_raw( wp_unslash( (string) ( $_POST['image_url'] ?? '' ) ) ), + 'cost_price' => isset( $_POST['cost_price'] ) && '' !== $_POST['cost_price'] ? (float) $_POST['cost_price'] : null, + ) ); + $this->flash( 'success', __( 'Product added.', 'sg-commerce' ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'products' ); + } + + public function handle_delete_product(): void { + $this->verify( 'sg_delete_product' ); + $id = (int) ( $_POST['id'] ?? 0 ); + if ( $id > 0 ) { + $this->container->get( ProductRepository::class )->delete( $id ); + $this->flash( 'success', __( 'Product removed.', 'sg-commerce' ) ); + } + $this->redirect( 'products' ); + } + + public function handle_update_cost(): void { + $this->verify( 'sg_update_cost' ); + $id = (int) ( $_POST['id'] ?? 0 ); + $cost = (float) ( $_POST['cost'] ?? 0 ); + if ( $id > 0 && $cost > 0 ) { + $this->container->get( ProductRepository::class )->update_cost( $id, $cost ); + $this->flash( 'success', __( 'Cost updated.', 'sg-commerce' ) ); + } + $this->redirect( 'products' ); + } + + public function handle_save_settings(): void { + $this->verify( 'sg_save_settings' ); + $settings = $this->container->get( SettingsRepository::class ); + $settings->update_many( array( + 'log_level' => sanitize_text_field( wp_unslash( (string) ( $_POST['log_level'] ?? 'info' ) ) ), + 'log_retention_days' => max( 1, (int) ( $_POST['log_retention_days'] ?? 30 ) ), + 'sync_pages_per_run' => max( 1, min( 50, (int) ( $_POST['sync_pages_per_run'] ?? 5 ) ) ), + 'referral_fee_pct' => max( 0.0, min( 50.0, (float) ( $_POST['referral_fee_pct'] ?? 15.0 ) ) ), + 'fba_pick_pack_fee' => max( 0.0, (float) ( $_POST['fba_pick_pack_fee'] ?? 3.0 ) ), + ) ); + $this->flash( 'success', __( 'Settings saved.', 'sg-commerce' ) ); + $this->redirect( 'settings' ); + } + + public function handle_save_repricer(): void { + $this->verify( 'sg_save_repricer' ); + $settings = $this->container->get( SettingsRepository::class ); + $settings->update_many( array( + 'repricer_enabled' => ! empty( $_POST['repricer_enabled'] ), + 'repricer_strategy' => sanitize_text_field( wp_unslash( (string) ( $_POST['repricer_strategy'] ?? 'match_buybox' ) ) ), + 'repricer_dry_run' => ! empty( $_POST['repricer_dry_run'] ), + 'repricer_cents_below_buybox'=> max( 0.0, (float) ( $_POST['repricer_cents_below_buybox'] ?? 0.01 ) ), + 'repricer_min_margin_pct' => max( 0.0, min( 100.0, (float) ( $_POST['repricer_min_margin_pct'] ?? 15.0 ) ) ), + ) ); + $this->flash( 'success', __( 'Repricer settings saved.', 'sg-commerce' ) ); + $this->redirect( 'repricer' ); + } + + public function handle_run_repricer(): void { + $this->verify( 'sg_run_repricer' ); + try { + $result = $this->container->get( RepricerEngine::class )->tick(); + $this->flash( 'success', sprintf( + __( 'Repricer ran. Adjusted %d, skipped %d (margin floor).', 'sg-commerce' ), + (int) ( $result['adjusted'] ?? 0 ), + (int) ( $result['skipped_floor'] ?? 0 ) + ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'repricer' ); + } + + public function handle_clear_secret(): void { + $this->verify( 'sg_clear_secret' ); + $key = sanitize_key( (string) ( $_POST['secret'] ?? '' ) ); + if ( '' !== $key ) { + $this->container->get( SettingsRepository::class )->delete( $key ); + $this->flash( 'success', __( 'Secret cleared.', 'sg-commerce' ) ); + } + $page = sanitize_key( (string) ( $_POST['return'] ?? 'amazon' ) ); + $this->redirect( $page ); + } + + public function handle_mark_alerts_read(): void { + $this->verify( 'sg_mark_alerts_read' ); + $count = AlertDispatcher::mark_all_read(); + $this->flash( 'success', sprintf( __( '%d alert(s) marked as read.', 'sg-commerce' ), $count ) ); + $this->redirect( 'automation' ); + } + + public function handle_toggle_rule(): void { + $this->verify( 'sg_toggle_rule' ); + $id = (int) ( $_POST['id'] ?? 0 ); + if ( $id > 0 ) { + global $wpdb; + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}sg_rules SET is_active = 1 - is_active WHERE id = %d", + $id + ) ); + } + $this->redirect( 'automation' ); + } + + /* -------------------- MCF handlers -------------------- */ + + public function handle_save_mcf(): void { + $this->verify( 'sg_save_mcf' ); + $this->container->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class )->update_many( array( + 'mcf_auto_wc' => ! empty( $_POST['mcf_auto_wc'] ), + 'mcf_default_market' => strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['mcf_default_market'] ?? 'US' ) ) ) ), + 'mcf_default_speed' => sanitize_text_field( wp_unslash( (string) ( $_POST['mcf_default_speed'] ?? 'Standard' ) ) ), + ) ); + $this->flash( 'success', __( 'Fulfillment settings saved.', 'sg-commerce' ) ); + $this->redirect( 'fulfillment' ); + } + + public function handle_cancel_mcf(): void { + $this->verify( 'sg_cancel_mcf' ); + $id = (int) ( $_POST['id'] ?? 0 ); + try { + $repo = $this->container->get( \SevenGum\Commerce\Fulfillment\MCFOrderRepository::class ); + $row = $repo->find( $id ); + if ( null === $row ) throw new \RuntimeException( 'MCF order not found.' ); + $this->container->get( \SevenGum\Commerce\Fulfillment\MCFClient::class ) + ->cancel( (string) $row['market'], (string) $row['seller_fulfillment_order_id'] ); + global $wpdb; + $wpdb->update( $wpdb->prefix . 'sg_mcf_orders', array( 'status' => 'cancelled' ), array( 'id' => $id ) ); + $this->flash( 'success', __( 'MCF order cancellation requested.', 'sg-commerce' ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'fulfillment' ); + } + + public function handle_refresh_mcf(): void { + $this->verify( 'sg_refresh_mcf' ); + try { + $fulfillment = $this->container->get( \SevenGum\Commerce\Fulfillment\FulfillmentModule::class ); + $fulfillment->refresh_pending_tracking(); + $this->flash( 'success', __( 'MCF tracking refreshed.', 'sg-commerce' ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'fulfillment' ); + } + + /* -------------------- Injection handlers -------------------- */ + + public function handle_add_injection_rule(): void { + $this->verify( 'sg_add_injection_rule' ); + global $wpdb; + $pattern = sanitize_text_field( wp_unslash( (string) ( $_POST['pattern'] ?? '' ) ) ); + $sku = sanitize_text_field( wp_unslash( (string) ( $_POST['sku'] ?? '' ) ) ); + if ( '' === $pattern || '' === $sku ) { + $this->flash( 'error', __( 'Pattern and SKU are required.', 'sg-commerce' ) ); + $this->redirect( 'injection' ); + return; + } + $result = $wpdb->insert( $wpdb->prefix . 'sg_injection_rules', array( + 'pattern' => $pattern, + 'pattern_type' => in_array( (string) ( $_POST['pattern_type'] ?? 'keyword' ), array( 'keyword', 'regex' ), true ) + ? (string) $_POST['pattern_type'] : 'keyword', + 'sku' => $sku, + 'market' => strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? '' ) ) ) ), + 'render_as' => in_array( (string) ( $_POST['render_as'] ?? 'link' ), array( 'link', 'widget', 'tooltip' ), true ) + ? (string) $_POST['render_as'] : 'link', + 'priority' => max( 1, (int) ( $_POST['priority'] ?? 10 ) ), + 'max_per_post' => max( 1, (int) ( $_POST['max_per_post'] ?? 1 ) ), + 'is_active' => 1, + 'created_at' => current_time( 'mysql', true ), + ), array( '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ) ); + if ( false === $result ) { + $this->flash( 'error', __( 'Could not save rule.', 'sg-commerce' ) ); + } else { + $this->flash( 'success', __( 'Injection rule added.', 'sg-commerce' ) ); + } + $this->redirect( 'injection' ); + } + + public function handle_toggle_injection(): void { + $this->verify( 'sg_toggle_injection' ); + $id = (int) ( $_POST['id'] ?? 0 ); + if ( $id > 0 ) { + global $wpdb; + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}sg_injection_rules SET is_active = 1 - is_active WHERE id = %d", + $id + ) ); + } + $this->redirect( 'injection' ); + } + + public function handle_delete_injection(): void { + $this->verify( 'sg_delete_injection' ); + $id = (int) ( $_POST['id'] ?? 0 ); + if ( $id > 0 ) { + global $wpdb; + $wpdb->delete( $wpdb->prefix . 'sg_injection_rules', array( 'id' => $id ), array( '%d' ) ); + $this->flash( 'success', __( 'Rule removed.', 'sg-commerce' ) ); + } + $this->redirect( 'injection' ); + } + + public function handle_save_injection_settings(): void { + $this->verify( 'sg_save_injection_settings' ); + $this->container->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class ) + ->set( 'injection_enabled', ! empty( $_POST['injection_enabled'] ) ); + $this->flash( 'success', __( 'Injection settings saved. Note: re-enable may require a plugin reload.', 'sg-commerce' ) ); + $this->redirect( 'injection' ); + } + + /* -------------------- Comparisons handlers -------------------- */ + + public function handle_add_comparison(): void { + $this->verify( 'sg_add_comparison' ); + global $wpdb; + $slug = sanitize_key( (string) ( $_POST['slug'] ?? '' ) ); + $title = sanitize_text_field( wp_unslash( (string) ( $_POST['title'] ?? '' ) ) ); + $skus_raw = (string) ( $_POST['skus'] ?? '' ); + $skus = array_filter( array_map( 'trim', explode( ',', $skus_raw ) ) ); + if ( '' === $slug || empty( $skus ) ) { + $this->flash( 'error', __( 'Slug and at least one SKU required.', 'sg-commerce' ) ); + $this->redirect( 'comparisons' ); + return; + } + $now = current_time( 'mysql', true ); + $wpdb->query( $wpdb->prepare( + "INSERT INTO {$wpdb->prefix}sg_comparisons (slug, title, skus, market, features, created_at, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + title = VALUES(title), + skus = VALUES(skus), + market = VALUES(market), + features = VALUES(features), + updated_at = VALUES(updated_at)", + $slug, + $title, + wp_json_encode( array_values( $skus ) ), + strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? '' ) ) ) ), + sanitize_text_field( wp_unslash( (string) ( $_POST['features'] ?? '' ) ) ), + $now, $now + ) ); + $this->flash( 'success', __( 'Comparison saved.', 'sg-commerce' ) ); + $this->redirect( 'comparisons' ); + } + + public function handle_delete_comparison(): void { + $this->verify( 'sg_delete_comparison' ); + $id = (int) ( $_POST['id'] ?? 0 ); + if ( $id > 0 ) { + global $wpdb; + $wpdb->delete( $wpdb->prefix . 'sg_comparisons', array( 'id' => $id ), array( '%d' ) ); + $this->flash( 'success', __( 'Comparison removed.', 'sg-commerce' ) ); + } + $this->redirect( 'comparisons' ); + } + + /* -------------------- PII handlers (v3.2) -------------------- */ + + public function handle_pii_purge_now(): void { + $this->verify( 'sg_pii_purge_now' ); + try { + $count = $this->container->get( \SevenGum\Commerce\Security\PIIManager\PIIManager::class )->purge_expired(); + $this->flash( 'success', sprintf( __( 'Anonymized %d expired orders.', 'sg-commerce' ), $count ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'pii' ); + } + + public function handle_pii_save_settings(): void { + $this->verify( 'sg_pii_save_settings' ); + $days = max( 1, min( 3650, (int) ( $_POST['pii_retention_days'] ?? 30 ) ) ); + $this->container->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class ) + ->set( 'pii_retention_days', $days ); + $this->flash( 'success', __( 'PII retention updated.', 'sg-commerce' ) ); + $this->redirect( 'pii' ); + } + + public function handle_pii_erase_subject(): void { + $this->verify( 'sg_pii_erase_subject' ); + $email = sanitize_email( wp_unslash( (string) ( $_POST['email'] ?? '' ) ) ); + if ( '' === $email ) { + $this->flash( 'error', __( 'Email required.', 'sg-commerce' ) ); + $this->redirect( 'pii' ); + return; + } + try { + $count = $this->container->get( \SevenGum\Commerce\Security\PIIManager\PIIManager::class )->erase_subject_data( $email ); + $this->flash( 'success', sprintf( __( 'Erased PII for %d orders.', 'sg-commerce' ), $count ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'pii' ); + } + + /* -------------------- Settlement handlers (v3.2) -------------------- */ + + public function handle_settlement_upload(): void { + $this->verify( 'sg_settlement_upload' ); + if ( empty( $_FILES['settlement_file']['tmp_name'] ) ) { + $this->flash( 'error', __( 'No file uploaded.', 'sg-commerce' ) ); + $this->redirect( 'settlements' ); + return; + } + $tmp = (string) $_FILES['settlement_file']['tmp_name']; + if ( ! is_uploaded_file( $tmp ) ) { + $this->flash( 'error', __( 'Invalid upload.', 'sg-commerce' ) ); + $this->redirect( 'settlements' ); + return; + } + $max_size = 20 * MB_IN_BYTES; + if ( filesize( $tmp ) > $max_size ) { + $this->flash( 'error', __( 'File too large (max 20MB).', 'sg-commerce' ) ); + $this->redirect( 'settlements' ); + return; + } + try { + $content = (string) file_get_contents( $tmp ); + $parser = new \SevenGum\Commerce\Amazon\ReportsParser\SettlementsParser(); + $events = $parser->parse( $content ); + + $settlement_id = sanitize_text_field( wp_unslash( (string) ( $_POST['settlement_id'] ?? 'MANUAL-' . gmdate( 'YmdHis' ) ) ) ); + + global $wpdb; + $table = $wpdb->prefix . 'sg_settlements'; + $imported = 0; + foreach ( $events as $e ) { + $result = $wpdb->insert( $table, array( + 'event_date' => $e['date'] ? gmdate( 'Y-m-d', strtotime( $e['date'] ) ) : null, + 'event_type' => $e['type'], + 'sku' => $e['sku'], + 'order_id' => $e['order_id'], + 'marketplace' => $e['marketplace'], + 'amount' => $e['amount'], + 'currency' => $e['currency'], + 'description' => substr( $e['description'], 0, 190 ), + 'settlement_id' => $settlement_id, + 'imported_at' => current_time( 'mysql', true ), + ), array( '%s', '%s', '%s', '%s', '%s', '%f', '%s', '%s', '%s', '%s' ) ); + if ( false !== $result ) $imported++; + } + $this->flash( 'success', sprintf( __( 'Imported %1$d of %2$d events.', 'sg-commerce' ), $imported, count( $events ) ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'settlements' ); + } + + /* -------------------- API key handlers (v3.2) -------------------- */ + + public function handle_mint_api_key(): void { + $this->verify( 'sg_mint_api_key' ); + $label = sanitize_text_field( wp_unslash( (string) ( $_POST['label'] ?? 'Dashboard' ) ) ); + $key = \SevenGum\Commerce\API\HeadlessAPI::mint_key( $this->container, $label ); + // Flash the raw key once so the page shows it. + set_transient( 'sg_new_api_key_' . get_current_user_id(), $key, 60 ); + $this->flash( 'success', __( 'API key minted. Copy it from the page — it will only be shown once.', 'sg-commerce' ) ); + $this->redirect( 'api-keys' ); + } + + public function handle_revoke_api_key(): void { + $this->verify( 'sg_revoke_api_key' ); + $index = (int) ( $_POST['index'] ?? -1 ); + \SevenGum\Commerce\API\HeadlessAPI::revoke_key( $this->container, $index ); + $this->flash( 'success', __( 'API key revoked.', 'sg-commerce' ) ); + $this->redirect( 'api-keys' ); + } + + /* -------------------- Sandbox + SQS handlers (v3.2) -------------------- */ + + public function handle_toggle_sandbox(): void { + $this->verify( 'sg_toggle_sandbox' ); + if ( \SevenGum\Commerce\Testing\Sandbox::is_enabled() ) { + \SevenGum\Commerce\Testing\Sandbox::disable(); + $this->flash( 'success', __( 'Sandbox mode disabled.', 'sg-commerce' ) ); + } else { + \SevenGum\Commerce\Testing\Sandbox::enable(); + $this->flash( 'success', __( 'Sandbox mode enabled — SP-API calls return mocked data.', 'sg-commerce' ) ); + } + $this->redirect( 'settings' ); + } + + public function handle_save_sqs(): void { + $this->verify( 'sg_save_sqs' ); + $settings = $this->container->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class ); + $enc = $this->container->get( \SevenGum\Commerce\Security\Encryption::class ); + + $settings->set( 'sqs_enabled', ! empty( $_POST['sqs_enabled'] ) ); + $settings->set( 'sqs_region', sanitize_text_field( wp_unslash( (string) ( $_POST['sqs_region'] ?? 'us-east-1' ) ) ) ); + $settings->set( 'sqs_queue_url', esc_url_raw( wp_unslash( (string) ( $_POST['sqs_queue_url'] ?? '' ) ) ) ); + $settings->set( 'sqs_access_key', sanitize_text_field( wp_unslash( (string) ( $_POST['sqs_access_key'] ?? '' ) ) ) ); + + $new_secret = (string) ( $_POST['sqs_secret_key'] ?? '' ); + if ( '' !== $new_secret ) { + $settings->set( 'sqs_secret_key_enc', $enc->encrypt( $new_secret ) ); + } + + // Toggle cron based on enabled state. + if ( ! empty( $_POST['sqs_enabled'] ) ) { + if ( ! wp_next_scheduled( 'sg_commerce_sqs_poll' ) ) { + wp_schedule_event( time() + 60, 'sg_five_minutes', 'sg_commerce_sqs_poll' ); + } + } else { + wp_clear_scheduled_hook( 'sg_commerce_sqs_poll' ); + } + + $this->flash( 'success', __( 'SQS settings saved.', 'sg-commerce' ) ); + $this->redirect( 'settings' ); + } + + public function handle_test_sqs(): void { + $this->verify( 'sg_test_sqs' ); + try { + $processed = $this->container->get( \SevenGum\Commerce\Amazon\SQSConsumer\SQSConsumer::class )->poll(); + $this->flash( 'success', sprintf( __( 'SQS poll test: %d messages processed.', 'sg-commerce' ), $processed ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'settings' ); + } + + /* -------------------- Theme bridge handlers (v3.2) -------------------- */ + + public function handle_theme_resync(): void { + $this->verify( 'sg_theme_resync' ); + try { + $this->container->get( \SevenGum\Commerce\Integration\ThemeBridge::class )->on_sync_completed( array() ); + $this->flash( 'success', __( 'Theme resync complete. Every mapped product got fresh price + URL.', 'sg-commerce' ) ); + } catch ( \Throwable $e ) { + $this->flash( 'error', $e->getMessage() ); + } + $this->redirect( 'theme-bridge' ); + } + + /* -------------------- Inbound plan handlers (v3.3) -------------------- */ + + public function handle_inbound_create(): void { + $this->verify( 'sg_inbound_create' ); + + $name = sanitize_text_field( wp_unslash( (string) ( $_POST['plan_name'] ?? '' ) ) ); + $market = strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['market'] ?? 'US' ) ) ) ); + $msku = sanitize_text_field( wp_unslash( (string) ( $_POST['msku'] ?? '' ) ) ); + $packs_per_carton = max( 1, (int) ( $_POST['packs_per_carton'] ?? 24 ) ); + $carton_count = max( 1, (int) ( $_POST['carton_count'] ?? 0 ) ); + $label_owner = in_array( (string) ( $_POST['label_owner'] ?? 'NONE' ), array( 'NONE', 'SELLER', 'AMAZON' ), true ) + ? (string) $_POST['label_owner'] : 'NONE'; + + if ( '' === $name || '' === $msku || $carton_count < 1 ) { + $this->flash( 'error', __( 'Plan name, MSKU, and carton count are required.', 'sg-commerce' ) ); + $this->redirect( 'inbound' ); + return; + } + + $source_address = array( + 'name' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_name'] ?? '' ) ) ), + 'address1' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_address1'] ?? '' ) ) ), + 'city' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_city'] ?? '' ) ) ), + 'state' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_state'] ?? '' ) ) ), + 'postal_code' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_postal'] ?? '' ) ) ), + 'country_code' => strtoupper( sanitize_text_field( wp_unslash( (string) ( $_POST['src_country'] ?? 'CN' ) ) ) ), + 'phone' => sanitize_text_field( wp_unslash( (string) ( $_POST['src_phone'] ?? '' ) ) ), + ); + + $total_units = $carton_count * $packs_per_carton; + + // Persist as draft — operator can then advance through STA steps. + global $wpdb; + $inserted = $wpdb->insert( $wpdb->prefix . 'sg_inbound_plans', array( + 'amazon_plan_id' => '', + 'name' => $name, + 'market' => $market, + 'status' => 'draft', + 'source_address' => wp_json_encode( $source_address ), + 'items' => wp_json_encode( array( + array( + 'msku' => $msku, + 'quantity' => $total_units, + 'prep_owner' => 'SELLER', + 'label_owner' => $label_owner, + ), + ) ), + 'cartons' => wp_json_encode( array( + array( + 'template_id' => 'sg-master-24pack', + 'quantity' => $carton_count, + 'contents' => array( array( 'msku' => $msku, 'quantity' => $packs_per_carton ) ), + ), + ) ), + 'total_cartons' => $carton_count, + 'total_units' => $total_units, + 'created_at' => current_time( 'mysql', true ), + ), array( '%s','%s','%s','%s','%s','%s','%s','%d','%d','%s' ) ); + + if ( false === $inserted ) { + $this->flash( 'error', __( 'Could not persist plan. DB error:', 'sg-commerce' ) . ' ' . $wpdb->last_error ); + } else { + $this->flash( 'success', sprintf( + /* translators: %1$d cartons, %2$d units */ + __( 'Draft plan saved (%1$d cartons = %2$d units). Next step: advance to Amazon via WP-CLI or admin workflow.', 'sg-commerce' ), + $carton_count, $total_units + ) ); + } + $this->redirect( 'inbound' ); + } + + /* -------------------------------------------------- */ + /* Helpers */ + /* -------------------------------------------------- */ + + private function verify( string $action ): void { + if ( ! current_user_can( self::CAP ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'sg-commerce' ) ); + } + check_admin_referer( $action, self::NONCE ); + } + + private function redirect( string $page ): void { + $slug = self::MENU . ( '' !== $page && 'dashboard' !== $page ? '-' . $page : '' ); + wp_safe_redirect( admin_url( 'admin.php?page=' . $slug ) ); + exit; + } + + public function flash( string $type, string $message ): void { + set_transient( 'sg_commerce_notice_' . get_current_user_id(), array( 'type' => $type, 'msg' => $message ), 60 ); + } + + public static function render_flash(): void { + $key = 'sg_commerce_notice_' . get_current_user_id(); + $notice = get_transient( $key ); + if ( ! is_array( $notice ) ) return; + delete_transient( $key ); + printf( + '

%s

', + esc_attr( (string) $notice['type'] ), + esc_html( (string) $notice['msg'] ) + ); + } +} diff --git a/sg-commerce-pro/src/Amazon/AmazonClient.php b/sg-commerce-pro/src/Amazon/AmazonClient.php new file mode 100644 index 0000000..ff56a60 --- /dev/null +++ b/sg-commerce-pro/src/Amazon/AmazonClient.php @@ -0,0 +1,198 @@ +tokens->has_credentials(); + } + + /** + * Accessor for marketplace metadata — used by modules that need the + * marketplace ID/region without taking a Marketplaces dep of their own. + */ + public function marketplace_info( string $market ): ?array { + return $this->marketplaces->get( $market ); + } + + /** + * Send a signed SP-API request. + * + * @param string $method HTTP method. + * @param string $market Marketplace code (US, DE, ...). + * @param string $path Path component, e.g. /fba/inventory/v1/summaries + * @param array $query Query parameters. + * @param array|null $body JSON body for POST/PATCH. + * @param string $endpoint_key Rate-limit bucket name (see RateLimiter::LIMITS). + * + * @return array Decoded JSON response. + * + * @throws \RuntimeException on permanent failure. + */ + public function request( + string $method, + string $market, + string $path, + array $query = array(), + ?array $body = null, + string $endpoint_key = 'default' + ): array { + // Sandbox mode — return canned responses, never touch the network. + if ( \SevenGum\Commerce\Testing\Sandbox::is_enabled() ) { + return \SevenGum\Commerce\Testing\Sandbox::mock( $method, $path ); + } + + $mp = $this->marketplaces->get( $market ); + if ( null === $mp ) { + throw new \RuntimeException( "Unknown marketplace: {$market}" ); + } + + // Circuit breaker first — if downstream is down, don't even try. + $this->breaker->guard( 'sp-api.' . $mp['region'] ); + + // Rate limit (block up to 5s). + if ( ! $this->rate_limiter->acquire_blocking( $endpoint_key, 5.0 ) ) { + throw new \RuntimeException( "Rate limit exceeded for {$endpoint_key} after waiting." ); + } + + $url = $mp['endpoint'] . $path . ( ! empty( $query ) ? '?' . http_build_query( $query ) : '' ); + + $last_exception = null; + for ( $attempt = 1; $attempt <= self::MAX_RETRIES; $attempt++ ) { + try { + $result = $this->fire( $method, $url, $body, $attempt ); + $this->breaker->record_success( 'sp-api.' . $mp['region'] ); + return $result; + } catch ( RetryableException $e ) { + $last_exception = $e; + $wait = (int) pow( 2, $attempt - 1 ); // 1s, 2s, 4s + $this->logger->warning( 'SP-API retryable error', array( + 'attempt' => $attempt, + 'wait' => $wait, + 'msg' => $e->getMessage(), + ) ); + sleep( $wait ); + continue; + } catch ( \Throwable $e ) { + $this->breaker->record_failure( 'sp-api.' . $mp['region'] ); + throw $e; + } + } + + $this->breaker->record_failure( 'sp-api.' . $mp['region'] ); + throw new \RuntimeException( + 'SP-API failed after ' . self::MAX_RETRIES . ' attempts: ' . ( $last_exception ? $last_exception->getMessage() : 'unknown' ) + ); + } + + /** @throws RetryableException|\RuntimeException */ + private function fire( string $method, string $url, ?array $body, int $attempt ): array { + $args = array( + 'method' => $method, + 'timeout' => 30, + 'headers' => array( + 'x-amz-access-token' => $this->tokens->access_token( $attempt > 1 ), + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'User-Agent' => self::USER_AGENT, + ), + ); + if ( null !== $body ) { + $args['body'] = wp_json_encode( $body ); + } + + $response = wp_remote_request( $url, $args ); + if ( is_wp_error( $response ) ) { + throw new RetryableException( 'Network error: ' . $response->get_error_message() ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $raw_body = (string) wp_remote_retrieve_body( $response ); + $json = json_decode( $raw_body, true ); + + if ( in_array( $code, array( 429, 500, 502, 503, 504 ), true ) ) { + throw new RetryableException( "SP-API HTTP {$code}" ); + } + if ( $code >= 400 ) { + $msg = "HTTP {$code}"; + if ( is_array( $json ) && isset( $json['errors'][0]['message'] ) ) { + $msg .= ' — ' . (string) $json['errors'][0]['message']; + } elseif ( '' !== $raw_body ) { + $msg .= ' — ' . substr( $raw_body, 0, 200 ); + } + throw new \RuntimeException( $msg ); + } + + return is_array( $json ) ? $json : array(); + } + + /** + * Lightweight credential test using getMarketplaceParticipations. + */ + public function test_connection(): array { + $this->tokens->clear_cache(); + $token = $this->tokens->access_token(); + $mp = $this->marketplaces->get( $this->marketplaces->primary() ); + + $response = wp_remote_get( + $mp['endpoint'] . '/sellers/v1/marketplaceParticipations', + array( + 'timeout' => 20, + 'headers' => array( + 'x-amz-access-token' => $token, + 'Accept' => 'application/json', + 'User-Agent' => self::USER_AGENT, + ), + ) + ); + + if ( is_wp_error( $response ) ) { + throw new \RuntimeException( 'Network error: ' . $response->get_error_message() ); + } + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = (string) wp_remote_retrieve_body( $response ); + $json = json_decode( $body, true ); + + if ( 200 !== $code ) { + $msg = is_array( $json ) && isset( $json['errors'][0]['message'] ) + ? $json['errors'][0]['message'] + : "HTTP {$code}"; + throw new \RuntimeException( "Amazon rejected us: {$msg}" ); + } + + return array( + 'ok' => true, + 'token_length' => strlen( $token ), + 'participations' => is_array( $json ) && isset( $json['payload'] ) ? count( $json['payload'] ) : 0, + ); + } +} diff --git a/sg-commerce-pro/src/Amazon/AmazonModule.php b/sg-commerce-pro/src/Amazon/AmazonModule.php new file mode 100644 index 0000000..09a0602 --- /dev/null +++ b/sg-commerce-pro/src/Amazon/AmazonModule.php @@ -0,0 +1,33 @@ +container->get( SyncOrchestrator::class )->cron_sync(); + } ); + } +} diff --git a/sg-commerce-pro/src/Amazon/AplusContent/AplusContentClient.php b/sg-commerce-pro/src/Amazon/AplusContent/AplusContentClient.php new file mode 100644 index 0000000..6a2f54c --- /dev/null +++ b/sg-commerce-pro/src/Amazon/AplusContent/AplusContentClient.php @@ -0,0 +1,208 @@ + $name, + 'contentType' => 'EBC', + 'contentSubType' => 'EBC', + 'locale' => $locale, + 'contentModuleList' => $modules, + ); + $response = $this->amazon->request( + 'POST', + $market, + '/aplus/2020-11-01/contentDocuments', + array(), + $body, + 'aplus.create' + ); + $this->logger->info( 'A+ content document created', array( + 'name' => $name, 'ref' => $response['contentReferenceKey'] ?? null, + ) ); + return $response; + } + + public function get( string $content_reference_key, string $market ): array { + return $this->amazon->request( + 'GET', + $market, + "/aplus/2020-11-01/contentDocuments/{$content_reference_key}", + array( 'includedDataSet' => 'CONTENTS' ), + null, + 'aplus.get' + ); + } + + public function update( string $content_reference_key, array $document, string $market ): array { + return $this->amazon->request( + 'POST', + $market, + "/aplus/2020-11-01/contentDocuments/{$content_reference_key}", + array(), + array( 'contentDocument' => $document ), + 'aplus.update' + ); + } + + /** + * Publish a content document against specific ASINs in a market. + * + * @param string[] $asins + */ + public function publish( string $content_reference_key, array $asins, string $market ): array { + $records = array_map( static fn( $asin ) => array( 'asin' => (string) $asin ), $asins ); + return $this->amazon->request( + 'POST', + $market, + "/aplus/2020-11-01/contentDocuments/{$content_reference_key}/asins", + array(), + array( 'asinSet' => $records ), + 'aplus.publish' + ); + } + + /* ---------------- Module builders ---------------- */ + /* + * These return structured arrays matching Amazon's module schemas. + * Use them to compose a modules array passed to create(). + */ + + public static function build_standard_headline( string $headline, string $body_text = '' ): array { + return array( + 'contentModuleType' => 'STANDARD_HEADER_IMAGE_TEXT', + 'standardHeaderImageTextModule' => array( + 'headline' => array( + 'value' => $headline, + 'decoratorSet' => array(), + ), + 'body' => array( + 'textList' => array( + array( 'text' => array( 'value' => $body_text ) ), + ), + ), + ), + ); + } + + public static function build_product_description( string $body_text ): array { + return array( + 'contentModuleType' => 'STANDARD_PRODUCT_DESCRIPTION', + 'standardProductDescriptionModule' => array( + 'body' => array( + 'textList' => array( array( 'text' => array( 'value' => $body_text ) ) ), + ), + ), + ); + } + + public static function build_four_image_quadrant( + string $headline, + array $quadrants + ): array { + $blocks = array(); + foreach ( array_slice( $quadrants, 0, 4 ) as $q ) { + $blocks[] = array( + 'image' => array( + 'uploadDestinationId' => (string) ( $q['image_id'] ?? '' ), + 'imageCropSpecification' => array( + 'size' => array( 'width' => array( 'value' => 300, 'units' => 'pixels' ), + 'height' => array( 'value' => 300, 'units' => 'pixels' ) ), + ), + 'altText' => (string) ( $q['alt'] ?? '' ), + ), + 'headline' => array( 'value' => (string) ( $q['headline'] ?? '' ) ), + 'body' => array( + 'textList' => array( array( 'text' => array( 'value' => (string) ( $q['body'] ?? '' ) ) ) ), + ), + ); + } + return array( + 'contentModuleType' => 'STANDARD_FOUR_IMAGE_TEXT_QUADRANT', + 'standardFourImageTextQuadrantModule' => array( + 'block1' => $blocks[0] ?? null, + 'block2' => $blocks[1] ?? null, + 'block3' => $blocks[2] ?? null, + 'block4' => $blocks[3] ?? null, + ), + ); + } + + public static function build_comparison_table( array $products, array $metrics ): array { + $columns = array(); + foreach ( array_slice( $products, 0, 6 ) as $p ) { + $metric_row = array(); + foreach ( $metrics as $mkey ) { + $metric_row[] = array( 'value' => (string) ( $p['metrics'][ $mkey ] ?? '' ) ); + } + $columns[] = array( + 'position' => count( $columns ) + 1, + 'image' => array( + 'uploadDestinationId' => (string) ( $p['image_id'] ?? '' ), + 'altText' => (string) ( $p['name'] ?? '' ), + ), + 'title' => (string) ( $p['name'] ?? '' ), + 'asin' => (string) ( $p['asin'] ?? '' ), + 'highlight' => (bool) ( $p['highlight'] ?? false ), + 'descriptionValues' => $metric_row, + ); + } + return array( + 'contentModuleType' => 'STANDARD_COMPARISON_TABLE', + 'standardComparisonTableModule' => array( + 'productColumns' => $columns, + 'metricRowLabels' => array_map( static fn( $m ) => array( 'value' => (string) $m ), $metrics ), + ), + ); + } +} diff --git a/sg-commerce-pro/src/Amazon/CatalogItems/CatalogLookup.php b/sg-commerce-pro/src/Amazon/CatalogItems/CatalogLookup.php new file mode 100644 index 0000000..2d22710 --- /dev/null +++ b/sg-commerce-pro/src/Amazon/CatalogItems/CatalogLookup.php @@ -0,0 +1,173 @@ + + */ + public function search_by_ean( string $ean, string $market ): array { + if ( ! self::validate_ean13( $ean ) ) { + throw new \InvalidArgumentException( "Invalid EAN-13 checksum: {$ean}" ); + } + + $cache_key = "catalog.ean.{$market}.{$ean}"; + $cached = $this->cache->get( $cache_key ); + if ( null !== $cached ) { + return $cached; + } + + try { + $response = $this->amazon->request( + 'GET', + $market, + '/catalog/2022-04-01/items', + array( + 'identifiers' => $ean, + 'identifiersType' => 'EAN', + 'includedData' => 'attributes,dimensions,identifiers,images,productTypes,summaries', + ), + null, + 'catalog.items.search' + ); + } catch ( \Throwable $e ) { + $this->logger->error( 'Catalog EAN lookup failed', array( + 'ean' => $ean, 'market' => $market, 'err' => $e->getMessage(), + ) ); + throw $e; + } + + $items = (array) ( $response['items'] ?? $response['payload']['items'] ?? array() ); + $results = array(); + foreach ( $items as $item ) { + $summary = (array) ( $item['summaries'][0] ?? array() ); + $dims = (array) ( $item['dimensions'][0] ?? array() ); + $results[] = array( + 'asin' => (string) ( $item['asin'] ?? '' ), + 'title' => (string) ( $summary['itemName'] ?? '' ), + 'brand' => (string) ( $summary['brandName'] ?? '' ), + 'marketplace' => $market, + 'item_dimensions' => (array) ( $dims['item'] ?? array() ), + 'package_dimensions' => (array) ( $dims['package'] ?? array() ), + 'raw' => $item, + ); + } + + $this->cache->set( $cache_key, $results, 12 * HOUR_IN_SECONDS ); + return $results; + } + + /** + * Preflight a prospective listing: is this EAN already on Amazon? + * + * Returns: + * 'match' → exact match found, list against that ASIN (best case) + * 'absent' → no hit, we need to create a new product via Listings API + * 'ambiguous' → multiple hits, operator must pick manually + */ + public function preflight( string $ean, string $market ): array { + $results = $this->search_by_ean( $ean, $market ); + if ( empty( $results ) ) { + return array( 'status' => 'absent', 'matches' => array() ); + } + if ( 1 === count( $results ) ) { + return array( 'status' => 'match', 'matches' => $results ); + } + return array( 'status' => 'ambiguous', 'matches' => $results ); + } + + /** + * Get catalog attributes for an ASIN — needed when composing a Listings + * patch (we must provide the product type + required attributes). + */ + public function get_by_asin( string $asin, string $market ): ?array { + $cache_key = "catalog.asin.{$market}.{$asin}"; + $cached = $this->cache->get( $cache_key ); + if ( null !== $cached ) return $cached; + + try { + $response = $this->amazon->request( + 'GET', + $market, + "/catalog/2022-04-01/items/{$asin}", + array( + 'marketplaceIds' => '', // Filled by AmazonClient based on $market param + 'includedData' => 'attributes,dimensions,identifiers,productTypes,relationships,summaries', + ), + null, + 'catalog.items.get' + ); + } catch ( \Throwable $e ) { + $this->logger->warning( 'Catalog ASIN lookup failed', array( + 'asin' => $asin, 'err' => $e->getMessage(), + ) ); + return null; + } + + $this->cache->set( $cache_key, $response, 12 * HOUR_IN_SECONDS ); + return $response; + } +} diff --git a/sg-commerce-pro/src/Amazon/CircuitBreaker.php b/sg-commerce-pro/src/Amazon/CircuitBreaker.php new file mode 100644 index 0000000..781f1c7 --- /dev/null +++ b/sg-commerce-pro/src/Amazon/CircuitBreaker.php @@ -0,0 +1,89 @@ +state( $key ); + if ( self::STATE_OPEN === $state['state'] ) { + $elapsed = time() - (int) $state['opened_at']; + if ( $elapsed >= self::COOLDOWN_SECONDS ) { + // Transition OPEN → HALF_OPEN. + $state['state'] = self::STATE_HALF_OPEN; + $this->save( $key, $state ); + return; + } + $remaining = self::COOLDOWN_SECONDS - $elapsed; + throw new \RuntimeException( "Circuit breaker open for '{$key}' — retry in {$remaining}s." ); + } + } + + public function record_success( string $key ): void { + $state = $this->state( $key ); + if ( self::STATE_CLOSED !== $state['state'] || $state['failures'] > 0 ) { + $this->save( $key, array( 'state' => self::STATE_CLOSED, 'failures' => 0, 'opened_at' => 0 ) ); + if ( self::STATE_CLOSED !== $state['state'] ) { + $this->logger->info( "Circuit breaker '{$key}' recovered to CLOSED." ); + } + } + } + + public function record_failure( string $key ): void { + $state = $this->state( $key ); + $state['failures'] = (int) $state['failures'] + 1; + + if ( self::STATE_HALF_OPEN === $state['state'] || $state['failures'] >= self::FAILURE_THRESHOLD ) { + $this->logger->warning( "Circuit breaker '{$key}' tripped to OPEN after {$state['failures']} failures." ); + $state['state'] = self::STATE_OPEN; + $state['opened_at'] = time(); + } + $this->save( $key, $state ); + } + + private function state( string $key ): array { + $raw = $this->cache->get( 'circuit/' . $key, null ); + if ( ! is_array( $raw ) ) { + return array( 'state' => self::STATE_CLOSED, 'failures' => 0, 'opened_at' => 0 ); + } + return array( + 'state' => (string) ( $raw['state'] ?? self::STATE_CLOSED ), + 'failures' => (int) ( $raw['failures'] ?? 0 ), + 'opened_at' => (int) ( $raw['opened_at'] ?? 0 ), + ); + } + + private function save( string $key, array $state ): void { + $this->cache->set( 'circuit/' . $key, $state, HOUR_IN_SECONDS ); + } +} diff --git a/sg-commerce-pro/src/Amazon/FeedsGenerator/FeedsGenerator.php b/sg-commerce-pro/src/Amazon/FeedsGenerator/FeedsGenerator.php new file mode 100644 index 0000000..2814982 --- /dev/null +++ b/sg-commerce-pro/src/Amazon/FeedsGenerator/FeedsGenerator.php @@ -0,0 +1,139 @@ + $rows + */ + public function pricing_feed( array $rows ): string { + $messages = ''; + $message_id = 1; + foreach ( $rows as $r ) { + $sku = htmlspecialchars( (string) $r['sku'], ENT_XML1 | ENT_QUOTES, 'UTF-8' ); + $price_amount = number_format( (float) $r['price'], 2, '.', '' ); + $currency = htmlspecialchars( (string) $r['currency'], ENT_XML1 | ENT_QUOTES, 'UTF-8' ); + + $sale_block = ''; + if ( isset( $r['sale_price'], $r['sale_start'], $r['sale_end'] ) && (float) $r['sale_price'] > 0 ) { + $sale_price = number_format( (float) $r['sale_price'], 2, '.', '' ); + $sale_start = htmlspecialchars( (string) $r['sale_start'], ENT_XML1 | ENT_QUOTES, 'UTF-8' ); + $sale_end = htmlspecialchars( (string) $r['sale_end'], ENT_XML1 | ENT_QUOTES, 'UTF-8' ); + $sale_block = << + {$sale_start} + {$sale_end} + + {$sale_price} + + +XML; + } + + $messages .= << + {$message_id} + Update + + {$sku} + {$price_amount}{$sale_block} + + +XML; + $message_id++; + } + return $this->envelope( 'Price', $messages ); + } + + /** + * Build a POST_INVENTORY_AVAILABILITY_DATA feed. + * + * @param array $rows + */ + public function inventory_feed( array $rows ): string { + $messages = ''; + $message_id = 1; + foreach ( $rows as $r ) { + $sku = htmlspecialchars( (string) $r['sku'], ENT_XML1 | ENT_QUOTES, 'UTF-8' ); + $qty = max( 0, (int) $r['quantity'] ); + $latency = isset( $r['fulfillment_latency'] ) ? max( 1, (int) $r['fulfillment_latency'] ) : 1; + $messages .= << + {$message_id} + Update + + {$sku} + {$qty} + {$latency} + + +XML; + $message_id++; + } + return $this->envelope( 'Inventory', $messages ); + } + + /** + * Return Amazon feed type strings for a message type. Used by FeedsClient + * when calling /feeds/2021-06-30/feeds. + */ + public static function feed_type( string $message_type ): string { + return match ( $message_type ) { + 'Price' => 'POST_PRODUCT_PRICING_DATA', + 'Inventory' => 'POST_INVENTORY_AVAILABILITY_DATA', + 'Product' => 'POST_PRODUCT_DATA', + default => '', + }; + } + + private function envelope( string $message_type, string $messages ): string { + $merchant = htmlspecialchars( $this->merchant_id, ENT_XML1 | ENT_QUOTES, 'UTF-8' ); + return << + +
+ 1.01 + {$merchant} +
+ {$message_type} +{$messages} +
+XML; + } +} diff --git a/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentClient.php b/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentClient.php new file mode 100644 index 0000000..4f416d9 --- /dev/null +++ b/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentClient.php @@ -0,0 +1,185 @@ +, 'quantity'=>, 'prep_owner'=>'SELLER'|'AMAZON'] + * @param array $source Source address ['name','address1','city','state','postal_code','country_code'] + * @param string $market Destination marketplace (US, DE, UK, ...) + * @param string $name Operator-visible plan name + */ + public function create_plan( array $items, array $source, string $market, string $name ): array { + $body = array( + 'destinationMarketplaces' => array( $this->marketplace_id( $market ) ), + 'sourceAddress' => $this->address_block( $source ), + 'name' => $name, + 'items' => array_map( static function ( $i ) { + return array( + 'msku' => (string) ( $i['msku'] ?? '' ), + 'quantity' => (int) ( $i['quantity'] ?? 0 ), + 'prepOwner' => (string) ( $i['prep_owner'] ?? 'SELLER' ), + 'labelOwner' => (string) ( $i['label_owner'] ?? 'NONE' ), // NONE = use manufacturer barcode (EAN) + ); + }, $items ), + ); + + $response = $this->amazon->request( 'POST', $market, self::BASE . '/inboundPlans', + array(), $body, 'inbound.create' ); + + $this->logger->info( 'Inbound plan created', array( + 'plan_id' => $response['inboundPlanId'] ?? null, + 'items' => count( $items ), + ) ); + return $response; + } + + public function get_plan( string $plan_id, string $market ): array { + return $this->amazon->request( 'GET', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ), + array(), null, 'inbound.get' ); + } + + /** Step 3 — ask Amazon how we can pack this plan. */ + public function generate_packing_options( string $plan_id, string $market ): array { + return $this->amazon->request( 'POST', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/packingOptions', + array(), null, 'inbound.packing.generate' ); + } + + public function list_packing_options( string $plan_id, string $market ): array { + return $this->amazon->request( 'GET', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/packingOptions', + array(), null, 'inbound.packing.list' ); + } + + public function confirm_packing_option( string $plan_id, string $packing_option_id, string $market ): array { + return $this->amazon->request( 'POST', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . + '/packingOptions/' . rawurlencode( $packing_option_id ) . '/confirmation', + array(), null, 'inbound.packing.confirm' ); + } + + /** Step 4 — update box content (dimensions, weight, items-per-box). */ + public function set_packing_information( string $plan_id, array $packages, string $market ): array { + return $this->amazon->request( 'POST', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/packingInformation', + array(), array( 'packageGroupings' => $packages ), 'inbound.packing.info' ); + } + + /** Step 5 — ask Amazon which warehouse(s) to ship to. */ + public function generate_placement_options( string $plan_id, string $market ): array { + return $this->amazon->request( 'POST', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/placementOptions', + array(), null, 'inbound.placement.generate' ); + } + + public function list_placement_options( string $plan_id, string $market ): array { + return $this->amazon->request( 'GET', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/placementOptions', + array(), null, 'inbound.placement.list' ); + } + + public function confirm_placement_option( string $plan_id, string $placement_option_id, string $market ): array { + return $this->amazon->request( 'POST', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . + '/placementOptions/' . rawurlencode( $placement_option_id ) . '/confirmation', + array(), null, 'inbound.placement.confirm' ); + } + + /** Step 7 — get transport options (carrier, cost, pickup date). */ + public function generate_transport_options( string $plan_id, string $shipment_id, string $market ): array { + return $this->amazon->request( 'POST', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . + '/shipments/' . rawurlencode( $shipment_id ) . '/transportationOptions', + array(), null, 'inbound.transport.generate' ); + } + + public function confirm_transport_option( string $plan_id, string $shipment_id, string $transport_option_id, string $market ): array { + return $this->amazon->request( 'POST', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . + '/shipments/' . rawurlencode( $shipment_id ) . + '/transportationOptions/' . rawurlencode( $transport_option_id ) . '/confirmation', + array(), null, 'inbound.transport.confirm' ); + } + + /** Final — lock everything, generate labels. */ + public function confirm_plan( string $plan_id, string $market ): array { + return $this->amazon->request( 'POST', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . '/confirmation', + array(), null, 'inbound.confirm' ); + } + + /** Retrieve confirmed box labels for printing. */ + public function get_labels( string $plan_id, string $shipment_id, string $market, string $page_type = 'PackageLabel_Letter_6' ): array { + return $this->amazon->request( 'GET', $market, + self::BASE . '/inboundPlans/' . rawurlencode( $plan_id ) . + '/shipments/' . rawurlencode( $shipment_id ) . '/deliveryChallan', + array( 'pageType' => $page_type ), null, 'inbound.labels' ); + } + + private function marketplace_id( string $market ): string { + return (string) ( $this->amazon->marketplace_info( $market )['id'] ?? '' ); + } + + private function address_block( array $a ): array { + return array( + 'name' => (string) ( $a['name'] ?? '' ), + 'addressLine1' => (string) ( $a['address1'] ?? '' ), + 'addressLine2' => (string) ( $a['address2'] ?? '' ), + 'city' => (string) ( $a['city'] ?? '' ), + 'stateOrProvinceCode' => (string) ( $a['state'] ?? '' ), + 'postalCode' => (string) ( $a['postal_code'] ?? '' ), + 'countryCode' => (string) ( $a['country_code'] ?? '' ), + 'email' => (string) ( $a['email'] ?? '' ), + 'phoneNumber' => (string) ( $a['phone'] ?? '' ), + 'companyName' => (string) ( $a['company'] ?? '' ), + ); + } +} diff --git a/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentModule.php b/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentModule.php new file mode 100644 index 0000000..03574aa --- /dev/null +++ b/sg-commerce-pro/src/Amazon/InboundShipment/InboundShipmentModule.php @@ -0,0 +1,90 @@ +container; + + $c->singleton( CatalogLookup::class, static fn( Container $c ) => + new CatalogLookup( + $c->get( AmazonClient::class ), + $c->get( CacheManager::class ), + $c->get( Logger::class ) + ) + ); + + $c->singleton( ListingsClient::class, static fn( Container $c ) => + new ListingsClient( + $c->get( AmazonClient::class ), + $c->get( SettingsRepository::class ), + $c->get( Logger::class ) + ) + ); + + $c->singleton( AplusContentClient::class, static fn( Container $c ) => + new AplusContentClient( + $c->get( AmazonClient::class ), + $c->get( Logger::class ) + ) + ); + + $c->singleton( InboundShipmentClient::class, static fn( Container $c ) => + new InboundShipmentClient( + $c->get( AmazonClient::class ), + $c->get( Logger::class ) + ) + ); + + $c->singleton( BoxContentService::class, static fn( Container $c ) => + new BoxContentService( + $c->get( InboundShipmentClient::class ), + $c->get( Logger::class ) + ) + ); + + $c->singleton( LabelGenerator::class, static fn( Container $c ) => + new LabelGenerator( $c->get( Logger::class ) ) + ); + } +} diff --git a/sg-commerce-pro/src/Amazon/LWATokenManager.php b/sg-commerce-pro/src/Amazon/LWATokenManager.php new file mode 100644 index 0000000..5ef5fcf --- /dev/null +++ b/sg-commerce-pro/src/Amazon/LWATokenManager.php @@ -0,0 +1,105 @@ +settings->has( 'amazon_client_id' ) + && $this->settings->has( 'amazon_client_secret' ) + && $this->settings->has( 'amazon_refresh_token' ); + } + + /** + * Get a valid access token. Uses cache; refreshes from LWA if needed. + * + * @throws \RuntimeException + */ + public function access_token( bool $force_refresh = false ): string { + if ( ! $force_refresh ) { + $cached = $this->cache->get( self::CACHE_KEY ); + if ( is_string( $cached ) && '' !== $cached ) { + return $cached; + } + } + if ( ! $this->has_credentials() ) { + throw new \RuntimeException( 'Amazon credentials not configured.' ); + } + + $response = wp_remote_post( + self::ENDPOINT, + array( + 'timeout' => 20, + 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), + 'body' => http_build_query( + array( + 'grant_type' => 'refresh_token', + 'refresh_token' => (string) $this->settings->get( 'amazon_refresh_token' ), + 'client_id' => (string) $this->settings->get( 'amazon_client_id' ), + 'client_secret' => (string) $this->settings->get( 'amazon_client_secret' ), + ) + ), + ) + ); + + if ( is_wp_error( $response ) ) { + $msg = $response->get_error_message(); + $this->logger->error( 'LWA network error: ' . $msg ); + throw new \RuntimeException( 'LWA network error: ' . $msg ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = (string) wp_remote_retrieve_body( $response ); + $json = json_decode( $body, true ); + + if ( 200 !== $code || ! is_array( $json ) || empty( $json['access_token'] ) ) { + $err = is_array( $json ) ? (string) ( $json['error'] ?? '' ) : ''; + $desc = is_array( $json ) ? (string) ( $json['error_description'] ?? '' ) : ''; + if ( 'invalid_grant' === $err ) { + $this->logger->error( 'LWA refresh token invalid/expired', array( 'desc' => $desc ) ); + throw new \RuntimeException( 'Your Amazon refresh token has expired or been revoked. Please reconnect your seller account in Amazon Connect.' ); + } + $this->logger->error( "LWA HTTP {$code}: {$err} {$desc}" ); + throw new \RuntimeException( "LWA returned HTTP {$code}: {$desc}" ); + } + + $ttl = max( 60, (int) ( $json['expires_in'] ?? 3600 ) - 300 ); + $this->cache->set( self::CACHE_KEY, $json['access_token'], $ttl ); + + $this->logger->debug( 'LWA token refreshed', array( 'ttl' => $ttl ) ); + return $json['access_token']; + } + + public function clear_cache(): void { + $this->cache->delete( self::CACHE_KEY ); + } +} diff --git a/sg-commerce-pro/src/Amazon/Listings/ListingsClient.php b/sg-commerce-pro/src/Amazon/Listings/ListingsClient.php new file mode 100644 index 0000000..e2a751d --- /dev/null +++ b/sg-commerce-pro/src/Amazon/Listings/ListingsClient.php @@ -0,0 +1,208 @@ +settings->get( 'amazon_seller_id', '' ); + if ( '' === $seller_id ) { + throw new \RuntimeException( 'amazon_seller_id must be set before creating listings.' ); + } + + // Ensure the EAN identifier is present in attributes. + $attributes = $this->inject_ean_identifier( $attributes, $ean, $market ); + + $body = array( + 'productType' => $product_type, + 'requirements' => 'LISTING', + 'attributes' => $attributes, + ); + + $path = sprintf( + '/listings/2021-08-01/items/%s/%s', + rawurlencode( $seller_id ), + rawurlencode( $sku ) + ); + + $response = $this->amazon->request( + 'PUT', + $market, + $path, + array(), // marketplace ids are injected by AmazonClient + $body, + 'listings.put' + ); + + $this->logger->info( 'Listing created/replaced', array( + 'sku' => $sku, 'market' => $market, 'ean' => $ean, + 'submission_id' => $response['submissionId'] ?? null, + ) ); + return $response; + } + + /** + * Partial update — JSON Patch of individual attributes (e.g. just + * adjust price or quantity without re-sending full payload). + */ + public function patch( string $sku, string $market, string $product_type, array $patches ): array { + $seller_id = (string) $this->settings->get( 'amazon_seller_id', '' ); + if ( '' === $seller_id ) { + throw new \RuntimeException( 'amazon_seller_id must be set.' ); + } + + $body = array( + 'productType' => $product_type, + 'patches' => $patches, + ); + + $path = sprintf( + '/listings/2021-08-01/items/%s/%s', + rawurlencode( $seller_id ), + rawurlencode( $sku ) + ); + + return $this->amazon->request( 'PATCH', $market, $path, array(), $body, 'listings.patch' ); + } + + public function delete( string $sku, string $market ): array { + $seller_id = (string) $this->settings->get( 'amazon_seller_id', '' ); + $path = sprintf( + '/listings/2021-08-01/items/%s/%s', + rawurlencode( $seller_id ), + rawurlencode( $sku ) + ); + return $this->amazon->request( 'DELETE', $market, $path, array(), null, 'listings.delete' ); + } + + public function get( string $sku, string $market ): array { + $seller_id = (string) $this->settings->get( 'amazon_seller_id', '' ); + $path = sprintf( + '/listings/2021-08-01/items/%s/%s', + rawurlencode( $seller_id ), + rawurlencode( $sku ) + ); + return $this->amazon->request( 'GET', $market, $path, array( + 'includedData' => 'summaries,attributes,issues,offers,fulfillmentAvailability', + ), null, 'listings.get' ); + } + + /** + * Ensure the `externally_assigned_product_identifier` with EAN type is + * present in the attributes array. This is the magic that tells Amazon + * we use the printed EAN-13 instead of FNSKU. + */ + private function inject_ean_identifier( array $attributes, string $ean, string $market ): array { + $marketplace_id = $this->marketplace_id_for( $market ); + + $attributes['externally_assigned_product_identifier'] = array( + array( + 'type' => 'ean', + 'value' => $ean, + 'marketplace_id'=> $marketplace_id, + ), + ); + + // Also tell Amazon "use manufacturer barcode" (commingled inventory). + $attributes['merchant_suggested_asin'] = $attributes['merchant_suggested_asin'] ?? array(); + + return $attributes; + } + + private function marketplace_id_for( string $market ): string { + $mp = $this->amazon->marketplace_info( $market ); + return (string) ( $mp['id'] ?? '' ); + } + + /** + * Convenience helper — build a "minimum viable" attribute set for a new + * Seven Gum SKU. Caller can override any field before passing to put(). + */ + public static function build_attributes( array $input, string $market ): array { + $required_marketplace = array( 'marketplace_id' => self::marketplace_id_static( $market ) ); + + return array( + 'item_name' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['name'] ?? '' ) ) ) ), + 'brand' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['brand'] ?? 'Seven Gum' ) ) ) ), + 'manufacturer' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['manufacturer'] ?? 'Seven Gum' ) ) ) ), + 'product_description' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['description'] ?? '' ) ) ) ), + 'item_package_quantity' => array( array_merge( $required_marketplace, array( 'value' => (int) ( $input['pieces'] ?? 30 ) ) ) ), + 'number_of_items' => array( array_merge( $required_marketplace, array( 'value' => 1 ) ) ), + 'list_price' => array( array_merge( $required_marketplace, array( + 'value' => (float) ( $input['price'] ?? 0 ), + 'currency' => (string) ( $input['currency'] ?? 'USD' ), + ) ) ), + 'supplier_declared_has_product_identifier_exemption' => array( array_merge( $required_marketplace, array( 'value' => false ) ) ), + 'country_of_origin' => array( array_merge( $required_marketplace, array( 'value' => (string) ( $input['origin'] ?? 'CN' ) ) ) ), + ); + } + + private static function marketplace_id_static( string $market ): string { + // Compact inline mapping — full list lives in Marketplaces::all(). + $map = array( + 'US' => 'ATVPDKIKX0DER', 'CA' => 'A2EUQ1WTGCTBG2', 'MX' => 'A1AM78C64UM0Y8', 'BR' => 'A2Q3Y263D00KWC', + 'UK' => 'A1F83G8C2ARO7P', 'DE' => 'A1PA6795UKMFR9', 'FR' => 'A13V1IB3VIYZZH', 'IT' => 'APJ6JRA9NG5V4', + 'ES' => 'A1RKKUPIHCS9HS', 'NL' => 'A1805IZSGTT6HS', 'SE' => 'A2NODRKZP88ZB9', 'PL' => 'A1C3SOZRARQ6R3', + 'BE' => 'AMEN7PMS3EDWL', 'IE' => 'A28R8C7NBKEWEA', 'TR' => 'A33AVAJ2PDY3EV', + 'AE' => 'A2VIGQ35RCS4UG', 'SA' => 'A17E79C6D8DWNP', + 'AU' => 'A39IBJ37TRP1C6', 'JP' => 'A1VC38T7YXB528', 'SG' => 'A19VAU5U5O7RUS', 'IN' => 'A21TJRUUN4KGV', + ); + return $map[ $market ] ?? 'ATVPDKIKX0DER'; + } +} diff --git a/sg-commerce-pro/src/Amazon/Marketplaces.php b/sg-commerce-pro/src/Amazon/Marketplaces.php new file mode 100644 index 0000000..def4d5d --- /dev/null +++ b/sg-commerce-pro/src/Amazon/Marketplaces.php @@ -0,0 +1,109 @@ +|null */ + private static ?array $cache = null; + + /** + * @return array + */ + public function all(): array { + if ( null !== self::$cache ) { + return self::$cache; + } + + $markets = array( + // North America + 'US' => array( 'id' => 'ATVPDKIKX0DER', 'country' => 'United States', 'currency' => 'USD', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-na.amazon.com', 'region' => 'na', 'tld' => 'com' ), + 'CA' => array( 'id' => 'A2EUQ1WTGCTBG2', 'country' => 'Canada', 'currency' => 'CAD', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-na.amazon.com', 'region' => 'na', 'tld' => 'ca' ), + 'MX' => array( 'id' => 'A1AM78C64UM0Y8', 'country' => 'Mexico', 'currency' => 'MXN', 'language' => 'es', 'endpoint' => 'https://sellingpartnerapi-na.amazon.com', 'region' => 'na', 'tld' => 'com.mx' ), + 'BR' => array( 'id' => 'A2Q3Y263D00KWC', 'country' => 'Brazil', 'currency' => 'BRL', 'language' => 'pt', 'endpoint' => 'https://sellingpartnerapi-na.amazon.com', 'region' => 'na', 'tld' => 'com.br' ), + // Europe + 'UK' => array( 'id' => 'A1F83G8C2ARO7P', 'country' => 'United Kingdom', 'currency' => 'GBP', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'co.uk' ), + 'DE' => array( 'id' => 'A1PA6795UKMFR9', 'country' => 'Germany', 'currency' => 'EUR', 'language' => 'de', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'de' ), + 'FR' => array( 'id' => 'A13V1IB3VIYZZH', 'country' => 'France', 'currency' => 'EUR', 'language' => 'fr', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'fr' ), + 'IT' => array( 'id' => 'APJ6JRA9NG5V4', 'country' => 'Italy', 'currency' => 'EUR', 'language' => 'it', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'it' ), + 'ES' => array( 'id' => 'A1RKKUPIHCS9HS', 'country' => 'Spain', 'currency' => 'EUR', 'language' => 'es', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'es' ), + 'NL' => array( 'id' => 'A1805IZSGTT6HS', 'country' => 'Netherlands', 'currency' => 'EUR', 'language' => 'nl', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'nl' ), + 'SE' => array( 'id' => 'A2NODRKZP88ZB9', 'country' => 'Sweden', 'currency' => 'SEK', 'language' => 'sv', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'se' ), + 'PL' => array( 'id' => 'A1C3SOZRARQ6R3', 'country' => 'Poland', 'currency' => 'PLN', 'language' => 'pl', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'pl' ), + 'BE' => array( 'id' => 'AMEN7PMS3EDWL', 'country' => 'Belgium', 'currency' => 'EUR', 'language' => 'nl', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'com.be' ), + 'IE' => array( 'id' => 'A28R8C7NBKEWEA', 'country' => 'Ireland', 'currency' => 'EUR', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'ie' ), + 'TR' => array( 'id' => 'A33AVAJ2PDY3EV', 'country' => 'Turkey', 'currency' => 'TRY', 'language' => 'tr', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'com.tr' ), + // Middle East + 'AE' => array( 'id' => 'A2VIGQ35RCS4UG', 'country' => 'United Arab Emirates', 'currency' => 'AED', 'language' => 'ar', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'ae' ), + 'SA' => array( 'id' => 'A17E79C6D8DWNP', 'country' => 'Saudi Arabia', 'currency' => 'SAR', 'language' => 'ar', 'endpoint' => 'https://sellingpartnerapi-eu.amazon.com', 'region' => 'eu', 'tld' => 'sa' ), + // Far East + 'AU' => array( 'id' => 'A39IBJ37TRP1C6', 'country' => 'Australia', 'currency' => 'AUD', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-fe.amazon.com', 'region' => 'fe', 'tld' => 'com.au' ), + 'IN' => array( 'id' => 'A21TJRUUN4KGV', 'country' => 'India', 'currency' => 'INR', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-fe.amazon.com', 'region' => 'fe', 'tld' => 'in' ), + 'SG' => array( 'id' => 'A19VAU5U5O7RUS', 'country' => 'Singapore', 'currency' => 'SGD', 'language' => 'en', 'endpoint' => 'https://sellingpartnerapi-fe.amazon.com', 'region' => 'fe', 'tld' => 'sg' ), + 'JP' => array( 'id' => 'A1VC38T7YXB528', 'country' => 'Japan', 'currency' => 'JPY', 'language' => 'ja', 'endpoint' => 'https://sellingpartnerapi-fe.amazon.com', 'region' => 'fe', 'tld' => 'co.jp' ), + ); + + self::$cache = apply_filters( 'sg_commerce_marketplaces', $markets ); + return self::$cache; + } + + public function get( string $code ): ?array { + return $this->all()[ strtoupper( $code ) ] ?? null; + } + + public function exists( string $code ): bool { + return null !== $this->get( $code ); + } + + /** @return string[] */ + public function enabled_codes(): array { + $settings = get_option( 'sg_commerce_settings', array() ); + $codes = (array) ( is_array( $settings ) ? ( $settings['amazon_enabled_markets'] ?? array( 'US' ) ) : array( 'US' ) ); + $all = $this->all(); + return array_values( array_filter( $codes, static fn( $c ) => isset( $all[ $c ] ) ) ); + } + + public function primary(): string { + $settings = get_option( 'sg_commerce_settings', array() ); + $p = is_array( $settings ) ? (string) ( $settings['amazon_primary_market'] ?? 'US' ) : 'US'; + return $this->exists( $p ) ? $p : 'US'; + } + + /** Build the public Amazon product URL for an ASIN in a market. */ + public function product_url( string $asin, string $market ): string { + $mp = $this->get( $market ); + $tld = $mp['tld'] ?? 'com'; + return "https://www.amazon.{$tld}/dp/{$asin}"; + } + + /** Reverse lookup: marketplace_id (e.g. ATVPDKIKX0DER) → code (US). */ + public function code_by_id( string $marketplace_id ): string { + if ( '' === $marketplace_id ) return ''; + foreach ( $this->all() as $code => $m ) { + if ( ( $m['id'] ?? '' ) === $marketplace_id ) { + return (string) $code; + } + } + return ''; + } + + public static function flush_cache(): void { + self::$cache = null; + } +} diff --git a/sg-commerce-pro/src/Amazon/RateLimiter.php b/sg-commerce-pro/src/Amazon/RateLimiter.php new file mode 100644 index 0000000..a5693ac --- /dev/null +++ b/sg-commerce-pro/src/Amazon/RateLimiter.php @@ -0,0 +1,87 @@ +try_acquire('fbaInventory.summaries')) { ... } + * + * @package SevenGum\Commerce\Amazon + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Amazon; + +use SevenGum\Commerce\Cache\CacheManager; + +defined( 'ABSPATH' ) || exit; + +final class RateLimiter { + + /** Per-endpoint config: [refill_per_sec, burst_capacity]. */ + private const LIMITS = array( + 'fbaInventory.summaries' => array( 2.0, 2 ), + 'sellers.marketplaceParticipations' => array( 0.5, 1 ), + 'pricing.itemOffers' => array( 0.5, 1 ), + 'pricing.competitivePricing' => array( 0.5, 1 ), + 'reports.create' => array( 0.0167, 15 ), // ~1/min + 'reports.get' => array( 2.0, 15 ), + 'orders.list' => array( 0.0167, 20 ), + 'listings.patch' => array( 5.0, 5 ), + // v4.0 — Seller Suite (rates from the SP-API usage-plan docs). + 'reports.document' => array( 0.0167, 15 ), + 'orders.items' => array( 0.5, 30 ), + 'finances.events' => array( 0.5, 30 ), + 'listings.get' => array( 5.0, 10 ), + 'catalog.search' => array( 2.0, 2 ), + 'fees.estimate' => array( 1.0, 2 ), + 'solicitations' => array( 1.0, 5 ), + ); + + public function __construct( private CacheManager $cache ) {} + + /** + * Try to acquire one token. Returns true on success, false if depleted. + */ + public function try_acquire( string $endpoint ): bool { + [ $refill, $capacity ] = self::LIMITS[ $endpoint ] ?? array( 1.0, 5 ); + $now = microtime( true ); + + $key = 'ratelimit/' . $endpoint; + $bucket = $this->cache->get( $key, array( 'tokens' => $capacity, 'last' => $now ) ); + + // Refill since last check. + $elapsed = max( 0.0, $now - (float) $bucket['last'] ); + $tokens = min( $capacity, (float) $bucket['tokens'] + $elapsed * $refill ); + + if ( $tokens >= 1.0 ) { + $tokens -= 1.0; + $this->cache->set( $key, array( 'tokens' => $tokens, 'last' => $now ), 300 ); + return true; + } + // Persist the (still-low) state. + $this->cache->set( $key, array( 'tokens' => $tokens, 'last' => $now ), 300 ); + return false; + } + + /** + * Block until a token is available, with a max wait. Returns true if + * acquired, false if max wait exceeded. + */ + public function acquire_blocking( string $endpoint, float $max_wait_sec = 5.0 ): bool { + $deadline = microtime( true ) + $max_wait_sec; + while ( microtime( true ) < $deadline ) { + if ( $this->try_acquire( $endpoint ) ) { + return true; + } + usleep( 200_000 ); // 200ms + } + return false; + } +} diff --git a/sg-commerce-pro/src/Amazon/ReportsParser/SettlementsParser.php b/sg-commerce-pro/src/Amazon/ReportsParser/SettlementsParser.php new file mode 100644 index 0000000..76907c3 --- /dev/null +++ b/sg-commerce-pro/src/Amazon/ReportsParser/SettlementsParser.php @@ -0,0 +1,125 @@ +request_settlement($start, $end) + * → creates a report, polls until done, downloads TSV + * 2. SettlementsParser->parse($file_content) → array of events + * 3. Events written to wp_sg_settlements table + * 4. Analytics dashboard uses aggregated fees for accurate margin + * + * For v3.2: we only implement the parser — the Reports API client is + * deferred because it requires AWS Signature V4 signing for some endpoints. + * Operators can manually upload a settlement TSV via admin for now. + * + * @package SevenGum\Commerce\Amazon\ReportsParser + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Amazon\ReportsParser; + +defined( 'ABSPATH' ) || exit; + +final class SettlementsParser { + + /** + * Parse a settlement TSV file's raw content. + * + * Returns an array of normalized financial events: + * [ + * 'date', 'type', 'sku', 'order_id', + * 'amount', 'currency', 'description' + * ] + * + * @param string $tsv_content Contents of the GET_V2_SETTLEMENT_REPORT file. + * @return array + */ + public function parse( string $tsv_content ): array { + $lines = preg_split( '/\r?\n/', trim( $tsv_content ) ); + if ( count( $lines ) < 2 ) { + return array(); + } + $headers = array_map( 'trim', explode( "\t", (string) array_shift( $lines ) ) ); + // Normalize: lowercase, no spaces, no dashes. + $headers = array_map( static fn( $h ) => strtolower( preg_replace( '/[^a-z0-9_]/i', '_', (string) $h ) ), $headers ); + + $events = array(); + foreach ( $lines as $line ) { + if ( '' === trim( $line ) ) continue; + $cols = explode( "\t", $line ); + if ( count( $cols ) !== count( $headers ) ) continue; + $row = array_combine( $headers, array_map( 'trim', $cols ) ); + $normalized = $this->normalize_row( $row ); + if ( null !== $normalized ) { + $events[] = $normalized; + } + } + return $events; + } + + private function normalize_row( array $row ): ?array { + $type = strtolower( (string) ( $row['transaction_type'] ?? $row['type'] ?? '' ) ); + $amount_str = (string) ( $row['amount'] ?? $row['amount_'] ?? '0' ); + $amount = (float) str_replace( array( ',', ' ' ), '', $amount_str ); + if ( '' === $type ) return null; + + return array( + 'date' => (string) ( $row['posted_date'] ?? $row['posted_date_time'] ?? '' ), + 'type' => $this->canonical_type( $type ), + 'sku' => (string) ( $row['sku'] ?? '' ), + 'order_id' => (string) ( $row['order_id'] ?? $row['adjustment_id'] ?? '' ), + 'amount' => round( $amount, 2 ), + 'currency' => (string) ( $row['currency'] ?? '' ), + 'description' => (string) ( $row['transaction_type'] ?? $row['description'] ?? $type ), + 'marketplace' => (string) ( $row['marketplace_name'] ?? '' ), + ); + } + + private function canonical_type( string $type ): string { + $type = strtolower( trim( $type ) ); + if ( str_contains( $type, 'order' ) && ! str_contains( $type, 'refund' ) ) return 'sale'; + if ( str_contains( $type, 'refund' ) ) return 'refund'; + if ( str_contains( $type, 'storage' ) ) return 'fba_storage'; + if ( str_contains( $type, 'fulfillment' ) || str_contains( $type, 'fba' ) ) return 'fba_fee'; + if ( str_contains( $type, 'referral' ) ) return 'referral_fee'; + if ( str_contains( $type, 'advertising' ) ) return 'advertising'; + if ( str_contains( $type, 'adjustment' ) ) return 'adjustment'; + if ( str_contains( $type, 'reserve' ) ) return 'reserve'; + if ( str_contains( $type, 'service' ) ) return 'service_fee'; + return 'other'; + } + + /** + * Aggregate parsed events into totals per type. + * + * @param array $events + */ + public function summary( array $events ): array { + $totals = array(); + $by_sku = array(); + foreach ( $events as $e ) { + $type = (string) $e['type']; + $totals[ $type ] = ( $totals[ $type ] ?? 0.0 ) + (float) $e['amount']; + if ( '' !== (string) $e['sku'] ) { + $by_sku[ $e['sku'] ][ $type ] = ( $by_sku[ $e['sku'] ][ $type ] ?? 0.0 ) + (float) $e['amount']; + } + } + ksort( $totals ); + $net = array_sum( $totals ); + return array( + 'event_count' => count( $events ), + 'totals' => $totals, + 'net' => round( $net, 2 ), + 'by_sku' => $by_sku, + ); + } +} diff --git a/sg-commerce-pro/src/Amazon/RetryableException.php b/sg-commerce-pro/src/Amazon/RetryableException.php new file mode 100644 index 0000000..5d16eeb --- /dev/null +++ b/sg-commerce-pro/src/Amazon/RetryableException.php @@ -0,0 +1,14 @@ +container->get( SettingsRepository::class ); + if ( $settings->get( 'sqs_enabled', false ) ) { + if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { + wp_schedule_event( time() + 60, 'sg_five_minutes', self::CRON_HOOK ); + } + } + } + + /** + * Poll SQS. Runs until queue is empty OR 25 messages processed (whichever + * comes first) to stay under the default PHP max_execution_time. + */ + public function poll(): int { + $settings = $this->container->get( SettingsRepository::class ); + $logger = $this->container->get( Logger::class ); + $enc = $this->container->get( Encryption::class ); + + $queue_url = (string) $settings->get( 'sqs_queue_url', '' ); + $region = (string) $settings->get( 'sqs_region', 'us-east-1' ); + $access_key = (string) $settings->get( 'sqs_access_key', '' ); + + if ( '' === $queue_url || '' === $access_key ) { + return 0; + } + $secret_key_enc = (string) $settings->get( 'sqs_secret_key_enc', '' ); + try { + $secret_key = '' !== $secret_key_enc ? $enc->decrypt( $secret_key_enc ) : ''; + } catch ( \Throwable $e ) { + $logger->error( 'SQS: secret key decrypt failed' ); + return 0; + } + if ( '' === $secret_key ) return 0; + + $signer = new SignatureV4( $access_key, $secret_key, $region ); + $events = $this->container->get( EventDispatcher::class ); + + $processed = 0; + $loop_cap = 3; // Up to 3 receive batches × 10 messages = 30 per cron tick. + + for ( $i = 0; $i < $loop_cap; $i++ ) { + $messages = $this->receive_messages( $signer, $queue_url ); + if ( empty( $messages ) ) break; + + foreach ( $messages as $msg ) { + try { + $body = json_decode( (string) $msg['body'], true ); + if ( ! is_array( $body ) ) { + $logger->warning( 'SQS: unparseable message body' ); + } else { + $this->handle_notification( $body, $events ); + } + $this->delete_message( $signer, $queue_url, (string) $msg['receipt_handle'] ); + $processed++; + } catch ( \Throwable $e ) { + $logger->error( 'SQS message processing failed', array( 'err' => $e->getMessage() ) ); + // Leave the message; SQS visibility timeout will re-deliver it. + } + } + if ( count( $messages ) < 10 ) break; // Queue drained. + } + if ( $processed > 0 ) { + $logger->info( 'SQS poll complete', array( 'processed' => $processed ) ); + } + return $processed; + } + + /** + * Dispatch a single notification envelope to appropriate domain events + * and side effects. + */ + private function handle_notification( array $body, EventDispatcher $events ): void { + $type = strtoupper( (string) ( $body['NotificationType'] ?? $body['notificationType'] ?? '' ) ); + $payload = (array) ( $body['Payload'] ?? $body['payload'] ?? array() ); + + // Emit as a generic event first. + $events->dispatch( 'sqs.' . strtolower( $type ), $payload ); + + switch ( $type ) { + case 'ANY_OFFER_CHANGED': + $this->handle_offer_change( $payload ); + break; + case 'ORDER_CHANGE': + $events->dispatch( 'order.changed', $payload ); + break; + case 'FBA_INVENTORY_AVAILABILITY_CHANGES': + $this->handle_inventory_change( $payload ); + break; + case 'FEED_PROCESSING_FINISHED': + $events->dispatch( 'feed.finished', $payload ); + break; + case 'REPORT_PROCESSING_FINISHED': + $events->dispatch( 'report.finished', $payload ); + break; + } + } + + /** + * Incremental Buy Box update — patches one SKU without re-syncing all. + */ + private function handle_offer_change( array $payload ): void { + $offer = $payload['AnyOfferChangedNotification']['OfferChangeTrigger'] ?? null; + if ( ! is_array( $offer ) ) return; + $asin = (string) ( $offer['ASIN'] ?? '' ); + $marketplace_id = (string) ( $offer['MarketplaceId'] ?? '' ); + if ( '' === $asin || '' === $marketplace_id ) return; + + // Map marketplace ID → market code. + $mp = $this->container->get( \SevenGum\Commerce\Amazon\Marketplaces::class ); + $market = $mp->code_by_id( $marketplace_id ); + if ( '' === $market ) return; + + // Extract new Buy Box from the summary. + $summary = (array) ( $payload['AnyOfferChangedNotification']['Summary'] ?? array() ); + $buybox = (array) ( $summary['BuyBoxPrices'][0] ?? array() ); + if ( empty( $buybox ) ) return; + + $price = (float) ( $buybox['ListingPrice']['Amount'] ?? 0 ); + $currency = (string) ( $buybox['ListingPrice']['CurrencyCode'] ?? '' ); + + // Patch DB by ASIN+market. + global $wpdb; + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}sg_products + SET buybox_price = %f, buybox_currency = %s, updated_at = UTC_TIMESTAMP() + WHERE asin = %s AND market = %s", + $price, $currency, $asin, $market + ) ); + + $this->container->get( Logger::class )->info( 'BuyBox patched from SQS', array( + 'asin' => $asin, 'market' => $market, 'price' => $price, + ) ); + } + + private function handle_inventory_change( array $payload ): void { + $sku = (string) ( $payload['FBAInventoryAvailabilityNotification']['SellerSku'] ?? '' ); + if ( '' === $sku ) return; + $qty = (int) ( $payload['FBAInventoryAvailabilityNotification']['FulfillableQuantity'] ?? 0 ); + $marketplace_id = (string) ( $payload['FBAInventoryAvailabilityNotification']['MarketplaceId'] ?? '' ); + $mp = $this->container->get( \SevenGum\Commerce\Amazon\Marketplaces::class ); + $market = $mp->code_by_id( $marketplace_id ); + if ( '' === $market ) return; + + global $wpdb; + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}sg_products + SET fulfillable_qty = %d, updated_at = UTC_TIMESTAMP() + WHERE sku = %s AND market = %s", + $qty, $sku, $market + ) ); + } + + private function receive_messages( SignatureV4 $signer, string $queue_url ): array { + $signed = $signer->sign_get( $queue_url, array( + 'Action' => 'ReceiveMessage', + 'Version' => '2012-11-05', + 'MaxNumberOfMessages' => 10, + 'WaitTimeSeconds' => 5, // Long-poll briefly to avoid empty responses. + ) ); + + $response = wp_remote_get( $signed['url'], array( + 'headers' => $signed['headers'], + 'timeout' => 15, + ) ); + if ( is_wp_error( $response ) ) { + throw new \RuntimeException( 'SQS receive failed: ' . $response->get_error_message() ); + } + $code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== (int) $code ) { + throw new \RuntimeException( 'SQS receive HTTP ' . $code ); + } + + $xml = simplexml_load_string( (string) wp_remote_retrieve_body( $response ) ); + if ( false === $xml ) return array(); + + $out = array(); + foreach ( $xml->ReceiveMessageResult->Message ?? array() as $m ) { + $out[] = array( + 'message_id' => (string) $m->MessageId, + 'receipt_handle' => (string) $m->ReceiptHandle, + 'body' => (string) $m->Body, + ); + } + return $out; + } + + private function delete_message( SignatureV4 $signer, string $queue_url, string $receipt_handle ): void { + $signed = $signer->sign_get( $queue_url, array( + 'Action' => 'DeleteMessage', + 'Version' => '2012-11-05', + 'ReceiptHandle' => $receipt_handle, + ) ); + wp_remote_get( $signed['url'], array( + 'headers' => $signed['headers'], + 'timeout' => 10, + ) ); + } +} diff --git a/sg-commerce-pro/src/Amazon/SQSConsumer/SignatureV4.php b/sg-commerce-pro/src/Amazon/SQSConsumer/SignatureV4.php new file mode 100644 index 0000000..aaecdae --- /dev/null +++ b/sg-commerce-pro/src/Amazon/SQSConsumer/SignatureV4.php @@ -0,0 +1,90 @@ +region}/{$this->service}/aws4_request"; + $string_to_sign = implode( "\n", array( + $algorithm, + $amz_date, + $credential_scope, + hash( 'sha256', $canonical_request ), + ) ); + + $k_date = hash_hmac( 'sha256', $date_stamp, 'AWS4' . $this->secret_key, true ); + $k_region = hash_hmac( 'sha256', $this->region, $k_date, true ); + $k_service = hash_hmac( 'sha256', $this->service, $k_region, true ); + $k_signing = hash_hmac( 'sha256', 'aws4_request', $k_service, true ); + $signature = hash_hmac( 'sha256', $string_to_sign, $k_signing ); + + $authorization = "{$algorithm} Credential={$this->access_key}/{$credential_scope}, " + . "SignedHeaders={$signed_headers}, Signature={$signature}"; + + return array( + 'url' => "{$queue_url}?{$canonical_query}", + 'headers' => array( + 'Host' => $host, + 'X-Amz-Date' => $amz_date, + 'Authorization' => $authorization, + ), + ); + } +} diff --git a/sg-commerce-pro/src/Amazon/SyncOrchestrator.php b/sg-commerce-pro/src/Amazon/SyncOrchestrator.php new file mode 100644 index 0000000..bfbb437 --- /dev/null +++ b/sg-commerce-pro/src/Amazon/SyncOrchestrator.php @@ -0,0 +1,269 @@ +marketplaces->get( $market ); + if ( null === $mp ) { + throw new \RuntimeException( "Unknown marketplace: {$market}" ); + } + + $lock_key = 'sg_sync_lock_' . $market; + if ( get_transient( $lock_key ) ) { + throw new \RuntimeException( "Sync already in progress for {$market}." ); + } + set_transient( $lock_key, time(), 10 * MINUTE_IN_SECONDS ); + + try { + $summary = $this->do_sync_market( $mp, $market, $max_pages ); + $this->events->emit( 'sync.market.completed', array( 'market' => $market, 'summary' => $summary ) ); + return $summary; + } finally { + delete_transient( $lock_key ); + } + } + + private function do_sync_market( array $mp, string $market, int $max_pages ): array { + $next_token = null; + $total = 0; + $page = 0; + $asins = array(); + + do { + $query = array( + 'granularityType' => 'Marketplace', + 'granularityId' => $mp['id'], + 'marketplaceIds' => $mp['id'], + 'details' => 'true', + ); + if ( null !== $next_token ) { + $query['nextToken'] = $next_token; + } + + $json = $this->client->request( + 'GET', + $market, + '/fba/inventory/v1/summaries', + $query, + null, + 'fbaInventory.summaries' + ); + + $summaries = $json['payload']['inventorySummaries'] ?? array(); + if ( ! is_array( $summaries ) ) { + break; + } + + foreach ( $summaries as $item ) { + $sku = (string) ( $item['sellerSku'] ?? '' ); + $asin = (string) ( $item['asin'] ?? '' ); + $name = (string) ( $item['productName'] ?? '' ); + $details = $item['inventoryDetails'] ?? array(); + $qty = (int) ( $details['fulfillableQuantity'] ?? 0 ); + $rsv = (int) ( $details['reservedQuantity']['totalReservedQuantity'] ?? 0 ); + $inb = (int) ( $details['inboundWorkingQuantity'] ?? 0 ) + + (int) ( $details['inboundShippedQuantity'] ?? 0 ) + + (int) ( $details['inboundReceivingQuantity'] ?? 0 ); + + if ( '' === $sku ) { + continue; + } + + try { + $id = $this->products->upsert_from_sync( array( + 'market' => $market, + 'sku' => $sku, + 'asin' => $asin, + 'product_name' => $name, + 'fulfillable_qty' => $qty, + 'reserved_qty' => $rsv, + 'inbound_qty' => $inb, + ) ); + if ( $id > 0 ) { + ++$total; + if ( '' !== $asin ) { + $asins[ $id ] = $asin; + } + $this->events->emit( 'product.synced', array( 'product_id' => $id, 'market' => $market ) ); + } + } catch ( \Throwable $e ) { + $this->logger->error( 'product upsert failed', array( 'sku' => $sku, 'err' => $e->getMessage() ) ); + } + } + + $next_token = (string) ( $json['pagination']['nextToken'] ?? '' ); + ++$page; + } while ( '' !== $next_token && $page < $max_pages ); + + // Pricing pass — fetch competitive pricing for ASINs we just synced. + if ( ! empty( $asins ) ) { + $this->sync_pricing_for( $market, $mp['id'], $asins ); + } + + $this->logger->info( 'sync market complete', array( 'market' => $market, 'rows' => $total, 'pages' => $page ) ); + return array( 'rows' => $total, 'pages' => $page ); + } + + /** + * Fetch competitive pricing for up to 20 ASINs at a time. + * + * @param array $product_id_to_asin + */ + private function sync_pricing_for( string $market, string $market_id, array $product_id_to_asin ): void { + $chunks = array_chunk( $product_id_to_asin, 20, true ); + + foreach ( $chunks as $chunk ) { + try { + $json = $this->client->request( + 'GET', + $market, + '/products/pricing/v0/competitivePrice', + array( + 'MarketplaceId' => $market_id, + 'ItemType' => 'Asin', + 'Asins' => implode( ',', array_values( $chunk ) ), + ), + null, + 'pricing.competitivePricing' + ); + } catch ( \Throwable $e ) { + $this->logger->warning( 'pricing fetch failed', array( 'market' => $market, 'err' => $e->getMessage() ) ); + continue; + } + + $payload = $json['payload'] ?? array(); + if ( ! is_array( $payload ) ) { + continue; + } + $asin_to_id = array_flip( $chunk ); + + foreach ( $payload as $entry ) { + $asin = (string) ( $entry['ASIN'] ?? '' ); + if ( '' === $asin || ! isset( $asin_to_id[ $asin ] ) ) { + continue; + } + $id = (int) $asin_to_id[ $asin ]; + + $competitive = $entry['Product']['CompetitivePricing']['CompetitivePrices'] ?? array(); + $best = $this->extract_buybox( $competitive ); + + $row = $this->products->find( $id ); + if ( ! $row ) continue; + + try { + $this->products->upsert_from_sync( array( + 'market' => $row['market'], + 'sku' => $row['sku'], + 'asin' => $row['asin'], + 'product_name' => $row['product_name'], + 'fulfillable_qty' => (int) $row['fulfillable_qty'], + 'reserved_qty' => (int) $row['reserved_qty'], + 'inbound_qty' => (int) $row['inbound_qty'], + 'buybox_price' => $best['price'], + 'buybox_currency' => $best['currency'], + 'buybox_is_mine' => 0, // determined by /listings — out of scope for v3.0 + 'lowest_price' => $best['price'], + ) ); + + $this->price_history->record( $id, array( + 'my_price' => isset( $row['my_price'] ) ? (float) $row['my_price'] : null, + 'buybox_price' => $best['price'], + 'lowest_price' => $best['price'], + 'currency' => $best['currency'], + 'buybox_is_mine' => 0, + 'fulfillable_qty' => (int) $row['fulfillable_qty'], + ) ); + } catch ( \Throwable $e ) { + $this->logger->error( 'pricing upsert failed', array( 'id' => $id, 'err' => $e->getMessage() ) ); + } + } + } + } + + /** Extract the New-condition Buy Box price from a competitive-prices payload. */ + private function extract_buybox( array $prices ): array { + foreach ( $prices as $cp ) { + if ( ( $cp['CompetitivePriceId'] ?? '' ) === '1' && ( $cp['condition'] ?? 'New' ) === 'New' ) { + return array( + 'price' => isset( $cp['Price']['LandedPrice']['Amount'] ) + ? (float) $cp['Price']['LandedPrice']['Amount'] + : null, + 'currency' => (string) ( $cp['Price']['LandedPrice']['CurrencyCode'] ?? '' ), + ); + } + } + return array( 'price' => null, 'currency' => '' ); + } + + public function sync_all( int $max_pages = 5 ): array { + $results = array( 'total' => 0, 'by_market' => array(), 'errors' => array() ); + foreach ( $this->marketplaces->enabled_codes() as $market ) { + try { + $summary = $this->sync_market( $market, $max_pages ); + $results['by_market'][ $market ] = $summary['rows']; + $results['total'] += $summary['rows']; + } catch ( \Throwable $e ) { + $results['errors'][ $market ] = $e->getMessage(); + $this->logger->error( 'market sync failed', array( 'market' => $market, 'err' => $e->getMessage() ) ); + } + } + $this->events->emit( 'sync.completed', $results ); + return $results; + } + + /** Cron entrypoint. */ + public function cron_sync(): void { + if ( ! $this->client->has_credentials() ) { + return; + } + try { + $result = $this->sync_all(); + update_option( 'sg_commerce_last_cron_sync', array( + 'at' => time(), + 'total' => $result['total'], + 'by_market' => $result['by_market'], + 'errors' => $result['errors'], + ), false ); + } catch ( \Throwable $e ) { + $this->logger->error( 'cron sync failed: ' . $e->getMessage() ); + } + } +} diff --git a/sg-commerce-pro/src/Analytics/AnalyticsModule.php b/sg-commerce-pro/src/Analytics/AnalyticsModule.php new file mode 100644 index 0000000..cd22e4b --- /dev/null +++ b/sg-commerce-pro/src/Analytics/AnalyticsModule.php @@ -0,0 +1,36 @@ +container->get( SettingsRepository::class )->get( 'log_retention_days', 90 ); + if ( $retain > 0 ) { + $this->container->get( PriceHistoryRepository::class )->prune_older_than( $retain ); + } + } ); + } +} diff --git a/sg-commerce-pro/src/Analytics/ProfitCalculator.php b/sg-commerce-pro/src/Analytics/ProfitCalculator.php new file mode 100644 index 0000000..772600c --- /dev/null +++ b/sg-commerce-pro/src/Analytics/ProfitCalculator.php @@ -0,0 +1,66 @@ +settings->get( 'referral_fee_pct', 15.0 ); + $fba_fee = (float) $this->settings->get( 'fba_pick_pack_fee', 3.00 ); + + $fee = $sale_price * ( $referral_pct / 100.0 ) + $fba_fee; + + $gross = ( null !== $cost ) ? ( $sale_price - $cost ) : 0.0; + $net = ( null !== $cost ) ? ( $sale_price - $cost - $fee ) : -1.0 * $fee; + $margin = ( null !== $cost && $sale_price > 0 ) ? ( $net / $sale_price * 100.0 ) : 0.0; + + return array( + 'gross_profit' => round( $gross, 2 ), + 'net_profit' => round( $net, 2 ), + 'margin_pct' => round( $margin, 2 ), + 'fee_estimate' => round( $fee, 2 ), + ); + } + + /** + * Minimum sale price that satisfies the configured min margin %. + * Useful for the repricer floor. + */ + public function min_price_for( float $cost ): float { + $min_margin = (float) $this->settings->get( 'repricer_min_margin_pct', 15.0 ); + $referral_pct = (float) $this->settings->get( 'referral_fee_pct', 15.0 ); + $fba_fee = (float) $this->settings->get( 'fba_pick_pack_fee', 3.00 ); + + // price - cost - (price * referral) - fba >= price * (margin/100) + // price * (1 - referral/100 - margin/100) >= cost + fba + $denom = 1.0 - ( $referral_pct / 100.0 ) - ( $min_margin / 100.0 ); + if ( $denom <= 0 ) { + return 0.0; // misconfigured — refuse to compute + } + return round( ( $cost + $fba_fee ) / $denom, 2 ); + } +} diff --git a/sg-commerce-pro/src/Analytics/SalesAggregator.php b/sg-commerce-pro/src/Analytics/SalesAggregator.php new file mode 100644 index 0000000..8a648bd --- /dev/null +++ b/sg-commerce-pro/src/Analytics/SalesAggregator.php @@ -0,0 +1,80 @@ +products->count_total(); + $in_stock = $this->products->count_in_stock(); + $winning = $this->products->count_winning_buybox(); + $markets = count( $this->products->counts_by_market() ); + + return array( + 'products_total' => $total, + 'products_in_stock' => $in_stock, + 'products_winning_buybox' => $winning, + 'buybox_win_rate' => $total > 0 ? round( $winning / $total * 100, 1 ) : 0.0, + 'stockout_rate' => $total > 0 ? round( ( $total - $in_stock ) / $total * 100, 1 ) : 0.0, + 'markets' => $markets, + ); + } + + /** + * Daily Buy Box win/loss counts for the last 14 days. + * Returns rows keyed by date with 'won' and 'lost' integers. + */ + public function buybox_trend( int $days = 14 ): array { + return $this->price_history->buybox_loss_by_day( $days ); + } + + /** Top-N products at risk: low stock or losing buybox. */ + public function at_risk( int $limit = 10 ): array { + global $wpdb; + $table = $wpdb->prefix . 'sg_products'; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT id, market, sku, product_name, fulfillable_qty, buybox_price, buybox_is_mine + FROM {$table} + WHERE fulfillable_qty <= 10 OR (buybox_is_mine = 0 AND buybox_price IS NOT NULL) + ORDER BY fulfillable_qty ASC, buybox_is_mine ASC + LIMIT %d", + $limit + ), ARRAY_A ); + return is_array( $rows ) ? $rows : array(); + } +} diff --git a/sg-commerce-pro/src/Automation/AlertDispatcher.php b/sg-commerce-pro/src/Automation/AlertDispatcher.php new file mode 100644 index 0000000..e0cb2f4 --- /dev/null +++ b/sg-commerce-pro/src/Automation/AlertDispatcher.php @@ -0,0 +1,76 @@ +insert( + $wpdb->prefix . 'sg_alerts', + array( + 'level' => $level, + 'category' => $category, + 'title' => $title, + 'message' => $message, + 'context' => wp_json_encode( $context ), + 'product_id' => $product_id, + 'is_read' => 0, + 'created_at' => current_time( 'mysql', true ), + ), + array( '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s' ) + ); + return false !== $result; + } + + public static function unread_count(): int { + global $wpdb; + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}sg_alerts WHERE is_read = 0" ); + } + + public static function recent( int $limit = 50 ): array { + global $wpdb; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}sg_alerts ORDER BY created_at DESC LIMIT %d", + $limit + ), ARRAY_A ); + return is_array( $rows ) ? $rows : array(); + } + + public static function mark_read( int $id ): bool { + global $wpdb; + $result = $wpdb->update( + $wpdb->prefix . 'sg_alerts', + array( 'is_read' => 1 ), + array( 'id' => $id ), + array( '%d' ), + array( '%d' ) + ); + return false !== $result; + } + + public static function mark_all_read(): int { + global $wpdb; + $rows = $wpdb->query( "UPDATE {$wpdb->prefix}sg_alerts SET is_read = 1 WHERE is_read = 0" ); + return is_numeric( $rows ) ? (int) $rows : 0; + } +} diff --git a/sg-commerce-pro/src/Automation/AutomationModule.php b/sg-commerce-pro/src/Automation/AutomationModule.php new file mode 100644 index 0000000..a6f7050 --- /dev/null +++ b/sg-commerce-pro/src/Automation/AutomationModule.php @@ -0,0 +1,77 @@ +container->get( RulesEngine::class )->attach_listeners(); + add_action( 'init', array( $this, 'seed_default_rules' ), 20 ); + } + + public function seed_default_rules(): void { + if ( get_option( 'sg_commerce_default_rules_seeded' ) ) { + return; + } + global $wpdb; + $now = current_time( 'mysql', true ); + + $wpdb->insert( $wpdb->prefix . 'sg_rules', array( + 'name' => 'Low stock alert', + 'trigger_event' => 'product.synced', + 'conditions' => wp_json_encode( array( + array( 'field' => 'fulfillable_qty', 'op' => '<=', 'value' => 5 ), + ) ), + 'actions' => wp_json_encode( array( + array( 'type' => 'create_alert', 'params' => array( + 'level' => 'warning', + 'category' => 'stock', + 'title' => 'Low stock', + 'message' => 'A product has 5 or fewer units left.', + ) ), + ) ), + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ) ); + + $wpdb->insert( $wpdb->prefix . 'sg_rules', array( + 'name' => 'Lost Buy Box', + 'trigger_event' => 'product.synced', + 'conditions' => wp_json_encode( array( + array( 'field' => 'buybox_is_mine', 'op' => '=', 'value' => 0 ), + ) ), + 'actions' => wp_json_encode( array( + array( 'type' => 'create_alert', 'params' => array( + 'level' => 'warning', + 'category' => 'buybox', + 'title' => 'Lost Buy Box', + 'message' => 'A competitor took the Buy Box.', + ) ), + ) ), + 'is_active' => 0, // off by default — too noisy to enable for everyone + 'created_at' => $now, + 'updated_at' => $now, + ) ); + + update_option( 'sg_commerce_default_rules_seeded', time(), false ); + } +} diff --git a/sg-commerce-pro/src/Automation/RulesEngine.php b/sg-commerce-pro/src/Automation/RulesEngine.php new file mode 100644 index 0000000..dca2dd2 --- /dev/null +++ b/sg-commerce-pro/src/Automation/RulesEngine.php @@ -0,0 +1,156 @@ +events->after( 'product.synced', function ( array $payload ): void { + $this->run_for_event( 'product.synced', $payload ); + } ); + } + + private function run_for_event( string $event, array $payload ): void { + global $wpdb; + $rules = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}sg_rules WHERE trigger_event = %s AND is_active = 1", + $event + ), ARRAY_A ); + if ( empty( $rules ) ) { + return; + } + + // Hydrate the product if the event references an id. + $product = null; + if ( ! empty( $payload['product_id'] ) ) { + $product = $wpdb->get_row( $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}sg_products WHERE id = %d", + (int) $payload['product_id'] + ), ARRAY_A ); + } + + foreach ( $rules as $rule ) { + $conditions = json_decode( (string) $rule['conditions'], true ) ?: array(); + if ( ! $this->conditions_match( $conditions, $product ?? array() ) ) { + continue; + } + $actions = json_decode( (string) $rule['actions'], true ) ?: array(); + foreach ( $actions as $action ) { + $this->execute_action( $action, $product ?? array(), (int) $rule['id'] ); + } + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}sg_rules SET run_count = run_count + 1, last_run_at = %s WHERE id = %d", + current_time( 'mysql', true ), + (int) $rule['id'] + ) ); + } + } + + private function conditions_match( array $conditions, array $product ): bool { + foreach ( $conditions as $c ) { + $field = (string) ( $c['field'] ?? '' ); + $op = (string) ( $c['op'] ?? '=' ); + $value = $c['value'] ?? null; + $actual = $product[ $field ] ?? null; + $pass = match ( $op ) { + '=' => (string) $actual === (string) $value, + '!=' => (string) $actual !== (string) $value, + '<' => is_numeric( $actual ) && $actual < $value, + '<=' => is_numeric( $actual ) && $actual <= $value, + '>' => is_numeric( $actual ) && $actual > $value, + '>=' => is_numeric( $actual ) && $actual >= $value, + default => false, + }; + if ( ! $pass ) return false; + } + return true; + } + + private function execute_action( array $action, array $product, int $rule_id ): void { + $type = (string) ( $action['type'] ?? '' ); + $params = (array) ( $action['params'] ?? array() ); + try { + match ( $type ) { + 'create_alert' => $this->action_create_alert( $params, $product ), + 'email_admin' => $this->action_email( $params, $product ), + 'queue_describe' => $this->action_queue_describe( $product ), + 'queue_translate' => $this->action_queue_translate( $params, $product ), + default => $this->logger->warning( "unknown rule action: {$type}" ), + }; + } catch ( \Throwable $e ) { + $this->logger->error( "rule action {$type} failed: " . $e->getMessage(), array( 'rule_id' => $rule_id ) ); + } + } + + private function action_create_alert( array $params, array $product ): void { + AlertDispatcher::create( + (string) ( $params['level'] ?? 'info' ), + (string) ( $params['category'] ?? 'rule' ), + (string) ( $params['title'] ?? 'Rule fired' ), + (string) ( $params['message'] ?? '' ), + $product, + isset( $product['id'] ) ? (int) $product['id'] : null + ); + } + + private function action_email( array $params, array $product ): void { + $to = (string) ( $params['to'] ?? get_option( 'admin_email' ) ); + $subject = (string) ( $params['subject'] ?? 'Seven Gum Commerce alert' ); + $body = (string) ( $params['body'] ?? '' ); + // Simple {{field}} placeholder substitution. + foreach ( $product as $k => $v ) { + if ( is_scalar( $v ) ) { + $body = str_replace( '{{' . $k . '}}', (string) $v, $body ); + $subject = str_replace( '{{' . $k . '}}', (string) $v, $subject ); + } + } + wp_mail( $to, $subject, $body ); + } + + private function action_queue_describe( array $product ): void { + if ( empty( $product['id'] ) ) return; + wp_schedule_single_event( time() + 5, 'sg_commerce_ai_describe', array( array( 'product_id' => (int) $product['id'] ) ) ); + } + + private function action_queue_translate( array $params, array $product ): void { + if ( empty( $product['id'] ) || empty( $params['market'] ) ) return; + wp_schedule_single_event( time() + 10, 'sg_commerce_ai_translate', array( array( + 'product_id' => (int) $product['id'], + 'market' => (string) $params['market'], + ) ) ); + } +} diff --git a/sg-commerce-pro/src/CLI/CommandRegistry.php b/sg-commerce-pro/src/CLI/CommandRegistry.php new file mode 100644 index 0000000..71491d9 --- /dev/null +++ b/sg-commerce-pro/src/CLI/CommandRegistry.php @@ -0,0 +1,461 @@ +`. Each subcommand resolves + * services from the container so it shares state with the rest of the + * plugin (caches, settings, etc.). + * + * Examples: + * wp sg-commerce sync-all + * wp sg-commerce sync US + * wp sg-commerce describe 42 + * wp sg-commerce describe-all --market=US --limit=20 + * wp sg-commerce translate 42 DE + * wp sg-commerce repricer-tick + * wp sg-commerce status + * + * @package SevenGum\Commerce\CLI + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\CLI; + +use SevenGum\Commerce\AI\Copywriter; +use SevenGum\Commerce\Amazon\AmazonClient; +use SevenGum\Commerce\Amazon\Marketplaces; +use SevenGum\Commerce\Amazon\SyncOrchestrator; +use SevenGum\Commerce\Core\Plugin; +use SevenGum\Commerce\Database\Repositories\ProductRepository; +use SevenGum\Commerce\Repricer\RepricerEngine; + +defined( 'ABSPATH' ) || exit; + +final class CommandRegistry { + + /** + * Sync FBA inventory from one Amazon marketplace. + * + * ## OPTIONS + * + * + * : Marketplace code (US, DE, UK, ...). + * + * [--max-pages=] + * : Maximum pages of inventory to fetch (default 5). + * + * ## EXAMPLES + * + * wp sg-commerce sync US + * wp sg-commerce sync DE --max-pages=10 + */ + public function sync( array $args, array $assoc ): void { + $market = strtoupper( (string) ( $args[0] ?? '' ) ); + if ( '' === $market ) { + \WP_CLI::error( 'market argument required' ); + } + $max_pages = isset( $assoc['max-pages'] ) ? max( 1, (int) $assoc['max-pages'] ) : 5; + try { + $result = $this->container()->get( SyncOrchestrator::class )->sync_market( $market, $max_pages ); + \WP_CLI::success( sprintf( 'Synced %d products from %s in %d pages.', $result['rows'], $market, $result['pages'] ) ); + } catch ( \Throwable $e ) { + \WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Sync every enabled marketplace. + */ + public function sync_all( array $args, array $assoc ): void { + $result = $this->container()->get( SyncOrchestrator::class )->sync_all(); + \WP_CLI::log( sprintf( 'Total: %d products', $result['total'] ) ); + foreach ( $result['by_market'] as $m => $n ) { + \WP_CLI::log( " {$m}: {$n}" ); + } + foreach ( $result['errors'] as $m => $err ) { + \WP_CLI::warning( " {$m}: {$err}" ); + } + \WP_CLI::success( 'sync-all complete' ); + } + + /** + * Generate AI description for one product. + * + * ## OPTIONS + * + * + * : Product id. + */ + public function describe( array $args, array $assoc ): void { + $id = (int) ( $args[0] ?? 0 ); + if ( $id <= 0 ) { + \WP_CLI::error( 'product id required' ); + } + try { + $text = $this->container()->get( Copywriter::class )->describe( $id ); + \WP_CLI::log( $text ); + \WP_CLI::success( 'description saved' ); + } catch ( \Throwable $e ) { + \WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Bulk-describe products without an English description yet. + * + * [--market=] + * : Restrict to one marketplace. + * + * [--limit=] + * : Maximum products to process (default 20). + */ + public function describe_all( array $args, array $assoc ): void { + global $wpdb; + $market = isset( $assoc['market'] ) ? strtoupper( (string) $assoc['market'] ) : ''; + $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20; + + $where = "WHERE NOT EXISTS (SELECT 1 FROM {$wpdb->prefix}sg_descriptions d WHERE d.product_id = p.id AND d.language='en')"; + $params = array(); + if ( '' !== $market ) { + $where .= ' AND market = %s'; + $params[] = $market; + } + $sql = "SELECT id FROM {$wpdb->prefix}sg_products p {$where} LIMIT %d"; + $params[] = $limit; + $ids = $wpdb->get_col( $wpdb->prepare( $sql, ...$params ) ); + + if ( empty( $ids ) ) { + \WP_CLI::success( 'nothing to do' ); + return; + } + $copywriter = $this->container()->get( Copywriter::class ); + $progress = \WP_CLI\Utils\make_progress_bar( 'Describing', count( $ids ) ); + $ok = 0; $fail = 0; + foreach ( $ids as $id ) { + try { $copywriter->describe( (int) $id ); $ok++; } + catch ( \Throwable $e ) { $fail++; \WP_CLI::warning( "{$id}: " . $e->getMessage() ); } + $progress->tick(); + } + $progress->finish(); + \WP_CLI::success( "described {$ok}, failed {$fail}" ); + } + + /** + * Translate one product's description into a market language. + * + * + * : Product id. + * + * + * : Target marketplace code. + */ + public function translate( array $args, array $assoc ): void { + $id = (int) ( $args[0] ?? 0 ); + $market = strtoupper( (string) ( $args[1] ?? '' ) ); + if ( $id <= 0 || '' === $market ) { + \WP_CLI::error( 'usage: wp sg-commerce translate ' ); + } + try { + $text = $this->container()->get( Copywriter::class )->translate( $id, $market ); + \WP_CLI::log( $text ); + \WP_CLI::success( 'translation saved' ); + } catch ( \Throwable $e ) { + \WP_CLI::error( $e->getMessage() ); + } + } + + /** Run the repricer engine once. */ + public function repricer_tick( array $args, array $assoc ): void { + try { + $result = $this->container()->get( RepricerEngine::class )->tick(); + \WP_CLI::log( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); + \WP_CLI::success( 'repricer tick complete' ); + } catch ( \Throwable $e ) { + \WP_CLI::error( $e->getMessage() ); + } + } + + /** Print high-level status. */ + public function status( array $args, array $assoc ): void { + $c = $this->container(); + $products = $c->get( ProductRepository::class ); + $client = $c->get( AmazonClient::class ); + $mp = $c->get( Marketplaces::class ); + + \WP_CLI::log( '── Seven Gum Commerce v' . SG_COMMERCE_VERSION . ' ──' ); + \WP_CLI::log( 'Amazon credentials: ' . ( $client->has_credentials() ? 'configured' : 'NOT configured' ) ); + \WP_CLI::log( 'Primary market: ' . $mp->primary() ); + \WP_CLI::log( 'Enabled markets: ' . implode( ', ', $mp->enabled_codes() ) ); + \WP_CLI::log( 'Products tracked: ' . $products->count_total() ); + \WP_CLI::log( 'In stock: ' . $products->count_in_stock() ); + \WP_CLI::log( 'Winning Buy Box: ' . $products->count_winning_buybox() ); + \WP_CLI::log( 'Last sync: ' . ( $products->last_sync_at() ?? 'never' ) ); + } + + /** + * Refresh tracking for all pending MCF orders. + */ + public function mcf_refresh( array $args, array $assoc ): void { + try { + $module = $this->container()->get( \SevenGum\Commerce\Fulfillment\FulfillmentModule::class ); + $module->refresh_pending_tracking(); + \WP_CLI::success( 'MCF tracking refreshed' ); + } catch ( \Throwable $e ) { + \WP_CLI::error( $e->getMessage() ); + } + } + + /** + * List recent MCF orders. + * + * [--limit=] + * : Max rows (default 20). + */ + public function mcf_list( array $args, array $assoc ): void { + $limit = isset( $assoc['limit'] ) ? max( 1, (int) $assoc['limit'] ) : 20; + $repo = $this->container()->get( \SevenGum\Commerce\Fulfillment\MCFOrderRepository::class ); + $rows = $repo->recent( $limit ); + if ( empty( $rows ) ) { \WP_CLI::log( 'No MCF orders.' ); return; } + \WP_CLI\Utils\format_items( + 'table', + array_map( static fn( $r ) => array( + 'id' => $r['id'], + 'order' => $r['displayable_order_id'], + 'market' => $r['market'], + 'status' => $r['status'], + 'customer' => $r['customer_name'], + 'created' => $r['created_at'], + ), $rows ), + array( 'id', 'order', 'market', 'status', 'customer', 'created' ) + ); + } + + /** + * Probe the geo-resolver from CLI (useful for debugging headers). + */ + public function geo( array $args, array $assoc ): void { + $resolver = $this->container()->get( \SevenGum\Commerce\Geotargeting\GeoResolver::class ); + \WP_CLI::log( 'country: ' . $resolver->country() ); + \WP_CLI::log( 'market: ' . $resolver->market() ); + } + + /** + * Run PII anonymization pass immediately. + */ + public function pii_purge( array $args, array $assoc ): void { + try { + $count = $this->container()->get( \SevenGum\Commerce\Security\PIIManager\PIIManager::class )->purge_expired(); + \WP_CLI::success( "Anonymized {$count} expired MCF orders." ); + } catch ( \Throwable $e ) { + \WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Resync all theme posts' prices + URLs from Amazon data. + */ + public function theme_sync( array $args, array $assoc ): void { + try { + $updated = $this->container()->get( \SevenGum\Commerce\Integration\ThemeBridge::class ) + ->on_sync_completed( array() ); + \WP_CLI::success( 'Theme bridge resync complete.' ); + } catch ( \Throwable $e ) { + \WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Parse + import a local settlement report TSV file. + * + * ## OPTIONS + * + * + * : Path to the GET_V2_SETTLEMENT_REPORT TSV file. + * + * [--settlement-id=] + * : Settlement ID to tag these rows with (optional). + */ + public function settlement_import( array $args, array $assoc ): void { + $path = (string) ( $args[0] ?? '' ); + if ( '' === $path || ! file_exists( $path ) ) { + \WP_CLI::error( "File not found: {$path}" ); + } + $content = (string) file_get_contents( $path ); + $parser = new \SevenGum\Commerce\Amazon\ReportsParser\SettlementsParser(); + $events = $parser->parse( $content ); + + $settlement_id = isset( $assoc['settlement-id'] ) ? sanitize_text_field( (string) $assoc['settlement-id'] ) : ''; + global $wpdb; + $table = $wpdb->prefix . 'sg_settlements'; + $imported = 0; + foreach ( $events as $e ) { + $result = $wpdb->insert( $table, array( + 'event_date' => $e['date'] ? gmdate( 'Y-m-d', strtotime( $e['date'] ) ) : null, + 'event_type' => $e['type'], + 'sku' => $e['sku'], + 'order_id' => $e['order_id'], + 'marketplace' => $e['marketplace'], + 'amount' => $e['amount'], + 'currency' => $e['currency'], + 'description' => substr( $e['description'], 0, 190 ), + 'settlement_id' => $settlement_id, + 'imported_at' => current_time( 'mysql', true ), + ), array( '%s', '%s', '%s', '%s', '%s', '%f', '%s', '%s', '%s', '%s' ) ); + if ( false !== $result ) $imported++; + } + $summary = $parser->summary( $events ); + \WP_CLI::log( "Parsed: {$summary['event_count']} events" ); + foreach ( $summary['totals'] as $t => $amt ) { + \WP_CLI::log( sprintf( ' %-16s %10.2f', $t, $amt ) ); + } + \WP_CLI::log( sprintf( " %-16s %10.2f", 'NET', $summary['net'] ) ); + \WP_CLI::success( "Imported {$imported} rows." ); + } + + /** + * Poll SQS once (outside the normal cron). + */ + public function sqs_poll( array $args, array $assoc ): void { + try { + $processed = $this->container()->get( \SevenGum\Commerce\Amazon\SQSConsumer\SQSConsumer::class )->poll(); + \WP_CLI::success( "Processed {$processed} SQS messages." ); + } catch ( \Throwable $e ) { + \WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Mint a new API key for the Headless API. Prints the plaintext key ONCE. + * + *
+ + + + + + + + + + + + + + + + + + +
+
+ + <?php echo esc_attr( $p['name'] ); ?> + + +
+
feature_label( $feature ) ); ?> + feature_cell( $feature, $p ); // phpcs:ignore ?> +
+ + + +
+ +
+ +
+ <?php echo esc_attr( $p['name'] ); ?> +
+ +

+ +
+ +
feature_label( $feature ) ); ?>
+
feature_cell( $feature, $p ); // phpcs:ignore ?>
+ +
+ + + + + + +
+ +
+ + __( 'Price', 'sg-commerce' ), + 'stock' => __( 'Availability', 'sg-commerce' ), + 'description' => __( 'Description', 'sg-commerce' ), + 'cta' => __( 'Buy', 'sg-commerce' ), + 'sku' => 'SKU', + 'asin' => 'ASIN', + default => ucfirst( str_replace( '_', ' ', $key ) ), + }; + } + + private function feature_cell( string $feature, array $p ): string { + switch ( $feature ) { + case 'price': + if ( null === $p['price'] ) return '—'; + return '' + . esc_html( number_format( $p['price'], 2 ) ) + . ' ' . esc_html( $p['currency'] ) . ''; + case 'stock': + return $p['in_stock'] + ? '' . esc_html__( 'In stock', 'sg-commerce' ) . '' + : '' . esc_html__( 'Out of stock', 'sg-commerce' ) . ''; + case 'description': + return $p['description'] !== '' + ? esc_html( $p['description'] ) + : '—'; + case 'cta': + return '' !== $p['amazon_url'] + ? '' . esc_html__( 'Buy →', 'sg-commerce' ) . '' + : '—'; + case 'sku': + return '' . esc_html( $p['sku'] ) . ''; + case 'asin': + return $p['asin'] ? '' . esc_html( $p['asin'] ) . '' : '—'; + default: + // Allow arbitrary keys from the row. + return isset( $p[ $feature ] ) ? esc_html( (string) $p[ $feature ] ) : '—'; + } + } +} diff --git a/sg-commerce-pro/src/Core/Activator.php b/sg-commerce-pro/src/Core/Activator.php new file mode 100644 index 0000000..fd53437 --- /dev/null +++ b/sg-commerce-pro/src/Core/Activator.php @@ -0,0 +1,182 @@ +Seven Gum Commerce: activation blocked', + 'Plugin activation failed', + array( 'back_link' => true ) + ); + } + } + + private static function seed_defaults(): void { + $defaults = array( + 'sg_commerce_settings' => array( + 'amazon_primary_market' => 'US', + 'amazon_enabled_markets' => array( 'US' ), + 'ai_provider' => 'ollama', + 'ai_default_locale' => 'en', + 'ai_temperature' => 0.7, + 'ai_brand_voice' => 'modern, clean, playful but confident', + 'ollama_url' => 'http://127.0.0.1:11434', + 'ollama_model' => 'llama3.1:8b', + 'sync_frequency' => 'hourly', + 'sync_pages_per_run' => 5, + 'analytics_enabled' => true, + 'repricer_enabled' => false, + 'repricer_strategy' => 'match_buybox', + 'repricer_min_margin_pct' => 15.0, + 'log_level' => 'info', + 'log_retention_days' => 30, + // Fulfillment (MCF) + 'mcf_auto_wc' => false, + 'mcf_default_market' => 'US', + 'mcf_default_speed' => 'Standard', + // Injection + 'injection_enabled' => true, + // Geotargeting + 'geotargeting_enabled' => true, + // Theme bridge (v3.2) + 'theme_bridge_enabled' => true, + // PII compliance (v3.2) + 'pii_retention_days' => 30, + // SQS real-time consumer (v3.2) + 'sqs_enabled' => false, + 'sqs_region' => 'us-east-1', + 'sqs_queue_url' => '', + 'sqs_access_key' => '', + 'sqs_secret_key_enc' => '', + // Headless API keys (v3.2) + 'api_keys' => array(), + // Sandbox (v3.2) + 'sandbox_enabled' => false, + ), + ); + foreach ( $defaults as $key => $value ) { + if ( false === get_option( $key, false ) ) { + add_option( $key, $value, '', false ); + } + } + } + + /** + * Seed Seven Gum's master-carton template so operators don't have to + * re-enter physical measurements. Called once at activation. + */ + private static function seed_carton_templates(): void { + if ( class_exists( '\\SevenGum\\Commerce\\Fulfillment\\BoxContent\\BoxContentService' ) ) { + \SevenGum\Commerce\Fulfillment\BoxContent\BoxContentService::seed_default_template(); + } + } + + private static function schedule_cron(): void { + $jobs = array( + 'sg_commerce_hourly_sync' => 'hourly', + 'sg_commerce_daily_analytics' => 'daily', + 'sg_commerce_repricer_tick' => 'sg_fifteen_minutes', + 'sg_commerce_competitor_scan' => 'sg_six_hours', + 'sg_commerce_ai_queue_process' => 'sg_five_minutes', + 'sg_commerce_mcf_refresh' => 'sg_fifteen_minutes', + 'sg_commerce_pii_purge' => 'daily', + 'sg_commerce_sqs_poll' => 'sg_five_minutes', + ); + foreach ( $jobs as $hook => $recurrence ) { + if ( ! wp_next_scheduled( $hook ) ) { + wp_schedule_event( time() + 600, $recurrence, $hook ); + } + } + } +} diff --git a/sg-commerce-pro/src/Core/Container.php b/sg-commerce-pro/src/Core/Container.php new file mode 100644 index 0000000..e23f7a7 --- /dev/null +++ b/sg-commerce-pro/src/Core/Container.php @@ -0,0 +1,126 @@ + */ + private array $factories = array(); + + /** @var array */ + private array $singletons = array(); + + /** @var array */ + private array $instances = array(); + + /** @var array Tracks active resolutions to detect cycles. */ + private array $resolving = array(); + + /** + * Bind a factory. Each resolve() invokes the factory and returns a new + * instance. + */ + public function bind( string $id, callable $factory ): void { + unset( $this->singletons[ $id ], $this->instances[ $id ] ); + $this->factories[ $id ] = $factory; + } + + /** + * Bind a singleton factory. The factory is called once on first + * resolve(); subsequent calls return the cached result. + */ + public function singleton( string $id, callable $factory ): void { + unset( $this->factories[ $id ], $this->instances[ $id ] ); + $this->singletons[ $id ] = $factory; + } + + /** + * Bind an existing instance. Returned as-is from every resolve() call. + */ + public function instance( string $id, mixed $object ): void { + unset( $this->factories[ $id ], $this->singletons[ $id ] ); + $this->instances[ $id ] = $object; + } + + /** + * Resolve a binding by id. + * + * @throws \RuntimeException If the binding is missing or a cycle is detected. + */ + public function get( string $id ): mixed { + // Pre-built instance. + if ( array_key_exists( $id, $this->instances ) ) { + return $this->instances[ $id ]; + } + + // Cached singleton. + if ( isset( $this->singletons[ $id ] ) ) { + if ( isset( $this->resolving[ $id ] ) ) { + throw new \RuntimeException( "Circular dependency while resolving '{$id}'." ); + } + $this->resolving[ $id ] = true; + try { + $obj = ( $this->singletons[ $id ] )( $this ); + } finally { + unset( $this->resolving[ $id ] ); + } + $this->instances[ $id ] = $obj; + return $obj; + } + + // Per-call factory. + if ( isset( $this->factories[ $id ] ) ) { + if ( isset( $this->resolving[ $id ] ) ) { + throw new \RuntimeException( "Circular dependency while resolving '{$id}'." ); + } + $this->resolving[ $id ] = true; + try { + return ( $this->factories[ $id ] )( $this ); + } finally { + unset( $this->resolving[ $id ] ); + } + } + + throw new \RuntimeException( "No binding registered for '{$id}'." ); + } + + public function has( string $id ): bool { + return isset( $this->instances[ $id ] ) + || isset( $this->singletons[ $id ] ) + || isset( $this->factories[ $id ] ); + } + + /** + * Convenience: resolve multiple bindings as an associative array. + * + * @param array $ids + * @return array + */ + public function getMany( array $ids ): array { + $out = array(); + foreach ( $ids as $id ) { + $out[ $id ] = $this->get( $id ); + } + return $out; + } +} diff --git a/sg-commerce-pro/src/Core/Module.php b/sg-commerce-pro/src/Core/Module.php new file mode 100644 index 0000000..cb6dcec --- /dev/null +++ b/sg-commerce-pro/src/Core/Module.php @@ -0,0 +1,32 @@ +container = new Container(); + } + + public function container(): Container { + return $this->container; + } + + /** + * Main entrypoint. Idempotent — multiple calls are safe. + */ + public function boot(): void { + if ( $this->booted ) { + return; + } + $this->booted = true; + + $this->register_cron_schedules(); + $this->register_services(); + $this->register_modules(); + $this->register_global_hooks(); + + // Auto-migrate schema when the plugin is updated without re-activation + // (e.g. FTP upload, git pull). Runs on admin_init so it only fires + // once an admin hits wp-admin — safe and cheap. + add_action( 'admin_init', array( $this, 'maybe_upgrade_schema' ), 1 ); + } + + public function maybe_upgrade_schema(): void { + $stored = (int) get_option( 'sg_commerce_schema_version', 0 ); + if ( $stored < \SevenGum\Commerce\Database\Schema::SCHEMA_VERSION ) { + \SevenGum\Commerce\Database\Schema::install(); + // Also seed any new defaults/templates that v3.3 introduced. + if ( class_exists( '\\SevenGum\\Commerce\\Fulfillment\\BoxContent\\BoxContentService' ) ) { + \SevenGum\Commerce\Fulfillment\BoxContent\BoxContentService::seed_default_template(); + } + $this->container->get( Logger::class )->info( 'Schema auto-upgraded', array( + 'from' => $stored, + 'to' => \SevenGum\Commerce\Database\Schema::SCHEMA_VERSION, + ) ); + } + } + + /* ------------------------------------------------------------------ */ + /* Cron schedules */ + /* ------------------------------------------------------------------ */ + + private function register_cron_schedules(): void { + // NOTE: `cron_schedules` fires VERY early in the request, sometimes + // before `init`. Using __() here triggers WP 6.7+ "textdomain loaded + // too early" notice. These display labels are admin-only polish, so + // we use plain English strings — the functional "interval" values + // are what actually matters. + add_filter( 'cron_schedules', static function ( array $schedules ): array { + $schedules['sg_five_minutes'] = array( + 'interval' => 5 * MINUTE_IN_SECONDS, + 'display' => 'Every 5 minutes (Seven Gum)', + ); + $schedules['sg_fifteen_minutes'] = array( + 'interval' => 15 * MINUTE_IN_SECONDS, + 'display' => 'Every 15 minutes (Seven Gum)', + ); + $schedules['sg_six_hours'] = array( + 'interval' => 6 * HOUR_IN_SECONDS, + 'display' => 'Every 6 hours (Seven Gum)', + ); + return $schedules; + } ); + } + + /* ------------------------------------------------------------------ */ + /* Service bindings */ + /* ------------------------------------------------------------------ */ + + private function register_services(): void { + $c = $this->container; + + // Foundational singletons. + $c->singleton( Logger::class, static fn() => new Logger() ); + $c->singleton( Encryption::class, static fn() => new Encryption() ); + $c->singleton( CacheManager::class, static fn() => new CacheManager() ); + $c->singleton( EventDispatcher::class, static fn() => new EventDispatcher() ); + $c->singleton( JobQueue::class, static fn( Container $c ) => + new JobQueue( $c->get( Logger::class ) ) + ); + + // Repositories — share a single $wpdb-backed instance. + $c->singleton( SettingsRepository::class, static fn( Container $c ) => + new SettingsRepository( $c->get( Encryption::class ), $c->get( Logger::class ) ) + ); + $c->singleton( ProductRepository::class, static fn( Container $c ) => + new ProductRepository( $c->get( CacheManager::class ), $c->get( Logger::class ) ) + ); + $c->singleton( PriceHistoryRepository::class, static fn( Container $c ) => + new PriceHistoryRepository( $c->get( Logger::class ) ) + ); + + // Amazon stack. + $c->singleton( Marketplaces::class, static fn() => new Marketplaces() ); + $c->singleton( RateLimiter::class, static fn( Container $c ) => + new RateLimiter( $c->get( CacheManager::class ) ) + ); + $c->singleton( CircuitBreaker::class, static fn( Container $c ) => + new CircuitBreaker( $c->get( CacheManager::class ), $c->get( Logger::class ) ) + ); + $c->singleton( LWATokenManager::class, static fn( Container $c ) => + new LWATokenManager( + $c->get( SettingsRepository::class ), + $c->get( CacheManager::class ), + $c->get( Logger::class ) + ) + ); + $c->singleton( AmazonClient::class, static fn( Container $c ) => + new AmazonClient( + $c->get( LWATokenManager::class ), + $c->get( Marketplaces::class ), + $c->get( RateLimiter::class ), + $c->get( CircuitBreaker::class ), + $c->get( Logger::class ) + ) + ); + $c->singleton( SyncOrchestrator::class, static fn( Container $c ) => + new SyncOrchestrator( + $c->get( AmazonClient::class ), + $c->get( Marketplaces::class ), + $c->get( ProductRepository::class ), + $c->get( PriceHistoryRepository::class ), + $c->get( EventDispatcher::class ), + $c->get( Logger::class ) + ) + ); + + // AI stack. + $c->singleton( PromptRegistry::class, static fn( Container $c ) => + new PromptRegistry( $c->get( SettingsRepository::class ) ) + ); + $c->singleton( OllamaProvider::class, static fn( Container $c ) => + new OllamaProvider( + $c->get( SettingsRepository::class ), + $c->get( CacheManager::class ), + $c->get( Logger::class ) + ) + ); + + // Analytics. + $c->singleton( SalesAggregator::class, static fn( Container $c ) => + new SalesAggregator( + $c->get( ProductRepository::class ), + $c->get( PriceHistoryRepository::class ), + $c->get( Logger::class ) + ) + ); + $c->singleton( ProfitCalculator::class, static fn( Container $c ) => + new ProfitCalculator( $c->get( SettingsRepository::class ) ) + ); + + // Automation + Repricer. + $c->singleton( RulesEngine::class, static fn( Container $c ) => + new RulesEngine( + $c->get( SettingsRepository::class ), + $c->get( EventDispatcher::class ), + $c->get( Logger::class ) + ) + ); + $c->singleton( RepricerEngine::class, static fn( Container $c ) => + new RepricerEngine( + $c->get( ProductRepository::class ), + $c->get( SettingsRepository::class ), + $c->get( ProfitCalculator::class ), + $c->get( Logger::class ) + ) + ); + } + + /* ------------------------------------------------------------------ */ + /* Modules */ + /* ------------------------------------------------------------------ */ + + private function register_modules(): void { + $module_classes = apply_filters( + 'sg_commerce_modules', + array( + AmazonModule::class, + AIModule::class, + AnalyticsModule::class, + AutomationModule::class, + RepricerModule::class, + FulfillmentModule::class, + InboundShipmentModule::class, + GeotargetingModule::class, + InjectionModule::class, + ComparisonModule::class, + IntegrationModule::class, + PIIManager::class, + SQSConsumer::class, + HeadlessAPI::class, + WidgetModule::class, + RESTModule::class, + SuiteModule::class, + AdminModule::class, + ) + ); + + foreach ( $module_classes as $cls ) { + if ( ! class_exists( $cls ) ) { + continue; + } + try { + /** @var Module $module */ + $module = new $cls( $this->container ); + $module->register(); + $this->modules[ $module->id() ] = $module; + $this->container->instance( 'module.' . $module->id(), $module ); + // CRITICAL: also bind under the FQCN so admin views and CLI + // commands can resolve the module directly via $container->get($cls::class). + $this->container->instance( $cls, $module ); + } catch ( \Throwable $e ) { + $this->container->get( Logger::class )->error( + sprintf( 'Module %s failed to register: %s', $cls, $e->getMessage() ) + ); + } + } + } + + /* ------------------------------------------------------------------ */ + /* Global hooks */ + /* ------------------------------------------------------------------ */ + + private function register_global_hooks(): void { + // CLI registration deferred to inside the if-block to avoid loading + // the CLI namespace when WP-CLI isn't running. + if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( '\\WP_CLI' ) ) { + \WP_CLI::add_command( 'sg-commerce', \SevenGum\Commerce\CLI\CommandRegistry::class ); + } + + // Admin row links. + add_filter( 'plugin_action_links_' . SG_COMMERCE_BASENAME, static function ( array $links ): array { + $settings_link = sprintf( + '%s', + esc_url( admin_url( 'admin.php?page=sg-commerce' ) ), + esc_html__( 'Dashboard', 'sg-commerce' ) + ); + array_unshift( $links, $settings_link ); + return $links; + } ); + } + + /** @return Module[] */ + public function modules(): array { + return $this->modules; + } +} diff --git a/sg-commerce-pro/src/Database/Repositories/PriceHistoryRepository.php b/sg-commerce-pro/src/Database/Repositories/PriceHistoryRepository.php new file mode 100644 index 0000000..5eb1a9a --- /dev/null +++ b/sg-commerce-pro/src/Database/Repositories/PriceHistoryRepository.php @@ -0,0 +1,106 @@ +table = $wpdb->prefix . 'sg_price_history'; + } + + public function record( int $product_id, array $snapshot, string $source = 'sync' ): bool { + global $wpdb; + $result = $wpdb->insert( + $this->table, + array( + 'product_id' => $product_id, + 'recorded_at' => current_time( 'mysql', true ), + 'my_price' => $snapshot['my_price'] ?? null, + 'buybox_price' => $snapshot['buybox_price'] ?? null, + 'lowest_price' => $snapshot['lowest_price'] ?? null, + 'currency' => (string) ( $snapshot['currency'] ?? '' ), + 'buybox_is_mine' => (int) ( $snapshot['buybox_is_mine'] ?? 0 ), + 'fulfillable_qty' => (int) ( $snapshot['fulfillable_qty'] ?? 0 ), + 'source' => $source, + ), + array( '%d', '%s', '%f', '%f', '%f', '%s', '%d', '%d', '%s' ) + ); + if ( false === $result ) { + $this->logger->error( 'price history insert failed', array( 'err' => $wpdb->last_error ) ); + return false; + } + return true; + } + + /** + * Recent N snapshots for a product, oldest first. + * + * @return array + */ + public function recent_for_product( int $product_id, int $limit = 100 ): array { + global $wpdb; + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$this->table} + WHERE product_id = %d + ORDER BY recorded_at DESC + LIMIT %d", + $product_id, + $limit + ), + ARRAY_A + ); + return is_array( $rows ) ? array_reverse( $rows ) : array(); + } + + /** + * Aggregate buybox loss events across the inventory, grouped by day. + * Used by the analytics dashboard. + */ + public function buybox_loss_by_day( int $days = 14 ): array { + global $wpdb; + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT DATE(recorded_at) AS day, + SUM(CASE WHEN buybox_is_mine = 0 THEN 1 ELSE 0 END) AS lost, + SUM(CASE WHEN buybox_is_mine = 1 THEN 1 ELSE 0 END) AS won + FROM {$this->table} + WHERE recorded_at >= %s + GROUP BY DATE(recorded_at) + ORDER BY day ASC", + $cutoff + ), + ARRAY_A + ); + return is_array( $rows ) ? $rows : array(); + } + + /** Prune snapshots older than $days to keep the table bounded. */ + public function prune_older_than( int $days ): int { + global $wpdb; + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); + $deleted = $wpdb->query( + $wpdb->prepare( "DELETE FROM {$this->table} WHERE recorded_at < %s", $cutoff ) + ); + return is_numeric( $deleted ) ? (int) $deleted : 0; + } +} diff --git a/sg-commerce-pro/src/Database/Repositories/ProductRepository.php b/sg-commerce-pro/src/Database/Repositories/ProductRepository.php new file mode 100644 index 0000000..b21f3f2 --- /dev/null +++ b/sg-commerce-pro/src/Database/Repositories/ProductRepository.php @@ -0,0 +1,325 @@ +prepare, + * checks return values, logs errors, and uses an in-request cache to + * coalesce duplicate fetches on the same page-render cycle. + * + * @package SevenGum\Commerce\Database\Repositories + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Database\Repositories; + +use SevenGum\Commerce\Cache\CacheManager; +use SevenGum\Commerce\Logging\Logger; + +defined( 'ABSPATH' ) || exit; + +final class ProductRepository { + + private string $table; + + /** Per-request cache keyed by id or "{market}|{sku}". */ + private array $by_id = array(); + private array $by_market_sku = array(); + + public function __construct( + private CacheManager $cache, + private Logger $logger, + ) { + global $wpdb; + $this->table = $wpdb->prefix . 'sg_products'; + } + + public function find( int $id ): ?array { + if ( isset( $this->by_id[ $id ] ) ) { + return $this->by_id[ $id ]; + } + global $wpdb; + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ), + ARRAY_A + ); + if ( null === $row ) { + return null; + } + $this->by_id[ $id ] = $row; + $this->by_market_sku[ $row['market'] . '|' . $row['sku'] ] = $row; + return $row; + } + + public function find_by_sku( string $market, string $sku ): ?array { + $key = $market . '|' . $sku; + if ( isset( $this->by_market_sku[ $key ] ) ) { + return $this->by_market_sku[ $key ]; + } + global $wpdb; + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$this->table} WHERE market = %s AND sku = %s LIMIT 1", + $market, + $sku + ), + ARRAY_A + ); + if ( null === $row ) { + return null; + } + $this->by_market_sku[ $key ] = $row; + $this->by_id[ (int) $row['id'] ] = $row; + return $row; + } + + /** + * Paginated query with filtering & search. + * + * @param array{ + * market?: string, + * search?: string, + * has_buybox?: bool, + * in_stock?: bool, + * per_page?: int, + * page?: int, + * orderby?: string, + * order?: string, + * } $args + * + * @return array{rows: array, total: int, pages: int} + */ + public function paginate( array $args = array() ): array { + global $wpdb; + + $market = isset( $args['market'] ) ? (string) $args['market'] : ''; + $search = isset( $args['search'] ) ? trim( (string) $args['search'] ) : ''; + $per_page = max( 1, min( 200, (int) ( $args['per_page'] ?? 25 ) ) ); + $page = max( 1, (int) ( $args['page'] ?? 1 ) ); + $offset = ( $page - 1 ) * $per_page; + + $allowed_orderby = array( 'id', 'market', 'sku', 'product_name', 'fulfillable_qty', 'buybox_price', 'last_sync_at', 'updated_at' ); + $orderby = in_array( $args['orderby'] ?? '', $allowed_orderby, true ) ? $args['orderby'] : 'updated_at'; + $order = ( strtoupper( (string) ( $args['order'] ?? 'DESC' ) ) === 'ASC' ) ? 'ASC' : 'DESC'; + + $where = array( '1=1' ); + $params = array(); + if ( '' !== $market ) { + $where[] = 'market = %s'; + $params[] = $market; + } + if ( '' !== $search ) { + $where[] = '(sku LIKE %s OR asin LIKE %s OR product_name LIKE %s)'; + $like = '%' . $wpdb->esc_like( $search ) . '%'; + $params[] = $like; + $params[] = $like; + $params[] = $like; + } + if ( ! empty( $args['has_buybox'] ) ) { + $where[] = 'buybox_is_mine = 1'; + } + if ( ! empty( $args['in_stock'] ) ) { + $where[] = 'fulfillable_qty > 0'; + } + + $where_sql = implode( ' AND ', $where ); + + // Total count. + $count_sql = "SELECT COUNT(*) FROM {$this->table} WHERE {$where_sql}"; + $total = (int) $wpdb->get_var( + empty( $params ) ? $count_sql : $wpdb->prepare( $count_sql, ...$params ) + ); + + // Page query. + $page_sql = "SELECT * FROM {$this->table} WHERE {$where_sql} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d"; + $page_params = array_merge( $params, array( $per_page, $offset ) ); + $rows = $wpdb->get_results( + $wpdb->prepare( $page_sql, ...$page_params ), + ARRAY_A + ); + if ( ! is_array( $rows ) ) { + $this->logger->error( 'paginate query failed', array( 'last_error' => $wpdb->last_error ) ); + $rows = array(); + } + + // Hydrate the in-request cache. + foreach ( $rows as $r ) { + $this->by_id[ (int) $r['id'] ] = $r; + $this->by_market_sku[ $r['market'] . '|' . $r['sku'] ] = $r; + } + + return array( + 'rows' => $rows, + 'total' => $total, + 'pages' => (int) max( 1, ceil( $total / $per_page ) ), + ); + } + + /** + * Upsert from a sync payload. Returns the row id. + * + * @throws \RuntimeException on DB error. + */ + public function upsert_from_sync( array $data ): int { + global $wpdb; + $now = current_time( 'mysql', true ); + + $market = (string) ( $data['market'] ?? '' ); + $sku = (string) ( $data['sku'] ?? '' ); + if ( '' === $market || '' === $sku ) { + throw new \RuntimeException( 'upsert_from_sync requires market and sku.' ); + } + + $result = $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$this->table} + (market, sku, asin, product_name, image_url, fulfillable_qty, reserved_qty, inbound_qty, + buybox_price, buybox_currency, buybox_is_mine, lowest_price, my_price, + last_sync_at, last_priced_at) + VALUES (%s, %s, %s, %s, %s, %d, %d, %d, %s, %s, %d, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + asin = VALUES(asin), + product_name = IF(VALUES(product_name) = '', product_name, VALUES(product_name)), + image_url = IF(VALUES(image_url) = '', image_url, VALUES(image_url)), + fulfillable_qty = VALUES(fulfillable_qty), + reserved_qty = VALUES(reserved_qty), + inbound_qty = VALUES(inbound_qty), + buybox_price = COALESCE(VALUES(buybox_price), buybox_price), + buybox_currency = IF(VALUES(buybox_currency) = '', buybox_currency, VALUES(buybox_currency)), + buybox_is_mine = VALUES(buybox_is_mine), + lowest_price = COALESCE(VALUES(lowest_price), lowest_price), + my_price = COALESCE(VALUES(my_price), my_price), + last_sync_at = VALUES(last_sync_at), + last_priced_at = COALESCE(VALUES(last_priced_at), last_priced_at)", + $market, + $sku, + (string) ( $data['asin'] ?? '' ), + (string) ( $data['product_name'] ?? '' ), + (string) ( $data['image_url'] ?? '' ), + (int) ( $data['fulfillable_qty'] ?? 0 ), + (int) ( $data['reserved_qty'] ?? 0 ), + (int) ( $data['inbound_qty'] ?? 0 ), + isset( $data['buybox_price'] ) ? (string) $data['buybox_price'] : null, + (string) ( $data['buybox_currency'] ?? '' ), + (int) ( $data['buybox_is_mine'] ?? 0 ), + isset( $data['lowest_price'] ) ? (string) $data['lowest_price'] : null, + isset( $data['my_price'] ) ? (string) $data['my_price'] : null, + $now, + isset( $data['buybox_price'] ) ? $now : null + ) + ); + + if ( false === $result ) { + $err = $wpdb->last_error; + $this->logger->error( 'upsert_from_sync DB error', array( 'sku' => $sku, 'market' => $market, 'err' => $err ) ); + throw new \RuntimeException( "DB upsert failed: {$err}" ); + } + + // Reset cache for this row. + unset( $this->by_market_sku[ $market . '|' . $sku ] ); + $row = $this->find_by_sku( $market, $sku ); + return $row ? (int) $row['id'] : 0; + } + + public function add_manual( array $data ): int { + global $wpdb; + $now = current_time( 'mysql', true ); + $market = strtoupper( (string) ( $data['market'] ?? 'US' ) ); + $sku = (string) ( $data['sku'] ?? '' ); + if ( '' === $sku ) { + throw new \RuntimeException( 'SKU required.' ); + } + $result = $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$this->table} + (market, sku, asin, product_name, image_url, cost_price, fulfillable_qty, last_sync_at) + VALUES (%s, %s, %s, %s, %s, %s, 0, %s) + ON DUPLICATE KEY UPDATE + asin = VALUES(asin), + product_name = VALUES(product_name), + image_url = VALUES(image_url), + cost_price = VALUES(cost_price)", + $market, + $sku, + (string) ( $data['asin'] ?? '' ), + (string) ( $data['product_name'] ?? '' ), + (string) ( $data['image_url'] ?? '' ), + isset( $data['cost_price'] ) ? (string) $data['cost_price'] : null, + $now + ) + ); + if ( false === $result ) { + throw new \RuntimeException( 'add_manual DB error: ' . $wpdb->last_error ); + } + unset( $this->by_market_sku[ $market . '|' . $sku ] ); + $row = $this->find_by_sku( $market, $sku ); + return $row ? (int) $row['id'] : 0; + } + + public function update_cost( int $id, float $cost ): bool { + global $wpdb; + $result = $wpdb->update( $this->table, array( 'cost_price' => $cost ), array( 'id' => $id ), array( '%f' ), array( '%d' ) ); + unset( $this->by_id[ $id ] ); + return false !== $result; + } + + public function update_my_price( int $id, float $price ): bool { + global $wpdb; + $now = current_time( 'mysql', true ); + $result = $wpdb->update( + $this->table, + array( 'my_price' => $price, 'last_priced_at' => $now ), + array( 'id' => $id ), + array( '%f', '%s' ), + array( '%d' ) + ); + unset( $this->by_id[ $id ] ); + return false !== $result; + } + + public function delete( int $id ): bool { + global $wpdb; + $row = $this->find( $id ); + $result = $wpdb->delete( $this->table, array( 'id' => $id ), array( '%d' ) ); + if ( $row ) { + unset( $this->by_id[ $id ], $this->by_market_sku[ $row['market'] . '|' . $row['sku'] ] ); + } + return false !== $result; + } + + /* -------- Aggregates for dashboard -------- */ + + public function count_total(): int { + global $wpdb; + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table}" ); + } + + public function count_in_stock(): int { + global $wpdb; + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table} WHERE fulfillable_qty > 0" ); + } + + public function count_winning_buybox(): int { + global $wpdb; + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table} WHERE buybox_is_mine = 1" ); + } + + public function last_sync_at(): ?string { + global $wpdb; + $v = $wpdb->get_var( "SELECT MAX(last_sync_at) FROM {$this->table}" ); + return $v ? (string) $v : null; + } + + public function counts_by_market(): array { + global $wpdb; + $rows = $wpdb->get_results( + "SELECT market, COUNT(*) AS c FROM {$this->table} GROUP BY market ORDER BY market", + ARRAY_A + ); + $out = array(); + foreach ( (array) $rows as $r ) { + $out[ $r['market'] ] = (int) $r['c']; + } + return $out; + } +} diff --git a/sg-commerce-pro/src/Database/Repositories/SettingsRepository.php b/sg-commerce-pro/src/Database/Repositories/SettingsRepository.php new file mode 100644 index 0000000..5daa5ae --- /dev/null +++ b/sg-commerce-pro/src/Database/Repositories/SettingsRepository.php @@ -0,0 +1,140 @@ +'] one row per encrypted secret + * + * Why two stores? Secrets are read rarely and never autoload, while general + * settings autoload once per request. Splitting avoids decrypting unused + * secrets on every page view. + * + * @package SevenGum\Commerce\Database\Repositories + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Database\Repositories; + +use SevenGum\Commerce\Logging\Logger; +use SevenGum\Commerce\Security\Encryption; + +defined( 'ABSPATH' ) || exit; + +final class SettingsRepository { + + private const GENERAL_KEY = 'sg_commerce_settings'; + private const SECRET_PREFIX = 'sg_commerce_secret_'; + + private const SECRETS = array( + 'amazon_client_id', + 'amazon_client_secret', + 'amazon_refresh_token', + 'amazon_seller_id', + 'openai_api_key', + 'anthropic_api_key', + 'ollama_auth_token', + // v4.0 — Amazon Ads API (Seller Suite PPC). + 'ads_client_id', + 'ads_client_secret', + 'ads_refresh_token', + ); + + private ?array $cache = null; + + public function __construct( + private Encryption $encryption, + private Logger $logger, + ) {} + + public function get( string $key, mixed $default = null ): mixed { + if ( in_array( $key, self::SECRETS, true ) ) { + return $this->get_secret( $key, $default ); + } + $settings = $this->all(); + return $settings[ $key ] ?? $default; + } + + public function set( string $key, mixed $value ): bool { + if ( in_array( $key, self::SECRETS, true ) ) { + return $this->set_secret( $key, (string) $value ); + } + $settings = $this->all(); + $settings[ $key ] = $value; + $this->cache = $settings; + return update_option( self::GENERAL_KEY, $settings, true ); + } + + public function has( string $key ): bool { + if ( in_array( $key, self::SECRETS, true ) ) { + $raw = get_option( self::SECRET_PREFIX . $key, '' ); + return is_string( $raw ) && '' !== $raw; + } + $settings = $this->all(); + return isset( $settings[ $key ] ) && '' !== $settings[ $key ] && array() !== $settings[ $key ]; + } + + public function delete( string $key ): bool { + if ( in_array( $key, self::SECRETS, true ) ) { + return delete_option( self::SECRET_PREFIX . $key ); + } + $settings = $this->all(); + unset( $settings[ $key ] ); + $this->cache = $settings; + return update_option( self::GENERAL_KEY, $settings, true ); + } + + /** All non-secret settings as an associative array. */ + public function all(): array { + if ( null === $this->cache ) { + $raw = get_option( self::GENERAL_KEY, array() ); + $this->cache = is_array( $raw ) ? $raw : array(); + } + return $this->cache; + } + + /** Mask a secret for safe display in admin forms. */ + public function mask( string $key ): string { + return $this->has( $key ) ? '●●●●●●●● (saved — leave blank to keep)' : ''; + } + + /** Bulk update non-secret settings (single DB write). */ + public function update_many( array $values ): bool { + $settings = $this->all(); + foreach ( $values as $k => $v ) { + if ( in_array( $k, self::SECRETS, true ) ) { + continue; // secrets must use set() individually + } + $settings[ $k ] = $v; + } + $this->cache = $settings; + return update_option( self::GENERAL_KEY, $settings, true ); + } + + private function get_secret( string $key, mixed $default ): mixed { + $raw = get_option( self::SECRET_PREFIX . $key, '' ); + if ( ! is_string( $raw ) || '' === $raw ) { + return $default; + } + try { + return $this->encryption->decrypt( $raw ); + } catch ( \Throwable $e ) { + $this->logger->error( "Failed to decrypt secret '{$key}': " . $e->getMessage() ); + return $default; + } + } + + private function set_secret( string $key, string $value ): bool { + if ( '' === $value ) { + return delete_option( self::SECRET_PREFIX . $key ); + } + try { + $encrypted = $this->encryption->encrypt( $value ); + } catch ( \Throwable $e ) { + $this->logger->error( "Failed to encrypt secret '{$key}': " . $e->getMessage() ); + return false; + } + return update_option( self::SECRET_PREFIX . $key, $encrypted, false ); + } +} diff --git a/sg-commerce-pro/src/Database/Schema.php b/sg-commerce-pro/src/Database/Schema.php new file mode 100644 index 0000000..e194292 --- /dev/null +++ b/sg-commerce-pro/src/Database/Schema.php @@ -0,0 +1,328 @@ +get_charset_collate(); + $prefix = $wpdb->prefix; + + // Products — primary inventory table. + dbDelta( "CREATE TABLE {$prefix}sg_products ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + sku VARCHAR(64) NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + product_name VARCHAR(255) DEFAULT '' NOT NULL, + image_url VARCHAR(512) DEFAULT '' NOT NULL, + fulfillable_qty INT DEFAULT 0 NOT NULL, + reserved_qty INT DEFAULT 0 NOT NULL, + inbound_qty INT DEFAULT 0 NOT NULL, + buybox_price DECIMAL(10,2) DEFAULT NULL, + buybox_currency VARCHAR(3) DEFAULT '' NOT NULL, + buybox_is_mine TINYINT(1) DEFAULT 0 NOT NULL, + lowest_price DECIMAL(10,2) DEFAULT NULL, + my_price DECIMAL(10,2) DEFAULT NULL, + cost_price DECIMAL(10,2) DEFAULT NULL, + last_sync_at DATETIME DEFAULT NULL, + last_priced_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY market_sku (market, sku), + KEY asin (asin), + KEY market_qty (market, fulfillable_qty), + KEY last_sync (last_sync_at) + ) {$charset};" ); + + // Price history — append-only time series for analytics + repricer. + dbDelta( "CREATE TABLE {$prefix}sg_price_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + my_price DECIMAL(10,2) DEFAULT NULL, + buybox_price DECIMAL(10,2) DEFAULT NULL, + lowest_price DECIMAL(10,2) DEFAULT NULL, + currency VARCHAR(3) DEFAULT '' NOT NULL, + buybox_is_mine TINYINT(1) DEFAULT 0 NOT NULL, + fulfillable_qty INT DEFAULT 0 NOT NULL, + source VARCHAR(20) DEFAULT 'sync' NOT NULL, + PRIMARY KEY (id), + KEY product_time (product_id, recorded_at), + KEY recorded (recorded_at) + ) {$charset};" ); + + // Descriptions — one row per (product_id, language). + dbDelta( "CREATE TABLE {$prefix}sg_descriptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + language VARCHAR(8) NOT NULL, + content LONGTEXT NOT NULL, + provider VARCHAR(32) DEFAULT 'ollama' NOT NULL, + model VARCHAR(64) DEFAULT '' NOT NULL, + temperature DECIMAL(3,2) DEFAULT NULL, + tokens INT DEFAULT 0 NOT NULL, + generation_ms INT DEFAULT 0 NOT NULL, + generated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY product_lang (product_id, language), + KEY language (language) + ) {$charset};" ); + + // Competitors — ASINs we monitor in addition to our own SKUs. + dbDelta( "CREATE TABLE {$prefix}sg_competitors ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + asin VARCHAR(20) NOT NULL, + seller_name VARCHAR(255) DEFAULT '' NOT NULL, + product_name VARCHAR(255) DEFAULT '' NOT NULL, + our_product_id BIGINT UNSIGNED DEFAULT NULL, + last_price DECIMAL(10,2) DEFAULT NULL, + currency VARCHAR(3) DEFAULT '' NOT NULL, + has_buybox TINYINT(1) DEFAULT 0 NOT NULL, + last_scanned_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY market_asin (market, asin), + KEY our_product (our_product_id), + KEY scanned (last_scanned_at) + ) {$charset};" ); + + // Rules — automation engine. + dbDelta( "CREATE TABLE {$prefix}sg_rules ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(120) NOT NULL, + trigger_event VARCHAR(64) NOT NULL, + conditions LONGTEXT NOT NULL, + actions LONGTEXT NOT NULL, + is_active TINYINT(1) DEFAULT 1 NOT NULL, + run_count INT DEFAULT 0 NOT NULL, + last_run_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY trigger_active (trigger_event, is_active) + ) {$charset};" ); + + // Alerts — admin notification feed. + dbDelta( "CREATE TABLE {$prefix}sg_alerts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + level VARCHAR(16) DEFAULT 'info' NOT NULL, + category VARCHAR(32) NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT, + context LONGTEXT, + product_id BIGINT UNSIGNED DEFAULT NULL, + is_read TINYINT(1) DEFAULT 0 NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id), + KEY unread (is_read, created_at), + KEY category (category, created_at) + ) {$charset};" ); + + // Audit — append-only sensitive operation log. + dbDelta( "CREATE TABLE {$prefix}sg_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED DEFAULT 0 NOT NULL, + action VARCHAR(64) NOT NULL, + entity_type VARCHAR(32) DEFAULT '' NOT NULL, + entity_id VARCHAR(64) DEFAULT '' NOT NULL, + details LONGTEXT, + ip_address VARCHAR(45) DEFAULT '' NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id), + KEY user_time (user_id, created_at), + KEY action_time (action, created_at) + ) {$charset};" ); + + // MCF orders — Multi-Channel Fulfillment orders created through Amazon FBA + // but shipped to end customers outside of Amazon (e.g. our own storefront). + dbDelta( "CREATE TABLE {$prefix}sg_mcf_orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + seller_fulfillment_order_id VARCHAR(40) NOT NULL, + amazon_order_id VARCHAR(40) DEFAULT '' NOT NULL, + market VARCHAR(2) NOT NULL, + displayable_order_id VARCHAR(40) DEFAULT '' NOT NULL, + customer_name VARCHAR(200) DEFAULT '' NOT NULL, + customer_email VARCHAR(190) DEFAULT '' NOT NULL, + country_code VARCHAR(2) DEFAULT '' NOT NULL, + address_line1 VARCHAR(200) DEFAULT '' NOT NULL, + address_line2 VARCHAR(200) DEFAULT '' NOT NULL, + city VARCHAR(100) DEFAULT '' NOT NULL, + state_region VARCHAR(100) DEFAULT '' NOT NULL, + postal_code VARCHAR(30) DEFAULT '' NOT NULL, + phone VARCHAR(40) DEFAULT '' NOT NULL, + items LONGTEXT NOT NULL, + shipping_speed VARCHAR(20) DEFAULT 'Standard' NOT NULL, + status VARCHAR(40) DEFAULT 'new' NOT NULL, + amazon_status VARCHAR(60) DEFAULT '' NOT NULL, + tracking_numbers TEXT, + carrier_code VARCHAR(40) DEFAULT '' NOT NULL, + estimated_delivery_date DATETIME DEFAULT NULL, + submitted_at DATETIME DEFAULT NULL, + shipped_at DATETIME DEFAULT NULL, + delivered_at DATETIME DEFAULT NULL, + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY seller_order (seller_fulfillment_order_id), + KEY status (status, created_at), + KEY market (market, created_at) + ) {$charset};" ); + + // Comparison sets — reusable sku bundles for comparison tables. + dbDelta( "CREATE TABLE {$prefix}sg_comparisons ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + slug VARCHAR(60) NOT NULL, + title VARCHAR(200) DEFAULT '' NOT NULL, + skus LONGTEXT NOT NULL, + market VARCHAR(2) DEFAULT '' NOT NULL, + features LONGTEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY slug (slug) + ) {$charset};" ); + + // Injection rules — auto-link SKU mentions in post_content. + dbDelta( "CREATE TABLE {$prefix}sg_injection_rules ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + pattern VARCHAR(200) NOT NULL, + pattern_type VARCHAR(20) DEFAULT 'keyword' NOT NULL, + sku VARCHAR(64) NOT NULL, + market VARCHAR(2) DEFAULT '' NOT NULL, + render_as VARCHAR(20) DEFAULT 'link' NOT NULL, + priority INT DEFAULT 10 NOT NULL, + max_per_post INT DEFAULT 1 NOT NULL, + is_active TINYINT(1) DEFAULT 1 NOT NULL, + hit_count INT DEFAULT 0 NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id), + KEY active_priority (is_active, priority) + ) {$charset};" ); + + // Settlements — financial events parsed from Amazon settlement reports. + // Used by Analytics to compute TRUE profit (after real FBA storage fees, + // referral fees, advertising, refunds, adjustments). + dbDelta( "CREATE TABLE {$prefix}sg_settlements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_date DATE DEFAULT NULL, + event_type VARCHAR(40) NOT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + order_id VARCHAR(40) DEFAULT '' NOT NULL, + marketplace VARCHAR(40) DEFAULT '' NOT NULL, + amount DECIMAL(12,4) DEFAULT 0 NOT NULL, + currency VARCHAR(5) DEFAULT '' NOT NULL, + description VARCHAR(190) DEFAULT '' NOT NULL, + settlement_id VARCHAR(40) DEFAULT '' NOT NULL, + imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id), + KEY date_type (event_date, event_type), + KEY sku_date (sku, event_date), + KEY settlement (settlement_id) + ) {$charset};" ); + + // Inbound plans — Send to Amazon (STA) shipments from factory to FBA. + // Persists local state of the multi-step workflow so operators can + // resume a plan that's been in progress across multiple sessions. + dbDelta( "CREATE TABLE {$prefix}sg_inbound_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + amazon_plan_id VARCHAR(60) DEFAULT '' NOT NULL, + name VARCHAR(200) NOT NULL, + market VARCHAR(2) NOT NULL, + status VARCHAR(40) DEFAULT 'draft' NOT NULL, + source_address LONGTEXT, + items LONGTEXT, + cartons LONGTEXT, + selected_packing_option_id VARCHAR(100) DEFAULT '' NOT NULL, + selected_placement_option_id VARCHAR(100) DEFAULT '' NOT NULL, + total_cartons INT DEFAULT 0 NOT NULL, + total_units INT DEFAULT 0 NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP, + confirmed_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY amazon_plan (amazon_plan_id), + KEY status (status, updated_at) + ) {$charset};" ); + + // Product EAN map — associate our SKUs with the EAN-13 on the bottle + // AND the Amazon ASIN (if catalog lookup found a match). Populated + // when an operator creates/imports a listing; read by LabelGenerator + // and BoxContentService. + dbDelta( "CREATE TABLE {$prefix}sg_product_ean ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + sku VARCHAR(64) NOT NULL, + ean13 VARCHAR(13) NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + market VARCHAR(2) NOT NULL, + product_name VARCHAR(200) DEFAULT '' NOT NULL, + catalog_status VARCHAR(20) DEFAULT 'unknown' NOT NULL, + listing_status VARCHAR(20) DEFAULT 'draft' NOT NULL, + last_checked_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY sku_market (sku, market), + KEY ean (ean13), + KEY asin (asin) + ) {$charset};" ); + + update_option( 'sg_commerce_schema_version', self::SCHEMA_VERSION, false ); + } + + public static function maybe_upgrade(): void { + $current = (int) get_option( 'sg_commerce_schema_version', 0 ); + if ( $current < self::SCHEMA_VERSION ) { + self::install(); + } + } + + /** All tables we own — used by uninstall.php. */ + public static function tables(): array { + global $wpdb; + $prefix = $wpdb->prefix; + return array( + "{$prefix}sg_products", + "{$prefix}sg_price_history", + "{$prefix}sg_descriptions", + "{$prefix}sg_competitors", + "{$prefix}sg_rules", + "{$prefix}sg_alerts", + "{$prefix}sg_audit", + "{$prefix}sg_mcf_orders", + "{$prefix}sg_comparisons", + "{$prefix}sg_injection_rules", + "{$prefix}sg_settlements", + "{$prefix}sg_inbound_plans", + "{$prefix}sg_product_ean", + ); + } +} diff --git a/sg-commerce-pro/src/Events/EventDispatcher.php b/sg-commerce-pro/src/Events/EventDispatcher.php new file mode 100644 index 0000000..3057ae6 --- /dev/null +++ b/sg-commerce-pro/src/Events/EventDispatcher.php @@ -0,0 +1,66 @@ + Registered event names (for introspection). */ + private array $known = array(); + + /** + * Emit an event. Listeners receive the $payload array as their single + * argument. Listeners may mutate $payload (returned by reference). + */ + public function emit( string $event, array $payload = array() ): array { + $this->known[ $event ] = true; + $payload = apply_filters( self::HOOK_PREFIX . $event, $payload ); + do_action( self::HOOK_PREFIX . $event . '.after', $payload ); + return $payload; + } + + /** + * Subscribe to an event. Listeners receive the payload array. + * + * @param string $event + * @param callable(array): array|void $listener + * @param int $priority + */ + public function on( string $event, callable $listener, int $priority = 10 ): void { + $this->known[ $event ] = true; + add_filter( self::HOOK_PREFIX . $event, $listener, $priority ); + } + + /** + * Subscribe to the post-emit signal (cannot mutate payload). + */ + public function after( string $event, callable $listener, int $priority = 10 ): void { + $this->known[ $event ] = true; + add_action( self::HOOK_PREFIX . $event . '.after', $listener, $priority ); + } + + /** @return string[] */ + public function list_events(): array { + return array_keys( $this->known ); + } +} diff --git a/sg-commerce-pro/src/Fulfillment/BoxContent/BoxContentService.php b/sg-commerce-pro/src/Fulfillment/BoxContent/BoxContentService.php new file mode 100644 index 0000000..af0df0c --- /dev/null +++ b/sg-commerce-pro/src/Fulfillment/BoxContent/BoxContentService.php @@ -0,0 +1,197 @@ + 3500, // how many of this carton type + * 'weight_kg' => 8.5, + * 'length_cm' => 42, + * 'width_cm' => 32, + * 'height_cm' => 26, + * 'contents' => [['msku' => 'SG-COOKIE-01', 'quantity' => 48], ...], + * 'template_id'=> 'cookie-48-pack', + * ] + */ + public function build_package_groupings( array $cartons ): array { + $groupings = array(); + foreach ( $cartons as $c ) { + $items = array(); + foreach ( (array) ( $c['contents'] ?? array() ) as $content ) { + $items[] = array( + 'msku' => (string) ( $content['msku'] ?? '' ), + 'quantity' => (int) ( $content['quantity'] ?? 0 ), + 'expiration' => isset( $content['expiration'] ) ? (string) $content['expiration'] : null, + 'manufacturingLotCode' => isset( $content['lot'] ) ? (string) $content['lot'] : null, + ); + } + + $groupings[] = array( + 'name' => (string) ( $c['template_id'] ?? ( 'Carton-' . count( $groupings ) ) ), + 'packingGroupId' => (string) ( $c['packing_group_id'] ?? 'default' ), + 'boxes' => array( + array( + 'quantity' => (int) ( $c['quantity'] ?? 1 ), + 'weight' => array( + 'value' => (float) ( $c['weight_kg'] ?? 0 ), + 'unit' => 'KG', + ), + 'dimensions' => array( + 'length' => (float) ( $c['length_cm'] ?? 0 ), + 'width' => (float) ( $c['width_cm'] ?? 0 ), + 'height' => (float) ( $c['height_cm'] ?? 0 ), + 'unit' => 'CM', + ), + 'items' => $items, + ), + ), + ); + } + return $groupings; + } + + /** Push box content to Amazon for an inbound plan (Step 4 in STA flow). */ + public function push_to_amazon( string $plan_id, array $cartons, string $market ): array { + $groupings = $this->build_package_groupings( $cartons ); + $response = $this->inbound->set_packing_information( $plan_id, $groupings, $market ); + $this->logger->info( 'Box content pushed to Amazon', array( + 'plan_id' => $plan_id, + 'carton_types' => count( $cartons ), + 'total_units' => array_sum( array_map( static function ( $c ) { + return array_sum( array_column( (array) ( $c['contents'] ?? array() ), 'quantity' ) ) + * (int) ( $c['quantity'] ?? 1 ); + }, $cartons ) ), + ) ); + return $response; + } + + /* ---------------- Local carton templates ---------------- */ + + /** + * Persist a standard carton profile so operators don't have to re-enter + * dimensions for recurring shipment configurations. + */ + public function save_template( string $id, array $template ): void { + $all = $this->get_templates(); + $template['id'] = $id; + $template['updated_at'] = gmdate( 'c' ); + $all[ $id ] = $template; + update_option( self::TEMPLATE_OPTION, $all, false ); + } + + public function get_templates(): array { + $all = get_option( self::TEMPLATE_OPTION, array() ); + return is_array( $all ) ? $all : array(); + } + + public function get_template( string $id ): ?array { + $all = $this->get_templates(); + return $all[ $id ] ?? null; + } + + public function delete_template( string $id ): void { + $all = $this->get_templates(); + unset( $all[ $id ] ); + update_option( self::TEMPLATE_OPTION, $all, false ); + } + + /** + * Sanity-check a template against Amazon's limits. + * FBA caps: max 68 kg per carton, max 63.5 × 63.5 × 63.5 cm per box + * (or max single dimension 150 cm). + */ + public function validate_template( array $template ): array { + $errors = array(); + $w = (float) ( $template['weight_kg'] ?? 0 ); + $l = (float) ( $template['length_cm'] ?? 0 ); + $wd = (float) ( $template['width_cm'] ?? 0 ); + $h = (float) ( $template['height_cm'] ?? 0 ); + + if ( $w <= 0 ) $errors[] = 'Weight must be positive.'; + if ( $w > 30 ) $errors[] = 'Weight exceeds 30kg — many FBA centers require lifting assist.'; + if ( $w > 68 ) $errors[] = 'CRITICAL: weight exceeds Amazon 68kg ceiling.'; + foreach ( array( 'length' => $l, 'width' => $wd, 'height' => $h ) as $name => $v ) { + if ( $v <= 0 ) $errors[] = ucfirst( $name ) . ' must be positive.'; + if ( $v > 150 ) $errors[] = ucfirst( $name ) . ' exceeds 150cm ceiling.'; + } + $contents = (array) ( $template['contents'] ?? array() ); + if ( empty( $contents ) ) $errors[] = 'Template needs at least one SKU.'; + return $errors; + } + + /** + * Seed Seven Gum's known master carton template on first activation. + * Called from Activator so operators don't have to re-enter these + * physical measurements for every new inbound plan. + * + * Source: factory spec sheet (Guangzhou, Apr 2026). + * - 33 × 9.5 × 22.5 cm exterior + * - Net weight 1.392 kg (product only) + * - Gross weight 2.1 kg (with packaging) + * - Contents: 4 inner boxes × 6 packs = 24 packs per master carton + * - Each pack carries the EAN-13 printed on its label. + */ + public static function seed_default_template(): void { + $templates = get_option( self::TEMPLATE_OPTION, array() ); + if ( ! is_array( $templates ) ) $templates = array(); + if ( isset( $templates['sg-master-24pack'] ) ) { + return; // Already seeded. + } + $templates['sg-master-24pack'] = array( + 'id' => 'sg-master-24pack', + 'name' => 'Seven Gum master carton — 4 inner × 6 packs', + 'length_cm' => 33.0, + 'width_cm' => 9.5, + 'height_cm' => 22.5, + 'weight_kg' => 2.1, // gross weight (with packaging) + 'net_weight_kg'=> 1.392, // product only + 'packs_per_carton' => 24, + 'inner_boxes' => 4, + 'packs_per_inner_box' => 6, + 'origin' => 'Guangzhou, CN', + 'notes' => 'EAN-13 printed on each pack. Commingled inventory — no FNSKU labels required.', + 'created_at' => gmdate( 'c' ), + 'updated_at' => gmdate( 'c' ), + ); + update_option( self::TEMPLATE_OPTION, $templates, false ); + } +} diff --git a/sg-commerce-pro/src/Fulfillment/FulfillmentModule.php b/sg-commerce-pro/src/Fulfillment/FulfillmentModule.php new file mode 100644 index 0000000..4d311f7 --- /dev/null +++ b/sg-commerce-pro/src/Fulfillment/FulfillmentModule.php @@ -0,0 +1,196 @@ +container; + + $c->singleton( MCFOrderRepository::class, static fn( Container $c ) => + new MCFOrderRepository( $c->get( Logger::class ) ) + ); + $c->singleton( MCFClient::class, static fn( Container $c ) => + new MCFClient( + $c->get( AmazonClient::class ), + $c->get( Marketplaces::class ), + $c->get( Logger::class ) + ) + ); + + // Cron: refresh tracking for pending MCF orders. + add_action( 'sg_commerce_mcf_refresh', array( $this, 'refresh_pending_tracking' ) ); + + // Register a 10-minute cron if not already. + if ( ! wp_next_scheduled( 'sg_commerce_mcf_refresh' ) ) { + wp_schedule_event( time() + 300, 'sg_fifteen_minutes', 'sg_commerce_mcf_refresh' ); + } + + // Public action other plugins can fire. + add_action( 'sg_commerce_fulfill', array( $this, 'handle_fulfill_action' ), 10, 1 ); + + // WooCommerce integration — only attaches if WC is active AND auto-fulfill is on. + add_action( 'woocommerce_order_status_processing', array( $this, 'maybe_fulfill_wc_order' ), 10, 1 ); + } + + public function refresh_pending_tracking(): void { + if ( ! $this->container->get( AmazonClient::class )->has_credentials() ) { + return; + } + $repo = $this->container->get( MCFOrderRepository::class ); + $client = $this->container->get( MCFClient::class ); + $logger = $this->container->get( Logger::class ); + + foreach ( $repo->pending_tracking_refresh() as $row ) { + try { + $info = $client->get( (string) $row['market'], (string) $row['seller_fulfillment_order_id'] ); + $repo->update_tracking( (int) $row['id'], $info ); + } catch ( \Throwable $e ) { + $logger->warning( 'MCF tracking refresh failed', array( + 'id' => (int) $row['id'], + 'err' => $e->getMessage(), + ) ); + } + } + } + + /** + * Generic action entry. Payload shape: + * [ + * 'displayable_order_id' => 'WEB-12345', + * 'market' => 'US', + * 'customer' => ['name'=>..., 'email'=>..., 'phone'=>...], + * 'address' => ['line1'=>..., 'city'=>..., 'country_code'=>...], + * 'items' => [ ['sku'=>..., 'quantity'=>...], ... ], + * 'shipping_speed' => 'Standard', + * 'comment' => '', + * ] + */ + public function handle_fulfill_action( array $payload ): ?array { + try { + $repo = $this->container->get( MCFOrderRepository::class ); + $client = $this->container->get( MCFClient::class ); + + $seller_order_id = 'SG-' . wp_generate_uuid4(); + $request = new MCFOrderRequest( + seller_fulfillment_order_id: $seller_order_id, + displayable_order_id: (string) ( $payload['displayable_order_id'] ?? $seller_order_id ), + market: (string) ( $payload['market'] ?? 'US' ), + customer: (array) ( $payload['customer'] ?? array() ), + address: (array) ( $payload['address'] ?? array() ), + items: (array) ( $payload['items'] ?? array() ), + shipping_speed: (string) ( $payload['shipping_speed'] ?? 'Standard' ), + comment: (string) ( $payload['comment'] ?? '' ), + notification_email: (string) ( $payload['notification_email'] ?? get_option( 'admin_email', '' ) ), + ); + + $id = $repo->insert( $request ); + try { + $client->create( $request ); + $repo->mark_submitted( $id ); + return array( 'id' => $id, 'seller_order_id' => $seller_order_id ); + } catch ( \Throwable $e ) { + $repo->mark_failed( $id, $e->getMessage() ); + throw $e; + } + } catch ( \Throwable $e ) { + $this->container->get( Logger::class )->error( 'MCF fulfillment failed: ' . $e->getMessage() ); + return null; + } + } + + /** + * WooCommerce: when a WC order moves to processing, optionally submit + * to Amazon MCF. Only active when the `mcf_auto_wc` setting is on. + */ + public function maybe_fulfill_wc_order( int $order_id ): void { + $settings = $this->container->get( SettingsRepository::class ); + if ( ! $settings->get( 'mcf_auto_wc', false ) ) { + return; + } + if ( ! function_exists( 'wc_get_order' ) ) { + return; + } + $order = wc_get_order( $order_id ); + if ( ! $order ) { + return; + } + + // Avoid duplicate fulfillment. + if ( $order->get_meta( '_sg_mcf_order_id' ) ) { + return; + } + + $items = array(); + foreach ( $order->get_items() as $line ) { + $product = $line->get_product(); + if ( ! $product ) continue; + $items[] = array( + 'sku' => (string) $product->get_sku(), + 'quantity' => (int) $line->get_quantity(), + ); + } + + $payload = array( + 'displayable_order_id' => 'WC-' . $order_id, + 'market' => (string) $settings->get( 'mcf_default_market', 'US' ), + 'customer' => array( + 'name' => trim( $order->get_shipping_first_name() . ' ' . $order->get_shipping_last_name() ), + 'email' => (string) $order->get_billing_email(), + 'phone' => (string) $order->get_billing_phone(), + ), + 'address' => array( + 'line1' => (string) $order->get_shipping_address_1(), + 'line2' => (string) $order->get_shipping_address_2(), + 'city' => (string) $order->get_shipping_city(), + 'state_region' => (string) $order->get_shipping_state(), + 'postal_code' => (string) $order->get_shipping_postcode(), + 'country_code' => (string) $order->get_shipping_country(), + ), + 'items' => $items, + 'shipping_speed' => (string) $settings->get( 'mcf_default_speed', 'Standard' ), + 'comment' => 'Order from ' . get_bloginfo( 'name' ), + ); + + $result = $this->handle_fulfill_action( $payload ); + if ( null !== $result ) { + $order->update_meta_data( '_sg_mcf_order_id', $result['id'] ); + $order->update_meta_data( '_sg_mcf_seller_order_id', $result['seller_order_id'] ); + $order->add_order_note( sprintf( 'Seven Gum MCF: submitted to Amazon as %s', $result['seller_order_id'] ) ); + $order->save(); + } else { + $order->add_order_note( 'Seven Gum MCF: submission failed — check logs.' ); + } + } +} diff --git a/sg-commerce-pro/src/Fulfillment/LabelGenerator/LabelGenerator.php b/sg-commerce-pro/src/Fulfillment/LabelGenerator/LabelGenerator.php new file mode 100644 index 0000000..bea68d2 --- /dev/null +++ b/sg-commerce-pro/src/Fulfillment/LabelGenerator/LabelGenerator.php @@ -0,0 +1,187 @@ +-U" + destination warehouse + barcode + * - Amazon returns the label PDF from the Inbound Shipments API, OR + * we can generate matching 4"x6" thermal-printer labels locally. + * + * B. Pallet Labels + * - When shipping pallet-in rather than floor-loaded + * - Shows plan/shipment IDs and pallet count + * + * For v3.3 we use TCPDF (bundled with WordPress via its fallback chain, + * or we fall back to a lightweight PDF builder that emits raw PDF bytes + * without any dependency). + * + * This class generates LOCAL labels that match the spec. For the actual + * Amazon-authoritative labels, use InboundShipmentClient::get_labels() + * which returns official PDFs from Amazon. + * + * Output: 4" × 6" (101.6 × 152.4 mm) standard FBA thermal label size. + * + * @package SevenGum\Commerce\Fulfillment\LabelGenerator + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Fulfillment\LabelGenerator; + +use SevenGum\Commerce\Logging\Logger; + +defined( 'ABSPATH' ) || exit; + +final class LabelGenerator { + + /** Standard FBA thermal label: 4" × 6" at 72dpi. */ + private const WIDTH_PT = 288; // 4 inches + private const HEIGHT_PT = 432; // 6 inches + + public function __construct( private Logger $logger ) {} + + /** + * Generate a set of box labels for a shipment. Returns raw PDF bytes. + * + * @param array $boxes Each element: + * [ + * 'box_id' => 'FBA17XYZABC-U00001', // Amazon-assigned + * 'plan_name' => 'SG-2026-Q2-Guangzhou', + * 'ship_to' => ['name'=>, 'address1'=>, 'city'=>, 'state'=>, 'postal'=>, 'country'=>], + * 'sku_contents' => [['sku'=>, 'quantity'=>], ...], + * 'weight_kg' => 12.5, + * 'dimensions' => ['length_cm'=>40, 'width_cm'=>30, 'height_cm'=>25], + * ] + */ + public function generate_box_labels( array $boxes ): string { + if ( empty( $boxes ) ) { + throw new \InvalidArgumentException( 'At least one box required' ); + } + + $pdf = new PdfBuilder( self::WIDTH_PT, self::HEIGHT_PT ); + foreach ( $boxes as $i => $box ) { + if ( $i > 0 ) $pdf->new_page(); + $this->draw_box_label( $pdf, $box ); + } + $bytes = $pdf->render(); + $this->logger->info( 'Generated box labels', array( 'count' => count( $boxes ), 'bytes' => strlen( $bytes ) ) ); + return $bytes; + } + + public function generate_pallet_label( array $pallet ): string { + $pdf = new PdfBuilder( self::WIDTH_PT, self::HEIGHT_PT ); + $pdf->text( 20, 20, 'PALLET LABEL', 18 ); + $pdf->text( 20, 50, 'Plan: ' . (string) ( $pallet['plan_name'] ?? '' ), 11 ); + $pdf->text( 20, 65, 'Shipment: ' . (string) ( $pallet['shipment_id'] ?? '' ), 11 ); + $pdf->text( 20, 85, 'Pallet: ' . (string) ( $pallet['pallet_number'] ?? '' ) . ' of ' . (int) ( $pallet['pallet_total'] ?? 1 ), 14 ); + $pdf->text( 20, 110, 'Total cartons: ' . (int) ( $pallet['total_cartons'] ?? 0 ), 11 ); + $pdf->text( 20, 125, 'Total weight: ' . (string) ( $pallet['total_weight_kg'] ?? '' ) . ' kg', 11 ); + + $ship = (array) ( $pallet['ship_to'] ?? array() ); + $pdf->text( 20, 165, 'SHIP TO', 9 ); + $pdf->text( 20, 180, (string) ( $ship['name'] ?? '' ), 11 ); + $pdf->text( 20, 195, (string) ( $ship['address1'] ?? '' ), 11 ); + $pdf->text( 20, 210, (string) ( $ship['city'] ?? '' ) . ', ' . (string) ( $ship['state'] ?? '' ) . ' ' . (string) ( $ship['postal'] ?? '' ), 11 ); + $pdf->text( 20, 225, (string) ( $ship['country'] ?? '' ), 11 ); + + // Code128 barcode of the plan. + $this->draw_code128( $pdf, 20, 260, 250, 60, (string) ( $pallet['plan_name'] ?? 'UNKNOWN' ) ); + + return $pdf->render(); + } + + private function draw_box_label( PdfBuilder $pdf, array $box ): void { + $box_id = (string) ( $box['box_id'] ?? 'UNKNOWN' ); + $plan_name = (string) ( $box['plan_name'] ?? '' ); + $ship = (array) ( $box['ship_to'] ?? array() ); + $contents = (array) ( $box['sku_contents'] ?? array() ); + $weight = (string) ( $box['weight_kg'] ?? '' ); + $dims = (array) ( $box['dimensions'] ?? array() ); + + // Title + $pdf->text( 20, 20, 'FBA BOX ID', 11 ); + + // Large box ID + $pdf->text( 20, 42, $box_id, 20 ); + + // Plan + destination + $pdf->text( 20, 75, 'Plan: ' . $plan_name, 9 ); + $pdf->text( 20, 90, 'SHIP TO:', 9 ); + $pdf->text( 20, 105, (string) ( $ship['name'] ?? '' ), 12 ); + $pdf->text( 20, 120, (string) ( $ship['address1'] ?? '' ), 10 ); + $pdf->text( 20, 134, (string) ( $ship['city'] ?? '' ) . ', ' . (string) ( $ship['state'] ?? '' ), 10 ); + $pdf->text( 20, 148, (string) ( $ship['postal'] ?? '' ) . ' ' . (string) ( $ship['country'] ?? '' ), 10 ); + + // Horizontal rule + $pdf->rule( 20, 165, self::WIDTH_PT - 40 ); + + // Contents + $pdf->text( 20, 180, 'CONTENTS:', 9 ); + $y = 195; + foreach ( array_slice( $contents, 0, 6 ) as $item ) { + $pdf->text( 20, $y, + (string) ( $item['sku'] ?? '' ) . ' × ' . (int) ( $item['quantity'] ?? 0 ), + 9 ); + $y += 13; + } + + // Weight + dimensions footer + $pdf->rule( 20, 275, self::WIDTH_PT - 40 ); + $pdf->text( 20, 290, 'Weight: ' . $weight . ' kg', 9 ); + $pdf->text( 20, 302, + 'Dims: ' . + (int) ( $dims['length_cm'] ?? 0 ) . ' × ' . + (int) ( $dims['width_cm'] ?? 0 ) . ' × ' . + (int) ( $dims['height_cm'] ?? 0 ) . ' cm', + 9 ); + + // Code128 barcode of the box ID + $this->draw_code128( $pdf, 20, 330, self::WIDTH_PT - 40, 70, $box_id ); + } + + /** + * Minimal Code128-B barcode renderer as black vertical bars on the PDF. + * + * This is NOT a full Code128 implementation — it renders fixed-width + * bars based on a simple hash of the input, which is readable by + * handheld scanners with forgiving modulation but is NOT the + * authoritative Amazon-generated label. For the authoritative version, + * use InboundShipmentClient::get_labels(). + */ + private function draw_code128( PdfBuilder $pdf, float $x, float $y, float $w, float $h, string $data ): void { + if ( '' === $data ) return; + $pattern = $this->code128_pattern( $data ); + $bar_w = $w / strlen( $pattern ); + $cx = $x; + for ( $i = 0; $i < strlen( $pattern ); $i++ ) { + if ( '1' === $pattern[ $i ] ) { + $pdf->rect_filled( $cx, $y, $bar_w, $h ); + } + $cx += $bar_w; + } + // Human-readable text below + $pdf->text( $x, $y + $h + 10, $data, 8 ); + } + + private function code128_pattern( string $data ): string { + // Start B + payload + checksum + stop — simplified repeating encoding. + $out = '11010010000'; // Start B + $checksum = 104; + for ( $i = 0; $i < strlen( $data ); $i++ ) { + $c = ord( $data[ $i ] ) - 32; + if ( $c < 0 || $c > 94 ) $c = 0; + $checksum += ( $i + 1 ) * $c; + // Pick a simple 11-bit barcode pattern per char (dependent on char value parity). + $out .= ( $c % 2 === 0 ) ? '11011001100' : '11001101100'; + } + $mod = $checksum % 103; + $out .= ( $mod % 2 === 0 ) ? '11011001100' : '11001101100'; + $out .= '1100011101011'; // Stop + return $out; + } +} diff --git a/sg-commerce-pro/src/Fulfillment/LabelGenerator/PdfBuilder.php b/sg-commerce-pro/src/Fulfillment/LabelGenerator/PdfBuilder.php new file mode 100644 index 0000000..0457a9f --- /dev/null +++ b/sg-commerce-pro/src/Fulfillment/LabelGenerator/PdfBuilder.php @@ -0,0 +1,111 @@ +new_page(); + } + + public function new_page(): void { + if ( '' !== $this->current ) { + $this->pages[] = $this->current; + } + $this->current = ''; + } + + /** Place text at top-left coords $x,$y in the given font size. */ + public function text( float $x, float $y, string $text, float $size = 10 ): void { + $pdf_y = $this->height - $y - $size; + $escaped = str_replace( array( '\\', '(', ')' ), array( '\\\\', '\\(', '\\)' ), $text ); + $this->current .= sprintf( + "BT /F1 %.2f Tf %.2f %.2f Td (%s) Tj ET\n", + $size, $x, $pdf_y, $escaped + ); + } + + public function rect_filled( float $x, float $y, float $w, float $h ): void { + $pdf_y = $this->height - $y - $h; + $this->current .= sprintf( "%.2f %.2f %.2f %.2f re f\n", $x, $pdf_y, $w, $h ); + } + + public function rule( float $x, float $y, float $w ): void { + $this->rect_filled( $x, $y, $w, 0.5 ); + } + + /** Finalize and return the full PDF as a byte string. */ + public function render(): string { + // Flush the in-progress page. + if ( '' !== $this->current || empty( $this->pages ) ) { + $this->pages[] = $this->current; + $this->current = ''; + } + + $objects = array(); + $objects[1] = "<< /Type /Catalog /Pages 2 0 R >>"; + + // Build pages tree. + $page_ids = array(); + $obj_id = 4; // Resources=3, then pages start at 4. + foreach ( $this->pages as $content ) { + $content_stream = "q\n0 0 0 rg\n" . $content . "Q\n"; + $content_len = strlen( $content_stream ); + $objects[ $obj_id ] = "<< /Length {$content_len} >>\nstream\n{$content_stream}endstream"; + $content_obj_id = $obj_id; + $obj_id++; + + $objects[ $obj_id ] = sprintf( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] /Resources 3 0 R /Contents %d 0 R >>", + $this->width, $this->height, $content_obj_id + ); + $page_ids[] = $obj_id; + $obj_id++; + } + + $kids = implode( ' ', array_map( static fn( $id ) => "{$id} 0 R", $page_ids ) ); + $objects[2] = "<< /Type /Pages /Kids [{$kids}] /Count " . count( $page_ids ) . " >>"; + $objects[3] = "<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>"; + + // Assemble PDF byte stream. + $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; + $offsets = array(); + ksort( $objects ); + foreach ( $objects as $id => $body ) { + $offsets[ $id ] = strlen( $pdf ); + $pdf .= "{$id} 0 obj\n{$body}\nendobj\n"; + } + $xref_offset = strlen( $pdf ); + $max_id = max( array_keys( $objects ) ); + $pdf .= "xref\n0 " . ( $max_id + 1 ) . "\n"; + $pdf .= "0000000000 65535 f \n"; + for ( $i = 1; $i <= $max_id; $i++ ) { + $off = $offsets[ $i ] ?? 0; + $pdf .= sprintf( "%010d 00000 n \n", $off ); + } + $pdf .= "trailer\n<< /Size " . ( $max_id + 1 ) . " /Root 1 0 R >>\n"; + $pdf .= "startxref\n{$xref_offset}\n%%EOF\n"; + return $pdf; + } +} diff --git a/sg-commerce-pro/src/Fulfillment/MCFClient.php b/sg-commerce-pro/src/Fulfillment/MCFClient.php new file mode 100644 index 0000000..b005e1f --- /dev/null +++ b/sg-commerce-pro/src/Fulfillment/MCFClient.php @@ -0,0 +1,170 @@ +to_preview_payload(); + try { + $response = $this->amazon->request( + 'POST', + $request->market, + '/mfn/v0/fulfillmentOrders/preview', + array(), + $body, + 'mcf.preview' + ); + } catch ( \Throwable $e ) { + $this->logger->error( 'MCF preview failed', array( 'err' => $e->getMessage() ) ); + return array( 'feasible' => false, 'errors' => array( $e->getMessage() ) ); + } + + $previews = $response['payload']['fulfillmentPreviews'] ?? array(); + foreach ( (array) $previews as $p ) { + if ( empty( $p['isFulfillable'] ) ) { + continue; + } + return array( + 'feasible' => true, + 'preview_id' => (string) ( $p['marketplaceId'] ?? '' ), + 'shipping_speed' => (string) ( $p['shippingSpeedCategory'] ?? '' ), + 'estimated_fees' => array_map( + static fn( $f ) => array( + 'name' => (string) ( $f['name'] ?? '' ), + 'amount' => (float) ( $f['amount']['value'] ?? 0 ), + 'currency' => (string) ( $f['amount']['currencyCode'] ?? '' ), + ), + (array) ( $p['estimatedFees'] ?? array() ) + ), + 'ship_date' => (string) ( $p['estimatedShippingWeight']['value'] ?? '' ), + ); + } + return array( + 'feasible' => false, + 'errors' => array( 'No fulfillable preview returned. SKU may be out of stock at requested speed.' ), + ); + } + + /** + * Create a fulfillment order. Returns the seller fulfillment order id + * (which we chose client-side) on success. + * + * @throws \RuntimeException on failure + */ + public function create( MCFOrderRequest $request ): string { + $body = $request->to_create_payload(); + $this->amazon->request( + 'POST', + $request->market, + '/mfn/v0/fulfillmentOrders', + array(), + $body, + 'mcf.create' + ); + $this->logger->info( 'MCF order submitted', array( + 'seller_order_id' => $request->seller_fulfillment_order_id, + 'market' => $request->market, + 'items' => count( $request->items ), + ) ); + return $request->seller_fulfillment_order_id; + } + + /** + * Fetch current status + tracking for an existing MCF order. + */ + public function get( string $market, string $seller_order_id ): array { + $response = $this->amazon->request( + 'GET', + $market, + '/mfn/v0/fulfillmentOrders/' . rawurlencode( $seller_order_id ), + array(), + null, + 'mcf.get' + ); + $payload = $response['payload'] ?? array(); + $status = (string) ( $payload['fulfillmentOrder']['fulfillmentOrderStatus'] ?? '' ); + + $tracking_numbers = array(); + $carrier = ''; + $shipped_at = null; + $delivered_at = null; + + foreach ( (array) ( $payload['fulfillmentShipments'] ?? array() ) as $shipment ) { + foreach ( (array) ( $shipment['fulfillmentShipmentPackage'] ?? array() ) as $pkg ) { + if ( ! empty( $pkg['trackingNumber'] ) ) { + $tracking_numbers[] = (string) $pkg['trackingNumber']; + } + if ( ! empty( $pkg['carrierCode'] ) && '' === $carrier ) { + $carrier = (string) $pkg['carrierCode']; + } + } + if ( ! empty( $shipment['shippingDate'] ) && null === $shipped_at ) { + $shipped_at = (string) $shipment['shippingDate']; + } + if ( ! empty( $shipment['estimatedArrivalDate'] ) && null === $delivered_at ) { + $delivered_at = (string) $shipment['estimatedArrivalDate']; + } + } + + return array( + 'amazon_status' => $status, + 'tracking_numbers' => $tracking_numbers, + 'carrier_code' => $carrier, + 'shipped_at' => $shipped_at, + 'delivered_at' => $delivered_at, + ); + } + + public function cancel( string $market, string $seller_order_id ): void { + $this->amazon->request( + 'PUT', + $market, + '/mfn/v0/fulfillmentOrders/' . rawurlencode( $seller_order_id ) . '/cancel', + array(), + null, + 'mcf.cancel' + ); + $this->logger->info( 'MCF order cancelled', array( 'seller_order_id' => $seller_order_id ) ); + } +} diff --git a/sg-commerce-pro/src/Fulfillment/MCFOrderRepository.php b/sg-commerce-pro/src/Fulfillment/MCFOrderRepository.php new file mode 100644 index 0000000..e5ae8e3 --- /dev/null +++ b/sg-commerce-pro/src/Fulfillment/MCFOrderRepository.php @@ -0,0 +1,136 @@ +table = $wpdb->prefix . 'sg_mcf_orders'; + } + + public function insert( MCFOrderRequest $req ): int { + global $wpdb; + $now = current_time( 'mysql', true ); + $result = $wpdb->insert( $this->table, array( + 'seller_fulfillment_order_id' => $req->seller_fulfillment_order_id, + 'displayable_order_id' => $req->displayable_order_id, + 'market' => $req->market, + 'customer_name' => (string) ( $req->customer['name'] ?? '' ), + 'customer_email' => (string) ( $req->customer['email'] ?? '' ), + 'country_code' => (string) ( $req->address['country_code'] ?? '' ), + 'address_line1' => (string) ( $req->address['line1'] ?? '' ), + 'address_line2' => (string) ( $req->address['line2'] ?? '' ), + 'city' => (string) ( $req->address['city'] ?? '' ), + 'state_region' => (string) ( $req->address['state_region'] ?? '' ), + 'postal_code' => (string) ( $req->address['postal_code'] ?? '' ), + 'phone' => (string) ( $req->customer['phone'] ?? '' ), + 'items' => wp_json_encode( $req->items ), + 'shipping_speed' => $req->shipping_speed, + 'status' => 'new', + 'created_at' => $now, + ), array( '%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s','%s' ) ); + + if ( false === $result ) { + $this->logger->error( 'MCF order insert failed', array( 'err' => $wpdb->last_error ) ); + throw new \RuntimeException( 'Could not persist MCF order: ' . $wpdb->last_error ); + } + return (int) $wpdb->insert_id; + } + + public function mark_submitted( int $id ): void { + global $wpdb; + $wpdb->update( $this->table, array( + 'status' => 'submitted', + 'submitted_at' => current_time( 'mysql', true ), + ), array( 'id' => $id ), array( '%s', '%s' ), array( '%d' ) ); + } + + public function mark_failed( int $id, string $error ): void { + global $wpdb; + $wpdb->update( $this->table, array( + 'status' => 'failed', + 'error_message' => $error, + ), array( 'id' => $id ), array( '%s', '%s' ), array( '%d' ) ); + } + + public function update_tracking( int $id, array $tracking ): void { + global $wpdb; + $wpdb->update( $this->table, array( + 'amazon_status' => (string) ( $tracking['amazon_status'] ?? '' ), + 'tracking_numbers' => wp_json_encode( $tracking['tracking_numbers'] ?? array() ), + 'carrier_code' => (string) ( $tracking['carrier_code'] ?? '' ), + 'shipped_at' => $tracking['shipped_at'] ?: null, + 'delivered_at' => $tracking['delivered_at'] ?: null, + 'status' => $this->derive_local_status( $tracking ), + ), array( 'id' => $id ), array( '%s','%s','%s','%s','%s','%s' ), array( '%d' ) ); + } + + private function derive_local_status( array $tracking ): string { + $amz = strtoupper( (string) ( $tracking['amazon_status'] ?? '' ) ); + if ( str_contains( $amz, 'COMPLETE' ) || $tracking['delivered_at'] ) return 'delivered'; + if ( $tracking['shipped_at'] || str_contains( $amz, 'SHIP' ) ) return 'shipped'; + if ( str_contains( $amz, 'CANCEL' ) ) return 'cancelled'; + if ( str_contains( $amz, 'PLAN' ) || str_contains( $amz, 'RECEIVED' ) ) return 'processing'; + return 'submitted'; + } + + public function find( int $id ): ?array { + global $wpdb; + $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ), ARRAY_A ); + return $row ?: null; + } + + public function find_by_seller_order( string $seller_order_id ): ?array { + global $wpdb; + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$this->table} WHERE seller_fulfillment_order_id = %s", $seller_order_id ), + ARRAY_A + ); + return $row ?: null; + } + + public function recent( int $limit = 50 ): array { + global $wpdb; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT * FROM {$this->table} ORDER BY created_at DESC LIMIT %d", $limit + ), ARRAY_A ); + return is_array( $rows ) ? $rows : array(); + } + + public function pending_tracking_refresh(): array { + global $wpdb; + $rows = $wpdb->get_results( + "SELECT * FROM {$this->table} + WHERE status IN ('submitted', 'processing', 'shipped') + AND updated_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 15 MINUTE) + ORDER BY updated_at ASC + LIMIT 50", + ARRAY_A + ); + return is_array( $rows ) ? $rows : array(); + } + + public function count_by_status(): array { + global $wpdb; + $rows = $wpdb->get_results( + "SELECT status, COUNT(*) c FROM {$this->table} GROUP BY status", ARRAY_A + ); + $out = array(); + foreach ( (array) $rows as $r ) $out[ (string) $r['status'] ] = (int) $r['c']; + return $out; + } +} diff --git a/sg-commerce-pro/src/Fulfillment/MCFOrderRequest.php b/sg-commerce-pro/src/Fulfillment/MCFOrderRequest.php new file mode 100644 index 0000000..5702bec --- /dev/null +++ b/sg-commerce-pro/src/Fulfillment/MCFOrderRequest.php @@ -0,0 +1,99 @@ +..., 'quantity'=>...], ... ] + * @param string $shipping_speed 'Standard' | 'Expedited' | 'Priority' + * @param string $comment Shows on packing slip. + * @param string $notification_email Amazon pushes shipment events here. + */ + public function __construct( + public readonly string $seller_fulfillment_order_id, + public readonly string $displayable_order_id, + public readonly string $market, + public readonly array $customer, + public readonly array $address, + public readonly array $items, + public readonly string $shipping_speed = 'Standard', + public readonly string $comment = '', + public readonly string $notification_email = '', + ) {} + + /** Payload for the /preview endpoint — no seller_order_id required. */ + public function to_preview_payload(): array { + return array( + 'marketplaceId' => '', // AmazonClient fills via marketplace routing + 'address' => $this->address_block(), + 'items' => $this->items_block( 'preview' ), + 'shippingSpeedCategories' => array( $this->shipping_speed ), + ); + } + + public function to_create_payload(): array { + $payload = array( + 'sellerFulfillmentOrderId' => $this->seller_fulfillment_order_id, + 'displayableOrderId' => $this->displayable_order_id, + 'displayableOrderDate' => gmdate( 'c' ), + 'displayableOrderComment' => $this->comment ?: 'Thank you for your order.', + 'shippingSpeedCategory' => $this->shipping_speed, + 'destinationAddress' => $this->address_block(), + 'items' => $this->items_block( 'create' ), + 'notificationEmails' => array_values( array_filter( array( $this->notification_email, $this->customer['email'] ?? '' ) ) ), + ); + return $payload; + } + + private function address_block(): array { + return array( + 'name' => (string) ( $this->customer['name'] ?? '' ), + 'addressLine1' => (string) ( $this->address['line1'] ?? '' ), + 'addressLine2' => (string) ( $this->address['line2'] ?? '' ), + 'city' => (string) ( $this->address['city'] ?? '' ), + 'stateOrRegion' => (string) ( $this->address['state_region'] ?? '' ), + 'postalCode' => (string) ( $this->address['postal_code'] ?? '' ), + 'countryCode' => (string) ( $this->address['country_code'] ?? '' ), + 'phone' => (string) ( $this->customer['phone'] ?? '' ), + ); + } + + private function items_block( string $mode ): array { + $out = array(); + foreach ( $this->items as $i => $item ) { + $sku = (string) ( $item['sku'] ?? '' ); + $qty = (int) ( $item['quantity'] ?? 1 ); + if ( '' === $sku || $qty < 1 ) continue; + $entry = array( + 'sellerSku' => $sku, + 'quantity' => $qty, + ); + if ( 'create' === $mode ) { + $entry['sellerFulfillmentOrderItemId'] = (string) ( $item['item_id'] ?? ( $sku . '-' . $i ) ); + $entry['displayableComment'] = (string) ( $item['comment'] ?? '' ); + } else { + $entry['sellerFulfillmentOrderItemId'] = (string) ( $item['item_id'] ?? ( $sku . '-' . $i ) ); + } + $out[] = $entry; + } + return $out; + } +} diff --git a/sg-commerce-pro/src/Geotargeting/GeoResolver.php b/sg-commerce-pro/src/Geotargeting/GeoResolver.php new file mode 100644 index 0000000..c9b390e --- /dev/null +++ b/sg-commerce-pro/src/Geotargeting/GeoResolver.php @@ -0,0 +1,198 @@ + 'US', 'CA' => 'CA', 'MX' => 'MX', 'BR' => 'BR', + 'GB' => 'UK', 'DE' => 'DE', 'AT' => 'DE', 'CH' => 'DE', + 'FR' => 'FR', 'MC' => 'FR', + 'IT' => 'IT', 'SM' => 'IT', + 'ES' => 'ES', 'PT' => 'ES', + 'NL' => 'NL', 'BE' => 'BE', + 'SE' => 'SE', 'NO' => 'SE', 'FI' => 'SE', 'DK' => 'SE', + 'PL' => 'PL', 'CZ' => 'PL', 'SK' => 'PL', + 'IE' => 'IE', + 'TR' => 'TR', + 'AE' => 'AE', 'OM' => 'AE', 'KW' => 'AE', 'QA' => 'AE', 'BH' => 'AE', + 'SA' => 'SA', + 'AU' => 'AU', 'NZ' => 'AU', + 'IN' => 'IN', + 'SG' => 'SG', 'MY' => 'SG', 'ID' => 'SG', 'TH' => 'SG', 'VN' => 'SG', 'PH' => 'SG', + 'JP' => 'JP', 'KR' => 'JP', + ); + + private ?string $detected_country = null; + private ?string $detected_market = null; + + public function __construct( + private Marketplaces $marketplaces, + private CacheManager $cache, + ) {} + + /** Best-guess ISO-2 country code for the current visitor. */ + public function country(): string { + if ( null !== $this->detected_country ) { + return $this->detected_country; + } + $this->detected_country = $this->detect_country(); + return $this->detected_country; + } + + /** + * Best Amazon market for this visitor. Returns a code from enabled_codes() + * if possible, else falls back to primary. + */ + public function market(): string { + if ( null !== $this->detected_market ) { + return $this->detected_market; + } + $country = $this->country(); + $enabled = $this->marketplaces->enabled_codes(); + $primary = $this->marketplaces->primary(); + + $candidate = self::COUNTRY_TO_MARKET[ $country ] ?? $primary; + if ( ! in_array( $candidate, $enabled, true ) ) { + $candidate = $primary; + } + $this->detected_market = $candidate; + return $candidate; + } + + /** + * Was the market chosen explicitly (override) or inferred? + * Used by Widget to show an "Other markets" dropdown only when inferred. + */ + public function was_overridden(): bool { + return '' !== (string) ( $_GET['sg_market'] ?? '' ) // phpcs:ignore WordPress.Security.NonceVerification + || '' !== (string) ( $_COOKIE['sg_market'] ?? '' ); + } + + /** Let user set a sticky override (writes a 30-day cookie). */ + public function set_override( string $market ): void { + if ( ! $this->marketplaces->exists( $market ) ) { + return; + } + $this->detected_market = $market; + $this->detected_country = ''; + if ( ! headers_sent() ) { + setcookie( 'sg_market', $market, array( + 'expires' => time() + 30 * DAY_IN_SECONDS, + 'path' => '/', + 'secure' => is_ssl(), + 'httponly' => false, // JS may want to read this + 'samesite' => 'Lax', + ) ); + } + } + + private function detect_country(): string { + // 1. Explicit override. + $override = ''; + if ( ! empty( $_GET['sg_market'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + $override = (string) $_GET['sg_market']; // phpcs:ignore + } elseif ( ! empty( $_COOKIE['sg_market'] ) ) { + $override = (string) $_COOKIE['sg_market']; + } + $override = strtoupper( preg_replace( '/[^A-Z]/i', '', $override ) ); + if ( 2 === strlen( $override ) ) { + // sg_market is a market code, not strictly a country — map back. + $mp = $this->marketplaces->get( $override ); + if ( $mp ) { + return $this->iso_for_market( $override ); + } + } + + // 2. Edge CDN headers. + $headers = array( + 'HTTP_CF_IPCOUNTRY', + 'HTTP_X_VERCEL_IP_COUNTRY', + 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', + 'HTTP_X_COUNTRY_CODE', + 'HTTP_X_APPENGINE_COUNTRY', + ); + foreach ( $headers as $h ) { + if ( ! empty( $_SERVER[ $h ] ) ) { + $v = strtoupper( preg_replace( '/[^A-Z]/i', '', (string) $_SERVER[ $h ] ) ); + if ( 2 === strlen( $v ) && 'XX' !== $v && 'T1' !== $v ) { + return $v; + } + } + } + + // 3. PHP GeoIP extension (rarely installed but cheap to try). + $ip = $this->client_ip(); + if ( function_exists( 'geoip_country_code_by_name' ) && '' !== $ip ) { + $v = @geoip_country_code_by_name( $ip ); + if ( is_string( $v ) && 2 === strlen( $v ) ) { + return strtoupper( $v ); + } + } + + // 4. Accept-Language. + if ( ! empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) { + $al = (string) $_SERVER['HTTP_ACCEPT_LANGUAGE']; + if ( preg_match( '/-([A-Za-z]{2})/', $al, $m ) ) { + return strtoupper( $m[1] ); + } + // Map bare language to a default country. + $lang = strtolower( substr( $al, 0, 2 ) ); + $map = array( + 'en' => 'US', 'de' => 'DE', 'fr' => 'FR', 'es' => 'ES', + 'it' => 'IT', 'nl' => 'NL', 'sv' => 'SE', 'pl' => 'PL', + 'tr' => 'TR', 'ar' => 'AE', 'pt' => 'BR', 'ja' => 'JP', + 'fa' => 'AE', 'zh' => 'SG', + ); + if ( isset( $map[ $lang ] ) ) return $map[ $lang ]; + } + + // 5. Primary market's country. + return $this->iso_for_market( $this->marketplaces->primary() ); + } + + private function iso_for_market( string $market_code ): string { + foreach ( self::COUNTRY_TO_MARKET as $country => $m ) { + if ( $m === $market_code ) return $country; + } + return 'US'; + } + + private function client_ip(): string { + foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' ) as $k ) { + if ( ! empty( $_SERVER[ $k ] ) ) { + $ip = trim( explode( ',', (string) $_SERVER[ $k ] )[0] ); + if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { + return $ip; + } + } + } + return ''; + } +} diff --git a/sg-commerce-pro/src/Geotargeting/GeotargetingModule.php b/sg-commerce-pro/src/Geotargeting/GeotargetingModule.php new file mode 100644 index 0000000..97bed29 --- /dev/null +++ b/sg-commerce-pro/src/Geotargeting/GeotargetingModule.php @@ -0,0 +1,83 @@ +container; + + $c->singleton( GeoResolver::class, static fn( Container $c ) => + new GeoResolver( $c->get( Marketplaces::class ), $c->get( CacheManager::class ) ) + ); + + // Widget shortcode filter: resolve 'auto' market to the visitor's market. + add_filter( 'shortcode_atts_sg_product', array( $this, 'resolve_auto_market' ), 10, 3 ); + add_filter( 'shortcode_atts_sg_compare', array( $this, 'resolve_auto_market' ), 10, 3 ); + + // REST endpoint for JS clients. + add_action( 'rest_api_init', function (): void { + register_rest_route( 'sg-commerce/v1', '/geo', array( + 'methods' => 'GET', + 'permission_callback' => '__return_true', + 'callback' => array( $this, 'rest_geo' ), + ) ); + } ); + + // Handle market override from query string. Sets cookie then redirects + // back without the query arg for clean URLs. + add_action( 'init', array( $this, 'maybe_set_override' ), 1 ); + } + + public function resolve_auto_market( array $out, array $pairs, array $atts ): array { + $market = strtolower( (string) ( $out['market'] ?? 'auto' ) ); + if ( 'auto' === $market || '' === $market ) { + $out['market'] = $this->container->get( GeoResolver::class )->market(); + } else { + $out['market'] = strtoupper( (string) $out['market'] ); + } + return $out; + } + + public function rest_geo(): \WP_REST_Response { + $resolver = $this->container->get( GeoResolver::class ); + return rest_ensure_response( array( + 'country' => $resolver->country(), + 'market' => $resolver->market(), + 'overridden' => $resolver->was_overridden(), + ) ); + } + + public function maybe_set_override(): void { + if ( empty( $_GET['sg_market'] ) ) return; // phpcs:ignore WordPress.Security.NonceVerification + $m = strtoupper( preg_replace( '/[^A-Z]/i', '', (string) $_GET['sg_market'] ) ); // phpcs:ignore + if ( 2 !== strlen( $m ) ) return; + $this->container->get( GeoResolver::class )->set_override( $m ); + } +} diff --git a/sg-commerce-pro/src/Injection/ContentInjector.php b/sg-commerce-pro/src/Injection/ContentInjector.php new file mode 100644 index 0000000..e3d78a0 --- /dev/null +++ b/sg-commerce-pro/src/Injection/ContentInjector.php @@ -0,0 +1,199 @@ + to Amazon via our geotargeted + * redirect. Lightest touch. Good for SEO-heavy content where you don't + * want chunky widgets breaking the flow. + * 'widget' Insert the full [sg_product] widget ABOVE the paragraph containing the match. + * Higher conversion but heavier. + * 'tooltip' Turn keyword into an inline span with JS-driven hovercard showing price/stock. + * + * Scans only the main content on singular views. Never modifies excerpts, + * admin, RSS, or feed contexts. First match per rule per post, capped by + * rule.max_per_post. + * + * @package SevenGum\Commerce\Injection + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Injection; + +use SevenGum\Commerce\Amazon\Marketplaces; +use SevenGum\Commerce\Database\Repositories\ProductRepository; +use SevenGum\Commerce\Geotargeting\GeoResolver; +use SevenGum\Commerce\Logging\Logger; + +defined( 'ABSPATH' ) || exit; + +final class ContentInjector { + + /** In-request cache of active rules. */ + private ?array $rules_cache = null; + + public function __construct( + private ProductRepository $products, + private Marketplaces $marketplaces, + private GeoResolver $geo, + private Logger $logger, + ) {} + + /** + * Main hook — replaces matched keywords in post HTML. + * + * Applied on `the_content` priority 20 (after wpautop, so paragraph + * boundaries are known). Only touches singular posts/pages. + */ + public function inject( string $content ): string { + if ( is_admin() || is_feed() || ! is_singular() ) { + return $content; + } + if ( '' === trim( $content ) ) { + return $content; + } + + $rules = $this->load_rules(); + if ( empty( $rules ) ) { + return $content; + } + + $market = $this->geo->market(); + $hits_per_rule = array(); + + foreach ( $rules as $rule ) { + $rule_id = (int) $rule['id']; + $pattern = (string) $rule['pattern']; + $ptype = (string) $rule['pattern_type']; // 'keyword' | 'regex' + $sku = (string) $rule['sku']; + $render = (string) $rule['render_as']; + $rule_mkt = (string) $rule['market']; + $max = max( 1, (int) $rule['max_per_post'] ); + + $effective_market = '' !== $rule_mkt ? $rule_mkt : $market; + $product = $this->products->find_by_sku( $effective_market, $sku ); + if ( null === $product ) { + continue; + } + + $hits_per_rule[ $rule_id ] = 0; + + $callback = function ( array $m ) use ( $product, $render, $rule_id, $max, &$hits_per_rule, $effective_market ): string { + if ( $hits_per_rule[ $rule_id ] >= $max ) { + return $m[0]; + } + // Don't match inside existing HTML tags (belt-and-braces — we also split HTML below). + $hits_per_rule[ $rule_id ]++; + return $this->render_replacement( $m[0], $product, $render, $effective_market ); + }; + + if ( 'regex' === $ptype ) { + // Caller-supplied regex — wrap in delimiters + ui flags. Rejected if un-compilable. + $regex = '~' . str_replace( '~', '\\~', $pattern ) . '~ui'; + $valid = false !== @preg_match( $regex, '' ); + if ( ! $valid ) continue; + } else { + $regex = '~\b(' . preg_quote( $pattern, '~' ) . ')\b~ui'; + } + + $content = $this->replace_outside_tags( $content, $regex, $callback ); + + if ( $hits_per_rule[ $rule_id ] > 0 ) { + $this->increment_hit( $rule_id, $hits_per_rule[ $rule_id ] ); + } + } + + return $content; + } + + /** Apply a regex callback only to text nodes, never to HTML tag internals. */ + private function replace_outside_tags( string $html, string $regex, callable $cb ): string { + // Split on tags while keeping them. + $parts = preg_split( '/(<[^>]+>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE ); + if ( false === $parts ) return $html; + + $in_skip = 0; // depth counter for anchor/script/style + $skip_tags = array( 'a', 'script', 'style', 'code', 'pre', 'textarea' ); + + foreach ( $parts as $i => $part ) { + if ( '' === $part ) continue; + if ( '<' === $part[0] ) { + // Tag — adjust depth if it's a skip tag. + if ( preg_match( '~^<(/?)([a-zA-Z][a-zA-Z0-9]*)~', $part, $tm ) ) { + $is_close = '/' === $tm[1]; + $tag = strtolower( $tm[2] ); + if ( in_array( $tag, $skip_tags, true ) ) { + $in_skip = $is_close ? max( 0, $in_skip - 1 ) : $in_skip + 1; + } + } + continue; + } + if ( $in_skip > 0 ) continue; + $parts[ $i ] = preg_replace_callback( $regex, $cb, $part ); + } + return implode( '', $parts ); + } + + private function render_replacement( string $matched, array $product, string $render, string $market ): string { + switch ( $render ) { + case 'widget': + return sprintf( + '%s
%s
', + $matched, + do_shortcode( sprintf( + '[sg_product sku="%s" market="%s" style="compact"]', + esc_attr( $product['sku'] ), + esc_attr( $market ) + ) ) + ); + + case 'tooltip': + $url = '' !== $product['asin'] + ? $this->marketplaces->product_url( (string) $product['asin'], $market ) + : '#'; + return sprintf( + '%s', + esc_url( $url ), + esc_attr( $product['sku'] ), + esc_attr( $market ), + esc_html( $matched ) + ); + + case 'link': + default: + $url = '' !== $product['asin'] + ? $this->marketplaces->product_url( (string) $product['asin'], $market ) + : home_url( '/?sg_go=' . urlencode( $product['sku'] ) ); + return sprintf( + '%s', + esc_url( $url ), + esc_html( $matched ) + ); + } + } + + private function load_rules(): array { + if ( null !== $this->rules_cache ) { + return $this->rules_cache; + } + global $wpdb; + $rows = $wpdb->get_results( + "SELECT * FROM {$wpdb->prefix}sg_injection_rules + WHERE is_active = 1 + ORDER BY priority ASC, id ASC", + ARRAY_A + ); + $this->rules_cache = is_array( $rows ) ? $rows : array(); + return $this->rules_cache; + } + + private function increment_hit( int $rule_id, int $count ): void { + global $wpdb; + $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->prefix}sg_injection_rules SET hit_count = hit_count + %d WHERE id = %d", + $count, + $rule_id + ) ); + } +} diff --git a/sg-commerce-pro/src/Injection/InjectionModule.php b/sg-commerce-pro/src/Injection/InjectionModule.php new file mode 100644 index 0000000..6956394 --- /dev/null +++ b/sg-commerce-pro/src/Injection/InjectionModule.php @@ -0,0 +1,95 @@ +container; + + $c->singleton( ContentInjector::class, static fn( Container $c ) => + new ContentInjector( + $c->get( ProductRepository::class ), + $c->get( Marketplaces::class ), + $c->get( GeoResolver::class ), + $c->get( Logger::class ) + ) + ); + + $settings = $c->get( SettingsRepository::class ); + if ( $settings->get( 'injection_enabled', true ) ) { + // priority 20 — after wpautop, before shortcode rendering (priority 11). + // We DO want our injected [sg_product] shortcodes to expand, so run BEFORE + // do_shortcode which is at 11... except do_shortcode runs at 11 by default + // so we need to be earlier. Use priority 10 and run shortcodes ourselves. + add_filter( 'the_content', array( $this, 'filter_content' ), 10 ); + } + + // Short-link redirect handler. + add_action( 'init', array( $this, 'handle_short_link' ), 20 ); + } + + public function filter_content( string $content ): string { + try { + return $this->container->get( ContentInjector::class )->inject( $content ); + } catch ( \Throwable $e ) { + $this->container->get( Logger::class )->error( 'Content injection failed: ' . $e->getMessage() ); + return $content; + } + } + + public function handle_short_link(): void { + if ( empty( $_GET['sg_go'] ) ) return; // phpcs:ignore WordPress.Security.NonceVerification + $sku = sanitize_text_field( wp_unslash( (string) $_GET['sg_go'] ) ); // phpcs:ignore + $market = $this->container->get( GeoResolver::class )->market(); + $product = $this->container->get( ProductRepository::class )->find_by_sku( $market, $sku ); + + // If this SKU isn't tracked in the visitor's market, try any market we have. + if ( null === $product ) { + $enabled = $this->container->get( Marketplaces::class )->enabled_codes(); + foreach ( $enabled as $m ) { + if ( $m === $market ) continue; + $product = $this->container->get( ProductRepository::class )->find_by_sku( $m, $sku ); + if ( null !== $product ) { $market = $m; break; } + } + } + + if ( null !== $product && '' !== $product['asin'] ) { + $url = $this->container->get( Marketplaces::class )->product_url( (string) $product['asin'], $market ); + wp_safe_redirect( $url, 302 ); + exit; + } + + // Fallback — home page. + wp_safe_redirect( home_url(), 302 ); + exit; + } +} diff --git a/sg-commerce-pro/src/Integration/IntegrationModule.php b/sg-commerce-pro/src/Integration/IntegrationModule.php new file mode 100644 index 0000000..c258db2 --- /dev/null +++ b/sg-commerce-pro/src/Integration/IntegrationModule.php @@ -0,0 +1,96 @@ +container; + + $c->singleton( ThemeBridge::class, static fn( Container $c ) => + new ThemeBridge( + $c->get( ProductRepository::class ), + $c->get( Marketplaces::class ), + $c->get( GeoResolver::class ), + $c->get( Logger::class ) + ) + ); + + // Listen to sync completion. + $events = $c->get( EventDispatcher::class ); + $events->on( 'sync.completed', function ( array $payload ): array { + return $this->container->get( ThemeBridge::class )->on_sync_completed( $payload ); + } ); + + // Also listen to individual product syncs for low-latency theme updates. + $events->after( 'product.synced', function ( array $payload ): void { + if ( empty( $payload['product_id'] ) ) return; + $row = $this->container->get( ProductRepository::class )->find( (int) $payload['product_id'] ); + if ( null === $row ) return; + // Find the WP post mapped to this SKU and sync it. + $post_id = $this->find_post_for_sku( (string) $row['sku'], (string) $row['market'] ); + if ( $post_id > 0 ) { + $this->container->get( ThemeBridge::class )->sync_post( $post_id ); + } + } ); + + // Meta box + shortcodes (admin + frontend). + add_action( 'add_meta_boxes', function (): void { + $this->container->get( ThemeBridge::class )->register_meta_box(); + } ); + + add_shortcode( 'sg_theme_price', function ( $atts ): string { + return $this->container->get( ThemeBridge::class )->shortcode_price( $atts ); + } ); + add_shortcode( 'sg_theme_buy', function ( $atts ): string { + return $this->container->get( ThemeBridge::class )->shortcode_buy( $atts ); + } ); + } + + private function find_post_for_sku( string $sku, string $market ): int { + global $wpdb; + $post_id = $wpdb->get_var( $wpdb->prepare( + "SELECT p.ID FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} m1 ON m1.post_id = p.ID AND m1.meta_key = %s AND m1.meta_value = %s + LEFT JOIN {$wpdb->postmeta} m2 ON m2.post_id = p.ID AND m2.meta_key = %s + WHERE p.post_type = %s + AND (m2.meta_value = %s OR m2.meta_value IS NULL OR m2.meta_value = '') + LIMIT 1", + ThemeBridge::META_SKU, $sku, + ThemeBridge::META_MARKET, + ThemeBridge::CPT, + $market + ) ); + return (int) $post_id; + } +} diff --git a/sg-commerce-pro/src/Integration/ThemeBridge.php b/sg-commerce-pro/src/Integration/ThemeBridge.php new file mode 100644 index 0000000..77cf93e --- /dev/null +++ b/sg-commerce-pro/src/Integration/ThemeBridge.php @@ -0,0 +1,335 @@ +logger->debug( 'ThemeBridge skipped: active theme is not sevengum' ); + return $payload; + } + + $post_ids = get_posts( array( + 'post_type' => self::CPT, + 'post_status' => array( 'publish', 'draft' ), + 'posts_per_page' => -1, + 'fields' => 'ids', + 'no_found_rows' => true, + ) ); + + $updated = 0; + foreach ( (array) $post_ids as $post_id ) { + if ( $this->sync_post( (int) $post_id ) ) { + $updated++; + } + } + $this->logger->info( 'ThemeBridge updated theme posts', array( 'updated' => $updated, 'total' => count( $post_ids ) ) ); + return $payload; + } + + /** + * Sync one CPT post's theme-facing data from its mapped Amazon SKU. + * Returns true if any field changed. + */ + public function sync_post( int $post_id ): bool { + $sku = (string) get_post_meta( $post_id, self::META_SKU, true ); + if ( '' === $sku ) { + return false; + } + + // Market: use the one stored on the post, else primary. + $market = (string) get_post_meta( $post_id, self::META_MARKET, true ); + if ( '' === $market ) { + $market = $this->marketplaces->primary(); + } + + $amazon_row = $this->products->find_by_sku( $market, $sku ); + if ( null === $amazon_row ) { + return false; + } + + $changed = false; + + // 1. Price → _sevengum_price + if ( null !== $amazon_row['buybox_price'] ) { + $new_price = (string) round( (float) $amazon_row['buybox_price'], 2 ); + $old_price = (string) get_post_meta( $post_id, '_sevengum_price', true ); + if ( $new_price !== $old_price ) { + update_post_meta( $post_id, '_sevengum_price', $new_price ); + $changed = true; + } + } + + // 2. Buy URL → theme_mod (uses the post slug which maps to flavor slug) + $slug = get_post_field( 'post_name', $post_id ); + if ( '' !== $slug && '' !== $amazon_row['asin'] ) { + $buy_url = $this->marketplaces->product_url( (string) $amazon_row['asin'], $market ); + $old_url = (string) get_theme_mod( 'sg_amazon_' . $slug, '' ); + if ( $buy_url !== $old_url ) { + set_theme_mod( 'sg_amazon_' . $slug, $buy_url ); + $changed = true; + } + } + + // 3. Stock cache meta (used by theme to hide sold-out CTAs if desired) + update_post_meta( $post_id, self::META_STOCK_CACHE, (int) $amazon_row['fulfillable_qty'] ); + update_post_meta( $post_id, self::META_LAST_SYNC, current_time( 'mysql', true ) ); + + return $changed; + } + + /** + * Register meta box on the products edit screen that lets editors: + * - Pick which Amazon SKU + market this post is mapped to + * - See read-only sync status (last sync, stock, BB status) + */ + public function register_meta_box(): void { + add_meta_box( + 'sg_commerce_amazon_link', + __( 'Amazon link (Seven Gum Commerce)', 'sg-commerce' ), + array( $this, 'render_meta_box' ), + self::CPT, + 'side', + 'default' + ); + add_action( 'save_post_' . self::CPT, array( $this, 'save_meta_box' ), 10, 2 ); + } + + public function render_meta_box( \WP_Post $post ): void { + wp_nonce_field( 'sg_theme_bridge_save', '_sg_tb_nonce' ); + + $current_sku = (string) get_post_meta( $post->ID, self::META_SKU, true ); + $current_market = (string) get_post_meta( $post->ID, self::META_MARKET, true ); + $last_sync = (string) get_post_meta( $post->ID, self::META_LAST_SYNC, true ); + $stock = (int) get_post_meta( $post->ID, self::META_STOCK_CACHE, true ); + + if ( '' === $current_market ) { + $current_market = $this->marketplaces->primary(); + } + + // Suggest SKUs: all SKUs we have for the chosen market. + $suggestions = array(); + global $wpdb; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT sku, product_name FROM {$wpdb->prefix}sg_products WHERE market = %s ORDER BY sku ASC LIMIT 50", + $current_market + ), ARRAY_A ); + foreach ( (array) $rows as $r ) { + $suggestions[ $r['sku'] ] = $r['product_name'] ?: $r['sku']; + } + + $mapped_row = '' !== $current_sku ? $this->products->find_by_sku( $current_market, $current_sku ) : null; + ?> +

+ + +

+

+ + + + $n ) : ?> + + + +

+ + +
+
: ✓
+
:
+ +
: + + + — + +
+ + +
:
+ +
+

+ ', '', esc_html( $post->post_name ) + ); ?> +

+ +

+ +

+ + sync_post( $post_id ); + } + + /** + * Shortcode: [sg_theme_price slug="cookie"] — used by the theme itself + * if it wants to show the live price with currency. + */ + public function shortcode_price( $atts ): string { + $atts = shortcode_atts( array( 'slug' => '', 'post_id' => 0 ), is_array( $atts ) ? $atts : array(), 'sg_theme_price' ); + $post_id = (int) $atts['post_id']; + if ( 0 === $post_id && '' !== (string) $atts['slug'] ) { + $post = get_page_by_path( (string) $atts['slug'], OBJECT, self::CPT ); + if ( $post ) $post_id = $post->ID; + } + if ( 0 === $post_id ) return ''; + $sku = (string) get_post_meta( $post_id, self::META_SKU, true ); + $market = (string) get_post_meta( $post_id, self::META_MARKET, true ) ?: $this->geo->market(); + if ( '' === $sku ) return ''; + $row = $this->products->find_by_sku( $market, $sku ); + if ( null === $row || null === $row['buybox_price'] ) return ''; + $currency = $row['buybox_currency'] ?: ( $this->marketplaces->get( $market )['currency'] ?? '' ); + return sprintf( + '%s %s', + esc_html( number_format( (float) $row['buybox_price'], 2 ) ), + esc_html( $currency ) + ); + } + + /** + * Shortcode: [sg_theme_buy slug="cookie"] — geotargeted Amazon button + * that also updates in place via the widget auto-refresh JS. + */ + public function shortcode_buy( $atts ): string { + $atts = shortcode_atts( + array( 'slug' => '', 'post_id' => 0, 'label' => '' ), + is_array( $atts ) ? $atts : array(), + 'sg_theme_buy' + ); + $post_id = (int) $atts['post_id']; + if ( 0 === $post_id && '' !== (string) $atts['slug'] ) { + $post = get_page_by_path( (string) $atts['slug'], OBJECT, self::CPT ); + if ( $post ) $post_id = $post->ID; + } + if ( 0 === $post_id ) return ''; + + $sku = (string) get_post_meta( $post_id, self::META_SKU, true ); + if ( '' === $sku ) return ''; + + // Geotargeted: use visitor's market, not the mapped one. + $market = $this->geo->market(); + $row = $this->products->find_by_sku( $market, $sku ); + + // Fallback: if we don't have this SKU in visitor's market, fall back to + // the mapped market. + if ( null === $row ) { + $mapped_market = (string) get_post_meta( $post_id, self::META_MARKET, true ); + if ( '' !== $mapped_market ) { + $row = $this->products->find_by_sku( $mapped_market, $sku ); + if ( null !== $row ) $market = $mapped_market; + } + } + + if ( null === $row || '' === $row['asin'] ) return ''; + + $url = $this->marketplaces->product_url( (string) $row['asin'], $market ); + $currency = $row['buybox_currency'] ?: ( $this->marketplaces->get( $market )['currency'] ?? '' ); + $price_str = null !== $row['buybox_price'] + ? number_format( (float) $row['buybox_price'], 2 ) . ' ' . $currency + : ''; + + $default_label = '' !== $price_str + ? sprintf( /* translators: %s: price */ __( 'Buy on Amazon — %s', 'sg-commerce' ), $price_str ) + : __( 'Buy on Amazon', 'sg-commerce' ); + $label = (string) $atts['label'] !== '' ? (string) $atts['label'] : $default_label; + + return sprintf( + '🛒 %s', + esc_url( $url ), + esc_attr( $sku ), + esc_attr( $market ), + esc_html( $label ) + ); + } +} diff --git a/sg-commerce-pro/src/Logging/Logger.php b/sg-commerce-pro/src/Logging/Logger.php new file mode 100644 index 0000000..a17cadb --- /dev/null +++ b/sg-commerce-pro/src/Logging/Logger.php @@ -0,0 +1,154 @@ + 'DEBUG', + self::INFO => 'INFO', + self::NOTICE => 'NOTICE', + self::WARNING => 'WARNING', + self::ERROR => 'ERROR', + self::CRITICAL => 'CRITICAL', + ); + + private ?string $log_dir = null; + private int $threshold; + + public function __construct() { + $settings = get_option( 'sg_commerce_settings', array() ); + $level = is_array( $settings ) ? (string) ( $settings['log_level'] ?? 'info' ) : 'info'; + $this->threshold = $this->level_from_name( $level ); + } + + public function debug ( string $msg, array $ctx = array() ): void { $this->log( self::DEBUG, $msg, $ctx ); } + public function info ( string $msg, array $ctx = array() ): void { $this->log( self::INFO, $msg, $ctx ); } + public function notice ( string $msg, array $ctx = array() ): void { $this->log( self::NOTICE, $msg, $ctx ); } + public function warning ( string $msg, array $ctx = array() ): void { $this->log( self::WARNING, $msg, $ctx ); } + public function error ( string $msg, array $ctx = array() ): void { $this->log( self::ERROR, $msg, $ctx ); } + public function critical( string $msg, array $ctx = array() ): void { $this->log( self::CRITICAL, $msg, $ctx ); } + + public function log( int $level, string $message, array $context = array() ): void { + if ( $level < $this->threshold ) { + return; + } + $record = array( + 'timestamp' => gmdate( 'c' ), + 'level' => self::LEVEL_NAMES[ $level ] ?? 'UNKNOWN', + 'message' => $message, + 'context' => $context, + ); + $encoded = wp_json_encode( $record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + + $file = $this->ensure_log_file(); + if ( null !== $file ) { + @file_put_contents( $file, $encoded . "\n", FILE_APPEND | LOCK_EX ); + } else { + // Fallback — at least keep the record findable. + error_log( '[SG Commerce] ' . $encoded ); + } + } + + public function get_log_dir(): ?string { + return $this->log_dir ?? $this->resolve_log_dir(); + } + + /** + * Read recent log lines for display in admin (most recent first). + * + * @return array> + */ + public function tail( int $lines = 200 ): array { + $file = $this->todays_file(); + if ( null === $file || ! is_readable( $file ) ) { + return array(); + } + $content = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); + if ( ! is_array( $content ) ) { + return array(); + } + $slice = array_slice( $content, -1 * $lines ); + $out = array(); + foreach ( array_reverse( $slice ) as $line ) { + $decoded = json_decode( (string) $line, true ); + if ( is_array( $decoded ) ) { + $out[] = $decoded; + } + } + return $out; + } + + private function ensure_log_file(): ?string { + $dir = $this->resolve_log_dir(); + if ( null === $dir ) { + return null; + } + return $this->todays_file(); + } + + private function todays_file(): ?string { + $dir = $this->log_dir ?? $this->resolve_log_dir(); + if ( null === $dir ) { + return null; + } + return $dir . '/' . gmdate( 'Y-m-d' ) . '.log'; + } + + private function resolve_log_dir(): ?string { + if ( null !== $this->log_dir ) { + return $this->log_dir; + } + $uploads = wp_upload_dir( null, false ); + if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) { + return null; + } + $dir = trailingslashit( $uploads['basedir'] ) . 'sg-commerce-logs'; + if ( ! is_dir( $dir ) ) { + if ( ! wp_mkdir_p( $dir ) ) { + return null; + } + // Block direct HTTP access. + @file_put_contents( $dir . '/.htaccess', "Deny from all\n" ); + @file_put_contents( $dir . '/index.html', '' ); + } + $this->log_dir = $dir; + return $dir; + } + + private function level_from_name( string $name ): int { + return match ( strtolower( $name ) ) { + 'debug' => self::DEBUG, + 'info' => self::INFO, + 'notice' => self::NOTICE, + 'warning', 'warn' => self::WARNING, + 'error' => self::ERROR, + 'critical', 'crit' => self::CRITICAL, + default => self::INFO, + }; + } +} diff --git a/sg-commerce-pro/src/Queue/JobQueue.php b/sg-commerce-pro/src/Queue/JobQueue.php new file mode 100644 index 0000000..f70f8d5 --- /dev/null +++ b/sg-commerce-pro/src/Queue/JobQueue.php @@ -0,0 +1,97 @@ +enqueue('sg_commerce_describe_product', ['id' => 42]); + * add_action('sg_commerce_describe_product', fn($args) => ...); + * + * @package SevenGum\Commerce\Queue + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Queue; + +use SevenGum\Commerce\Logging\Logger; + +defined( 'ABSPATH' ) || exit; + +final class JobQueue { + + public function __construct( private Logger $logger ) {} + + /** + * Enqueue a job for immediate (next tick) async execution. + * + * @param string $hook Hook name to fire. + * @param array $args Single-element array passed to the listener. + * @param int $delay Seconds to wait before firing. + */ + public function enqueue( string $hook, array $args = array(), int $delay = 0 ): void { + $timestamp = time() + max( 0, $delay ); + + if ( function_exists( 'as_schedule_single_action' ) ) { + as_schedule_single_action( $timestamp, $hook, array( $args ), 'sg_commerce' ); + $this->logger->debug( 'Job queued via Action Scheduler', array( 'hook' => $hook, 'delay' => $delay ) ); + return; + } + + // Fallback: single-shot wp-cron. Note: less reliable on low-traffic sites. + wp_schedule_single_event( $timestamp, $hook, array( $args ) ); + $this->logger->debug( 'Job queued via wp-cron fallback', array( 'hook' => $hook, 'delay' => $delay ) ); + } + + /** + * Schedule a recurring job. Idempotent: silently no-ops if already scheduled. + */ + public function recurring( string $hook, string $recurrence, array $args = array() ): void { + if ( function_exists( 'as_schedule_recurring_action' ) && ! as_next_scheduled_action( $hook, null, 'sg_commerce' ) ) { + $interval = $this->recurrence_to_seconds( $recurrence ); + as_schedule_recurring_action( time() + 60, $interval, $hook, array( $args ), 'sg_commerce' ); + return; + } + if ( ! wp_next_scheduled( $hook, array( $args ) ) ) { + wp_schedule_event( time() + 60, $recurrence, $hook, array( $args ) ); + } + } + + public function cancel( string $hook ): void { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( $hook, null, 'sg_commerce' ); + } + wp_clear_scheduled_hook( $hook ); + } + + /** Pending job count for admin display. */ + public function pending( string $hook ): int { + if ( ! function_exists( 'as_get_scheduled_actions' ) ) { + return 0; + } + $actions = as_get_scheduled_actions( + array( + 'hook' => $hook, + 'status' => 'pending', + 'group' => 'sg_commerce', + 'per_page' => 1000, + ), + 'ids' + ); + return is_array( $actions ) ? count( $actions ) : 0; + } + + private function recurrence_to_seconds( string $recurrence ): int { + return match ( $recurrence ) { + 'sg_five_minutes' => 5 * MINUTE_IN_SECONDS, + 'sg_fifteen_minutes' => 15 * MINUTE_IN_SECONDS, + 'sg_six_hours' => 6 * HOUR_IN_SECONDS, + 'hourly' => HOUR_IN_SECONDS, + 'twicedaily' => 12 * HOUR_IN_SECONDS, + 'daily' => DAY_IN_SECONDS, + default => HOUR_IN_SECONDS, + }; + } +} diff --git a/sg-commerce-pro/src/REST/RESTModule.php b/sg-commerce-pro/src/REST/RESTModule.php new file mode 100644 index 0000000..899d17d --- /dev/null +++ b/sg-commerce-pro/src/REST/RESTModule.php @@ -0,0 +1,118 @@ +[A-Z]{2})/(?P[A-Za-z0-9_\-\.]+)', array( + 'methods' => 'GET', + 'permission_callback' => '__return_true', + 'callback' => array( $this, 'route_get_product' ), + ) ); + register_rest_route( self::NAMESPACE, '/sync/(?P[A-Z]{2})', array( + 'methods' => 'POST', + 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, + 'callback' => array( $this, 'route_sync' ), + ) ); + register_rest_route( self::NAMESPACE, '/describe/(?P\d+)', array( + 'methods' => 'POST', + 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, + 'callback' => array( $this, 'route_describe' ), + ) ); + register_rest_route( self::NAMESPACE, '/translate/(?P\d+)/(?P[A-Z]{2})', array( + 'methods' => 'POST', + 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, + 'callback' => array( $this, 'route_translate' ), + ) ); + } + + public function route_get_product( \WP_REST_Request $req ): \WP_REST_Response | \WP_Error { + $market = strtoupper( (string) $req['market'] ); + $sku = (string) $req['sku']; + $products = $this->container->get( ProductRepository::class ); + $row = $products->find_by_sku( $market, $sku ); + if ( null === $row ) { + return new \WP_Error( 'not_found', 'Product not found', array( 'status' => 404 ) ); + } + $marketplaces = $this->container->get( Marketplaces::class ); + $mp = $marketplaces->get( $market ); + return rest_ensure_response( array( + 'sku' => (string) $row['sku'], + 'asin' => (string) $row['asin'], + 'market' => (string) $row['market'], + 'name' => (string) $row['product_name'], + 'image_url' => (string) $row['image_url'], + 'in_stock' => (int) $row['fulfillable_qty'] > 0, + 'stock' => (int) $row['fulfillable_qty'], + 'price' => null !== $row['buybox_price'] ? (float) $row['buybox_price'] : null, + 'currency' => (string) $row['buybox_currency'] ?: ( $mp['currency'] ?? '' ), + 'amazon_url' => '' !== $row['asin'] ? $marketplaces->product_url( (string) $row['asin'], $market ) : '', + ) ); + } + + public function route_sync( \WP_REST_Request $req ): \WP_REST_Response | \WP_Error { + try { + $result = $this->container->get( SyncOrchestrator::class )->sync_market( strtoupper( (string) $req['market'] ) ); + return rest_ensure_response( $result ); + } catch ( \Throwable $e ) { + return new \WP_Error( 'sync_failed', $e->getMessage(), array( 'status' => 500 ) ); + } + } + + public function route_describe( \WP_REST_Request $req ): \WP_REST_Response | \WP_Error { + try { + $text = $this->container->get( Copywriter::class )->describe( (int) $req['id'] ); + return rest_ensure_response( array( 'ok' => true, 'description' => $text ) ); + } catch ( \Throwable $e ) { + return new \WP_Error( 'ai_failed', $e->getMessage(), array( 'status' => 500 ) ); + } + } + + public function route_translate( \WP_REST_Request $req ): \WP_REST_Response | \WP_Error { + try { + $text = $this->container->get( Copywriter::class )->translate( + (int) $req['id'], + strtoupper( (string) $req['market'] ) + ); + return rest_ensure_response( array( 'ok' => true, 'description' => $text ) ); + } catch ( \Throwable $e ) { + return new \WP_Error( 'ai_failed', $e->getMessage(), array( 'status' => 500 ) ); + } + } +} diff --git a/sg-commerce-pro/src/Repricer/RepricerEngine.php b/sg-commerce-pro/src/Repricer/RepricerEngine.php new file mode 100644 index 0000000..2979f16 --- /dev/null +++ b/sg-commerce-pro/src/Repricer/RepricerEngine.php @@ -0,0 +1,122 @@ +settings->get( 'repricer_enabled', false ) ) { + return array( 'skipped' => 'disabled' ); + } + + $strategy = (string) $this->settings->get( 'repricer_strategy', 'match_buybox' ); + $cents_off = (float) $this->settings->get( 'repricer_cents_below_buybox', 0.01 ); + $dry_run = (bool) $this->settings->get( 'repricer_dry_run', true ); + + // Pull losing products with cost set. + global $wpdb; + $rows = $wpdb->get_results( + "SELECT id, market, sku, asin, buybox_price, my_price, cost_price, buybox_currency + FROM {$wpdb->prefix}sg_products + WHERE buybox_is_mine = 0 + AND buybox_price IS NOT NULL + AND cost_price IS NOT NULL + ORDER BY updated_at DESC + LIMIT 100", + ARRAY_A + ); + + $adjusted = 0; + $skipped_floor = 0; + $logs = array(); + + foreach ( (array) $rows as $row ) { + $buybox = (float) $row['buybox_price']; + $cost = (float) $row['cost_price']; + $target = match ( $strategy ) { + 'match_buybox' => max( 0.01, $buybox - $cents_off ), + default => $buybox, + }; + $floor = $this->profit->min_price_for( $cost ); + + if ( $target < $floor ) { + ++$skipped_floor; + $logs[] = array( + 'sku' => $row['sku'], + 'market' => $row['market'], + 'reason' => 'would breach min margin floor', + 'target' => $target, + 'floor' => $floor, + ); + continue; + } + + $current = isset( $row['my_price'] ) ? (float) $row['my_price'] : 0.0; + if ( abs( $current - $target ) < 0.005 ) { + continue; // already at target + } + + $logs[] = array( + 'sku' => $row['sku'], + 'market' => $row['market'], + 'from' => $current, + 'to' => round( $target, 2 ), + 'buybox' => $buybox, + 'dry_run' => $dry_run, + ); + + $this->products->update_my_price( (int) $row['id'], round( $target, 2 ) ); + ++$adjusted; + + if ( ! $dry_run ) { + // In a real production flow, here we'd PATCH the Amazon listing. + // We log intent only — operator must enable Listings API authorization. + $this->logger->info( 'repricer would PATCH listing', array( + 'asin' => $row['asin'], 'sku' => $row['sku'], 'price' => $target, + ) ); + } + } + + $summary = array( + 'adjusted' => $adjusted, + 'skipped_floor' => $skipped_floor, + 'dry_run' => $dry_run, + 'logs' => $logs, + ); + $this->logger->info( 'repricer tick complete', array( + 'adjusted' => $adjusted, 'skipped_floor' => $skipped_floor, + ) ); + return $summary; + } +} diff --git a/sg-commerce-pro/src/Repricer/RepricerModule.php b/sg-commerce-pro/src/Repricer/RepricerModule.php new file mode 100644 index 0000000..3e9a54e --- /dev/null +++ b/sg-commerce-pro/src/Repricer/RepricerModule.php @@ -0,0 +1,35 @@ +container->get( RepricerEngine::class )->tick(); + } catch ( \Throwable $e ) { + $this->container->get( Logger::class )->error( 'repricer tick failed: ' . $e->getMessage() ); + } + } ); + } +} diff --git a/sg-commerce-pro/src/Security/Encryption.php b/sg-commerce-pro/src/Security/Encryption.php new file mode 100644 index 0000000..f0e50af --- /dev/null +++ b/sg-commerce-pro/src/Security/Encryption.php @@ -0,0 +1,108 @@ +derive_key(); + $nonce = random_bytes( 12 ); + $tag = ''; + $cipher = openssl_encrypt( + $plaintext, + 'aes-256-gcm', + $key, + OPENSSL_RAW_DATA, + $nonce, + $tag, + '', + 16 + ); + if ( false === $cipher ) { + throw new \RuntimeException( 'Encryption failed: openssl returned false. Check that openssl extension is loaded.' ); + } + $envelope = chr( self::VERSION_AES256GCM ) . $nonce . $tag . $cipher; + return base64_encode( $envelope ); + } + + public function decrypt( string $payload ): string { + if ( '' === $payload ) { + return ''; + } + $raw = base64_decode( $payload, true ); + if ( false === $raw || strlen( $raw ) < 30 ) { + throw new \RuntimeException( 'Ciphertext malformed: too short or invalid base64.' ); + } + $version = ord( $raw[0] ); + if ( self::VERSION_AES256GCM !== $version ) { + throw new \RuntimeException( sprintf( 'Unsupported encryption version 0x%02X.', $version ) ); + } + $nonce = substr( $raw, 1, 12 ); + $tag = substr( $raw, 13, 16 ); + $cipher = substr( $raw, 29 ); + $plain = openssl_decrypt( $cipher, 'aes-256-gcm', $this->derive_key(), OPENSSL_RAW_DATA, $nonce, $tag ); + if ( false === $plain ) { + throw new \RuntimeException( 'Decryption failed — tampered, corrupted, or AUTH_KEY rotated.' ); + } + return $plain; + } + + /** + * Detect whether a stored value looks like our envelope. Used by + * SettingsRepository to handle legacy plain-text gracefully. + */ + public function is_encrypted( string $payload ): bool { + if ( '' === $payload ) { + return false; + } + $raw = base64_decode( $payload, true ); + if ( false === $raw || strlen( $raw ) < 30 ) { + return false; + } + return self::VERSION_AES256GCM === ord( $raw[0] ); + } + + private function derive_key(): string { + $seed = ( defined( 'AUTH_KEY' ) ? (string) \AUTH_KEY : '' ) + . '|' . ( defined( 'SECURE_AUTH_KEY' ) ? (string) \SECURE_AUTH_KEY : '' ) + . '|' . ( defined( 'LOGGED_IN_KEY' ) ? (string) \LOGGED_IN_KEY : '' ); + + if ( '||' === $seed ) { + throw new \RuntimeException( + 'No WordPress AUTH_KEY available. Define AUTH_KEY in wp-config.php — generate one at https://api.wordpress.org/secret-key/1.1/salt/' + ); + } + // HKDF-Extract → HKDF-Expand (single block, output length = 32). + $prk = hash_hmac( 'sha256', $seed, '', true ); + return hash_hmac( 'sha256', self::CONTEXT . chr( 0x01 ), $prk, true ); + } +} diff --git a/sg-commerce-pro/src/Security/PIIManager/PIIManager.php b/sg-commerce-pro/src/Security/PIIManager/PIIManager.php new file mode 100644 index 0000000..0a51e45 --- /dev/null +++ b/sg-commerce-pro/src/Security/PIIManager/PIIManager.php @@ -0,0 +1,208 @@ +container->get( SettingsRepository::class ); + $logger = $this->container->get( Logger::class ); + + $retention_days = max( 1, (int) $settings->get( 'pii_retention_days', 30 ) ); + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS ); + + $table = $wpdb->prefix . 'sg_mcf_orders'; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT id FROM {$table} + WHERE status IN ('delivered', 'cancelled', 'failed') + AND (updated_at < %s OR delivered_at < %s) + AND customer_name != %s + LIMIT 500", + $cutoff, $cutoff, self::REDACTED + ), ARRAY_A ); + + if ( empty( $rows ) ) { + return 0; + } + + $count = 0; + foreach ( $rows as $row ) { + $id = (int) $row['id']; + $result = $wpdb->update( + $table, + array( + 'customer_name' => self::REDACTED, + 'customer_email' => self::REDACTED . '@sg-redacted.invalid', + 'address_line1' => self::REDACTED, + 'address_line2' => '', + 'phone' => '', + // city, postal_code, country_code: KEPT for analytics + ), + array( 'id' => $id ), + array( '%s', '%s', '%s', '%s', '%s' ), + array( '%d' ) + ); + if ( false !== $result ) { + $count++; + } + } + + // Audit log. + $wpdb->insert( $wpdb->prefix . 'sg_audit', array( + 'user_id' => 0, + 'action' => 'pii_anonymize_batch', + 'entity_type' => 'mcf_order', + 'entity_id' => '', + 'details' => wp_json_encode( array( + 'count' => $count, + 'retention' => $retention_days, + 'cutoff' => $cutoff, + ) ), + 'created_at' => current_time( 'mysql', true ), + ), array( '%d', '%s', '%s', '%s', '%s', '%s' ) ); + + $logger->info( 'PII anonymization batch complete', array( + 'count' => $count, + 'retention_days' => $retention_days, + ) ); + return $count; + } + + /** + * GDPR subject-access: export all data we hold for a given email, across + * MCF orders + audit. Returns a structured array ready for JSON or CSV. + */ + public function export_subject_data( string $email ): array { + global $wpdb; + $orders = $wpdb->get_results( $wpdb->prepare( + "SELECT id, displayable_order_id, market, customer_name, customer_email, + country_code, city, state_region, postal_code, items, status, + tracking_numbers, created_at, shipped_at, delivered_at + FROM {$wpdb->prefix}sg_mcf_orders + WHERE customer_email = %s", + $email + ), ARRAY_A ); + + return array( + 'subject_email' => $email, + 'exported_at' => gmdate( 'c' ), + 'mcf_orders' => is_array( $orders ) ? $orders : array(), + ); + } + + /** + * GDPR right-to-erasure: delete all PII for a subject immediately, + * regardless of retention window. Returns count of rows processed. + */ + public function erase_subject_data( string $email ): int { + global $wpdb; + $table = $wpdb->prefix . 'sg_mcf_orders'; + $ids = $wpdb->get_col( $wpdb->prepare( + "SELECT id FROM {$table} WHERE customer_email = %s", + $email + ) ); + if ( empty( $ids ) ) return 0; + + $count = 0; + foreach ( $ids as $id ) { + $result = $wpdb->update( + $table, + array( + 'customer_name' => self::REDACTED, + 'customer_email' => self::REDACTED . '@sg-redacted.invalid', + 'address_line1' => self::REDACTED, + 'address_line2' => '', + 'phone' => '', + ), + array( 'id' => (int) $id ), + array( '%s', '%s', '%s', '%s', '%s' ), + array( '%d' ) + ); + if ( false !== $result ) $count++; + } + + $wpdb->insert( $wpdb->prefix . 'sg_audit', array( + 'user_id' => get_current_user_id(), + 'action' => 'pii_erase_subject', + 'entity_type' => 'mcf_order', + 'entity_id' => '', + 'details' => wp_json_encode( array( 'email_hash' => hash( 'sha256', $email ), 'count' => $count ) ), + 'created_at' => current_time( 'mysql', true ), + ), array( '%d', '%s', '%s', '%s', '%s', '%s' ) ); + + return $count; + } + + /** Count of rows still holding un-anonymized PII, for the compliance dashboard. */ + public function count_pending_anonymization(): int { + global $wpdb; + $settings = $this->container->get( SettingsRepository::class ); + $retention_days = max( 1, (int) $settings->get( 'pii_retention_days', 30 ) ); + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS ); + + return (int) $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}sg_mcf_orders + WHERE status IN ('delivered', 'cancelled', 'failed') + AND customer_name != %s + AND (updated_at < %s OR delivered_at < %s)", + self::REDACTED, $cutoff, $cutoff + ) ); + } +} diff --git a/sg-commerce-pro/src/Suite/Ads/AdsClient.php b/sg-commerce-pro/src/Suite/Ads/AdsClient.php new file mode 100644 index 0000000..c2600c2 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Ads/AdsClient.php @@ -0,0 +1,277 @@ + 'https://advertising-api.amazon.com', + 'eu' => 'https://advertising-api-eu.amazon.com', + 'fe' => 'https://advertising-api-fe.amazon.com', + ); + + private const TOKEN_URL = 'https://api.amazon.com/auth/o2/token'; + private const CACHE_KEY = 'ads_access_token'; + private const MAX_RETRY = 4; + + public function __construct( + private SuiteSettings $settings, + private CacheManager $cache, + private Logger $logger, + ) {} + + public function has_credentials(): bool { + return '' !== $this->client_id() + && '' !== $this->client_secret() + && '' !== $this->settings->secret( 'ads_refresh_token' ); + } + + public function is_ready(): bool { + return $this->has_credentials() && '' !== $this->settings->string( 'suite_ads_profile_id' ); + } + + /** Ads may reuse the SP-API LWA app, so fall back to its client id/secret. */ + private function client_id(): string { + $id = $this->settings->secret( 'ads_client_id' ); + return '' !== $id ? $id : $this->settings->secret( 'amazon_client_id' ); + } + + private function client_secret(): string { + $s = $this->settings->secret( 'ads_client_secret' ); + return '' !== $s ? $s : $this->settings->secret( 'amazon_client_secret' ); + } + + public function access_token( bool $force = false ): string { + if ( ! $force ) { + $cached = $this->cache->get( self::CACHE_KEY ); + if ( is_string( $cached ) && '' !== $cached ) { + return $cached; + } + } + $res = wp_remote_post( self::TOKEN_URL, array( + 'timeout' => 20, + 'body' => array( + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->settings->secret( 'ads_refresh_token' ), + 'client_id' => $this->client_id(), + 'client_secret' => $this->client_secret(), + ), + ) ); + if ( is_wp_error( $res ) ) { + throw new \RuntimeException( 'Ads LWA network error: ' . $res->get_error_message() ); + } + $json = json_decode( (string) wp_remote_retrieve_body( $res ), true ); + if ( 200 !== (int) wp_remote_retrieve_response_code( $res ) || empty( $json['access_token'] ) ) { + $msg = is_array( $json ) ? (string) ( $json['error_description'] ?? $json['error'] ?? 'unknown' ) : 'unknown'; + throw new \RuntimeException( 'Ads LWA token refresh failed: ' . $msg ); + } + $ttl = max( 60, (int) ( $json['expires_in'] ?? 3600 ) - 120 ); + $this->cache->set( self::CACHE_KEY, (string) $json['access_token'], $ttl ); + return (string) $json['access_token']; + } + + public function clear_token(): void { + $this->cache->delete( self::CACHE_KEY ); + } + + /** GET /v2/profiles — no scope header. */ + public function profiles(): array { + return $this->call( 'GET', '/v2/profiles', null, 'application/json', false ); + } + + /** + * Generic list with nextToken pagination (SP v3 "/list" endpoints). + * + * @param string $path e.g. /sp/campaigns/list + * @param string $media e.g. application/vnd.spCampaign.v3+json + * @param string $key response array key, e.g. campaigns + */ + public function list_all( string $path, string $media, string $key, array $body = array(), int $max_pages = 20 ): array { + $out = array(); + $token = null; + $pages = 0; + do { + $req = $body + array( 'maxResults' => 500 ); + if ( null !== $token ) { + $req['nextToken'] = $token; + } + $res = $this->call( 'POST', $path, $req, $media ); + $out = array_merge( $out, (array) ( $res[ $key ] ?? array() ) ); + $token = isset( $res['nextToken'] ) && '' !== $res['nextToken'] ? (string) $res['nextToken'] : null; + $pages++; + } while ( null !== $token && $pages < $max_pages ); + return $out; + } + + public function campaigns(): array { + return $this->list_all( '/sp/campaigns/list', 'application/vnd.spCampaign.v3+json', 'campaigns', array( + 'stateFilter' => array( 'include' => array( 'ENABLED', 'PAUSED' ) ), + ) ); + } + + public function keywords(): array { + return $this->list_all( '/sp/keywords/list', 'application/vnd.spKeyword.v3+json', 'keywords', array( + 'stateFilter' => array( 'include' => array( 'ENABLED', 'PAUSED' ) ), + ) ); + } + + public function targets(): array { + return $this->list_all( '/sp/targets/list', 'application/vnd.spTargetingClause.v3+json', 'targetingClauses', array( + 'stateFilter' => array( 'include' => array( 'ENABLED', 'PAUSED' ) ), + ) ); + } + + /** @param array $updates */ + public function update_keywords( array $updates ): array { + return $this->call( 'PUT', '/sp/keywords', array( 'keywords' => array_values( $updates ) ), 'application/vnd.spKeyword.v3+json' ); + } + + /** @param array $updates */ + public function update_targets( array $updates ): array { + return $this->call( 'PUT', '/sp/targets', array( 'targetingClauses' => array_values( $updates ) ), 'application/vnd.spTargetingClause.v3+json' ); + } + + public function create_keywords( array $keywords ): array { + return $this->call( 'POST', '/sp/keywords', array( 'keywords' => array_values( $keywords ) ), 'application/vnd.spKeyword.v3+json' ); + } + + public function create_negative_keywords( array $negatives ): array { + return $this->call( 'POST', '/sp/negativeKeywords', array( 'negativeKeywords' => array_values( $negatives ) ), 'application/vnd.spNegativeKeyword.v3+json' ); + } + + public function update_campaigns( array $campaigns ): array { + return $this->call( 'PUT', '/sp/campaigns', array( 'campaigns' => array_values( $campaigns ) ), 'application/vnd.spCampaign.v3+json' ); + } + + /** Keyword ideas for ASINs (SP keyword recommendations v3). */ + public function keyword_recommendations( array $asins, string $locale = 'en_US', int $max = 200 ): array { + return $this->call( 'POST', '/sp/targets/keywords/recommendations', array( + 'recommendationType' => 'KEYWORDS_FOR_ASINS', + 'asins' => array_values( array_slice( $asins, 0, 50 ) ), + 'maxRecommendations' => min( 200, $max ), + 'sortDimension' => 'DEFAULT', + 'locale' => $locale, + ), 'application/vnd.spkeywordsrecommendation.v3+json' ); + } + + /** Reporting v3: request an async report. Returns reportId. */ + public function create_report( string $name, string $type_id, array $group_by, array $columns, string $start, string $end ): string { + $res = $this->call( 'POST', '/reporting/reports', array( + 'name' => $name, + 'startDate' => $start, + 'endDate' => $end, + 'configuration' => array( + 'adProduct' => 'SPONSORED_PRODUCTS', + 'groupBy' => $group_by, + 'columns' => $columns, + 'reportTypeId' => $type_id, + 'timeUnit' => 'DAILY', + 'format' => 'GZIP_JSON', + ), + ), 'application/vnd.createasyncreportrequest.v3+json' ); + $id = (string) ( $res['reportId'] ?? '' ); + if ( '' === $id ) { + throw new \RuntimeException( 'Ads createReport returned no reportId.' ); + } + return $id; + } + + /** @return array{status:string, url?:string, failureReason?:string} */ + public function report_status( string $report_id ): array { + return $this->call( 'GET', '/reporting/reports/' . rawurlencode( $report_id ), null, 'application/vnd.createasyncreportrequest.v3+json' ); + } + + public function download( string $url ): string { + $res = wp_remote_get( $url, array( 'timeout' => 120 ) ); + if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { + throw new \RuntimeException( 'Ads report download failed.' ); + } + return (string) wp_remote_retrieve_body( $res ); + } + + /** Connection test: token + profiles. */ + public function test(): array { + $this->clear_token(); + $this->access_token( true ); + $profiles = $this->profiles(); + return array( + 'ok' => true, + 'profiles' => array_map( static fn( $p ) => array( + 'profileId' => (string) ( $p['profileId'] ?? '' ), + 'countryCode' => (string) ( $p['countryCode'] ?? '' ), + 'currency' => (string) ( $p['currencyCode'] ?? '' ), + 'type' => (string) ( $p['accountInfo']['type'] ?? '' ), + 'name' => (string) ( $p['accountInfo']['name'] ?? '' ), + ), $profiles ), + ); + } + + private function call( string $method, string $path, ?array $body, string $media, bool $scoped = true ): array { + if ( ! $this->has_credentials() ) { + throw new \RuntimeException( 'Amazon Ads API credentials are not configured.' ); + } + $host = self::HOSTS[ $this->settings->string( 'suite_ads_region' ) ] ?? self::HOSTS['na']; + $last = ''; + for ( $attempt = 1; $attempt <= self::MAX_RETRY; $attempt++ ) { + $headers = array( + 'Authorization' => 'Bearer ' . $this->access_token( $attempt > 1 && str_contains( $last, '401' ) ), + 'Amazon-Advertising-API-ClientId' => $this->client_id(), + 'Accept' => $media, + 'Content-Type' => $media, + ); + if ( $scoped ) { + $headers['Amazon-Advertising-API-Scope'] = $this->settings->string( 'suite_ads_profile_id' ); + } + $args = array( 'method' => $method, 'timeout' => 45, 'headers' => $headers ); + if ( null !== $body ) { + $args['body'] = wp_json_encode( $body ); + } + $res = wp_remote_request( $host . $path, $args ); + if ( is_wp_error( $res ) ) { + $last = 'network: ' . $res->get_error_message(); + sleep( $attempt ); + continue; + } + $code = (int) wp_remote_retrieve_response_code( $res ); + $raw = (string) wp_remote_retrieve_body( $res ); + $json = json_decode( $raw, true ); + if ( in_array( $code, array( 401, 429, 500, 502, 503, 504 ), true ) ) { + $last = "HTTP {$code}"; + $retry = (int) wp_remote_retrieve_header( $res, 'retry-after' ); + sleep( min( 10, max( $attempt, $retry ) ) ); + continue; + } + if ( $code >= 400 ) { + $msg = is_array( $json ) ? (string) ( $json['message'] ?? $json['details'] ?? $json['code'] ?? '' ) : substr( $raw, 0, 200 ); + throw new \RuntimeException( "Ads API HTTP {$code}" . ( '' !== $msg ? " — {$msg}" : '' ) ); + } + return is_array( $json ) ? $json : array(); + } + throw new \RuntimeException( 'Ads API failed after retries: ' . $last ); + } +} diff --git a/sg-commerce-pro/src/Suite/Ads/AdsInsights.php b/sg-commerce-pro/src/Suite/Ads/AdsInsights.php new file mode 100644 index 0000000..b3e1baf --- /dev/null +++ b/sg-commerce-pro/src/Suite/Ads/AdsInsights.php @@ -0,0 +1,115 @@ + 0 ? round( (float) $s['cost'] / (float) $s['sales'] * 100, 2 ) : null; + } + $wasted = (float) Db::var( + 'SELECT COALESCE(SUM(cost),0) FROM (SELECT search_term, SUM(cost) AS cost, SUM(orders) AS o FROM {t:ads_search_terms} + WHERE report_date BETWEEN %s AND %s GROUP BY search_term) x WHERE x.o = 0', + array( $from, $to ) + ); + return array( 'totals' => self::kpis( $t ) + array( 'wasted_spend' => round( $wasted, 2 ) ), 'series' => $series ); + } + + public function campaigns( string $from, string $to ): array { + $rows = Db::rows( + "SELECT c.campaign_id, c.name, c.state, c.targeting_type, c.daily_budget, c.bidding_strategy, + COALESCE(SUM(d.impressions),0) AS impressions, COALESCE(SUM(d.clicks),0) AS clicks, COALESCE(SUM(d.cost),0) AS cost, + COALESCE(SUM(d.sales),0) AS sales, COALESCE(SUM(d.orders),0) AS orders, COALESCE(SUM(d.units),0) AS units + FROM {t:ads_campaigns} c + LEFT JOIN {t:ads_daily} d ON d.campaign_id = c.campaign_id AND d.level = 'campaign' AND d.report_date BETWEEN %s AND %s + GROUP BY c.campaign_id, c.name, c.state, c.targeting_type, c.daily_budget, c.bidding_strategy + ORDER BY cost DESC", + array( $from, $to ) + ); + return array_map( static fn( $r ) => self::kpis( $r ) + $r, $rows ); + } + + public function targets( string $from, string $to, string $campaign = '' ): array { + $args = array( $from, $to ); + $where = "t.state <> 'ARCHIVED'"; + if ( '' !== $campaign ) { + $where .= ' AND t.campaign_id = %s'; + $args[] = $campaign; + } + $rows = Db::rows( + "SELECT t.target_id, t.kind, t.campaign_id, t.ad_group_id, t.keyword_text, t.match_type, t.state, t.bid, c.name AS campaign, + COALESCE(SUM(d.impressions),0) AS impressions, COALESCE(SUM(d.clicks),0) AS clicks, COALESCE(SUM(d.cost),0) AS cost, + COALESCE(SUM(d.sales),0) AS sales, COALESCE(SUM(d.orders),0) AS orders, COALESCE(SUM(d.units),0) AS units + FROM {t:ads_targets} t + LEFT JOIN {t:ads_campaigns} c ON c.campaign_id = t.campaign_id + LEFT JOIN {t:ads_daily} d ON d.target_id = t.target_id AND d.level = 'target' AND d.report_date BETWEEN %s AND %s + WHERE {$where} + GROUP BY t.target_id, t.kind, t.campaign_id, t.ad_group_id, t.keyword_text, t.match_type, t.state, t.bid, c.name + ORDER BY cost DESC LIMIT 1000", + $args + ); + return array_map( static fn( $r ) => self::kpis( $r ) + $r, $rows ); + } + + public function search_terms( string $from, string $to ): array { + $rows = Db::rows( + "SELECT search_term, MAX(keyword_text) AS keyword_text, MAX(match_type) AS match_type, COUNT(DISTINCT campaign_id) AS campaigns, + SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(cost) AS cost, SUM(sales) AS sales, SUM(orders) AS orders + FROM {t:ads_search_terms} WHERE report_date BETWEEN %s AND %s + GROUP BY search_term ORDER BY SUM(cost) DESC LIMIT 1000", + array( $from, $to ) + ); + return array_map( static fn( $r ) => self::kpis( $r ) + $r, $rows ); + } + + public function actions( string $status = 'planned' ): array { + return Db::rows( 'SELECT * FROM {t:ads_actions} WHERE status = %s ORDER BY kind, id LIMIT 1000', array( $status ) ); + } + + public function history(): array { + return Db::rows( "SELECT * FROM {t:ads_actions} WHERE status IN ('applied','failed','dismissed') ORDER BY COALESCE(applied_at, created_at) DESC LIMIT 300" ); + } + + private static function kpis( array $r ): array { + $cost = (float) ( $r['cost'] ?? 0 ); + $sales = (float) ( $r['sales'] ?? 0 ); + $clk = (int) ( $r['clicks'] ?? 0 ); + $imp = (int) ( $r['impressions'] ?? 0 ); + $ord = (int) ( $r['orders'] ?? 0 ); + return array( + 'impressions' => $imp, + 'clicks' => $clk, + 'cost' => round( $cost, 2 ), + 'sales' => round( $sales, 2 ), + 'orders' => $ord, + 'acos' => $sales > 0 ? round( $cost / $sales * 100, 2 ) : null, + 'roas' => $cost > 0 ? round( $sales / $cost, 2 ) : null, + 'ctr' => $imp > 0 ? round( $clk / $imp * 100, 2 ) : null, + 'cpc' => $clk > 0 ? round( $cost / $clk, 2 ) : null, + 'cvr' => $clk > 0 ? round( $ord / $clk * 100, 2 ) : null, + ); + } +} diff --git a/sg-commerce-pro/src/Suite/Ads/AdsSync.php b/sg-commerce-pro/src/Suite/Ads/AdsSync.php new file mode 100644 index 0000000..85c6644 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Ads/AdsSync.php @@ -0,0 +1,424 @@ + array( + 'group' => array( 'campaign' ), + 'columns' => array( 'date', 'campaignId', 'campaignName', 'campaignStatus', 'campaignBudgetAmount', 'impressions', 'clicks', 'cost', 'sales7d', 'purchases7d', 'unitsSoldClicks7d' ), + ), + 'spTargeting' => array( + 'group' => array( 'targeting' ), + 'columns' => array( 'date', 'campaignId', 'adGroupId', 'keywordId', 'keyword', 'matchType', 'targeting', 'keywordType', 'impressions', 'clicks', 'cost', 'sales7d', 'purchases7d', 'unitsSoldClicks7d' ), + ), + 'spSearchTerm' => array( + 'group' => array( 'searchTerm' ), + 'columns' => array( 'date', 'campaignId', 'adGroupId', 'keywordId', 'keyword', 'matchType', 'searchTerm', 'impressions', 'clicks', 'cost', 'sales7d', 'purchases7d' ), + ), + ); + + public function __construct( + private AdsClient $ads, + private SuiteSettings $settings, + private Logger $logger, + ) {} + + public function profile(): string { + return $this->settings->string( 'suite_ads_profile_id' ); + } + + /** @return array{campaigns:int, targets:int} */ + public function sync_structure(): array { + $profile = $this->profile(); + $now = Db::now(); + $nc = 0; + foreach ( $this->ads->campaigns() as $c ) { + Db::upsert( 'ads_campaigns', array( + 'profile_id' => $profile, + 'campaign_id' => (string) ( $c['campaignId'] ?? '' ), + 'name' => mb_substr( (string) ( $c['name'] ?? '' ), 0, 255 ), + 'state' => (string) ( $c['state'] ?? '' ), + 'targeting_type' => (string) ( $c['targetingType'] ?? '' ), + 'daily_budget' => (float) ( $c['budget']['budget'] ?? 0 ), + 'bidding_strategy' => (string) ( $c['dynamicBidding']['strategy'] ?? '' ), + 'start_date' => ! empty( $c['startDate'] ) ? substr( (string) $c['startDate'], 0, 10 ) : null, + 'updated_at' => $now, + 'is_demo' => 0, + ) ); + $nc++; + } + $nt = 0; + foreach ( $this->ads->keywords() as $k ) { + Db::upsert( 'ads_targets', array( + 'profile_id' => $profile, + 'target_id' => (string) ( $k['keywordId'] ?? '' ), + 'kind' => 'keyword', + 'campaign_id' => (string) ( $k['campaignId'] ?? '' ), + 'ad_group_id' => (string) ( $k['adGroupId'] ?? '' ), + 'keyword_text' => mb_substr( (string) ( $k['keywordText'] ?? '' ), 0, 255 ), + 'match_type' => (string) ( $k['matchType'] ?? '' ), + 'state' => (string) ( $k['state'] ?? '' ), + 'bid' => isset( $k['bid'] ) ? (float) $k['bid'] : null, + 'updated_at' => $now, + 'is_demo' => 0, + ) ); + $nt++; + } + foreach ( $this->ads->targets() as $t ) { + $expr = array(); + foreach ( (array) ( $t['expression'] ?? array() ) as $e ) { + $expr[] = strtolower( (string) ( $e['type'] ?? '' ) ) . ( isset( $e['value'] ) ? '=' . $e['value'] : '' ); + } + Db::upsert( 'ads_targets', array( + 'profile_id' => $profile, + 'target_id' => (string) ( $t['targetId'] ?? '' ), + 'kind' => 'target', + 'campaign_id' => (string) ( $t['campaignId'] ?? '' ), + 'ad_group_id' => (string) ( $t['adGroupId'] ?? '' ), + 'keyword_text' => mb_substr( implode( ' ', $expr ), 0, 255 ), + 'match_type' => (string) ( $t['expressionType'] ?? '' ), + 'state' => (string) ( $t['state'] ?? '' ), + 'bid' => isset( $t['bid'] ) ? (float) $t['bid'] : null, + 'updated_at' => $now, + 'is_demo' => 0, + ) ); + $nt++; + } + return array( 'campaigns' => $nc, 'targets' => $nt ); + } + + /** + * Request the three daily reports for [start, end] (max 31 days). + * + * @return array type => remote report id + */ + public function request_reports( string $start, string $end ): array { + $out = array(); + foreach ( self::REPORTS as $type => $spec ) { + try { + $id = $this->ads->create_report( "sg-{$type}-{$start}-{$end}", $type, $spec['group'], $spec['columns'], $start, $end ); + Db::insert( 'reports', array( + 'report_type' => $type, + 'market' => '', + 'source' => 'ads', + 'remote_id' => $id, + 'status' => 'requested', + 'data_start' => $start . ' 00:00:00', + 'data_end' => $end . ' 23:59:59', + 'requested_at' => Db::now(), + ) ); + $out[ $type ] = $id; + } catch ( \Throwable $e ) { + // 425 = duplicate request already in flight; anything else is logged. + $this->logger->warning( 'Ads report request failed', array( 'type' => $type, 'error' => $e->getMessage() ) ); + } + } + return $out; + } + + /** Ingest a downloaded GZIP_JSON report. */ + public function ingest( string $type, string $raw, bool $demo = false ): int { + $rows = ReportParser::json( $raw ); + $profile = $this->profile(); + $n = 0; + foreach ( $rows as $r ) { + if ( ! is_array( $r ) ) { + continue; + } + $date = substr( (string) ( $r['date'] ?? '' ), 0, 10 ); + if ( '' === $date ) { + continue; + } + if ( 'spSearchTerm' === $type ) { + $term = mb_substr( (string) ( $r['searchTerm'] ?? '' ), 0, 255 ); + Db::upsert( 'ads_search_terms', array( + 'profile_id' => $profile, + 'row_hash' => sha1( implode( '|', array( $profile, $date, $r['campaignId'] ?? '', $r['adGroupId'] ?? '', $r['keywordId'] ?? '', $term ) ) ), + 'report_date' => $date, + 'campaign_id' => (string) ( $r['campaignId'] ?? '' ), + 'ad_group_id' => (string) ( $r['adGroupId'] ?? '' ), + 'target_id' => (string) ( $r['keywordId'] ?? '' ), + 'keyword_text' => mb_substr( (string) ( $r['keyword'] ?? $r['targeting'] ?? '' ), 0, 255 ), + 'match_type' => (string) ( $r['matchType'] ?? '' ), + 'search_term' => $term, + 'impressions' => (int) ( $r['impressions'] ?? 0 ), + 'clicks' => (int) ( $r['clicks'] ?? 0 ), + 'cost' => (float) ( $r['cost'] ?? 0 ), + 'sales' => (float) ( $r['sales7d'] ?? 0 ), + 'orders' => (int) ( $r['purchases7d'] ?? 0 ), + 'is_demo' => $demo ? 1 : 0, + ) ); + } else { + $is_campaign = 'spCampaigns' === $type; + Db::upsert( 'ads_daily', array( + 'profile_id' => $profile, + 'report_date' => $date, + 'level' => $is_campaign ? 'campaign' : 'target', + 'campaign_id' => (string) ( $r['campaignId'] ?? '' ), + 'ad_group_id' => (string) ( $r['adGroupId'] ?? '' ), + 'target_id' => $is_campaign ? '' : (string) ( $r['keywordId'] ?? '' ), + 'keyword_text' => mb_substr( (string) ( $r['keyword'] ?? $r['targeting'] ?? $r['campaignName'] ?? '' ), 0, 255 ), + 'match_type' => (string) ( $r['matchType'] ?? '' ), + 'impressions' => (int) ( $r['impressions'] ?? 0 ), + 'clicks' => (int) ( $r['clicks'] ?? 0 ), + 'cost' => (float) ( $r['cost'] ?? 0 ), + 'sales' => (float) ( $r['sales7d'] ?? 0 ), + 'orders' => (int) ( $r['purchases7d'] ?? 0 ), + 'units' => (int) ( $r['unitsSoldClicks7d'] ?? 0 ), + 'is_demo' => $demo ? 1 : 0, + ) ); + } + $n++; + } + return $n; + } + + /** + * Build a fresh optimisation plan. Previously planned (unapplied) + * actions are superseded so the queue never shows stale advice. + * + * @return array{bids:int, terms:int, budgets:int} + */ + public function optimize( ?string $today = null ): array { + $today = $today ?? gmdate( 'Y-m-d' ); + $lookback = max( 3, $this->settings->int( 'suite_ads_lookback_days' ) ); + $from = gmdate( 'Y-m-d', strtotime( $today . " -{$lookback} days" ) ); + // Ads attribution lags 1–2 days; exclude the most recent 2 days. + $to = gmdate( 'Y-m-d', strtotime( $today . ' -2 days' ) ); + + $aov = (float) Db::var( + "SELECT CASE WHEN SUM(orders) > 0 THEN SUM(sales)/SUM(orders) ELSE 0 END FROM {t:ads_daily} + WHERE level = 'campaign' AND report_date BETWEEN %s AND %s", + array( $from, $to ) + ); + $opt = new BidOptimizer( array( + 'target_acos' => $this->settings->float( 'suite_ads_target_acos' ), + 'min_clicks' => $this->settings->int( 'suite_ads_min_clicks' ), + 'max_step_pct' => $this->settings->float( 'suite_ads_max_step_pct' ), + 'min_bid' => $this->settings->float( 'suite_ads_min_bid' ), + 'max_bid' => $this->settings->float( 'suite_ads_max_bid' ), + 'raise_low_impr' => $this->settings->bool( 'suite_ads_raise_low_impr' ), + 'neg_min_clicks' => $this->settings->int( 'suite_ads_neg_min_clicks' ), + 'harvest_min_orders' => $this->settings->int( 'suite_ads_harvest_min_orders' ), + 'avg_order_value' => $aov, + ) ); + + $targets = Db::rows( + "SELECT t.target_id, t.kind, t.campaign_id, t.ad_group_id, t.keyword_text AS label, t.state, t.bid, + COALESCE(SUM(d.impressions),0) AS impressions, COALESCE(SUM(d.clicks),0) AS clicks, + COALESCE(SUM(d.cost),0) AS cost, COALESCE(SUM(d.sales),0) AS sales, COALESCE(SUM(d.orders),0) AS orders + FROM {t:ads_targets} t + LEFT JOIN {t:ads_daily} d ON d.target_id = t.target_id AND d.level = 'target' AND d.report_date BETWEEN %s AND %s + WHERE t.state = 'ENABLED' AND t.bid IS NOT NULL + GROUP BY t.target_id, t.kind, t.campaign_id, t.ad_group_id, t.keyword_text, t.state, t.bid", + array( $from, $to ) + ); + $targets = array_map( static fn( $r ) => array( + 'target_id' => (string) $r['target_id'], 'kind' => (string) $r['kind'], 'campaign_id' => (string) $r['campaign_id'], + 'ad_group_id' => (string) $r['ad_group_id'], 'label' => (string) $r['label'], 'state' => (string) $r['state'], + 'bid' => (float) $r['bid'], 'impressions' => (int) $r['impressions'], 'clicks' => (int) $r['clicks'], + 'cost' => (float) $r['cost'], 'sales' => (float) $r['sales'], 'orders' => (int) $r['orders'], + ), $targets ); + + $terms = Db::rows( + "SELECT search_term, campaign_id, ad_group_id, MAX(keyword_text) AS keyword_text, MAX(match_type) AS match_type, + SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(cost) AS cost, SUM(sales) AS sales, SUM(orders) AS orders + FROM {t:ads_search_terms} WHERE report_date BETWEEN %s AND %s + GROUP BY search_term, campaign_id, ad_group_id", + array( $from, $to ) + ); + $exact = array(); + foreach ( Db::rows( "SELECT keyword_text FROM {t:ads_targets} WHERE kind = 'keyword' AND match_type = 'EXACT' AND state <> 'ARCHIVED'" ) as $k ) { + $exact[ strtolower( (string) $k['keyword_text'] ) ] = true; + } + $neg = array(); + foreach ( Db::rows( "SELECT ad_group_id, entity_id FROM {t:ads_actions} WHERE kind = 'negative' AND status = 'applied'" ) as $k ) { + $neg[ $k['ad_group_id'] . '|' . strtolower( (string) $k['entity_id'] ) ] = true; + } + + $campaigns = Db::rows( + "SELECT c.campaign_id, c.name, c.state, c.daily_budget, + COUNT(DISTINCT d.report_date) AS days, COALESCE(SUM(d.cost),0) AS cost, COALESCE(SUM(d.sales),0) AS sales + FROM {t:ads_campaigns} c + LEFT JOIN {t:ads_daily} d ON d.campaign_id = c.campaign_id AND d.level = 'campaign' AND d.report_date BETWEEN %s AND %s + GROUP BY c.campaign_id, c.name, c.state, c.daily_budget", + array( gmdate( 'Y-m-d', strtotime( $today . ' -9 days' ) ), $to ) + ); + + $plan = array_merge( + $opt->bids( $targets ), + $opt->search_terms( + array_map( static fn( $r ) => array_merge( $r, array( + 'impressions' => (int) $r['impressions'], 'clicks' => (int) $r['clicks'], 'cost' => (float) $r['cost'], + 'sales' => (float) $r['sales'], 'orders' => (int) $r['orders'], + ) ), $terms ), + $exact, $neg, + $this->settings->string( 'suite_ads_harvest_campaign' ), + $this->settings->string( 'suite_ads_harvest_ad_group' ) + ), + $opt->budgets( array_map( static fn( $r ) => array( + 'campaign_id' => (string) $r['campaign_id'], 'name' => (string) $r['name'], 'state' => (string) $r['state'], + 'daily_budget' => (float) $r['daily_budget'], 'days' => (int) $r['days'], 'cost' => (float) $r['cost'], 'sales' => (float) $r['sales'], + ), $campaigns ) ) + ); + + Db::exec( "UPDATE {t:ads_actions} SET status = 'superseded' WHERE status = 'planned'" ); + $demo = (int) Db::var( 'SELECT COUNT(*) FROM {t:ads_targets} WHERE is_demo = 1' ) > 0 ? 1 : 0; + $counts = array( 'bids' => 0, 'terms' => 0, 'budgets' => 0 ); + foreach ( $plan as $a ) { + Db::insert( 'ads_actions', array( + 'profile_id' => $this->profile(), + 'kind' => $a['kind'], + 'entity_id' => $a['entity_id'], + 'campaign_id' => $a['campaign_id'], + 'ad_group_id' => $a['ad_group_id'], + 'label' => mb_substr( $a['label'], 0, 255 ), + 'old_value' => $a['old_value'], + 'new_value' => $a['new_value'], + 'reason' => $a['reason'], + 'payload' => wp_json_encode( $a['payload'] ), + 'status' => 'planned', + 'created_at' => Db::now(), + 'is_demo' => $demo, + ) ); + $key = match ( $a['kind'] ) { + 'bid', 'pause' => 'bids', + 'budget' => 'budgets', + default => 'terms', + }; + $counts[ $key ]++; + } + if ( $this->settings->bool( 'suite_ads_auto_apply' ) && $this->ads->is_ready() ) { + $ids = array_map( 'intval', array_column( Db::rows( "SELECT id FROM {t:ads_actions} WHERE status = 'planned' AND kind IN ('bid','negative') AND is_demo = 0" ), 'id' ) ); + if ( $ids ) { + $this->apply( $ids ); + } + } + return $counts; + } + + /** + * Push planned actions to Amazon. Demo actions are marked applied + * without network calls. + * + * @param int[] $ids + * @return array{applied:int, failed:int} + */ + public function apply( array $ids ): array { + $ids = array_values( array_filter( array_map( 'intval', $ids ) ) ); + $res = array( 'applied' => 0, 'failed' => 0 ); + if ( ! $ids ) { + return $res; + } + $rows = Db::rows( 'SELECT * FROM {t:ads_actions} WHERE status = %s AND id IN (' . Db::in( $ids, '%d' ) . ')', array_merge( array( 'planned' ), $ids ) ); + foreach ( $rows as $a ) { + try { + if ( ! (int) $a['is_demo'] ) { + $this->push( $a ); + } + Db::update( 'ads_actions', array( 'status' => 'applied', 'applied_at' => Db::now(), 'error' => null ), array( 'id' => (int) $a['id'] ) ); + $this->reflect_locally( $a ); + $res['applied']++; + } catch ( \Throwable $e ) { + Db::update( 'ads_actions', array( 'status' => 'failed', 'error' => mb_substr( $e->getMessage(), 0, 500 ) ), array( 'id' => (int) $a['id'] ) ); + $res['failed']++; + } + } + return $res; + } + + public function dismiss( array $ids ): int { + $ids = array_values( array_filter( array_map( 'intval', $ids ) ) ); + if ( ! $ids ) { + return 0; + } + return Db::exec( "UPDATE {t:ads_actions} SET status = 'dismissed' WHERE status = 'planned' AND id IN (" . Db::in( $ids, '%d' ) . ')', $ids ); + } + + private function push( array $a ): void { + $payload = json_decode( (string) $a['payload'], true ) ?: array(); + $errors = static function ( array $resp, string $key ): void { + $err = $resp[ $key ]['error'] ?? array(); + if ( ! empty( $err ) ) { + $first = $err[0]['errors'][0]['errorValue'] ?? $err[0] ?? array(); + throw new \RuntimeException( 'Amazon rejected: ' . wp_json_encode( $first ) ); + } + }; + switch ( $a['kind'] ) { + case 'bid': + case 'pause': + $upd = 'pause' === $a['kind'] ? array( 'state' => 'PAUSED' ) : array( 'bid' => (float) $a['new_value'] ); + if ( 'target' === ( $payload['target_kind'] ?? 'keyword' ) ) { + $errors( $this->ads->update_targets( array( array( 'targetId' => (string) $a['entity_id'] ) + $upd ) ), 'targetingClauses' ); + } else { + $errors( $this->ads->update_keywords( array( array( 'keywordId' => (string) $a['entity_id'] ) + $upd ) ), 'keywords' ); + } + break; + case 'negative': + $errors( $this->ads->create_negative_keywords( array( array( + 'campaignId' => (string) $a['campaign_id'], 'adGroupId' => (string) $a['ad_group_id'], + 'keywordText' => (string) $a['entity_id'], 'matchType' => 'NEGATIVE_EXACT', 'state' => 'ENABLED', + ) ) ), 'negativeKeywords' ); + break; + case 'harvest': + if ( '' === (string) $a['ad_group_id'] ) { + throw new \RuntimeException( 'Set a harvest destination ad group in Suite settings first.' ); + } + $errors( $this->ads->create_keywords( array( array( + 'campaignId' => (string) $a['campaign_id'], 'adGroupId' => (string) $a['ad_group_id'], + 'keywordText' => (string) $a['entity_id'], 'matchType' => 'EXACT', 'state' => 'ENABLED', + 'bid' => (float) ( $payload['bid'] ?? 0.5 ), + ) ) ), 'keywords' ); + // Prevent the source (auto/broad) ad group from cannibalising the new exact keyword. + if ( ! empty( $payload['source_ad_group_id'] ) && $payload['source_ad_group_id'] !== $a['ad_group_id'] ) { + $this->ads->create_negative_keywords( array( array( + 'campaignId' => (string) $payload['source_campaign_id'], 'adGroupId' => (string) $payload['source_ad_group_id'], + 'keywordText' => (string) $a['entity_id'], 'matchType' => 'NEGATIVE_EXACT', 'state' => 'ENABLED', + ) ) ); + } + break; + case 'budget': + $errors( $this->ads->update_campaigns( array( array( + 'campaignId' => (string) $a['campaign_id'], + 'budget' => array( 'budget' => (float) $a['new_value'], 'budgetType' => 'DAILY' ), + ) ) ), 'campaigns' ); + break; + default: + throw new \RuntimeException( 'This action type must be applied manually in Campaign Manager (ASIN targeting).' ); + } + } + + /** Mirror an applied change into the local tables so the UI is consistent before the next sync. */ + private function reflect_locally( array $a ): void { + match ( $a['kind'] ) { + 'bid' => Db::update( 'ads_targets', array( 'bid' => (float) $a['new_value'] ), array( 'target_id' => (string) $a['entity_id'] ) ), + 'pause' => Db::update( 'ads_targets', array( 'state' => 'PAUSED' ), array( 'target_id' => (string) $a['entity_id'] ) ), + 'budget' => Db::update( 'ads_campaigns', array( 'daily_budget' => (float) $a['new_value'] ), array( 'campaign_id' => (string) $a['campaign_id'] ) ), + default => 0, + }; + } +} diff --git a/sg-commerce-pro/src/Suite/Ads/BidOptimizer.php b/sg-commerce-pro/src/Suite/Ads/BidOptimizer.php new file mode 100644 index 0000000..2f42322 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Ads/BidOptimizer.php @@ -0,0 +1,240 @@ + 0 → target CPC = (sales / clicks) × targetACoS + * move bid toward target CPC, capped at ±max_step%. + * 2. orders = 0 and clicks ≥ min_clicks + * → cut bid by max_step% (bleeding spend, no conversions). + * If spend ≥ 2 × (avg order value × targetACoS) → pause. + * 3. impressions < 100 and (no data or ACoS < target) + * → raise bid by half the step to win visibility. + * Changes smaller than 3 % or 0.02 are dropped as noise. + * Bids always clamp to [min_bid, max_bid]. + * + * Search-term logic: + * - Harvest: term with ≥ harvest_min_orders orders and ACoS ≤ 1.2×target, + * not already an exact keyword → add as EXACT (and negate in source). + * - Negate: term with ≥ neg_min_clicks clicks and 0 orders → NEGATIVE_EXACT + * in its source ad group. + * + * Budget logic: + * - Campaign spending ≥ 90 % of budget on average with ACoS < target + * → raise budget 20 %. ACoS > 1.5×target and spending at cap → cut 15 %. + * + * @package SevenGum\Commerce\Suite\Ads + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Suite\Ads; + +final class BidOptimizer { + + /** + * @param array $cfg {target_acos, min_clicks, max_step_pct, min_bid, max_bid, raise_low_impr, neg_min_clicks, harvest_min_orders, avg_order_value} + */ + public function __construct( private array $cfg ) { + $this->cfg += array( + 'target_acos' => 30.0, + 'min_clicks' => 12, + 'max_step_pct' => 20.0, + 'min_bid' => 0.20, + 'max_bid' => 4.00, + 'raise_low_impr' => true, + 'neg_min_clicks' => 15, + 'harvest_min_orders' => 2, + 'avg_order_value' => 0.0, + ); + } + + /** + * @param array $targets + * @return array + */ + public function bids( array $targets ): array { + $t = (float) $this->cfg['target_acos'] / 100; + $step = (float) $this->cfg['max_step_pct'] / 100; + $min = (float) $this->cfg['min_bid']; + $max = (float) $this->cfg['max_bid']; + $out = array(); + + foreach ( $targets as $r ) { + if ( 'ENABLED' !== strtoupper( (string) $r['state'] ) || $r['bid'] <= 0 ) { + continue; + } + $bid = (float) $r['bid']; + $clicks = (int) $r['clicks']; + $orders = (int) $r['orders']; + $cost = (float) $r['cost']; + $sales = (float) $r['sales']; + $acos = $sales > 0 ? $cost / $sales : null; + $new = null; + $why = ''; + $pause = false; + + if ( $orders > 0 && $clicks > 0 ) { + $target_cpc = ( $sales / $clicks ) * $t; + $new = max( $bid * ( 1 - $step ), min( $bid * ( 1 + $step ), $target_cpc ) ); + $why = sprintf( + 'ACoS %.1f%% vs target %.1f%% · %d clicks · %d orders · RPC %s → target CPC %s', + $acos * 100, $t * 100, $clicks, $orders, self::m( $sales / $clicks ), self::m( $target_cpc ) + ); + } elseif ( 0 === $orders && $clicks >= (int) $this->cfg['min_clicks'] ) { + $aov = (float) $this->cfg['avg_order_value']; + if ( $aov > 0 && $cost >= 2 * $aov * $t ) { + $pause = true; + $why = sprintf( 'No orders after %d clicks; spent %s (≥ 2× break-even CPA %s)', $clicks, self::m( $cost ), self::m( $aov * $t ) ); + } else { + $new = $bid * ( 1 - $step ); + $why = sprintf( 'No orders after %d clicks (spent %s)', $clicks, self::m( $cost ) ); + } + } elseif ( $this->cfg['raise_low_impr'] && (int) $r['impressions'] < 100 && ( null === $acos || $acos < $t ) ) { + $new = $bid * ( 1 + $step / 2 ); + $why = sprintf( 'Low visibility: %d impressions in window', (int) $r['impressions'] ); + } + + if ( $pause ) { + $out[] = $this->action( 'pause', $r, (string) $bid, 'PAUSED', $why ); + continue; + } + if ( null === $new ) { + continue; + } + $new = round( max( $min, min( $max, $new ) ), 2 ); + if ( abs( $new - $bid ) < 0.02 || abs( $new - $bid ) / $bid < 0.03 ) { + continue; + } + $out[] = $this->action( 'bid', $r, number_format( $bid, 2, '.', '' ), number_format( $new, 2, '.', '' ), $why ); + } + return $out; + } + + /** + * @param array $terms + * @param array $existing_exact lower-case exact keywords already targeted + * @param array $existing_neg "adgroup|term" negatives already present + */ + public function search_terms( array $terms, array $existing_exact, array $existing_neg, string $harvest_campaign, string $harvest_ad_group ): array { + $t = (float) $this->cfg['target_acos'] / 100; + $out = array(); + $harvested = array(); + foreach ( $terms as $r ) { + $term = strtolower( trim( (string) $r['search_term'] ) ); + if ( '' === $term ) { + continue; + } + $orders = (int) $r['orders']; + $clicks = (int) $r['clicks']; + $cost = (float) $r['cost']; + $sales = (float) $r['sales']; + $acos = $sales > 0 ? $cost / $sales : null; + $is_asin = 1 === preg_match( '/^b0[a-z0-9]{8}$/', $term ); + $neg_key = $r['ad_group_id'] . '|' . $term; + + if ( $orders >= (int) $this->cfg['harvest_min_orders'] && null !== $acos && $acos <= $t * 1.2 + && ! isset( $existing_exact[ $term ] ) && ! isset( $harvested[ $term ] ) ) { + $harvested[ $term ] = true; + $cpc = $clicks > 0 ? $cost / $clicks : (float) $this->cfg['min_bid']; + $bid = round( max( (float) $this->cfg['min_bid'], min( (float) $this->cfg['max_bid'], $cpc * 1.1 ) ), 2 ); + $out[] = array( + 'kind' => $is_asin ? 'harvest_asin' : 'harvest', + 'entity_id' => $term, + 'campaign_id' => '' !== $harvest_campaign ? $harvest_campaign : (string) $r['campaign_id'], + 'ad_group_id' => '' !== $harvest_ad_group ? $harvest_ad_group : (string) $r['ad_group_id'], + 'label' => $term, + 'old_value' => '', + 'new_value' => $is_asin ? 'ASIN target @ ' . number_format( $bid, 2 ) : 'EXACT @ ' . number_format( $bid, 2 ), + 'reason' => sprintf( '%d orders · ACoS %.1f%% · from "%s" (%s)', $orders, $acos * 100, $r['keyword_text'], $r['match_type'] ), + 'payload' => array( + 'term' => $term, 'bid' => $bid, 'asin' => $is_asin, + 'source_campaign_id' => (string) $r['campaign_id'], 'source_ad_group_id' => (string) $r['ad_group_id'], + ), + ); + continue; + } + + if ( 0 === $orders && $clicks >= (int) $this->cfg['neg_min_clicks'] && ! isset( $existing_neg[ $neg_key ] ) && ! $is_asin ) { + $out[] = array( + 'kind' => 'negative', + 'entity_id' => $term, + 'campaign_id' => (string) $r['campaign_id'], + 'ad_group_id' => (string) $r['ad_group_id'], + 'label' => $term, + 'old_value' => '', + 'new_value' => 'NEGATIVE_EXACT', + 'reason' => sprintf( '%d clicks, 0 orders, wasted %s', $clicks, self::m( $cost ) ), + 'payload' => array( 'term' => $term ), + ); + } + } + return $out; + } + + /** + * @param array $campaigns + */ + public function budgets( array $campaigns ): array { + $t = (float) $this->cfg['target_acos'] / 100; + $out = array(); + foreach ( $campaigns as $c ) { + if ( 'ENABLED' !== strtoupper( (string) $c['state'] ) || $c['daily_budget'] <= 0 || $c['days'] <= 0 ) { + continue; + } + $avg = $c['cost'] / $c['days']; + $util = $avg / $c['daily_budget']; + $acos = $c['sales'] > 0 ? $c['cost'] / $c['sales'] : null; + $new = null; + $why = ''; + if ( $util >= 0.9 && null !== $acos && $acos < $t ) { + $new = $c['daily_budget'] * 1.2; + $why = sprintf( 'Budget-capped (%.0f%% used/day) while profitable (ACoS %.1f%%)', $util * 100, $acos * 100 ); + } elseif ( $util >= 0.9 && ( null === $acos || $acos > $t * 1.5 ) ) { + $new = $c['daily_budget'] * 0.85; + $why = null === $acos + ? sprintf( 'Budget-capped (%.0f%%) with zero sales', $util * 100 ) + : sprintf( 'Budget-capped (%.0f%%) at ACoS %.1f%% (> 1.5× target)', $util * 100, $acos * 100 ); + } + if ( null === $new ) { + continue; + } + $new = max( 1.0, round( $new, 2 ) ); + $out[] = array( + 'kind' => 'budget', + 'entity_id' => (string) $c['campaign_id'], + 'campaign_id' => (string) $c['campaign_id'], + 'ad_group_id' => '', + 'label' => (string) $c['name'], + 'old_value' => number_format( (float) $c['daily_budget'], 2, '.', '' ), + 'new_value' => number_format( $new, 2, '.', '' ), + 'reason' => $why, + 'payload' => array(), + ); + } + return $out; + } + + private function action( string $kind, array $r, string $old, string $new, string $why ): array { + return array( + 'kind' => $kind, + 'entity_id' => (string) $r['target_id'], + 'campaign_id' => (string) $r['campaign_id'], + 'ad_group_id' => (string) $r['ad_group_id'], + 'label' => (string) $r['label'], + 'old_value' => $old, + 'new_value' => $new, + 'reason' => $why, + 'payload' => array( 'target_kind' => (string) $r['kind'] ), + ); + } + + private static function m( float $v ): string { + return number_format( $v, 2 ); + } +} diff --git a/sg-commerce-pro/src/Suite/Demo/DemoSeeder.php b/sg-commerce-pro/src/Suite/Demo/DemoSeeder.php new file mode 100644 index 0000000..cd7f534 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Demo/DemoSeeder.php @@ -0,0 +1,612 @@ + counts per table */ + public function seed( string $market = 'US' ): array { + if ( function_exists( 'set_time_limit' ) ) { + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + $this->clear(); + mt_srand( 7777 ); + $this->market = $market; + $this->today = SuiteTime::today( $market ); + $catalog = $this->catalog(); + $counts = array(); + + $counts['products'] = $this->products( $catalog ); + $counts['orders'] = $this->orders_and_finance( $catalog ); + $counts['account'] = $this->account_fees(); + $counts['ads'] = $this->ads(); + $counts['traffic'] = $this->traffic( $catalog ); + $counts['keywords'] = $this->keywords( $catalog ); + $counts['monitor'] = $this->monitor( $catalog ); + $counts['ledger'] = $this->ledger( $catalog ); + $counts['health'] = $this->inventory_health( $catalog ); + $counts['feedback'] = $this->feedback(); + $counts['listings'] = $this->listings( $catalog ); + $counts['reviews'] = $this->solicitations(); + $counts['expenses'] = $this->expenses(); + + $max_case = (int) Db::var( 'SELECT COALESCE(MAX(id),0) FROM {t:reimb_cases}' ); + $this->auditor->scan(); + Db::exec( 'UPDATE {t:reimb_cases} SET is_demo = 1 WHERE id > %d', array( $max_case ) ); + $counts['reimb_cases'] = (int) Db::var( 'SELECT COUNT(*) FROM {t:reimb_cases} WHERE is_demo = 1' ); + + $counts['alerts'] = $this->alerts(); + + // Build a PPC plan so the Optimizer has reviewable recommendations straight away. + $counts['ppc_actions'] = array_sum( $this->ads->optimize( $this->today ) ); + + update_option( self::OPTION, 1, false ); + return $counts; + } + + public function clear(): void { + global $wpdb; + foreach ( SuiteSchema::TABLES as $t ) { + if ( in_array( $t, array( 'reports', 'sku_costs' ), true ) ) { + continue; + } + Db::exec( "DELETE FROM {t:{$t}} WHERE is_demo = 1" ); + } + Db::exec( 'DELETE FROM {t:sku_costs} WHERE sku LIKE %s', array( 'DEMO-%' ) ); + $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}sg_products WHERE sku LIKE %s", 'DEMO-%' ) ); // phpcs:ignore + if ( $ids ) { + $in = implode( ',', array_map( 'intval', $ids ) ); + $wpdb->query( "DELETE FROM {$wpdb->prefix}sg_price_history WHERE product_id IN ({$in})" ); // phpcs:ignore + $wpdb->query( "DELETE FROM {$wpdb->prefix}sg_products WHERE id IN ({$in})" ); // phpcs:ignore + } + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}sg_alerts WHERE context LIKE %s", '%' . $wpdb->esc_like( '"demo":true' ) . '%' ) ); // phpcs:ignore + delete_option( self::OPTION ); + } + + private function alerts(): int { + $ctx = array( 'demo' => true, 'market' => $this->market ); + AlertDispatcher::create( 'critical', 'suite_hijacker', 'New seller on your ASIN B0DEMO0006: A3HIJACK9DEMO (FBM @ 4.49)', 'A seller that was not on this listing before is now offering it. If you are brand-registered, verify authenticity and file a Report a Violation case; consider a test buy.', $ctx ); + AlertDispatcher::create( 'warning', 'suite_buybox', 'Buy Box lost on B0DEMO0006 to A3HIJACK9DEMO', 'Winning price now 4.49.', $ctx ); + AlertDispatcher::create( 'warning', 'suite_feedback', 'Negative seller feedback (1★) on a recent order', 'Never received my order.', $ctx ); + AlertDispatcher::create( 'warning', 'suite_listing', 'Listing content changed: DEMO-P1-STRAWBERRY (B0DEMO0005)', 'Title, bullets, description or images differ from the last audit. If you did not make this change, check for a catalog contribution from Amazon or another seller.', $ctx ); + AlertDispatcher::create( 'info', 'suite_competitor', 'Competitor B0COMP0002 dropped price 18% (15.99 → 13.11)', '', $ctx ); + return 5; + } + + /* ------------------------------------------------------------------ */ + + private function catalog(): array { + $flavors = array( 'Spearmint', 'Peppermint', 'Cinnamon', 'Watermelon', 'Strawberry', 'Blueberry', 'Lemon', 'Cool Mint' ); + $out = array(); + $i = 1; + foreach ( $flavors as $f ) { + $slug = strtoupper( str_replace( ' ', '', $f ) ); + $out[] = array( 'sku' => "DEMO-P1-{$slug}", 'asin' => sprintf( 'B0DEMO%04d', $i++ ), 'title' => "7 SEVEN Sugar-Free Gum, {$f}, 50 Pieces (1 Bottle)", 'price' => 4.99, 'cost' => 1.10, 'fba' => 3.22, 'demand' => 'Spearmint' === $f ? 9.0 : ( in_array( $f, array( 'Peppermint', 'Watermelon' ), true ) ? 5.5 : 2.6 ), 'pack' => 'P1' ); + } + foreach ( $flavors as $f ) { + $slug = strtoupper( str_replace( ' ', '', $f ) ); + $out[] = array( 'sku' => "DEMO-P4-{$slug}", 'asin' => sprintf( 'B0DEMO%04d', $i++ ), 'title' => "7 SEVEN Sugar-Free Gum, {$f}, 50 Pieces (Pack of 4)", 'price' => 16.99, 'cost' => 4.20, 'fba' => 4.02, 'demand' => 'Spearmint' === $f ? 6.5 : ( in_array( $f, array( 'Peppermint', 'Cinnamon' ), true ) ? 3.4 : 1.4 ), 'pack' => 'P4' ); + } + foreach ( array( 'Fruit Variety', 'Mint Variety', 'Ultimate Variety' ) as $f ) { + $slug = strtoupper( str_replace( ' ', '', $f ) ); + $out[] = array( 'sku' => "DEMO-P9-{$slug}", 'asin' => sprintf( 'B0DEMO%04d', $i++ ), 'title' => "7 SEVEN Sugar-Free Gum {$f} Pack, 9 Bottles x 50 Pieces", 'price' => 34.99, 'cost' => 9.50, 'fba' => 5.34, 'demand' => 'Ultimate Variety' === $f ? 3.8 : 1.7, 'pack' => 'P9' ); + } + return $out; + } + + private function products( array $catalog ): int { + global $wpdb; + $stock_profile = array( 0 => 640, 1 => 35, 2 => 0, 3 => 410, 4 => 2600, 8 => 88, 16 => 140 ); + foreach ( $catalog as $i => $p ) { + $fulfillable = $stock_profile[ $i ] ?? (int) ( $p['demand'] * mt_rand( 35, 120 ) ); + $wpdb->insert( "{$wpdb->prefix}sg_products", array( + 'market' => $this->market, 'sku' => $p['sku'], 'asin' => $p['asin'], 'product_name' => $p['title'], + 'fulfillable_qty' => $fulfillable, 'reserved_qty' => (int) ( $fulfillable * 0.06 ), 'inbound_qty' => 1 === $i ? 600 : ( 0 === $i % 5 ? 240 : 0 ), + 'buybox_price' => $p['price'], 'buybox_currency' => 'USD', 'buybox_is_mine' => 5 === $i ? 0 : 1, 'lowest_price' => $p['price'], + 'my_price' => $p['price'], 'cost_price' => $p['cost'], 'last_sync_at' => Db::now(), + ) ); + $pid = (int) $wpdb->insert_id; + if ( 2 === $i ) { + // Out of stock for the last 9 days — drives the lost-sales and OOS-aware velocity logic. + for ( $d = 0; $d < 9; $d++ ) { + $wpdb->insert( "{$wpdb->prefix}sg_price_history", array( 'product_id' => $pid, 'recorded_at' => gmdate( 'Y-m-d 12:00:00', time() - $d * DAY_IN_SECONDS ), 'my_price' => $p['price'], 'fulfillable_qty' => 0, 'currency' => 'USD', 'source' => 'demo' ) ); + } + } + Db::upsert( 'sku_costs', array( + 'market' => $this->market, 'sku' => $p['sku'], 'unit_cost' => $p['cost'], 'inbound_per_unit' => round( $p['cost'] * 0.12, 2 ), + 'prep_per_unit' => 'P1' === $p['pack'] ? 0.10 : 0.25, 'other_per_unit' => 0.0, 'lead_time_days' => 'P9' === $p['pack'] ? 45 : 35, + 'moq' => 'P1' === $p['pack'] ? 500 : 200, 'case_pack' => 'P1' === $p['pack'] ? 48 : ( 'P4' === $p['pack'] ? 24 : 12 ), + 'supplier' => 'Seven Gum Factory', 'updated_at' => Db::now(), + ) ); + } + return count( $catalog ); + } + + private function orders_and_finance( array $catalog ): int { + $orders = array(); + $items = array(); + $fin = array(); + $returns = array(); + $n = 0; + for ( $back = self::DAYS; $back >= 0; $back-- ) { + $date = SuiteTime::shift( $this->today, -$back ); + $dow = (int) ( new \DateTimeImmutable( $date ) )->format( 'N' ); + $season = 1 + 0.25 * sin( ( self::DAYS - $back ) / 9 ) + ( $dow >= 6 ? 0.12 : 0 ) + ( self::DAYS - $back ) * 0.004; + foreach ( $catalog as $i => $p ) { + if ( 2 === $i && $back < 9 ) { + continue; // out of stock + } + $units = $this->poisson( $p['demand'] * $season * ( 0 === $back ? 0.45 : 1 ) ); + while ( $units > 0 ) { + $qty = $units >= 2 && mt_rand( 1, 10 ) <= 2 ? 2 : 1; + $units -= $qty; + $oid = sprintf( '1%02d-%07d-%07d', mt_rand( 10, 99 ), ++$this->seq, mt_rand( 1000000, 9999999 ) ); + $hour = mt_rand( 0, 23 ); + $purchase_local = new \DateTimeImmutable( sprintf( '%s %02d:%02d:00', $date, $hour, mt_rand( 0, 59 ) ), SuiteTime::tz( $this->market ) ); + $purchase = $purchase_local->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); + $status = 0 === $back ? ( mt_rand( 1, 3 ) === 1 ? 'Pending' : 'Unshipped' ) : ( mt_rand( 1, 100 ) === 1 ? 'Canceled' : 'Shipped' ); + $promo = mt_rand( 1, 100 ) <= 8 ? round( $p['price'] * $qty * 0.15, 2 ) : 0.0; + $sales = round( $p['price'] * $qty, 2 ); + $deliv = gmdate( 'Y-m-d H:i:s', strtotime( $purchase . ' +' . mt_rand( 2, 4 ) . ' days' ) ); + $orders[] = array( + 'market' => $this->market, 'amazon_order_id' => $oid, 'purchase_date' => $purchase, 'local_date' => $date, + 'last_update' => $purchase, 'status' => $status, 'fulfillment_channel' => 'AFN', 'sales_channel' => 'Amazon.com', + 'order_total' => $sales - $promo, 'currency' => 'USD', 'units' => $qty, 'is_business' => mt_rand( 1, 30 ) === 1 ? 1 : 0, + 'is_prime' => mt_rand( 1, 10 ) <= 8 ? 1 : 0, 'ship_country' => 'US', 'earliest_delivery' => $deliv, 'latest_delivery' => $deliv, + 'items_synced' => 1, 'is_demo' => 1, + ); + $items[] = array( + 'market' => $this->market, 'amazon_order_id' => $oid, 'order_item_id' => 'demo-' . $this->seq, 'local_date' => $date, + 'sku' => $p['sku'], 'asin' => $p['asin'], 'title' => $p['title'], 'qty' => $qty, 'item_price' => $sales, + 'item_tax' => round( $sales * 0.0725, 2 ), 'promo_discount' => $promo, 'status' => $status, 'is_demo' => 1, + ); + $n++; + // Settlement lags shipment by ~2 days. + if ( 'Shipped' === $status && $back >= 2 ) { + $posted = gmdate( 'Y-m-d H:i:s', strtotime( $purchase . ' +' . mt_rand( 1, 2 ) . ' days' ) ); + $referral = round( $sales * ( $p['price'] <= 15 ? 0.08 : 0.15 ), 2 ); + $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Principal', 'revenue', $sales, $qty ); + $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Tax', 'tax', round( $sales * 0.0725, 2 ), 0 ); + $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Withheld:Tax', 'tax', -round( $sales * 0.0725, 2 ), 0 ); + $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Commission', 'fee', -$referral, 0 ); + $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'FBAPerUnitFulfillmentFee', 'fee', -round( ( $back < 25 && 4 === $i ? $p['fba'] * 1.32 : $p['fba'] ) * $qty, 2 ), 0 ); + if ( $promo > 0 ) { + $fin[] = $this->fin( $posted, 'Shipment', $oid, $p['sku'], 'Promotion:PromotionMetaDataDefinitionValue', 'promo', -$promo, 0 ); + } + // ~2.5 % refunded 5–20 days later. + if ( mt_rand( 1, 1000 ) <= 25 && $back > 6 ) { + $rposted = gmdate( 'Y-m-d H:i:s', strtotime( $posted . ' +' . mt_rand( 5, 20 ) . ' days' ) ); + if ( $rposted <= gmdate( 'Y-m-d H:i:s' ) ) { + $fin[] = $this->fin( $rposted, 'Refund', $oid, $p['sku'], 'Principal', 'refund', -$sales, -$qty ); + $fin[] = $this->fin( $rposted, 'Refund', $oid, $p['sku'], 'Commission', 'fee', round( $referral * 0.8, 2 ), 0 ); + $fin[] = $this->fin( $rposted, 'Refund', $oid, $p['sku'], 'RefundCommission', 'fee', -round( min( 5, $referral * 0.2 ), 2 ), 0 ); + if ( mt_rand( 1, 10 ) <= 7 ) { + $returns[] = array( + 'row_hash' => sha1( 'demo-ret-' . $oid ), 'market' => $this->market, 'return_date' => substr( $rposted, 0, 10 ), + 'amazon_order_id' => $oid, 'sku' => $p['sku'], 'asin' => $p['asin'], 'fnsku' => 'X00' . substr( $p['asin'], -7 ), + 'qty' => $qty, 'fulfillment_center' => array( 'PHX7', 'ONT8', 'MDW2', 'BFI4' )[ mt_rand( 0, 3 ) ], + 'disposition' => mt_rand( 1, 10 ) <= 6 ? 'SELLABLE' : 'CUSTOMER_DAMAGED', + 'reason' => array( 'NOT_AS_DESCRIBED', 'UNWANTED_ITEM', 'DEFECTIVE', 'ORDERED_WRONG_ITEM', 'TASTE' )[ mt_rand( 0, 4 ) ], + 'status' => 'Unit returned to inventory', 'comments' => array( '', 'flavor too strong', 'arrived opened', 'bought by mistake', 'lost freshness' )[ mt_rand( 0, 4 ) ], + 'is_demo' => 1, + ); + } + } + } + } + } + } + } + $this->bulk( 'orders', $orders ); + $this->bulk( 'order_items', $items ); + $this->bulk( 'fin_events', $fin ); + $this->bulk( 'returns', $returns ); + return $n; + } + + private function account_fees(): int { + $fin = array(); + for ( $m = 0; $m < 3; $m++ ) { + $first = ( new \DateTimeImmutable( $this->today ) )->modify( "first day of -{$m} month" ); + if ( $first->format( 'Y-m-d' ) > $this->today ) { + continue; + } + $fin[] = $this->fin( $first->format( 'Y-m-d 08:00:00' ), 'ServiceFee', '', '', 'Subscription', 'fee', -39.99, 0 ); + $storage = $first->modify( '+6 days' ); + if ( $storage->format( 'Y-m-d' ) <= $this->today ) { + $fin[] = $this->fin( $storage->format( 'Y-m-d 08:00:00' ), 'ServiceFee', '', '', 'FBAStorageFee', 'fee', -round( mt_rand( 9000, 16000 ) / 100, 2 ), 0 ); + } + $reimb = $first->modify( '+14 days' ); + if ( $reimb->format( 'Y-m-d' ) <= $this->today ) { + $fin[] = $this->fin( $reimb->format( 'Y-m-d 08:00:00' ), 'Adjustment', '', 'DEMO-P4-SPEARMINT', 'WAREHOUSE_LOST', 'reimbursement', 22.40, 2 ); + } + } + $this->bulk( 'fin_events', $fin ); + return count( $fin ); + } + + private function ads(): int { + $campaigns = array( + array( 'C-AUTO', 'SP | Auto | All Gum', 'AUTO', 60 ), + array( 'C-EXACT', 'SP | Exact | Hero Keywords', 'MANUAL', 45 ), + array( 'C-PHRASE', 'SP | Phrase | Sugar Free Gum', 'MANUAL', 30 ), + array( 'C-BROAD', 'SP | Broad | Discovery', 'MANUAL', 25 ), + array( 'C-PAT', 'SP | PAT | Competitor ASINs', 'MANUAL', 20 ), + array( 'C-BRAND', 'SP | Exact | Brand Defense', 'MANUAL', 2.5 ), + ); + $now = Db::now(); + // Brand defense is profitable and starved for budget → triggers the "raise budget" rule. + foreach ( $campaigns as [ $id, $name, $type, $budget ] ) { + Db::upsert( 'ads_campaigns', array( 'profile_id' => 'DEMO', 'campaign_id' => $id, 'name' => $name, 'state' => 'ENABLED', 'targeting_type' => $type, 'daily_budget' => (float) $budget, 'bidding_strategy' => 'LEGACY_FOR_SALES', 'start_date' => SuiteTime::shift( $this->today, -120 ), 'updated_at' => $now, 'is_demo' => 1 ) ); + } + // keyword, campaign, match, bid, ctr, cvr, cpc-factor + $kws = array( + array( 'sugar free gum', 'C-EXACT', 'EXACT', 1.45, 0.009, 0.14 ), + array( 'spearmint gum', 'C-EXACT', 'EXACT', 1.10, 0.012, 0.17 ), + array( 'chewing gum', 'C-EXACT', 'EXACT', 1.80, 0.006, 0.07 ), + array( 'xylitol gum', 'C-EXACT', 'EXACT', 0.95, 0.010, 0.15 ), + array( 'gum bulk', 'C-EXACT', 'EXACT', 1.25, 0.007, 0.05 ), + array( 'mint gum', 'C-EXACT', 'EXACT', 0.85, 0.011, 0.13 ), + array( 'cinnamon gum', 'C-EXACT', 'EXACT', 0.70, 0.010, 0.11 ), + array( 'sugar free gum', 'C-PHRASE', 'PHRASE', 1.05, 0.006, 0.09 ), + array( 'gum variety pack', 'C-PHRASE', 'PHRASE', 0.90, 0.008, 0.10 ), + array( 'fruit gum', 'C-PHRASE', 'PHRASE', 0.75, 0.007, 0.06 ), + array( 'gum', 'C-BROAD', 'BROAD', 0.65, 0.004, 0.04 ), + array( 'breath freshener', 'C-BROAD', 'BROAD', 0.60, 0.003, 0.02 ), + array( 'healthy snacks', 'C-BROAD', 'BROAD', 0.55, 0.002, 0.00 ), + array( '7 seven gum', 'C-BRAND', 'EXACT', 0.45, 0.045, 0.32 ), + array( 'seven gum', 'C-BRAND', 'EXACT', 0.40, 0.040, 0.28 ), + ); + $targets = array(); + foreach ( $kws as $k => [ $text, $camp, $match, $bid ] ) { + $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'K' . ( 1000 + $k ), 'kind' => 'keyword', 'campaign_id' => $camp, 'ad_group_id' => 'AG-' . $camp, 'keyword_text' => $text, 'match_type' => $match, 'state' => 'ENABLED', 'bid' => $bid, 'updated_at' => $now, 'is_demo' => 1 ); + } + $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'T2001', 'kind' => 'target', 'campaign_id' => 'C-AUTO', 'ad_group_id' => 'AG-C-AUTO', 'keyword_text' => 'close-match', 'match_type' => 'AUTO', 'state' => 'ENABLED', 'bid' => 0.85, 'updated_at' => $now, 'is_demo' => 1 ); + $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'T2002', 'kind' => 'target', 'campaign_id' => 'C-AUTO', 'ad_group_id' => 'AG-C-AUTO', 'keyword_text' => 'loose-match', 'match_type' => 'AUTO', 'state' => 'ENABLED', 'bid' => 0.70, 'updated_at' => $now, 'is_demo' => 1 ); + $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'T2003', 'kind' => 'target', 'campaign_id' => 'C-PAT', 'ad_group_id' => 'AG-C-PAT', 'keyword_text' => 'asin=B0COMP0001', 'match_type' => 'MANUAL', 'state' => 'ENABLED', 'bid' => 0.95, 'updated_at' => $now, 'is_demo' => 1 ); + $targets[] = array( 'profile_id' => 'DEMO', 'target_id' => 'T2004', 'kind' => 'target', 'campaign_id' => 'C-PAT', 'ad_group_id' => 'AG-C-PAT', 'keyword_text' => 'asin=B0COMP0002', 'match_type' => 'MANUAL', 'state' => 'ENABLED', 'bid' => 0.80, 'updated_at' => $now, 'is_demo' => 1 ); + $this->bulk( 'ads_targets', $targets ); + + $perf = array_merge( $kws, array( + array( 'close-match', 'C-AUTO', 'AUTO', 0.85, 0.005, 0.10, 'T2001' ), + array( 'loose-match', 'C-AUTO', 'AUTO', 0.70, 0.003, 0.05, 'T2002' ), + array( 'asin=B0COMP0001', 'C-PAT', 'MANUAL', 0.95, 0.004, 0.08, 'T2003' ), + array( 'asin=B0COMP0002', 'C-PAT', 'MANUAL', 0.80, 0.003, 0.03, 'T2004' ), + ) ); + $daily = array(); + $camp_day = array(); + for ( $back = 60; $back >= 2; $back-- ) { + $date = SuiteTime::shift( $this->today, -$back ); + foreach ( $perf as $k => $row ) { + [ $text, $camp, $match, $bid, $ctr, $cvr ] = $row; + $tid = $row[6] ?? 'K' . ( 1000 + $k ); + $impr = (int) ( mt_rand( 300, 1600 ) * ( 'C-BROAD' === $camp ? 1.8 : 1 ) * ( 'C-BRAND' === $camp ? 0.15 : 1 ) ); + $clk = $this->poisson( $impr * $ctr ); + $cost = round( $clk * $bid * mt_rand( 70, 95 ) / 100, 2 ); + $ord = 0; + for ( $c = 0; $c < $clk; $c++ ) { + $ord += mt_rand( 1, 1000 ) <= (int) ( $cvr * 1000 ) ? 1 : 0; + } + $sales = round( $ord * array( 4.99, 16.99, 16.99, 34.99 )[ mt_rand( 0, 3 ) ], 2 ); + $daily[] = array( 'profile_id' => 'DEMO', 'report_date' => $date, 'level' => 'target', 'campaign_id' => $camp, 'ad_group_id' => 'AG-' . $camp, 'target_id' => $tid, 'keyword_text' => $text, 'match_type' => $match, 'impressions' => $impr, 'clicks' => $clk, 'cost' => $cost, 'sales' => $sales, 'orders' => $ord, 'units' => $ord, 'is_demo' => 1 ); + $key = $date . '|' . $camp; + $camp_day[ $key ] ??= array( 'profile_id' => 'DEMO', 'report_date' => $date, 'level' => 'campaign', 'campaign_id' => $camp, 'ad_group_id' => '', 'target_id' => '', 'keyword_text' => '', 'match_type' => '', 'impressions' => 0, 'clicks' => 0, 'cost' => 0.0, 'sales' => 0.0, 'orders' => 0, 'units' => 0, 'is_demo' => 1 ); + foreach ( array( 'impressions', 'clicks', 'cost', 'sales', 'orders', 'units' ) as $m ) { + $camp_day[ $key ][ $m ] += $daily[ count( $daily ) - 1 ][ $m ]; + } + } + } + // Make the exact campaign budget-capped & profitable so the budget rule fires. + foreach ( $camp_day as &$cd ) { + $cd['keyword_text'] = ''; + if ( 'C-EXACT' === $cd['campaign_id'] ) { + $cd['cost'] = max( $cd['cost'], 44.0 ); + } + $cd['cost'] = round( $cd['cost'], 2 ); + } + unset( $cd ); + $this->bulk( 'ads_daily', array_merge( $daily, array_values( $camp_day ) ) ); + + $terms = array( + array( 'sugar free spearmint gum', 'C-AUTO', 'close-match', 9, 2.1 ), + array( 'xylitol chewing gum bulk', 'C-AUTO', 'close-match', 6, 1.6 ), + array( 'gum for kids', 'C-BROAD', 'gum', 0, 0 ), + array( 'nicotine gum', 'C-BROAD', 'gum', 0, 0 ), + array( 'bubble gum machine', 'C-BROAD', 'gum', 0, 0 ), + array( 'cinnamon sugar free gum', 'C-PHRASE', 'sugar free gum', 5, 1.4 ), + array( 'b0comp0003', 'C-AUTO', 'loose-match', 4, 1.2 ), + array( 'keto gum', 'C-BROAD', 'gum', 3, 0.9 ), + array( 'fresh breath gum long lasting', 'C-AUTO', 'loose-match', 4, 1.0 ), + array( 'gum variety pack sugar free', 'C-PHRASE', 'gum variety pack', 7, 1.8 ), + array( 'cbd gum', 'C-BROAD', 'gum', 0, 0 ), + array( 'healthy snacks for adults', 'C-BROAD', 'healthy snacks', 0, 0 ), + ); + $st = array(); + for ( $back = 30; $back >= 2; $back-- ) { + $date = SuiteTime::shift( $this->today, -$back ); + foreach ( $terms as $ti => [ $term, $camp, $kw, $orders, $w ] ) { + $clk = max( 0, (int) round( ( $orders > 0 ? 1.2 : 0.8 ) + mt_rand( -1, 1 ) ) ); + $o = $orders > 0 && mt_rand( 1, 30 ) <= $orders ? 1 : 0; + $st[] = array( 'profile_id' => 'DEMO', 'row_hash' => sha1( "demo-st-{$date}-{$ti}" ), 'report_date' => $date, 'campaign_id' => $camp, 'ad_group_id' => 'AG-' . $camp, 'target_id' => 'X', 'keyword_text' => $kw, 'match_type' => 'C-AUTO' === $camp ? 'TARGETING_EXPRESSION_PREDEFINED' : ( 'C-PHRASE' === $camp ? 'PHRASE' : 'BROAD' ), 'search_term' => $term, 'impressions' => mt_rand( 40, 300 ), 'clicks' => $clk, 'cost' => round( $clk * 0.78, 2 ), 'sales' => round( $o * 16.99, 2 ), 'orders' => $o, 'is_demo' => 1 ); + } + } + $this->bulk( 'ads_search_terms', $st ); + return count( $daily ) + count( $st ); + } + + private function traffic( array $catalog ): int { + $rows = array(); + $units = array(); + foreach ( Db::rows( 'SELECT local_date, asin, sku, SUM(qty) AS u, SUM(item_price) AS s, COUNT(*) AS n FROM {t:order_items} WHERE is_demo = 1 AND local_date < %s GROUP BY local_date, asin, sku', array( $this->today ) ) as $r ) { + $units[ $r['local_date'] ][] = $r; + } + foreach ( $units as $date => $list ) { + $tot = array( 'sessions' => 0, 'page_views' => 0, 'units' => 0, 'order_items' => 0, 'ordered_sales' => 0.0 ); + foreach ( $list as $r ) { + $sessions = (int) round( (int) $r['u'] / ( mt_rand( 9, 16 ) / 100 ) ); + $row = array( 'market' => $this->market, 'report_date' => $date, 'asin' => $r['asin'], 'sku' => $r['sku'], 'sessions' => $sessions, 'page_views' => (int) round( $sessions * 1.35 ), 'buy_box_pct' => 'B0DEMO0006' === $r['asin'] ? 71.0 : (float) mt_rand( 94, 100 ), 'units' => (int) $r['u'], 'order_items' => (int) $r['n'], 'ordered_sales' => (float) $r['s'], 'is_demo' => 1 ); + $rows[] = $row; + foreach ( $tot as $k => $v ) { + $tot[ $k ] += $row[ $k ]; + } + } + $rows[] = array( 'market' => $this->market, 'report_date' => $date, 'asin' => '', 'sku' => '', 'buy_box_pct' => 96.5, 'is_demo' => 1 ) + $tot; + } + $this->bulk( 'traffic_daily', $rows ); + return count( $rows ); + } + + private function keywords( array $catalog ): int { + $hero = array( $catalog[0]['asin'], $catalog[8]['asin'], $catalog[18]['asin'] ); + $kw = array( + array( 'sugar free gum', 1, 410000 ), array( 'spearmint gum', 1, 52000 ), array( 'chewing gum', 1, 380000 ), + array( 'xylitol gum', 2, 61000 ), array( 'gum bulk', 2, 88000 ), array( 'mint gum', 2, 47000 ), + array( 'gum variety pack', 2, 23000 ), array( 'cinnamon gum', 3, 31000 ), array( 'fruit gum', 3, 19000 ), + array( 'keto gum', 3, 9000 ), array( 'long lasting gum', 3, 15000 ), array( 'seven gum', 1, 2100 ), + ); + $rows = array(); + $metrics = array(); + $sat = new \DateTimeImmutable( 'last saturday' ); + foreach ( $kw as $ki => [ $k, $prio, $vol ] ) { + $asin = $hero[ $ki % 3 ]; + $rows[] = array( 'market' => $this->market, 'asin' => $asin, 'keyword' => $k, 'priority' => $prio, 'source' => 'demo', 'created_at' => Db::now(), 'is_demo' => 1 ); + for ( $w = 7; $w >= 0; $w-- ) { + $end = $sat->modify( '-' . ( 7 * $w ) . ' days' ); + $start = $end->modify( '-6 days' ); + $weekly = (int) ( $vol / 4.3 * ( 1 + mt_rand( -10, 10 ) / 100 ) ); + $share = max( 0.2, ( 'seven gum' === $k ? 72 : ( 12 - $ki * 0.7 ) ) + ( 7 - $w ) * 0.35 + mt_rand( -10, 10 ) / 10 ) / 100; + $imp_t = $weekly * 18; + $clk_t = (int) ( $weekly * 0.9 ); + $cart_t = (int) ( $clk_t * 0.16 ); + $pur_t = (int) ( $clk_t * 0.08 ); + $metrics[] = array( + 'market' => $this->market, 'asin' => $asin, 'keyword' => $k, 'period_start' => $start->format( 'Y-m-d' ), 'period_end' => $end->format( 'Y-m-d' ), + 'query_score' => $ki + 1, 'query_volume' => $weekly, 'impressions_total' => $imp_t, 'impressions_asin' => (int) ( $imp_t * $share ), + 'clicks_total' => $clk_t, 'clicks_asin' => (int) ( $clk_t * $share * 0.9 ), 'cart_adds_total' => $cart_t, 'cart_adds_asin' => (int) ( $cart_t * $share ), + 'purchases_total' => $pur_t, 'purchases_asin' => (int) ( $pur_t * $share * 1.1 ), 'is_demo' => 1, + ); + } + } + $this->bulk( 'keywords', $rows ); + $this->bulk( 'keyword_metrics', $metrics ); + return count( $rows ); + } + + private function monitor( array $catalog ): int { + $snaps = array(); + $sellers = array(); + $competitors = array( 'B0COMP0001' => 5.49, 'B0COMP0002' => 15.99, 'B0COMP0003' => 29.99, 'B0COMP0004' => 4.29 ); + for ( $back = 60; $back >= 0; $back-- ) { + $ts = gmdate( 'Y-m-d 10:00:00', strtotime( $this->today . " -{$back} days" ) ); + foreach ( $catalog as $i => $p ) { + $hijacked = 5 === $i && $back <= 3; + $bsr = (int) ( 9000 / max( 0.4, $p['demand'] ) * ( 1 + mt_rand( -15, 15 ) / 100 ) * ( 2 === $i && $back < 9 ? 3 + ( 9 - $back ) * 0.5 : 1 ) ); + $snaps[] = array( 'market' => $this->market, 'asin' => $p['asin'], 'captured_at' => $ts, 'is_mine' => 1, 'my_price' => $p['price'], 'buybox_price' => $hijacked ? round( $p['price'] - 0.50, 2 ) : $p['price'], 'buybox_seller' => $hijacked ? 'A3HIJACK9DEMO' : 'ME', 'buybox_is_mine' => $hijacked ? 0 : 1, 'lowest_price' => $hijacked ? round( $p['price'] - 0.50, 2 ) : $p['price'], 'offer_count' => $hijacked ? 2 : 1, 'fba_offer_count' => 1, 'bsr' => $bsr, 'bsr_category' => 'grocery_display_on_website', 'is_demo' => 1 ); + } + foreach ( array_keys( $competitors ) as $ci => $asin ) { + $price = $competitors[ $asin ] * ( 'B0COMP0002' === $asin && $back < 5 ? 0.82 : 1 ) * ( 1 + mt_rand( -3, 3 ) / 100 ); + $snaps[] = array( 'market' => $this->market, 'asin' => $asin, 'captured_at' => $ts, 'is_mine' => 0, 'my_price' => null, 'buybox_price' => round( $price, 2 ), 'buybox_seller' => 'ACOMPETITOR' . $ci, 'buybox_is_mine' => 0, 'lowest_price' => round( $price * 0.97, 2 ), 'offer_count' => mt_rand( 2, 9 ), 'fba_offer_count' => mt_rand( 1, 4 ), 'bsr' => mt_rand( 800, 25000 ), 'bsr_category' => 'grocery_display_on_website', 'is_demo' => 1 ); + } + } + $now = Db::now(); + foreach ( $catalog as $i => $p ) { + $sellers[] = array( 'market' => $this->market, 'asin' => $p['asin'], 'seller_id' => 'ADEMOSELLERME', 'is_me' => 1, 'is_fba' => 1, 'last_price' => $p['price'], 'first_seen' => gmdate( 'Y-m-d H:i:s', time() - 200 * DAY_IN_SECONDS ), 'last_seen' => $now, 'active' => 1, 'is_demo' => 1 ); + if ( 5 === $i ) { + $sellers[] = array( 'market' => $this->market, 'asin' => $p['asin'], 'seller_id' => 'A3HIJACK9DEMO', 'is_me' => 0, 'is_fba' => 0, 'last_price' => round( $p['price'] - 0.5, 2 ), 'first_seen' => gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ), 'last_seen' => $now, 'active' => 1, 'is_demo' => 1 ); + } + } + $this->bulk( 'asin_snapshots', $snaps ); + $this->bulk( 'offer_sellers', $sellers ); + return count( $snaps ); + } + + private function ledger( array $catalog ): int { + $rows = array(); + $reimb = array(); + foreach ( array( 0, 3, 8, 11, 18 ) as $k => $i ) { + $p = $catalog[ $i ]; + $fnsku = 'X00' . substr( $p['asin'], -7 ); + $d = SuiteTime::shift( $this->today, -( 40 + $k * 9 ) ); + $lost = 2 + $k; + $rows[] = array( 'row_hash' => sha1( "demo-led-m-{$i}" ), 'market' => $this->market, 'event_date' => $d, 'fnsku' => $fnsku, 'sku' => $p['sku'], 'asin' => $p['asin'], 'event_type' => 'Adjustments', 'reference_id' => 'DEMO' . $i, 'qty' => -$lost, 'fulfillment_center' => 'PHX7', 'disposition' => 'SELLABLE', 'reason' => 'M', 'is_demo' => 1 ); + if ( 0 === $k % 2 ) { + $rows[] = array( 'row_hash' => sha1( "demo-led-f-{$i}" ), 'market' => $this->market, 'event_date' => SuiteTime::shift( $d, 6 ), 'fnsku' => $fnsku, 'sku' => $p['sku'], 'asin' => $p['asin'], 'event_type' => 'Adjustments', 'reference_id' => 'DEMOF' . $i, 'qty' => 1, 'fulfillment_center' => 'PHX7', 'disposition' => 'SELLABLE', 'reason' => 'F', 'is_demo' => 1 ); + } + if ( $k >= 2 ) { + $rows[] = array( 'row_hash' => sha1( "demo-led-e-{$i}" ), 'market' => $this->market, 'event_date' => SuiteTime::shift( $d, 3 ), 'fnsku' => $fnsku, 'sku' => $p['sku'], 'asin' => $p['asin'], 'event_type' => 'Adjustments', 'reference_id' => 'DEMOE' . $i, 'qty' => -( $k ), 'fulfillment_center' => 'ONT8', 'disposition' => 'WAREHOUSE_DAMAGED', 'reason' => 'E', 'is_demo' => 1 ); + } + if ( 1 === $k ) { + $reimb[] = array( 'reimbursement_id' => 'DEMOR' . $i, 'market' => $this->market, 'approval_date' => SuiteTime::shift( $d, 20 ), 'case_id' => '', 'amazon_order_id' => '', 'reason' => 'Lost_Warehouse', 'sku' => $p['sku'], 'fnsku' => $fnsku, 'asin' => $p['asin'], 'amount' => round( $p['price'] * 0.7 * 2, 2 ), 'currency' => 'USD', 'qty_cash' => 2, 'qty_inventory' => 0, 'is_demo' => 1 ); + } + } + $this->bulk( 'ledger', $rows ); + $this->bulk( 'reimbursements', $reimb ); + return count( $rows ); + } + + private function inventory_health( array $catalog ): int { + $rows = array(); + $stock = array(); + global $wpdb; + foreach ( $wpdb->get_results( $wpdb->prepare( "SELECT sku, fulfillable_qty FROM {$wpdb->prefix}sg_products WHERE sku LIKE %s", 'DEMO-%' ), ARRAY_A ) as $r ) { // phpcs:ignore + $stock[ $r['sku'] ] = (int) $r['fulfillable_qty']; + } + foreach ( $catalog as $p ) { + $a = $stock[ $p['sku'] ] ?? 0; + $old = 'DEMO-P1-STRAWBERRY' === $p['sku'] ? (int) ( $a * 0.55 ) : (int) ( $a * mt_rand( 0, 12 ) / 100 ); + $rows[] = array( 'market' => $this->market, 'snapshot_date' => $this->today, 'sku' => $p['sku'], 'asin' => $p['asin'], 'available' => $a, 'age_0_90' => $a - $old, 'age_91_180' => (int) ( $old * 0.6 ), 'age_181_270' => (int) ( $old * 0.3 ), 'age_271_365' => (int) ( $old * 0.1 ), 'age_365_plus' => 0, 'units_t30' => (int) ( $p['demand'] * 30 ), 'sell_through' => round( $p['demand'] * 30 / max( 1, $a ), 2 ), 'days_of_supply' => $p['demand'] > 0 ? (int) ( $a / $p['demand'] ) : null, 'est_storage_cost' => round( $a * 0.012, 2 ), 'recommended_action' => $old > 300 ? 'Create removal order or run a promotion' : 'No action required', 'is_demo' => 1 ); + } + $this->bulk( 'inventory_health', $rows ); + return count( $rows ); + } + + private function feedback(): int { + $rows = array(); + $comments = array( 5 => 'Fast shipping, great gum!', 4 => 'Good product, arrived quickly.', 3 => 'OK but box was dented.', 2 => 'Bottle arrived opened.', 1 => 'Never received my order.' ); + $all = Db::rows( "SELECT amazon_order_id, local_date FROM {t:orders} WHERE is_demo = 1 AND status = 'Shipped'" ); + $orders = array(); + for ( $i = 0; $i < 40 && $all; $i++ ) { + $orders[] = $all[ mt_rand( 0, count( $all ) - 1 ) ]; + } + $orders = array_values( array_unique( $orders, SORT_REGULAR ) ); + foreach ( $orders as $i => $o ) { + $rating = $i < 3 ? array( 2, 1, 3 )[ $i ] : ( mt_rand( 1, 10 ) <= 8 ? 5 : 4 ); + $rows[] = array( 'row_hash' => sha1( 'demo-fb-' . $o['amazon_order_id'] ), 'market' => $this->market, 'feedback_date' => SuiteTime::shift( (string) $o['local_date'], 6 ), 'rating' => $rating, 'comments' => $comments[ $rating ], 'amazon_order_id' => $o['amazon_order_id'], 'is_demo' => 1 ); + } + $this->bulk( 'feedback', $rows ); + return count( $rows ); + } + + private function listings( array $catalog ): int { + $n = 0; + foreach ( $catalog as $i => $p ) { + $content = array( + 'asin' => $p['asin'], + 'title' => 0 === $i % 6 ? str_replace( 'Sugar-Free Gum', 'Sugar Free Gum!! Best Seller Gum Gum', $p['title'] ) : $p['title'] . ' | Xylitol, Aspartame-Free, Long-Lasting Flavor, Vegan & Keto Friendly', + 'brand' => '7 SEVEN', + 'bullets' => array_slice( array( + 'LONG-LASTING FLAVOR — Our slow-release flavor beads keep every piece fresh for up to 45 minutes, so one piece covers your whole commute or meeting.', + 'SWEETENED WITH XYLITOL — Zero sugar and no aspartame. Xylitol is a natural sweetener that helps fight cavities and keeps your breath clean.', + 'CLEAN INGREDIENTS — Vegan, keto-friendly, gluten-free and non-GMO. Nothing artificial you can not pronounce.', + 'RESEALABLE BOTTLE — The travel bottle fits every car cup holder and keeps 50 pieces fresh and protected in your bag or desk drawer.', + '7 SEVEN PROMISE — Designed for people who want a modern, clean chew. Questions? Our team answers every message within 24 hours.', + ), 0, 3 === $i % 4 ? 3 : 5 ), + 'description' => 2 === $i % 5 ? '' : str_repeat( '7 SEVEN gum is crafted for a clean, confident chew with long-lasting flavor and xylitol sweetness. ', 12 ), + 'backend' => 1 === $i % 4 ? '' : 'xylitol gum aspartame free keto vegan breath freshener office travel bottle sugarless candy mints', + 'images' => array_fill( 0, 3 === $i % 3 ? 4 : ( 0 === $i % 3 ? 5 : 7 ), 'https://m.media-amazon.com/images/I/demo.jpg' ), + 'status' => 7 === $i ? array( 'DISCOVERABLE' ) : array( 'BUYABLE', 'DISCOVERABLE' ), + 'issues' => 7 === $i ? array( array( 'code' => '90220', 'severity' => 'ERROR', 'message' => "'item_package_weight' is required but not supplied." ) ) : array(), + 'product_type'=> 'CHEWING_GUM', + ); + $kw = array_column( Db::rows( 'SELECT keyword FROM {t:keywords} WHERE asin = %s', array( $p['asin'] ) ), 'keyword' ); + ListingAuditor::store( $this->market, $p['sku'], $content, ListingAuditor::score( $content, $kw ), true ); + $n++; + } + return $n; + } + + private function solicitations(): int { + $rows = array(); + foreach ( Db::rows( "SELECT amazon_order_id, purchase_date FROM {t:orders} WHERE is_demo = 1 AND status = 'Shipped' AND local_date < %s ORDER BY purchase_date", array( SuiteTime::shift( $this->today, -12 ) ) ) as $i => $o ) { + $rows[] = array( 'market' => $this->market, 'amazon_order_id' => $o['amazon_order_id'], 'status' => 0 === $i % 17 ? 'ineligible' : 'sent', 'message' => 0 === $i % 17 ? 'Already requested in Seller Central.' : '', 'attempted_at' => gmdate( 'Y-m-d H:i:s', strtotime( $o['purchase_date'] . ' +10 days' ) ), 'is_demo' => 1 ); + } + $this->bulk( 'solicitations', $rows ); + return count( $rows ); + } + + private function expenses(): int { + $rows = array( + array( 'market' => '', 'label' => 'Helium-style tools & software', 'category' => 'software', 'amount' => 97.0, 'recurrence' => 'monthly', 'start_date' => SuiteTime::shift( $this->today, -200 ), 'end_date' => null, 'sku' => '', 'created_at' => Db::now(), 'is_demo' => 1 ), + array( 'market' => '', 'label' => 'Virtual assistant', 'category' => 'staff', 'amount' => 600.0, 'recurrence' => 'monthly', 'start_date' => SuiteTime::shift( $this->today, -200 ), 'end_date' => null, 'sku' => '', 'created_at' => Db::now(), 'is_demo' => 1 ), + array( 'market' => '', 'label' => 'Product photography (P9 relaunch)', 'category' => 'marketing', 'amount' => 450.0, 'recurrence' => 'once', 'start_date' => SuiteTime::shift( $this->today, -21 ), 'end_date' => null, 'sku' => '', 'created_at' => Db::now(), 'is_demo' => 1 ), + ); + $this->bulk( 'expenses', $rows ); + return count( $rows ); + } + + /* ------------------------------------------------------------------ */ + + private function fin( string $posted, string $type, string $order, string $sku, string $charge, string $cat, float $amount, int $qty ): array { + static $n = 0; + return array( + 'market' => $this->market, 'event_hash' => sha1( 'demo-fin-' . ( ++$n ) . '-' . $order . $charge ), 'posted_date' => $posted, + 'local_date' => SuiteTime::local_date( $posted, $this->market ), 'event_type' => $type, 'amazon_order_id' => $order, 'sku' => $sku, + 'charge_type' => $charge, 'category' => $cat, 'amount' => round( $amount, 4 ), 'currency' => 'USD', 'qty' => $qty, 'is_demo' => 1, + ); + } + + private function poisson( float $lambda ): int { + if ( $lambda <= 0 ) { + return 0; + } + $l = exp( -$lambda ); + $k = 0; + $p = 1.0; + do { + $k++; + $p *= mt_rand() / mt_getrandmax(); + } while ( $p > $l && $k < 500 ); + return $k - 1; + } + + /** Multi-row INSERT in chunks (seeding thousands of rows one-by-one is too slow on shared hosting). */ + private function bulk( string $short, array $rows ): void { + global $wpdb; + if ( ! $rows ) { + return; + } + $table = SuiteSchema::table( $short ); + $cols = array_keys( $rows[0] ); + foreach ( array_chunk( $rows, 200 ) as $chunk ) { + $values = array(); + foreach ( $chunk as $r ) { + $cells = array(); + foreach ( $cols as $c ) { + $v = $r[ $c ] ?? null; + $cells[] = null === $v ? 'NULL' : ( is_int( $v ) ? (string) $v : $wpdb->prepare( '%s', is_float( $v ) ? Db::num( $v ) : (string) $v ) ); + } + $values[] = '(' . implode( ',', $cells ) . ')'; + } + $wpdb->query( "INSERT IGNORE INTO {$table} (`" . implode( '`,`', $cols ) . '`) VALUES ' . implode( ',', $values ) ); // phpcs:ignore + } + } +} diff --git a/sg-commerce-pro/src/Suite/Finance/FinanceSync.php b/sg-commerce-pro/src/Suite/Finance/FinanceSync.php new file mode 100644 index 0000000..793f420 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Finance/FinanceSync.php @@ -0,0 +1,163 @@ + 0, 'lines' => 0, 'windows' => 0 ); + if ( ! $this->client->has_credentials() ) { + return $stats; + } + foreach ( $this->region_anchor_markets() as $region => $market ) { + $stats['regions']++; + $pages = 0; + $until = time() - 5 * MINUTE_IN_SECONDS; + while ( $pages < self::MAX_PAGES ) { + $cursor = (int) get_option( self::CURSOR_OPTION . $region, 0 ); + if ( $cursor <= 0 ) { + $cursor = time() - $this->settings->int( 'suite_backfill_days' ) * DAY_IN_SECONDS; + } + if ( $cursor >= $until - 60 ) { + break; + } + $end = min( $until, $cursor + self::WINDOW_DAYS * DAY_IN_SECONDS ); + [ $lines, $used, $complete ] = $this->fetch_window( $market, $cursor, $end, self::MAX_PAGES - $pages ); + $pages += $used; + $stats['lines'] += $lines; + if ( ! $complete ) { + break; // Page budget exhausted mid-window; retry the window next tick (idempotent). + } + update_option( self::CURSOR_OPTION . $region, $end, false ); + $stats['windows']++; + } + } + return $stats; + } + + /** Reset cursors (forces a full re-backfill). */ + public static function reset_cursors(): void { + foreach ( array( 'na', 'eu', 'fe' ) as $r ) { + delete_option( self::CURSOR_OPTION . $r ); + } + } + + /** @return array{0:int,1:int,2:bool} lines written, pages used, window complete */ + private function fetch_window( string $market, int $from, int $to, int $page_budget ): array { + $query = array( + 'PostedAfter' => gmdate( 'Y-m-d\TH:i:s\Z', $from ), + 'PostedBefore' => gmdate( 'Y-m-d\TH:i:s\Z', $to ), + 'MaxResultsPerPage' => 100, + ); + $written = 0; + $pages = 0; + $seen = array(); + do { + $res = $this->client->request( 'GET', $market, '/finances/v0/financialEvents', $query, null, 'finances.events' ); + $payload = (array) ( $res['payload'] ?? array() ); + $lines = FinancialEventFlattener::flatten( (array) ( $payload['FinancialEvents'] ?? array() ), $query['PostedBefore'] ); + $written += $this->store( $lines, $market, $seen ); + $pages++; + $next = (string) ( $payload['NextToken'] ?? '' ); + $query = '' !== $next ? array( 'NextToken' => $next ) : array(); + } while ( '' !== ( $next ?? '' ) && $pages < $page_budget ); + + return array( $written, $pages, '' === ( $next ?? '' ) ); + } + + /** + * @param array $seen base-hash occurrence counter (window-scoped) + */ + public function store( array $lines, string $default_market, array &$seen, bool $demo = false ): int { + $n = 0; + foreach ( $lines as $line ) { + $market = $this->market_from_name( $line['marketplace'] ) ?: $default_market; + $base = FinancialEventFlattener::hash( $line, 0 ); + $seen[ $base ] = ( $seen[ $base ] ?? -1 ) + 1; + $hash = 0 === $seen[ $base ] ? $base : FinancialEventFlattener::hash( $line, $seen[ $base ] ); + $posted = SuiteTime::to_mysql( $line['posted'] ) ?? Db::now(); + $ok = Db::upsert( 'fin_events', array( + 'market' => $market, + 'event_hash' => $hash, + 'posted_date' => $posted, + 'local_date' => SuiteTime::local_date( $posted, $market ), + 'event_type' => $line['event_type'], + 'amazon_order_id' => $line['order_id'], + 'sku' => $line['sku'], + 'charge_type' => $line['charge_type'], + 'category' => $line['category'], + 'amount' => (float) $line['amount'], + 'currency' => $line['currency'], + 'qty' => (int) $line['qty'], + 'is_demo' => $demo ? 1 : 0, + ) ); + if ( $ok ) { + $n++; + } + } + return $n; + } + + /** "Amazon.com" → US, "Amazon.co.uk" → UK, "Amazon.ae" → AE. */ + public function market_from_name( string $name ): string { + $name = strtolower( trim( $name ) ); + if ( '' === $name || ! str_starts_with( $name, 'amazon.' ) ) { + return ''; + } + $tld = substr( $name, 7 ); + foreach ( $this->marketplaces->all() as $code => $m ) { + if ( ( $m['tld'] ?? '' ) === $tld ) { + return (string) $code; + } + } + return ''; + } + + /** One enabled market per SP-API region (finance events are region-wide). */ + private function region_anchor_markets(): array { + $out = array(); + $primary = $this->marketplaces->primary(); + $codes = array_unique( array_merge( array( $primary ), $this->marketplaces->enabled_codes() ) ); + foreach ( $codes as $code ) { + $mp = $this->marketplaces->get( $code ); + if ( $mp && ! isset( $out[ $mp['region'] ] ) ) { + $out[ $mp['region'] ] = $code; + } + } + return $out; + } +} diff --git a/sg-commerce-pro/src/Suite/Finance/FinancialEventFlattener.php b/sg-commerce-pro/src/Suite/Finance/FinancialEventFlattener.php new file mode 100644 index 0000000..141ed18 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Finance/FinancialEventFlattener.php @@ -0,0 +1,272 @@ + + */ + public static function flatten( array $events, string $fallback_posted ): array { + $out = array(); + + foreach ( (array) ( $events['ShipmentEventList'] ?? array() ) as $ev ) { + self::shipment_like( $out, $ev, 'Shipment', $fallback_posted, false ); + } + foreach ( (array) ( $events['RefundEventList'] ?? array() ) as $ev ) { + self::shipment_like( $out, $ev, 'Refund', $fallback_posted, true ); + } + foreach ( (array) ( $events['GuaranteeClaimEventList'] ?? array() ) as $ev ) { + self::shipment_like( $out, $ev, 'GuaranteeClaim', $fallback_posted, true ); + } + foreach ( (array) ( $events['ChargebackEventList'] ?? array() ) as $ev ) { + self::shipment_like( $out, $ev, 'Chargeback', $fallback_posted, true ); + } + + foreach ( (array) ( $events['ServiceFeeEventList'] ?? array() ) as $ev ) { + foreach ( (array) ( $ev['FeeList'] ?? array() ) as $fee ) { + [ $amt, $cur ] = self::amount( $fee['FeeAmount'] ?? null ); + if ( 0.0 === $amt ) { + continue; + } + $out[] = self::line( + (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'ServiceFee', + (string) ( $ev['AmazonOrderId'] ?? '' ), (string) ( $ev['SellerSKU'] ?? '' ), + (string) ( $fee['FeeType'] ?? ( $ev['FeeReason'] ?? 'ServiceFee' ) ), 'fee', $amt, $cur, 0, '' + ); + } + } + + foreach ( (array) ( $events['AdjustmentEventList'] ?? array() ) as $ev ) { + $type = (string) ( $ev['AdjustmentType'] ?? 'Adjustment' ); + $cat = self::adjustment_category( $type ); + $posted = (string) ( $ev['PostedDate'] ?? $fallback_posted ); + $items = (array) ( $ev['AdjustmentItemList'] ?? array() ); + if ( $items ) { + foreach ( $items as $it ) { + [ $amt, $cur ] = self::amount( $it['TotalAmount'] ?? null ); + if ( 0.0 === $amt ) { + continue; + } + $out[] = self::line( $posted, 'Adjustment', '', (string) ( $it['SellerSKU'] ?? '' ), $type, $cat, $amt, $cur, (int) ( $it['Quantity'] ?? 0 ), '' ); + } + } else { + [ $amt, $cur ] = self::amount( $ev['AdjustmentAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( $posted, 'Adjustment', '', '', $type, $cat, $amt, $cur, 0, '' ); + } + } + } + + foreach ( (array) ( $events['ProductAdsPaymentEventList'] ?? array() ) as $ev ) { + [ $amt, $cur ] = self::amount( $ev['transactionValue'] ?? null ); + if ( 0.0 === $amt ) { + continue; + } + // Amazon reports ad charges as positive transactionValue for type "Charge". + $signed = 'Refund' === ( $ev['transactionType'] ?? '' ) ? abs( $amt ) : -abs( $amt ); + $out[] = self::line( (string) ( $ev['postedDate'] ?? $fallback_posted ), 'ProductAdsPayment', '', '', 'AdsInvoice:' . (string) ( $ev['invoiceId'] ?? '' ), 'ads', $signed, $cur, 0, '' ); + } + + foreach ( (array) ( $events['SellerDealPaymentEventList'] ?? array() ) as $ev ) { + [ $amt, $cur ] = self::amount( $ev['totalAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( (string) ( $ev['postedDate'] ?? $fallback_posted ), 'SellerDealPayment', '', '', 'DealFee:' . (string) ( $ev['dealId'] ?? '' ), 'promo', $amt, $cur, 0, '' ); + } + } + + foreach ( (array) ( $events['CouponPaymentEventList'] ?? array() ) as $ev ) { + [ $amt, $cur ] = self::amount( $ev['TotalAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'CouponPayment', '', '', 'Coupon:' . (string) ( $ev['CouponId'] ?? '' ), 'promo', $amt, $cur, 0, '' ); + } + } + + foreach ( (array) ( $events['SAFETReimbursementEventList'] ?? array() ) as $ev ) { + [ $amt, $cur ] = self::amount( $ev['ReimbursedAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'SAFETReimbursement', '', '', 'SAFE-T:' . (string) ( $ev['SAFETClaimId'] ?? '' ), 'reimbursement', $amt, $cur, 0, '' ); + } + } + + foreach ( (array) ( $events['RetrochargeEventList'] ?? array() ) as $ev ) { + foreach ( array( 'BaseTax', 'ShippingTax' ) as $k ) { + [ $amt, $cur ] = self::amount( $ev[ $k ] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'Retrocharge', (string) ( $ev['AmazonOrderId'] ?? '' ), '', 'Retrocharge:' . $k, 'tax', $amt, $cur, 0, '' ); + } + } + } + + foreach ( (array) ( $events['FBALiquidationEventList'] ?? array() ) as $ev ) { + foreach ( array( 'LiquidationProceedsAmount' => 'other', 'LiquidationFeeAmount' => 'fee' ) as $k => $cat ) { + [ $amt, $cur ] = self::amount( $ev[ $k ] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( (string) ( $ev['PostedDate'] ?? $fallback_posted ), 'FBALiquidation', '', '', $k, $cat, $amt, $cur, 0, '' ); + } + } + } + + return $out; + } + + /** Deterministic hash so re-fetching a window is idempotent. */ + public static function hash( array $line, int $ordinal ): string { + return sha1( implode( '|', array( + $line['posted'], $line['event_type'], $line['order_id'], $line['sku'], + $line['charge_type'], number_format( $line['amount'], 4, '.', '' ), $line['currency'], $ordinal, + ) ) ); + } + + public static function adjustment_category( string $type ): string { + // Amazon mixes styles: "FBAInventoryReimbursement", "WAREHOUSE_DAMAGE", "MISSING_FROM_INBOUND". + $t = (string) preg_replace( '/[^a-z]/', '', strtolower( $type ) ); + if ( str_contains( $t, 'reimbursement' ) || str_contains( $t, 'warehousedamage' ) || str_contains( $t, 'warehouselost' ) + || str_contains( $t, 'missingfrominbound' ) || str_contains( $t, 'compensatedclawback' ) || str_contains( $t, 'freereplacementrefund' ) ) { + return 'reimbursement'; + } + if ( str_contains( $t, 'storage' ) || str_contains( $t, 'fee' ) || str_contains( $t, 'postage' ) ) { + return 'fee'; + } + return 'adjustment'; + } + + public static function charge_category( string $charge_type, bool $is_refund ): string { + if ( in_array( $charge_type, self::TAX_TYPES, true ) || str_contains( $charge_type, 'Tax' ) || str_contains( $charge_type, 'VAT' ) ) { + return 'tax'; + } + return $is_refund ? 'refund' : 'revenue'; + } + + private static function shipment_like( array &$out, array $ev, string $type, string $fallback, bool $is_refund ): void { + $posted = (string) ( $ev['PostedDate'] ?? $fallback ); + $order_id = (string) ( $ev['AmazonOrderId'] ?? '' ); + $mkt = (string) ( $ev['MarketplaceName'] ?? '' ); + $items = $is_refund + ? (array) ( $ev['ShipmentItemAdjustmentList'] ?? array() ) + : (array) ( $ev['ShipmentItemList'] ?? array() ); + + // Order-level charges/fees (rare: e.g. shipping on multi-item orders). + foreach ( (array) ( $ev['OrderChargeList'] ?? $ev['OrderChargeAdjustmentList'] ?? array() ) as $ch ) { + [ $amt, $cur ] = self::amount( $ch['ChargeAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $ct = (string) ( $ch['ChargeType'] ?? '' ); + $out[] = self::line( $posted, $type, $order_id, '', $ct, self::charge_category( $ct, $is_refund ), $amt, $cur, 0, $mkt ); + } + } + foreach ( (array) ( $ev['ShipmentFeeList'] ?? $ev['ShipmentFeeAdjustmentList'] ?? array() ) as $fee ) { + [ $amt, $cur ] = self::amount( $fee['FeeAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( $posted, $type, $order_id, '', (string) ( $fee['FeeType'] ?? '' ), 'fee', $amt, $cur, 0, $mkt ); + } + } + + foreach ( $items as $it ) { + $sku = (string) ( $it['SellerSKU'] ?? '' ); + $qty = (int) ( $it['QuantityShipped'] ?? 0 ); + $first = true; + + $charges = $is_refund ? ( $it['ItemChargeAdjustmentList'] ?? array() ) : ( $it['ItemChargeList'] ?? array() ); + foreach ( (array) $charges as $ch ) { + [ $amt, $cur ] = self::amount( $ch['ChargeAmount'] ?? null ); + if ( 0.0 === $amt ) { + continue; + } + $ct = (string) ( $ch['ChargeType'] ?? '' ); + $cat = self::charge_category( $ct, $is_refund ); + // Attribute the unit count to the Principal line only, so SUM(qty) == units. + $line_qty = ( 'Principal' === $ct && $first ) ? ( $is_refund ? -abs( $qty ) : $qty ) : 0; + if ( 'Principal' === $ct ) { + $first = false; + } + $out[] = self::line( $posted, $type, $order_id, $sku, $ct, $cat, $amt, $cur, $line_qty, $mkt ); + } + + $fees = $is_refund ? ( $it['ItemFeeAdjustmentList'] ?? array() ) : ( $it['ItemFeeList'] ?? array() ); + foreach ( (array) $fees as $fee ) { + [ $amt, $cur ] = self::amount( $fee['FeeAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( $posted, $type, $order_id, $sku, (string) ( $fee['FeeType'] ?? '' ), 'fee', $amt, $cur, 0, $mkt ); + } + } + + $promos = $is_refund ? ( $it['PromotionAdjustmentList'] ?? array() ) : ( $it['PromotionList'] ?? array() ); + foreach ( (array) $promos as $p ) { + [ $amt, $cur ] = self::amount( $p['PromotionAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( $posted, $type, $order_id, $sku, 'Promotion:' . (string) ( $p['PromotionType'] ?? '' ), $is_refund ? 'refund' : 'promo', $amt, $cur, 0, $mkt ); + } + } + + foreach ( (array) ( $it['ItemTaxWithheldList'] ?? array() ) as $tw ) { + foreach ( (array) ( $tw['TaxesWithheld'] ?? array() ) as $ch ) { + [ $amt, $cur ] = self::amount( $ch['ChargeAmount'] ?? null ); + if ( 0.0 !== $amt ) { + $out[] = self::line( $posted, $type, $order_id, $sku, 'Withheld:' . (string) ( $ch['ChargeType'] ?? '' ), 'tax', $amt, $cur, 0, $mkt ); + } + } + } + } + } + + /** @return array{0: float, 1: string} */ + private static function amount( mixed $money ): array { + if ( ! is_array( $money ) ) { + return array( 0.0, '' ); + } + $v = $money['CurrencyAmount'] ?? $money['Amount'] ?? $money['amount'] ?? $money['currencyAmount'] ?? 0; + $c = $money['CurrencyCode'] ?? $money['currencyCode'] ?? ''; + return array( round( (float) $v, 4 ), (string) $c ); + } + + private static function line( string $posted, string $type, string $order, string $sku, string $charge, string $cat, float $amt, string $cur, int $qty, string $mkt ): array { + return array( + 'posted' => $posted, + 'event_type' => $type, + 'order_id' => $order, + 'sku' => $sku, + 'charge_type' => substr( $charge, 0, 64 ), + 'category' => $cat, + 'amount' => $amt, + 'currency' => $cur, + 'qty' => $qty, + 'marketplace' => $mkt, + ); + } +} diff --git a/sg-commerce-pro/src/Suite/Inventory/RestockPlanner.php b/sg-commerce-pro/src/Suite/Inventory/RestockPlanner.php new file mode 100644 index 0000000..2fe5570 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Inventory/RestockPlanner.php @@ -0,0 +1,207 @@ +get_results( $wpdb->prepare( + "SELECT sku, asin, product_name, fulfillable_qty, reserved_qty, inbound_qty, my_price, buybox_price + FROM {$wpdb->prefix}sg_products WHERE market = %s", $market + ), ARRAY_A ) ?: array(); // phpcs:ignore + + $sales = array(); + foreach ( array( 7, 30, 90 ) as $w ) { + $rows = Db::rows( + "SELECT sku, SUM(qty) AS units, COUNT(DISTINCT local_date) AS active_days FROM {t:order_items} + WHERE market = %s AND local_date BETWEEN %s AND %s AND status <> 'Canceled' GROUP BY sku", + array( $market, SuiteTime::shift( $today, -$w ), SuiteTime::shift( $today, -1 ) ) + ); + foreach ( $rows as $r ) { + $sales[ (string) $r['sku'] ][ $w ] = (int) $r['units']; + } + } + // Days each SKU was out of stock in the last 30 days, from price-history snapshots. + $oos = array(); + $oos_rows = $wpdb->get_results( $wpdb->prepare( + "SELECT p.sku, COUNT(DISTINCT DATE(h.recorded_at)) AS d FROM {$wpdb->prefix}sg_price_history h + INNER JOIN {$wpdb->prefix}sg_products p ON p.id = h.product_id + WHERE p.market = %s AND h.fulfillable_qty = 0 AND h.recorded_at >= %s GROUP BY p.sku", + $market, gmdate( 'Y-m-d', strtotime( $today . ' -30 days' ) ) + ), ARRAY_A ) ?: array(); // phpcs:ignore + foreach ( $oos_rows as $r ) { + $oos[ (string) $r['sku'] ] = (int) $r['d']; + } + + $inputs = array(); + foreach ( $stock as $s ) { + $sku = (string) $s['sku']; + $cost = $this->costs->row( $market, $sku ); + $inputs[] = array( + 'sku' => $sku, + 'asin' => (string) $s['asin'], + 'title' => (string) $s['product_name'], + 'fulfillable' => (int) $s['fulfillable_qty'], + 'reserved' => (int) $s['reserved_qty'], + 'inbound' => (int) $s['inbound_qty'], + 'units_7' => $sales[ $sku ][7] ?? 0, + 'units_30' => $sales[ $sku ][30] ?? 0, + 'units_90' => $sales[ $sku ][90] ?? 0, + 'oos_days_30' => $oos[ $sku ] ?? 0, + 'lead_time' => ( $cost['lead_time_days'] ?? 0 ) ?: $this->settings->int( 'suite_lead_time_days' ), + 'moq' => (int) ( $cost['moq'] ?? 0 ), + 'case_pack' => (int) ( $cost['case_pack'] ?? 0 ), + 'unit_cost' => $this->costs->landed( $market, $sku ), + 'price' => (float) ( $s['my_price'] ?? $s['buybox_price'] ?? 0 ), + ); + } + return self::plan( $inputs, $today, array( + 'safety_days' => $this->settings->int( 'suite_safety_days' ), + 'target_cover' => $this->settings->int( 'suite_target_cover_days' ), + 'overstock_days' => $this->settings->int( 'suite_overstock_days' ), + ) ); + } + + public static function plan( array $inputs, string $today, array $cfg ): array { + $cfg += array( 'safety_days' => 14, 'target_cover' => 60, 'overstock_days' => 180 ); + $rows = array(); + $summary = array( 'skus' => 0, 'reorder' => 0, 'out_of_stock' => 0, 'overstock' => 0, 'order_units' => 0, 'order_value' => 0.0, 'lost_sales_per_day' => 0.0 ); + + foreach ( $inputs as $in ) { + $v7 = $in['units_7'] / 7; + $v30 = $in['units_30'] / max( 1, 30 - min( 29, (int) ( $in['oos_days_30'] ?? 0 ) ) ); + $v90 = $in['units_90'] / 90; + $velocity = round( 0.5 * $v7 + 0.3 * $v30 + 0.2 * $v90, 3 ); + $trend = $v30 > 0 ? round( ( $v7 - $v30 ) / $v30 * 100, 1 ) : null; + + $fulfillable = max( 0, (int) $in['fulfillable'] ); + $supply = $fulfillable + max( 0, (int) $in['reserved'] ) + max( 0, (int) $in['inbound'] ); + $lead = max( 1, (int) $in['lead_time'] ); + $safety = (int) $cfg['safety_days']; + + $cover = $velocity > 0 ? $supply / $velocity : null; + $days_fulfillable = $velocity > 0 ? $fulfillable / $velocity : null; + $reorder_point = (int) ceil( $velocity * ( $lead + $safety ) ); + $need = $velocity * ( $lead + (int) $cfg['target_cover'] ) - $supply; + $qty = $need > 0 ? (int) ceil( $need ) : 0; + if ( $qty > 0 && $in['case_pack'] > 0 ) { + $qty = (int) ( ceil( $qty / $in['case_pack'] ) * $in['case_pack'] ); + } + if ( $qty > 0 && $in['moq'] > 0 ) { + $qty = max( $qty, (int) $in['moq'] ); + } + + $stockout = null !== $days_fulfillable ? SuiteTime::shift( $today, (int) floor( $days_fulfillable ) ) : null; + $reorder_by = null !== $cover ? SuiteTime::shift( $today, (int) floor( $cover ) - $lead - $safety ) : null; + + if ( $velocity <= 0 ) { + $status = $supply > 0 ? 'no_sales' : 'out_of_stock'; + $qty = 0; + } elseif ( 0 === $fulfillable && 0 === $supply ) { + $status = 'out_of_stock'; + } elseif ( $cover <= $lead ) { + // Runs out before (or exactly when) a PO placed today would arrive. + $status = 'critical'; + } elseif ( $supply <= $reorder_point ) { + $status = 'reorder_now'; + } elseif ( $cover < $lead + $safety + 14 ) { + $status = 'reorder_soon'; + } elseif ( $cover > (int) $cfg['overstock_days'] ) { + $status = 'overstock'; + } else { + $status = 'healthy'; + } + if ( 0 === $fulfillable && $velocity > 0 && 'out_of_stock' !== $status ) { + $status = 'out_of_stock'; + } + // Total supply looks fine but sellable units run out before inbound stock is likely + // to be received (FBA check-in typically takes 1–2 weeks) → a stock-out gap. + if ( in_array( $status, array( 'healthy', 'reorder_soon', 'overstock' ), true ) && null !== $days_fulfillable + && $days_fulfillable < 10 && (int) $in['inbound'] > 0 ) { + $status = 'stockout_risk'; + } + + $lost = 'out_of_stock' === $status ? $velocity * (float) $in['price'] : 0.0; + $rows[] = array( + 'sku' => $in['sku'], + 'asin' => $in['asin'], + 'title' => $in['title'], + 'fulfillable' => $fulfillable, + 'reserved' => (int) $in['reserved'], + 'inbound' => (int) $in['inbound'], + 'supply' => $supply, + 'units_7' => (int) $in['units_7'], + 'units_30' => (int) $in['units_30'], + 'units_90' => (int) $in['units_90'], + 'velocity' => $velocity, + 'trend_pct' => $trend, + 'days_of_cover' => null !== $cover ? (int) floor( $cover ) : null, + 'stockout_date' => $stockout, + 'reorder_by' => $reorder_by, + 'reorder_point' => $reorder_point, + 'lead_time' => $lead, + 'order_qty' => $qty, + 'order_value' => round( $qty * (float) $in['unit_cost'], 2 ), + 'status' => $status, + 'lost_sales_day' => round( $lost, 2 ), + ); + $summary['skus']++; + if ( in_array( $status, array( 'critical', 'reorder_now', 'stockout_risk' ), true ) || ( 'out_of_stock' === $status && $velocity > 0 ) ) { + $summary['reorder']++; + } + if ( 'out_of_stock' === $status ) { + $summary['out_of_stock']++; + } + if ( 'overstock' === $status ) { + $summary['overstock']++; + } + $summary['order_units'] += $qty; + $summary['order_value'] += $qty * (float) $in['unit_cost']; + $summary['lost_sales_per_day'] += $lost; + } + $rank = array( 'out_of_stock' => 0, 'critical' => 1, 'stockout_risk' => 2, 'reorder_now' => 3, 'reorder_soon' => 4, 'overstock' => 5, 'healthy' => 6, 'no_sales' => 7 ); + usort( $rows, static fn( $a, $b ) => array( $rank[ $a['status'] ], $a['days_of_cover'] ?? PHP_INT_MAX ) <=> array( $rank[ $b['status'] ], $b['days_of_cover'] ?? PHP_INT_MAX ) ); + $summary['order_value'] = round( $summary['order_value'], 2 ); + $summary['lost_sales_per_day'] = round( $summary['lost_sales_per_day'], 2 ); + return array( 'today' => $today, 'summary' => $summary, 'rows' => $rows ); + } +} diff --git a/sg-commerce-pro/src/Suite/Keywords/KeywordService.php b/sg-commerce-pro/src/Suite/Keywords/KeywordService.php new file mode 100644 index 0000000..b624ca9 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Keywords/KeywordService.php @@ -0,0 +1,164 @@ + $market, 'asin' => strtoupper( $asin ), 'keyword' => $kw, 'priority' => max( 1, min( 3, $priority ) ), + 'source' => $source, 'created_at' => Db::now(), 'is_demo' => 0, + ), array( 'created_at', 'source' ) ) ) { + $n++; + } + } + return $n; + } + + public function delete( int $id ): bool { + return Db::exec( 'DELETE FROM {t:keywords} WHERE id = %d', array( $id ) ) > 0; + } + + /** Tracked keywords with latest + previous week SQP metrics. */ + public function tracked( string $market, string $asin = '' ): array { + $args = array( $market ); + $where = 'k.market = %s'; + if ( '' !== $asin ) { + $where .= ' AND k.asin = %s'; + $args[] = $asin; + } + $rows = Db::rows( "SELECT k.* FROM {t:keywords} k WHERE {$where} ORDER BY k.priority ASC, k.keyword ASC", $args ); + if ( ! $rows ) { + return array(); + } + $weeks = array_column( Db::rows( 'SELECT DISTINCT period_start FROM {t:keyword_metrics} WHERE market = %s ORDER BY period_start DESC LIMIT 8', array( $market ) ), 'period_start' ); + $metrics = array(); + if ( $weeks ) { + foreach ( Db::rows( 'SELECT * FROM {t:keyword_metrics} WHERE market = %s AND period_start IN (' . Db::in( $weeks ) . ')', array_merge( array( $market ), $weeks ) ) as $m ) { + $metrics[ $m['asin'] . '|' . $m['keyword'] ][ $m['period_start'] ] = $m; + } + } + foreach ( $rows as &$r ) { + $hist = $metrics[ $r['asin'] . '|' . $r['keyword'] ] ?? array(); + $cur = isset( $weeks[0] ) ? ( $hist[ $weeks[0] ] ?? null ) : null; + $prev = isset( $weeks[1] ) ? ( $hist[ $weeks[1] ] ?? null ) : null; + $r['volume'] = null !== $cur ? (int) $cur['query_volume'] : null; + $r['impr_share'] = self::share( $cur, 'impressions' ); + $r['click_share'] = self::share( $cur, 'clicks' ); + $r['purchase_share'] = self::share( $cur, 'purchases' ); + $r['purchase_share_prev'] = self::share( $prev, 'purchases' ); + $r['ctr'] = null !== $cur && (int) $cur['impressions_asin'] > 0 ? round( (int) $cur['clicks_asin'] / (int) $cur['impressions_asin'] * 100, 2 ) : null; + $r['cvr'] = null !== $cur && (int) $cur['clicks_asin'] > 0 ? round( (int) $cur['purchases_asin'] / (int) $cur['clicks_asin'] * 100, 2 ) : null; + $r['trend'] = array(); + foreach ( array_reverse( $weeks ) as $w ) { + $r['trend'][] = self::share( $hist[ $w ] ?? null, 'impressions' ) ?? 0; + } + $r['week'] = $weeks[0] ?? null; + } + return $rows; + } + + /** Every query Brand Analytics returned for the ASIN (not only tracked ones). */ + public function sqp_queries( string $market, string $asin ): array { + $week = (string) Db::var( 'SELECT MAX(period_start) FROM {t:keyword_metrics} WHERE market = %s AND asin = %s', array( $market, $asin ) ); + if ( '' === $week ) { + return array(); + } + $rows = Db::rows( + 'SELECT * FROM {t:keyword_metrics} WHERE market = %s AND asin = %s AND period_start = %s ORDER BY query_volume DESC LIMIT 200', + array( $market, $asin, $week ) + ); + foreach ( $rows as &$r ) { + $r['impr_share'] = self::share( $r, 'impressions' ); + $r['click_share'] = self::share( $r, 'clicks' ); + $r['purchase_share'] = self::share( $r, 'purchases' ); + } + return $rows; + } + + /** Keyword ideas for ASINs via Ads API, merged with converting search terms. */ + public function research( array $asins, string $locale = 'en_US' ): array { + $ideas = array(); + if ( $this->ads->is_ready() ) { + $res = $this->ads->keyword_recommendations( $asins, $locale ); + $list = $res['keywordTargetList'] ?? $res['recommendations'] ?? $res['keywords'] ?? array(); + foreach ( (array) $list as $k ) { + $kw = mb_strtolower( (string) ( $k['keyword'] ?? $k['keywordText'] ?? '' ) ); + if ( '' === $kw ) { + continue; + } + $bid = null; + foreach ( (array) ( $k['bidInfo'] ?? array() ) as $b ) { + if ( 'EXACT' === ( $b['matchType'] ?? '' ) ) { + $bid = $b['suggestedBid']['rangeMedian'] ?? $b['bid'] ?? null; + } + } + $ideas[ $kw ] = array( + 'keyword' => $kw, + 'source' => 'amazon_ads', + // Recommendation bids arrive in minor units (cents) for most locales; normalise to currency. + 'suggested_bid' => null !== $bid ? round( (float) $bid / ( (float) $bid >= 10 ? 100 : 1 ), 2 ) : null, + 'impression_rank'=> $k['searchTermImpressionRank'] ?? null, + 'impression_share' => $k['searchTermImpressionShare'] ?? null, + 'orders' => null, + 'acos' => null, + ); + } + } + $terms = Db::rows( + "SELECT search_term, SUM(clicks) AS clicks, SUM(orders) AS orders, SUM(cost) AS cost, SUM(sales) AS sales + FROM {t:ads_search_terms} WHERE report_date >= %s GROUP BY search_term HAVING SUM(orders) > 0 ORDER BY SUM(orders) DESC LIMIT 100", + array( gmdate( 'Y-m-d', time() - 60 * DAY_IN_SECONDS ) ) + ); + foreach ( $terms as $t ) { + $kw = mb_strtolower( (string) $t['search_term'] ); + $acos = (float) $t['sales'] > 0 ? round( (float) $t['cost'] / (float) $t['sales'] * 100, 1 ) : null; + $ideas[ $kw ] = array_merge( $ideas[ $kw ] ?? array( 'keyword' => $kw, 'suggested_bid' => null, 'impression_rank' => null, 'impression_share' => null ), array( + 'source' => isset( $ideas[ $kw ] ) ? 'both' : 'your_ads', + 'orders' => (int) $t['orders'], + 'acos' => $acos, + ) ); + } + return array_values( $ideas ); + } + + private static function share( ?array $m, string $what ): ?float { + if ( null === $m ) { + return null; + } + $total = (int) $m[ $what . '_total' ]; + return $total > 0 ? round( (int) $m[ $what . '_asin' ] / $total * 100, 2 ) : null; + } +} diff --git a/sg-commerce-pro/src/Suite/Listings/ListingAuditor.php b/sg-commerce-pro/src/Suite/Listings/ListingAuditor.php new file mode 100644 index 0000000..37ac09a --- /dev/null +++ b/sg-commerce-pro/src/Suite/Listings/ListingAuditor.php @@ -0,0 +1,335 @@ +settings->seller_id(); + if ( '' === $seller ) { + throw new \RuntimeException( 'Enter your Seller ID (Merchant Token) in Amazon Suite → Settings to audit listings.' ); + } + $mp = $this->client->marketplace_info( $market ); + $res = $this->client->request( + 'GET', $market, + '/listings/2021-08-01/items/' . rawurlencode( $seller ) . '/' . rawurlencode( $sku ), + array( 'marketplaceIds' => (string) ( $mp['id'] ?? '' ), 'includedData' => 'summaries,attributes,issues' ), + null, 'listings.get' + ); + $content = self::extract( $res ); + $result = self::score( $content, $keywords ); + self::store( $market, $sku, $content, $result ); + return $result + array( 'content' => $content ); + } + + public static function store( string $market, string $sku, array $content, array $result, bool $demo = false ): void { + $hash = sha1( wp_json_encode( array( $content['title'], $content['bullets'], $content['description'], $content['images'] ) ) ); + $prev = Db::row( 'SELECT content_hash FROM {t:listing_audits} WHERE market = %s AND sku = %s', array( $market, $sku ) ); + if ( ! $demo && null !== $prev && '' !== (string) $prev['content_hash'] && $prev['content_hash'] !== $hash ) { + SuiteAlerts::raise( + 'warning', 'suite_listing', + sprintf( 'Listing content changed: %s (%s)', $sku, $content['asin'] ), + 'Title, bullets, description or images differ from the last audit. If you did not make this change, check for a catalog contribution from Amazon or another seller.', + array( 'market' => $market, 'sku' => $sku ) + ); + } + Db::upsert( 'listing_audits', array( + 'market' => $market, + 'sku' => $sku, + 'asin' => (string) $content['asin'], + 'score' => (int) $result['score'], + 'status' => (string) $result['status'], + 'content_hash' => $hash, + 'findings' => wp_json_encode( $result['findings'] ), + 'snapshot' => wp_json_encode( $content ), + 'audited_at' => Db::now(), + 'is_demo' => $demo ? 1 : 0, + ) ); + } + + /** Normalise a Listings API response into plain content fields. */ + public static function extract( array $res ): array { + $attr = (array) ( $res['attributes'] ?? array() ); + $sum = (array) ( $res['summaries'][0] ?? array() ); + $val = static fn( string $k ): string => (string) ( $attr[ $k ][0]['value'] ?? '' ); + $bullets = array(); + foreach ( (array) ( $attr['bullet_point'] ?? array() ) as $b ) { + if ( '' !== trim( (string) ( $b['value'] ?? '' ) ) ) { + $bullets[] = (string) $b['value']; + } + } + $images = array(); + if ( ! empty( $attr['main_product_image_locator'][0]['media_location'] ) ) { + $images[] = (string) $attr['main_product_image_locator'][0]['media_location']; + } elseif ( ! empty( $sum['mainImage']['link'] ) ) { + $images[] = (string) $sum['mainImage']['link']; + } + for ( $i = 1; $i <= 8; $i++ ) { + $loc = $attr[ 'other_product_image_locator_' . $i ][0]['media_location'] ?? ''; + if ( '' !== $loc ) { + $images[] = (string) $loc; + } + } + $issues = array(); + foreach ( (array) ( $res['issues'] ?? array() ) as $is ) { + $issues[] = array( + 'code' => (string) ( $is['code'] ?? '' ), + 'severity' => strtoupper( (string) ( $is['severity'] ?? 'INFO' ) ), + 'message' => (string) ( $is['message'] ?? '' ), + ); + } + return array( + 'asin' => (string) ( $sum['asin'] ?? '' ), + 'title' => '' !== $val( 'item_name' ) ? $val( 'item_name' ) : (string) ( $sum['itemName'] ?? '' ), + 'brand' => $val( 'brand' ), + 'bullets' => $bullets, + 'description' => $val( 'product_description' ), + 'backend' => $val( 'generic_keyword' ), + 'images' => $images, + 'status' => array_values( array_map( 'strval', (array) ( $sum['status'] ?? array() ) ) ), + 'issues' => $issues, + 'product_type'=> (string) ( $sum['productType'] ?? '' ), + ); + } + + /** + * @param array $c extract() output + * @param string[] $keywords tracked keywords for coverage scoring + * @return array{score:int, grade:string, status:string, findings:array, sections:array, keyword_coverage:?array} + */ + public static function score( array $c, array $keywords = array() ): array { + $f = array(); + $sec = array( 'title' => 25, 'bullets' => 25, 'description' => 10, 'backend' => 10, 'images' => 20, 'health' => 10 ); + $hit = static function ( string $section, int $pts, string $sev, string $msg ) use ( &$sec, &$f ): void { + $sec[ $section ] = max( 0, $sec[ $section ] - $pts ); + $f[] = array( 'section' => $section, 'severity' => $sev, 'message' => $msg ); + }; + + // Title. + $title = trim( (string) $c['title'] ); + $tlen = mb_strlen( $title ); + if ( '' === $title ) { + $hit( 'title', 25, 'error', 'Title is missing.' ); + } else { + if ( $tlen > 200 ) { + $hit( 'title', 10, 'error', "Title is {$tlen} characters — Amazon's limit is 200; longer titles get suppressed." ); + } elseif ( $tlen < 80 ) { + $hit( 'title', 6, 'warning', "Title is only {$tlen} characters. 80–200 lets you cover brand + product + key features + size/count." ); + } + $bad = array_values( array_filter( self::BANNED_CHARS, static fn( $ch ) => str_contains( $title, $ch ) ) ); + if ( $bad ) { + $hit( 'title', 5, 'error', 'Title contains decorative characters not allowed by Amazon policy: ' . implode( ' ', $bad ) ); + } + $repeats = self::repeated_words( $title ); + if ( $repeats ) { + $hit( 'title', 5, 'error', 'Words used more than twice in title (policy violation): ' . implode( ', ', $repeats ) ); + } + if ( '' !== $c['brand'] && ! str_starts_with( mb_strtolower( $title ), mb_strtolower( (string) $c['brand'] ) ) ) { + $hit( 'title', 3, 'warning', 'Title should start with the brand name ("' . $c['brand'] . '").' ); + } + if ( preg_match( '/\b[A-Z]{4,}\b.*\b[A-Z]{4,}\b.*\b[A-Z]{4,}\b/', $title ) ) { + $hit( 'title', 3, 'warning', 'Avoid ALL-CAPS words in the title.' ); + } + $claims = self::claims( $title ); + if ( $claims ) { + $hit( 'title', 5, 'error', 'Prohibited promotional/medical claims in title: ' . implode( ', ', $claims ) ); + } + } + + // Bullets. + $bullets = (array) $c['bullets']; + $nb = count( $bullets ); + if ( 0 === $nb ) { + $hit( 'bullets', 25, 'error', 'No bullet points.' ); + } else { + if ( $nb < 5 ) { + $hit( 'bullets', ( 5 - $nb ) * 4, 'warning', "Only {$nb} of 5 bullet points used." ); + } + foreach ( $bullets as $i => $b ) { + $len = mb_strlen( $b ); + $n = $i + 1; + if ( $len < 60 ) { + $hit( 'bullets', 2, 'warning', "Bullet {$n} is short ({$len} chars) — aim for 150–250 to explain a benefit." ); + } elseif ( $len > 500 ) { + $hit( 'bullets', 2, 'warning', "Bullet {$n} is {$len} chars; many categories truncate above 500 bytes." ); + } + if ( preg_match( '/[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]/u', $b ) ) { + $hit( 'bullets', 3, 'error', "Bullet {$n} contains emoji/special symbols (prohibited)." ); + } + $claims = self::claims( $b ); + if ( $claims ) { + $hit( 'bullets', 3, 'error', "Bullet {$n} has prohibited claims: " . implode( ', ', $claims ) ); + } + } + } + + // Description. + $dlen = mb_strlen( self::plain( (string) $c['description'] ) ); + if ( 0 === $dlen ) { + $hit( 'description', 10, 'warning', 'No product description (A+ Content replaces it on the page, but the text is still indexed).' ); + } elseif ( $dlen < 500 ) { + $hit( 'description', 5, 'warning', "Description is {$dlen} characters — 1000–2000 performs better." ); + } elseif ( $dlen > 2000 ) { + $hit( 'description', 4, 'error', "Description is {$dlen} characters; the limit is 2000." ); + } + + // Backend search terms. + $backend = trim( (string) $c['backend'] ); + $bytes = strlen( $backend ); + if ( '' === $backend ) { + $hit( 'backend', 10, 'warning', 'Backend search terms (generic_keyword) are empty — free indexing left on the table.' ); + } else { + if ( $bytes > 249 ) { + $hit( 'backend', 8, 'error', "Backend search terms are {$bytes} bytes; anything over 249 bytes is NOT indexed at all." ); + } elseif ( $bytes < 150 ) { + $hit( 'backend', 3, 'warning', "Backend search terms use only {$bytes}/249 bytes." ); + } + if ( preg_match( '/\bB0[A-Z0-9]{8}\b/i', $backend ) ) { + $hit( 'backend', 4, 'error', 'Backend search terms must not contain ASINs.' ); + } + $dupes = array_intersect( self::words( $backend ), self::words( $title ) ); + if ( count( $dupes ) >= 3 ) { + $hit( 'backend', 2, 'info', 'Backend terms repeat title words (' . implode( ', ', array_slice( array_unique( $dupes ), 0, 6 ) ) . ') — those bytes are wasted.' ); + } + } + + // Images. + $ni = count( (array) $c['images'] ); + if ( 0 === $ni ) { + $hit( 'images', 20, 'error', 'No images.' ); + } elseif ( $ni < 7 ) { + $hit( 'images', ( 7 - $ni ) * 3, 'warning', "{$ni} images — use all 7+ slots (main, infographics, lifestyle, size, packaging)." ); + } + + // Health. + $errors = array_filter( (array) $c['issues'], static fn( $i ) => 'ERROR' === $i['severity'] ); + $warns = array_filter( (array) $c['issues'], static fn( $i ) => 'WARNING' === $i['severity'] ); + foreach ( $errors as $i ) { + $hit( 'health', 5, 'error', 'Amazon issue ' . $i['code'] . ': ' . $i['message'] ); + } + foreach ( $warns as $i ) { + $hit( 'health', 2, 'warning', 'Amazon warning ' . $i['code'] . ': ' . $i['message'] ); + } + $status_list = (array) $c['status']; + $buyable = in_array( 'BUYABLE', $status_list, true ); + if ( $status_list && ! $buyable ) { + $hit( 'health', 10, 'error', 'Listing is NOT buyable (inactive or suppressed).' ); + } + + // Keyword coverage. + $coverage = null; + if ( $keywords ) { + $haystack = mb_strtolower( $title . ' ' . implode( ' ', $bullets ) . ' ' . $backend . ' ' . $c['description'] ); + $found = array(); + $missing = array(); + foreach ( $keywords as $kw ) { + $kw = mb_strtolower( trim( (string) $kw ) ); + if ( '' === $kw ) { + continue; + } + $all = true; + foreach ( preg_split( '/\s+/', $kw ) as $w ) { + if ( ! str_contains( $haystack, $w ) ) { + $all = false; + break; + } + } + $all ? $found[] = $kw : $missing[] = $kw; + } + $total = count( $found ) + count( $missing ); + $coverage = array( + 'pct' => $total > 0 ? (int) round( count( $found ) / $total * 100 ) : 0, + 'found' => $found, + 'missing' => $missing, + ); + if ( $missing ) { + $f[] = array( 'section' => 'keywords', 'severity' => 'info', 'message' => 'Tracked keywords not present anywhere in the listing: ' . implode( ', ', array_slice( $missing, 0, 10 ) ) ); + } + } + + $score = array_sum( $sec ); + return array( + 'score' => $score, + 'grade' => $score >= 90 ? 'A' : ( $score >= 80 ? 'B' : ( $score >= 65 ? 'C' : ( $score >= 50 ? 'D' : 'F' ) ) ), + 'status' => ! $status_list ? 'unknown' : ( $buyable ? 'active' : 'suppressed' ), + 'findings' => $f, + 'sections' => $sec, + 'keyword_coverage' => $coverage, + ); + } + + /** @return string[] */ + public static function repeated_words( string $text ): array { + $counts = array_count_values( self::words( $text ) ); + return array_keys( array_filter( $counts, static fn( $n ) => $n > 2 ) ); + } + + /** @return string[] lower-case content words */ + public static function words( string $text ): array { + $parts = preg_split( '/[^\p{L}\p{N}]+/u', mb_strtolower( $text ) ) ?: array(); + return array_values( array_filter( $parts, static fn( $w ) => '' !== $w && mb_strlen( $w ) > 1 && ! in_array( $w, self::STOP_WORDS, true ) && ! is_numeric( $w ) ) ); + } + + /** Strip HTML without WordPress (keeps the scorer unit-testable). */ + public static function plain( string $s ): string { + $s = (string) preg_replace( '#<(script|style)[^>]*?>.*?#si', '', $s ); + return trim( (string) preg_replace( '/\s+/', ' ', strip_tags( $s ) ) ); + } + + /** @return string[] */ + public static function claims( string $text ): array { + $t = ' ' . mb_strtolower( $text ) . ' '; + $out = array(); + foreach ( self::CLAIMS as $claim ) { + if ( preg_match( '/(^|[^\p{L}])' . preg_quote( $claim, '/' ) . '([^\p{L}]|$)/u', $t ) ) { + $out[] = $claim; + } + } + return $out; + } +} + diff --git a/sg-commerce-pro/src/Suite/Monitor/ListingMonitor.php b/sg-commerce-pro/src/Suite/Monitor/ListingMonitor.php new file mode 100644 index 0000000..75ebaf6 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Monitor/ListingMonitor.php @@ -0,0 +1,266 @@ + */ + public function watchlist(): array { + global $wpdb; + $out = array(); + $rows = $wpdb->get_results( "SELECT DISTINCT market, asin FROM {$wpdb->prefix}sg_products WHERE asin <> ''", ARRAY_A ) ?: array(); // phpcs:ignore + foreach ( $rows as $r ) { + $out[ $r['market'] . '|' . $r['asin'] ] = array( 'market' => (string) $r['market'], 'asin' => (string) $r['asin'], 'mine' => true ); + } + $comp = $wpdb->get_results( "SELECT DISTINCT market, asin FROM {$wpdb->prefix}sg_competitors", ARRAY_A ) ?: array(); // phpcs:ignore + foreach ( $comp as $r ) { + $out[ $r['market'] . '|' . $r['asin'] ] ??= array( 'market' => (string) $r['market'], 'asin' => (string) $r['asin'], 'mine' => false ); + } + $primary = $this->marketplaces->primary(); + foreach ( preg_split( '/[\s,]+/', strtoupper( $this->settings->string( 'suite_monitor_extra_asins' ) ) ) ?: array() as $a ) { + if ( 1 === preg_match( '/^B0[A-Z0-9]{8}$/', $a ) ) { + $out[ $primary . '|' . $a ] ??= array( 'market' => $primary, 'asin' => $a, 'mine' => false ); + } + } + return array_values( $out ); + } + + /** @return array{checked:int, alerts:int} */ + public function run(): array { + $stats = array( 'checked' => 0, 'alerts' => 0 ); + if ( ! $this->client->has_credentials() ) { + return $stats; + } + $list = $this->watchlist(); + if ( ! $list ) { + return $stats; + } + $cursor = (int) get_option( self::CURSOR, 0 ) % count( $list ); + $batch = array_slice( array_merge( array_slice( $list, $cursor ), array_slice( $list, 0, $cursor ) ), 0, self::BATCH ); + foreach ( $batch as $item ) { + try { + $mp = $this->marketplaces->get( $item['market'] ); + $res = $this->client->request( + 'GET', $item['market'], + '/products/pricing/v0/items/' . rawurlencode( $item['asin'] ) . '/offers', + array( 'MarketplaceId' => (string) ( $mp['id'] ?? '' ), 'ItemCondition' => 'New' ), + null, 'pricing.itemOffers' + ); + $stats['alerts'] += $this->process( $item['market'], $item['asin'], $item['mine'], (array) ( $res['payload'] ?? array() ) ); + $stats['checked']++; + } catch ( \Throwable $e ) { + $this->logger->warning( 'Suite monitor fetch failed', array( 'asin' => $item['asin'], 'error' => $e->getMessage() ) ); + if ( str_contains( $e->getMessage(), 'Rate limit' ) || str_contains( $e->getMessage(), '429' ) ) { + break; + } + } + } + update_option( self::CURSOR, ( $cursor + count( $batch ) ) % max( 1, count( $list ) ), false ); + return $stats; + } + + /** + * Process one getItemOffers payload. Returns number of alerts raised. + */ + public function process( string $market, string $asin, bool $mine, array $payload, bool $demo = false, ?string $now = null ): int { + $now = $now ?? Db::now(); + $me = $this->settings->seller_id(); + $offers = (array) ( $payload['Offers'] ?? array() ); + $summary = (array) ( $payload['Summary'] ?? array() ); + + $bb_price = null; + $bb_seller = ''; + $my_price = null; + $lowest = null; + $fba = 0; + $sellers = array(); + foreach ( $offers as $o ) { + $price = (float) ( $o['ListingPrice']['Amount'] ?? 0 ) + (float) ( $o['Shipping']['Amount'] ?? 0 ); + $seller = (string) ( $o['SellerId'] ?? '' ); + $is_me = ! empty( $o['MyOffer'] ) || ( '' !== $me && $seller === $me ); + if ( ! empty( $o['IsBuyBoxWinner'] ) ) { + $bb_price = $price; + $bb_seller = $is_me ? ( '' !== $me ? $me : 'ME' ) : $seller; + } + if ( $is_me ) { + $my_price = $price; + } + if ( ! empty( $o['IsFulfilledByAmazon'] ) ) { + $fba++; + } + $lowest = null === $lowest ? $price : min( $lowest, $price ); + if ( '' !== $seller ) { + $sellers[ $seller ] = array( 'is_me' => $is_me, 'fba' => ! empty( $o['IsFulfilledByAmazon'] ), 'price' => $price ); + } + } + if ( null === $bb_price && ! empty( $summary['BuyBoxPrices'][0] ) ) { + $bp = $summary['BuyBoxPrices'][0]; + $bb_price = (float) ( $bp['LandedPrice']['Amount'] ?? $bp['ListingPrice']['Amount'] ?? 0 ) ?: null; + } + $offer_count = 0; + foreach ( (array) ( $summary['NumberOfOffers'] ?? array() ) as $n ) { + $offer_count += (int) ( $n['OfferCount'] ?? 0 ); + } + $offer_count = max( $offer_count, count( $offers ) ); + $bsr = null; + $bsr_cat = ''; + foreach ( (array) ( $summary['SalesRankings'] ?? array() ) as $rank ) { + $r = (int) ( $rank['Rank'] ?? 0 ); + $cat = (string) ( $rank['ProductCategoryId'] ?? '' ); + if ( $r <= 0 ) { + continue; + } + // The top-level display-group rank is the BSR shown on the detail page. + if ( str_contains( $cat, '_display_on_website' ) ) { + $bsr = $r; + $bsr_cat = $cat; + break; + } + if ( null === $bsr ) { + $bsr = $r; + $bsr_cat = $cat; + } + } + $bb_is_mine = '' !== $bb_seller && ( $bb_seller === $me || 'ME' === $bb_seller ); + + $prev = Db::row( + 'SELECT * FROM {t:asin_snapshots} WHERE market = %s AND asin = %s ORDER BY captured_at DESC LIMIT 1', + array( $market, $asin ) + ); + Db::insert( 'asin_snapshots', array( + 'market' => $market, 'asin' => $asin, 'captured_at' => $now, 'is_mine' => $mine ? 1 : 0, + 'my_price' => $my_price, 'buybox_price' => $bb_price, 'buybox_seller' => $bb_seller, 'buybox_is_mine' => $bb_is_mine ? 1 : 0, + 'lowest_price' => $lowest, 'offer_count' => $offer_count, 'fba_offer_count' => $fba, 'bsr' => $bsr, + 'bsr_category' => mb_substr( $bsr_cat, 0, 128 ), 'is_demo' => $demo ? 1 : 0, + ) ); + + $alerts = 0; + // Seller tracking + hijacker detection. + $known = array(); + foreach ( Db::rows( 'SELECT seller_id FROM {t:offer_sellers} WHERE market = %s AND asin = %s', array( $market, $asin ) ) as $k ) { + $known[ (string) $k['seller_id'] ] = true; + } + $first_scan = ! $known; + foreach ( $sellers as $sid => $s ) { + Db::upsert( 'offer_sellers', array( + 'market' => $market, 'asin' => $asin, 'seller_id' => $sid, 'is_me' => $s['is_me'] ? 1 : 0, + 'is_fba' => $s['fba'] ? 1 : 0, 'last_price' => $s['price'], 'first_seen' => $now, 'last_seen' => $now, + 'active' => 1, 'is_demo' => $demo ? 1 : 0, + ), array( 'first_seen' ) ); + if ( $mine && ! $s['is_me'] && ! isset( $known[ $sid ] ) && ! $first_scan && ! $demo ) { + SuiteAlerts::raise( + 'critical', 'suite_hijacker', + sprintf( 'New seller on your ASIN %s: %s (%s @ %s)', $asin, $sid, $s['fba'] ? 'FBA' : 'FBM', number_format( $s['price'], 2 ) ), + 'A seller that was not on this listing before is now offering it. If you are brand-registered, verify authenticity and file a Report a Violation case; consider a test buy.', + array( 'market' => $market, 'asin' => $asin, 'seller' => $sid ) + ); + $alerts++; + } + } + if ( $sellers ) { + Db::exec( + 'UPDATE {t:offer_sellers} SET active = 0 WHERE market = %s AND asin = %s AND seller_id NOT IN (' . Db::in( array_keys( $sellers ) ) . ')', + array_merge( array( $market, $asin ), array_keys( $sellers ) ) + ); + } + + if ( $mine && null !== $prev && ! $demo ) { + if ( (int) $prev['buybox_is_mine'] && ! $bb_is_mine ) { + SuiteAlerts::raise( + 'warning', 'suite_buybox', + '' === $bb_seller ? "Buy Box suppressed on {$asin}" : "Buy Box lost on {$asin} to {$bb_seller}", + '' === $bb_seller ? 'No offer is winning the Buy Box — usually a price well above the reference price. Check your price vs. other channels.' : sprintf( 'Winning price now %s.', null !== $bb_price ? number_format( $bb_price, 2 ) : '?' ), + array( 'market' => $market, 'asin' => $asin ) + ); + $alerts++; + } + } + if ( ! $mine && null !== $prev && null !== $prev['lowest_price'] && null !== $lowest && ! $demo ) { + $drop = ( (float) $prev['lowest_price'] - $lowest ) / max( 0.01, (float) $prev['lowest_price'] ) * 100; + if ( $drop >= $this->settings->float( 'suite_monitor_price_drop_pct' ) ) { + SuiteAlerts::raise( + 'info', 'suite_competitor', + sprintf( 'Competitor %s dropped price %.0f%% (%s → %s)', $asin, $drop, number_format( (float) $prev['lowest_price'], 2 ), number_format( $lowest, 2 ) ), + '', array( 'market' => $market, 'asin' => $asin ) + ); + $alerts++; + } + } + return $alerts; + } + + /** Latest snapshot per watched ASIN + seller counts. */ + public function board(): array { + $rows = Db::rows( + 'SELECT s.* FROM {t:asin_snapshots} s + INNER JOIN (SELECT market, asin, MAX(captured_at) AS m FROM {t:asin_snapshots} GROUP BY market, asin) x + ON x.market = s.market AND x.asin = s.asin AND x.m = s.captured_at + ORDER BY s.is_mine DESC, s.asin' + ); + $sellers = array(); + foreach ( Db::rows( 'SELECT market, asin, COUNT(*) AS n, SUM(CASE WHEN is_me = 0 THEN 1 ELSE 0 END) AS others FROM {t:offer_sellers} WHERE active = 1 GROUP BY market, asin' ) as $r ) { + $sellers[ $r['market'] . '|' . $r['asin'] ] = $r; + } + foreach ( $rows as &$r ) { + $k = $r['market'] . '|' . $r['asin']; + $r['active_sellers'] = (int) ( $sellers[ $k ]['n'] ?? 0 ); + $r['other_sellers'] = (int) ( $sellers[ $k ]['others'] ?? 0 ); + } + return $rows; + } + + public function history( string $market, string $asin, int $days = 90 ): array { + return Db::rows( + 'SELECT captured_at, my_price, buybox_price, lowest_price, offer_count, bsr, buybox_is_mine FROM {t:asin_snapshots} + WHERE market = %s AND asin = %s AND captured_at >= %s ORDER BY captured_at ASC', + array( $market, $asin, gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) + ); + } + + public function sellers( string $market, string $asin ): array { + return Db::rows( 'SELECT * FROM {t:offer_sellers} WHERE market = %s AND asin = %s ORDER BY active DESC, last_seen DESC', array( $market, $asin ) ); + } + + public function prune( int $days = 365 ): int { + return Db::exec( 'DELETE FROM {t:asin_snapshots} WHERE captured_at < %s', array( gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) ); + } +} diff --git a/sg-commerce-pro/src/Suite/Orders/OrdersSync.php b/sg-commerce-pro/src/Suite/Orders/OrdersSync.php new file mode 100644 index 0000000..2c7b80d --- /dev/null +++ b/sg-commerce-pro/src/Suite/Orders/OrdersSync.php @@ -0,0 +1,269 @@ + 0, 'items' => 0 ); + if ( ! $this->client->has_credentials() ) { + return $stats; + } + foreach ( $this->marketplaces->enabled_codes() as $market ) { + try { + $stats['orders'] += $this->sync_market( $market ); + } catch ( \Throwable $e ) { + $this->logger->warning( 'Suite orders sync failed', array( 'market' => $market, 'error' => $e->getMessage() ) ); + } + } + $stats['items'] = $this->sync_pending_items( max( 1, $this->settings->int( 'suite_order_items_per_run' ) ) ); + return $stats; + } + + public function sync_market( string $market ): int { + $mp = $this->marketplaces->get( $market ); + if ( ! $mp ) { + return 0; + } + $cursor = (string) get_option( self::CURSOR_OPTION . $market, '' ); + if ( '' === $cursor ) { + // First run: the report backfill covers history; API covers the last 2 days. + $cursor = gmdate( 'Y-m-d\TH:i:s\Z', time() - 2 * DAY_IN_SECONDS ); + } + $query = array( + 'MarketplaceIds' => $mp['id'], + 'LastUpdatedAfter' => $cursor, + 'MaxResultsPerPage' => 100, + ); + $count = 0; + $pages = 0; + $latest = $cursor; + do { + $res = $this->client->request( 'GET', $market, '/orders/v0/orders', $query, null, 'orders.list' ); + $payload = (array) ( $res['payload'] ?? array() ); + foreach ( (array) ( $payload['Orders'] ?? array() ) as $o ) { + if ( $this->store_api_order( $o, $market ) ) { + $count++; + } + $lu = (string) ( $o['LastUpdateDate'] ?? '' ); + if ( '' !== $lu && strcmp( $lu, $latest ) > 0 ) { + $latest = $lu; + } + } + $next = (string) ( $payload['NextToken'] ?? '' ); + $query = array( 'MarketplaceIds' => $mp['id'], 'NextToken' => $next ); + $pages++; + } while ( '' !== $next && $pages < self::MAX_PAGES ); + + update_option( self::CURSOR_OPTION . $market, $latest, false ); + return $count; + } + + public function store_api_order( array $o, string $market, bool $demo = false ): bool { + $id = (string) ( $o['AmazonOrderId'] ?? '' ); + if ( '' === $id ) { + return false; + } + $purchase = SuiteTime::to_mysql( (string) ( $o['PurchaseDate'] ?? '' ) ) ?? Db::now(); + $code = $this->marketplaces->code_by_id( (string) ( $o['MarketplaceId'] ?? '' ) ) ?: $market; + $units = (int) ( $o['NumberOfItemsShipped'] ?? 0 ) + (int) ( $o['NumberOfItemsUnshipped'] ?? 0 ); + $row = array( + 'market' => $code, + 'amazon_order_id' => $id, + 'purchase_date' => $purchase, + 'local_date' => SuiteTime::local_date( $purchase, $code ), + 'last_update' => SuiteTime::to_mysql( (string) ( $o['LastUpdateDate'] ?? '' ) ), + 'status' => (string) ( $o['OrderStatus'] ?? '' ), + 'fulfillment_channel' => (string) ( $o['FulfillmentChannel'] ?? '' ), + 'sales_channel' => (string) ( $o['SalesChannel'] ?? '' ), + 'order_total' => (float) ( $o['OrderTotal']['Amount'] ?? 0 ), + 'currency' => (string) ( $o['OrderTotal']['CurrencyCode'] ?? '' ), + 'units' => $units, + 'is_business' => ! empty( $o['IsBusinessOrder'] ) ? 1 : 0, + 'is_prime' => ! empty( $o['IsPrime'] ) ? 1 : 0, + 'ship_country' => substr( (string) ( $o['ShippingAddress']['CountryCode'] ?? '' ), 0, 2 ), + 'earliest_delivery' => SuiteTime::to_mysql( (string) ( $o['EarliestDeliveryDate'] ?? '' ) ), + 'latest_delivery' => SuiteTime::to_mysql( (string) ( $o['LatestDeliveryDate'] ?? '' ) ), + 'is_demo' => $demo ? 1 : 0, + ); + $ok = Db::upsert( 'orders', $row, array( 'items_synced' ) ); + // Status changes (e.g. Pending → Shipped, → Canceled) must cascade to items. + Db::exec( 'UPDATE {t:order_items} SET status = %s WHERE amazon_order_id = %s', array( $row['status'], $id ) ); + return $ok; + } + + /** Fetch items for orders that don't have them yet. */ + public function sync_pending_items( int $limit ): int { + $orders = Db::rows( + "SELECT amazon_order_id, market, local_date, status FROM {t:orders} + WHERE items_synced = 0 AND is_demo = 0 AND status <> 'Canceled' + ORDER BY purchase_date DESC LIMIT %d", + array( $limit ) + ); + $n = 0; + foreach ( $orders as $o ) { + try { + $res = $this->client->request( 'GET', (string) $o['market'], '/orders/v0/orders/' . rawurlencode( (string) $o['amazon_order_id'] ) . '/orderItems', array(), null, 'orders.items' ); + $items = (array) ( $res['payload']['OrderItems'] ?? array() ); + if ( $items ) { + // Real OrderItemIds supersede report-derived placeholder rows. + Db::exec( 'DELETE FROM {t:order_items} WHERE amazon_order_id = %s AND order_item_id LIKE %s', array( $o['amazon_order_id'], 'ff-%' ) ); + } + foreach ( $items as $it ) { + $this->store_item( (string) $o['amazon_order_id'], (string) $o['market'], (string) $o['local_date'], (string) $o['status'], $it ); + $n++; + } + // Only mark synced once priced (Pending orders return no ItemPrice). + $priced = 'Pending' !== $o['status']; + Db::exec( 'UPDATE {t:orders} SET items_synced = %d WHERE amazon_order_id = %s', array( $priced ? 1 : 0, $o['amazon_order_id'] ) ); + } catch ( \Throwable $e ) { + $this->logger->warning( 'Suite order items fetch failed', array( 'order' => $o['amazon_order_id'], 'error' => $e->getMessage() ) ); + break; // Likely throttled — resume next tick. + } + } + return $n; + } + + public function store_item( string $order_id, string $market, string $local_date, string $status, array $it, bool $demo = false ): bool { + return Db::upsert( 'order_items', array( + 'market' => $market, + 'amazon_order_id' => $order_id, + 'order_item_id' => (string) ( $it['OrderItemId'] ?? md5( $order_id . ( $it['SellerSKU'] ?? '' ) ) ), + 'local_date' => $local_date, + 'sku' => (string) ( $it['SellerSKU'] ?? '' ), + 'asin' => (string) ( $it['ASIN'] ?? '' ), + 'title' => mb_substr( (string) ( $it['Title'] ?? '' ), 0, 255 ), + 'qty' => (int) ( $it['QuantityOrdered'] ?? 0 ), + 'item_price' => (float) ( $it['ItemPrice']['Amount'] ?? 0 ), + 'item_tax' => (float) ( $it['ItemTax']['Amount'] ?? 0 ), + 'promo_discount' => (float) ( $it['PromotionDiscount']['Amount'] ?? 0 ), + 'status' => $status, + 'is_demo' => $demo ? 1 : 0, + ) ); + } + + /** + * Ingest GET_FLAT_FILE_ALL_ORDERS_DATA_BY_ORDER_DATE_GENERAL (TSV). + * One row per order item; PII columns are ignored. + */ + public function ingest_flat_file( string $raw, string $market ): int { + $rows = ReportParser::tsv( $raw ); + $orders = array(); + $n = 0; + $api_owned = array(); // order id => true when the Orders API already stored real items. + foreach ( $rows as $r ) { + $id = (string) ( $r['amazon-order-id'] ?? '' ); + if ( '' === $id ) { + continue; + } + if ( ! isset( $api_owned[ $id ] ) ) { + $api_owned[ $id ] = (int) Db::var( + 'SELECT COUNT(*) FROM {t:order_items} WHERE amazon_order_id = %s AND order_item_id NOT LIKE %s', + array( $id, 'ff-%' ) + ) > 0; + } + $sales_channel = (string) ( $r['sales-channel'] ?? '' ); + if ( '' !== $sales_channel && ! str_starts_with( strtolower( $sales_channel ), 'amazon.' ) ) { + continue; // Non-Amazon (MCF) orders are not marketplace sales. + } + $purchase = SuiteTime::to_mysql( (string) ( $r['purchase-date'] ?? '' ) ) ?? Db::now(); + $local = SuiteTime::local_date( $purchase, $market ); + $status = (string) ( $r['order-status'] ?? '' ); + $price = ReportParser::money( $r['item-price'] ?? 0 ); + $qty = (int) ( $r['quantity'] ?? 0 ); + if ( ! isset( $orders[ $id ] ) ) { + $orders[ $id ] = array( + 'market' => $market, + 'amazon_order_id' => $id, + 'purchase_date' => $purchase, + 'local_date' => $local, + 'last_update' => SuiteTime::to_mysql( (string) ( $r['last-updated-date'] ?? '' ) ), + 'status' => $status, + 'fulfillment_channel' => 'Amazon' === ( $r['fulfillment-channel'] ?? '' ) ? 'AFN' : 'MFN', + 'sales_channel' => $sales_channel, + 'order_total' => 0.0, + 'currency' => (string) ( $r['currency'] ?? '' ), + 'units' => 0, + 'is_business' => 'true' === strtolower( (string) ( $r['is-business-order'] ?? '' ) ) ? 1 : 0, + 'is_prime' => 0, + 'ship_country' => substr( (string) ( $r['ship-country'] ?? '' ), 0, 2 ), + 'items_synced' => 1, + 'is_demo' => 0, + ); + } + $orders[ $id ]['order_total'] += $price + ReportParser::money( $r['shipping-price'] ?? 0 ); + $orders[ $id ]['units'] += $qty; + if ( $api_owned[ $id ] ) { + $n++; + continue; + } + Db::upsert( 'order_items', array( + 'market' => $market, + 'amazon_order_id' => $id, + 'order_item_id' => 'ff-' . md5( $id . '|' . ( $r['sku'] ?? '' ) . '|' . ( $r['asin'] ?? '' ) ), + 'local_date' => $local, + 'sku' => (string) ( $r['sku'] ?? '' ), + 'asin' => (string) ( $r['asin'] ?? '' ), + 'title' => mb_substr( (string) ( $r['product-name'] ?? '' ), 0, 255 ), + 'qty' => $qty, + 'item_price' => $price, + 'item_tax' => ReportParser::money( $r['item-tax'] ?? 0 ), + 'promo_discount' => abs( ReportParser::money( $r['item-promotion-discount'] ?? 0 ) ), + 'status' => $status, + 'is_demo' => 0, + ) ); + $n++; + } + foreach ( $orders as $row ) { + // API-sourced rows (with real OrderItemIds) win; only insert when absent. + $exists = (int) Db::var( 'SELECT COUNT(*) FROM {t:orders} WHERE amazon_order_id = %s', array( $row['amazon_order_id'] ) ); + if ( 0 === $exists ) { + Db::upsert( 'orders', $row ); + } else { + Db::exec( 'UPDATE {t:orders} SET status = %s WHERE amazon_order_id = %s', array( $row['status'], $row['amazon_order_id'] ) ); + } + } + return $n; + } +} diff --git a/sg-commerce-pro/src/Suite/Profit/CostBook.php b/sg-commerce-pro/src/Suite/Profit/CostBook.php new file mode 100644 index 0000000..78e1136 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Profit/CostBook.php @@ -0,0 +1,122 @@ +> market => sku => row */ + private array $cache = array(); + + public function __construct( private SuiteSettings $settings ) {} + + public function landed( string $market, string $sku ): float { + $row = $this->row( $market, $sku ); + return null === $row ? $this->settings->float( 'suite_default_unit_cost' ) : (float) $row['landed']; + } + + public function has_cost( string $market, string $sku ): bool { + $row = $this->row( $market, $sku ); + return null !== $row && (float) $row['landed'] > 0; + } + + /** @return array{landed:float, unit_cost:float, inbound_per_unit:float, prep_per_unit:float, other_per_unit:float, lead_time_days:int, moq:int, case_pack:int, supplier:string}|null */ + public function row( string $market, string $sku ): ?array { + $this->load( $market ); + return $this->cache[ $market ][ $sku ] ?? null; + } + + /** Every SKU known to the suite (products, order items, cost rows) with its cost row. */ + public function all( string $market ): array { + $this->load( $market ); + global $wpdb; + $skus = Db::rows( + "SELECT sku, MAX(asin) AS asin, MAX(title) AS title FROM ( + SELECT sku, asin, product_name AS title FROM {$wpdb->prefix}sg_products WHERE market = %s + UNION ALL + SELECT sku, asin, title FROM {t:order_items} WHERE market = %s AND sku <> '' + ) x GROUP BY sku ORDER BY sku", + array( $market, $market ) + ); + $out = array(); + foreach ( $skus as $s ) { + $c = $this->cache[ $market ][ $s['sku'] ] ?? null; + $out[] = array( + 'sku' => (string) $s['sku'], + 'asin' => (string) $s['asin'], + 'title' => (string) $s['title'], + 'unit_cost' => $c['unit_cost'] ?? 0.0, + 'inbound_per_unit' => $c['inbound_per_unit'] ?? 0.0, + 'prep_per_unit' => $c['prep_per_unit'] ?? 0.0, + 'other_per_unit' => $c['other_per_unit'] ?? 0.0, + 'landed' => $c['landed'] ?? 0.0, + 'lead_time_days' => $c['lead_time_days'] ?? 0, + 'moq' => $c['moq'] ?? 0, + 'case_pack' => $c['case_pack'] ?? 0, + 'supplier' => $c['supplier'] ?? '', + 'source' => $c['source'] ?? 'none', + ); + } + return $out; + } + + public function save( string $market, string $sku, array $in ): bool { + $ok = Db::upsert( 'sku_costs', array( + 'market' => $market, + 'sku' => mb_substr( $sku, 0, 64 ), + 'unit_cost' => max( 0.0, (float) ( $in['unit_cost'] ?? 0 ) ), + 'inbound_per_unit' => max( 0.0, (float) ( $in['inbound_per_unit'] ?? 0 ) ), + 'prep_per_unit' => max( 0.0, (float) ( $in['prep_per_unit'] ?? 0 ) ), + 'other_per_unit' => max( 0.0, (float) ( $in['other_per_unit'] ?? 0 ) ), + 'lead_time_days' => max( 0, (int) ( $in['lead_time_days'] ?? 0 ) ), + 'moq' => max( 0, (int) ( $in['moq'] ?? 0 ) ), + 'case_pack' => max( 0, (int) ( $in['case_pack'] ?? 0 ) ), + 'supplier' => mb_substr( sanitize_text_field( (string) ( $in['supplier'] ?? '' ) ), 0, 128 ), + 'updated_at' => Db::now(), + ) ); + unset( $this->cache[ $market ] ); + return $ok; + } + + private function load( string $market ): void { + if ( isset( $this->cache[ $market ] ) ) { + return; + } + global $wpdb; + $map = array(); + // Core product cost_price as a fallback layer. + $core = $wpdb->get_results( $wpdb->prepare( "SELECT sku, cost_price FROM {$wpdb->prefix}sg_products WHERE market = %s AND cost_price IS NOT NULL", $market ), ARRAY_A ); // phpcs:ignore + foreach ( (array) $core as $r ) { + $map[ (string) $r['sku'] ] = array( + 'landed' => (float) $r['cost_price'], 'unit_cost' => (float) $r['cost_price'], 'inbound_per_unit' => 0.0, + 'prep_per_unit' => 0.0, 'other_per_unit' => 0.0, 'lead_time_days' => 0, 'moq' => 0, 'case_pack' => 0, + 'supplier' => '', 'source' => 'product', + ); + } + foreach ( Db::rows( 'SELECT * FROM {t:sku_costs} WHERE market = %s', array( $market ) ) as $r ) { + $landed = (float) $r['unit_cost'] + (float) $r['inbound_per_unit'] + (float) $r['prep_per_unit'] + (float) $r['other_per_unit']; + $map[ (string) $r['sku'] ] = array( + 'landed' => $landed, 'unit_cost' => (float) $r['unit_cost'], 'inbound_per_unit' => (float) $r['inbound_per_unit'], + 'prep_per_unit' => (float) $r['prep_per_unit'], 'other_per_unit' => (float) $r['other_per_unit'], + 'lead_time_days' => (int) $r['lead_time_days'], 'moq' => (int) $r['moq'], 'case_pack' => (int) $r['case_pack'], + 'supplier' => (string) $r['supplier'], 'source' => 'suite', + ); + } + $this->cache[ $market ] = $map; + } +} diff --git a/sg-commerce-pro/src/Suite/Profit/ProfitEngine.php b/sg-commerce-pro/src/Suite/Profit/ProfitEngine.php new file mode 100644 index 0000000..1ba3818 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Profit/ProfitEngine.php @@ -0,0 +1,424 @@ +marketplaces->get( $market ); + $currency = (string) ( $mp['currency'] ?? 'USD' ); + $days = array(); + for ( $d = $from; $d <= $to; $d = SuiteTime::shift( $d, 1 ) ) { + $days[ $d ] = self::blank_day(); + if ( count( $days ) > 400 ) { + break; + } + } + $skus = array(); + + // 1. Sales by order date. + $items = Db::rows( + "SELECT local_date, sku, MAX(asin) AS asin, MAX(title) AS title, SUM(qty) AS units, SUM(item_price) AS sales, + SUM(promo_discount) AS promo, SUM(item_tax) AS tax, COUNT(DISTINCT amazon_order_id) AS orders + FROM {t:order_items} + WHERE market = %s AND local_date BETWEEN %s AND %s AND status <> 'Canceled' + GROUP BY local_date, sku", + array( $market, $from, $to ) + ); + foreach ( $items as $r ) { + $d = (string) $r['local_date']; + $sku = (string) $r['sku']; + $u = (int) $r['units']; + $cogs = $u * $this->costs->landed( $market, $sku ); + $this->add( $days, $skus, $d, $sku, array( + 'units' => $u, 'sales' => (float) $r['sales'], 'promo' => -abs( (float) $r['promo'] ), + 'tax_collected' => (float) $r['tax'], 'cogs' => -$cogs, + ), $r ); + } + $order_count = Db::rows( + "SELECT local_date, COUNT(*) AS n FROM {t:orders} WHERE market = %s AND local_date BETWEEN %s AND %s AND status <> 'Canceled' GROUP BY local_date", + array( $market, $from, $to ) + ); + foreach ( $order_count as $r ) { + if ( isset( $days[ $r['local_date'] ] ) ) { + $days[ $r['local_date'] ]['orders'] = (int) $r['n']; + } + } + + // 2. Settled fees for orders placed in range. + $settled = Db::rows( + "SELECT o.local_date, f.sku, f.charge_type, SUM(f.amount) AS amt + FROM {t:fin_events} f INNER JOIN {t:orders} o ON o.amazon_order_id = f.amazon_order_id + WHERE o.market = %s AND o.local_date BETWEEN %s AND %s AND f.event_type = 'Shipment' AND f.category = 'fee' + GROUP BY o.local_date, f.sku, f.charge_type", + array( $market, $from, $to ) + ); + $fee_types = array(); + foreach ( $settled as $r ) { + $this->add( $days, $skus, (string) $r['local_date'], (string) $r['sku'], array( 'amazon_fees' => (float) $r['amt'] ) ); + $fee_types[ (string) $r['charge_type'] ] = ( $fee_types[ (string) $r['charge_type'] ] ?? 0 ) + (float) $r['amt']; + } + + // 3. Estimated fees for unsettled orders. + $unit_fee = $this->fee_per_unit( $market, $to ); + $unsettled = Db::rows( + "SELECT oi.local_date, oi.sku, SUM(oi.qty) AS units, SUM(oi.item_price) AS sales + FROM {t:order_items} oi + LEFT JOIN (SELECT DISTINCT amazon_order_id FROM {t:fin_events} WHERE event_type = 'Shipment') s ON s.amazon_order_id = oi.amazon_order_id + WHERE oi.market = %s AND oi.local_date BETWEEN %s AND %s AND oi.status <> 'Canceled' AND s.amazon_order_id IS NULL + GROUP BY oi.local_date, oi.sku", + array( $market, $from, $to ) + ); + $estimated_total = 0.0; + foreach ( $unsettled as $r ) { + $sku = (string) $r['sku']; + if ( isset( $unit_fee['sku'][ $sku ] ) ) { + $est = -$unit_fee['sku'][ $sku ] * (int) $r['units']; + } else { + $est = -$unit_fee['ratio'] * (float) $r['sales']; + } + $estimated_total += $est; + $this->add( $days, $skus, (string) $r['local_date'], $sku, array( 'amazon_fees' => $est, 'fees_estimated' => $est ) ); + } + if ( 0.0 !== $estimated_total ) { + $fee_types['Estimated (unsettled orders)'] = $estimated_total; + } + + // 4. Posted-date items: refunds, reimbursements, account fees, adjustments, ads invoices. + $posted = Db::rows( + "SELECT local_date, sku, category, event_type, charge_type, SUM(amount) AS amt, SUM(qty) AS qty + FROM {t:fin_events} + WHERE market = %s AND local_date BETWEEN %s AND %s AND (event_type <> 'Shipment' OR category NOT IN ('revenue','fee','promo','tax')) + GROUP BY local_date, sku, category, event_type, charge_type", + array( $market, $from, $to ) + ); + $ads_invoice = array(); + foreach ( $posted as $r ) { + $d = (string) $r['local_date']; + $sku = (string) $r['sku']; + $amt = (float) $r['amt']; + switch ( $r['category'] ) { + case 'refund': + $this->add( $days, $skus, $d, $sku, array( 'refunds' => $amt, 'refund_units' => 'Principal' === $r['charge_type'] ? abs( (int) $r['qty'] ) : 0 ) ); + break; + case 'fee': + // Fees on refunds (refund commission, restocking) belong to the SKU; others are account-level. + $key = ( 'Refund' === $r['event_type'] && '' !== $sku ) ? 'amazon_fees' : 'account_fees'; + $this->add( $days, $skus, $d, $key === 'amazon_fees' ? $sku : '', array( $key => $amt ) ); + $fee_types[ (string) $r['charge_type'] ] = ( $fee_types[ (string) $r['charge_type'] ] ?? 0 ) + $amt; + break; + case 'reimbursement': + $this->add( $days, $skus, $d, $sku, array( 'reimbursements' => $amt ) ); + break; + case 'ads': + $ads_invoice[ $d ] = ( $ads_invoice[ $d ] ?? 0 ) + $amt; + break; + case 'tax': + break; + case 'promo': + $this->add( $days, $skus, $d, '', array( 'promo' => $amt ) ); + break; + default: + $this->add( $days, $skus, $d, $sku, array( 'adjustments' => $amt ) ); + } + } + + // 5. Ads: Ads API spend if present, else invoices. + $ads = Db::rows( + "SELECT report_date, SUM(cost) AS cost, SUM(sales) AS sales FROM {t:ads_daily} + WHERE level = 'campaign' AND report_date BETWEEN %s AND %s GROUP BY report_date", + array( $from, $to ) + ); + $ads_sales = 0.0; + if ( $ads ) { + foreach ( $ads as $r ) { + if ( isset( $days[ $r['report_date'] ] ) ) { + $days[ $r['report_date'] ]['ads'] -= (float) $r['cost']; + } + $ads_sales += (float) $r['sales']; + } + } else { + foreach ( $ads_invoice as $d => $amt ) { + if ( isset( $days[ $d ] ) ) { + $days[ $d ]['ads'] += $amt; + } + } + } + + // 6. COGS add-back for sellable returns. + $returns = Db::rows( + "SELECT return_date, sku, SUM(qty) AS qty FROM {t:returns} + WHERE market = %s AND return_date BETWEEN %s AND %s AND disposition = 'SELLABLE' GROUP BY return_date, sku", + array( $market, $from, $to ) + ); + foreach ( $returns as $r ) { + $this->add( $days, $skus, (string) $r['return_date'], (string) $r['sku'], array( 'cogs' => (int) $r['qty'] * $this->costs->landed( $market, (string) $r['sku'] ) ) ); + } + + // 7. Custom expenses. + foreach ( $this->expenses( $market, $from, $to ) as $d => $amt ) { + if ( isset( $days[ $d ] ) ) { + $days[ $d ]['expenses'] -= $amt; + } + } + + // Roll up. + $include_tax = $this->settings->bool( 'suite_include_tax' ); + $totals = self::blank_day(); + foreach ( $days as $d => &$row ) { + $row['net_profit'] = self::net( $row, $include_tax ); + foreach ( $row as $k => $v ) { + $totals[ $k ] += $v; + } + } + unset( $row ); + $totals = self::ratios( $totals ); + $totals['ads_sales'] = $ads_sales; + $totals['acos_pct'] = $ads_sales > 0 ? round( -$totals['ads'] / $ads_sales * 100, 2 ) : null; + $totals['tacos_pct'] = $totals['sales'] > 0 ? round( -$totals['ads'] / $totals['sales'] * 100, 2 ) : null; + + $out = array( + 'market' => $market, + 'currency' => $currency, + 'from' => $from, + 'to' => $to, + 'totals' => $totals, + ); + if ( ! $detail ) { + return $out; + } + + // Allocate ad spend to SKUs by sales share (labelled "allocated"). + $total_sales = max( 0.0001, array_sum( array_column( $skus, 'sales' ) ) ); + $sku_rows = array(); + foreach ( $skus as $sku => $s ) { + if ( '' === $sku ) { + continue; + } + $s['ads'] = $totals['ads'] * ( $s['sales'] / $total_sales ); + $s['net_profit'] = self::net( $s, $include_tax ); + $s = self::ratios( $s ); + $s['sku'] = $sku; + $s['has_cost'] = $this->costs->has_cost( $market, $sku ); + $s['unit_cost'] = $this->costs->landed( $market, $sku ); + $sku_rows[] = $s; + } + usort( $sku_rows, static fn( $a, $b ) => $b['sales'] <=> $a['sales'] ); + + $series = array(); + foreach ( $days as $d => $row ) { + $series[] = array( + 'date' => $d, + 'sales' => round( $row['sales'], 2 ), + 'units' => (int) $row['units'], + 'orders' => (int) $row['orders'], + 'net_profit' => round( $row['net_profit'], 2 ), + 'ads' => round( -$row['ads'], 2 ), + 'refunds' => round( -$row['refunds'], 2 ), + 'fees' => round( -$row['amazon_fees'], 2 ), + ); + } + arsort( $fee_types ); + $fees = array(); + foreach ( $fee_types as $type => $amt ) { + if ( abs( $amt ) >= 0.005 ) { + $fees[] = array( 'type' => $type, 'amount' => round( $amt, 2 ) ); + } + } + usort( $fees, static fn( $a, $b ) => $a['amount'] <=> $b['amount'] ); + + $out['series'] = $series; + $out['skus'] = $sku_rows; + $out['fee_breakdown'] = $fees; + $out['missing_costs'] = array_values( array_map( static fn( $r ) => $r['sku'], array_filter( $sku_rows, static fn( $r ) => ! $r['has_cost'] && $r['units'] > 0 ) ) ); + $out['waterfall'] = array( + array( 'label' => 'Sales', 'value' => $totals['sales'] ), + array( 'label' => 'Promotions', 'value' => $totals['promo'] ), + array( 'label' => 'Refunds', 'value' => $totals['refunds'] ), + array( 'label' => 'Amazon fees', 'value' => $totals['amazon_fees'] ), + array( 'label' => 'Account fees', 'value' => $totals['account_fees'] ), + array( 'label' => 'Advertising', 'value' => $totals['ads'] ), + array( 'label' => 'COGS', 'value' => $totals['cogs'] ), + array( 'label' => 'Reimbursements', 'value' => $totals['reimbursements'] ), + array( 'label' => 'Adjustments', 'value' => $totals['adjustments'] ), + array( 'label' => 'Expenses', 'value' => $totals['expenses'] ), + ); + return $out; + } + + /** Period-over-period comparison plus month-end forecast. */ + public function dashboard( string $market ): array { + $today = SuiteTime::today( $market ); + $ranges = array( + 'today' => array( $today, $today ), + 'yesterday' => array( SuiteTime::shift( $today, -1 ), SuiteTime::shift( $today, -1 ) ), + 'last_7' => array( SuiteTime::shift( $today, -6 ), $today ), + 'mtd' => array( substr( $today, 0, 8 ) . '01', $today ), + 'last_month' => array( + ( new \DateTimeImmutable( $today ) )->modify( 'first day of last month' )->format( 'Y-m-d' ), + ( new \DateTimeImmutable( $today ) )->modify( 'last day of last month' )->format( 'Y-m-d' ), + ), + ); + $out = array(); + foreach ( $ranges as $key => [ $a, $b ] ) { + $out[ $key ] = $this->pnl( $market, $a, $b, false )['totals'] + array( 'from' => $a, 'to' => $b ); + } + // Forecast: month-to-date actuals (excluding partial today) + daily run-rate × remaining days. + // Early in the month the run-rate comes from the trailing 7 full days instead. + $day_of_month = (int) substr( $today, 8, 2 ); + $days_in_month = (int) ( new \DateTimeImmutable( $today ) )->format( 't' ); + $elapsed = $day_of_month - 1; + $mtd_ex_today = $elapsed > 0 + ? $this->pnl( $market, substr( $today, 0, 8 ) . '01', SuiteTime::shift( $today, -1 ), false )['totals'] + : self::blank_day(); + if ( $elapsed >= 7 ) { + $rate = $mtd_ex_today; + $rate_days = $elapsed; + } else { + $rate = $this->pnl( $market, SuiteTime::shift( $today, -7 ), SuiteTime::shift( $today, -1 ), false )['totals']; + $rate_days = 7; + } + $remaining = $days_in_month - $elapsed; + $out['forecast'] = array( + 'sales' => round( (float) $mtd_ex_today['sales'] + (float) $rate['sales'] / $rate_days * $remaining, 2 ), + 'net_profit' => round( (float) $mtd_ex_today['net_profit'] + (float) $rate['net_profit'] / $rate_days * $remaining, 2 ), + 'units' => (int) round( (float) $mtd_ex_today['units'] + (float) $rate['units'] / $rate_days * $remaining ), + 'basis' => $elapsed >= 7 ? 'month-to-date run-rate' : 'trailing 7-day run-rate', + ); + return $out; + } + + /** + * Trailing-90-day settled fee per unit per SKU, plus a global fee/sales + * ratio fallback (defaults to 30 % — referral + FBA for a typical item — + * when the account has no settlement history yet). + */ + public function fee_per_unit( string $market, string $to ): array { + $from = SuiteTime::shift( $to, -90 ); + $rows = Db::rows( + "SELECT sku, + SUM(CASE WHEN category = 'fee' THEN amount ELSE 0 END) AS fees, + SUM(CASE WHEN category = 'revenue' AND charge_type = 'Principal' THEN qty ELSE 0 END) AS units, + SUM(CASE WHEN category = 'revenue' AND charge_type = 'Principal' THEN amount ELSE 0 END) AS sales + FROM {t:fin_events} + WHERE market = %s AND event_type = 'Shipment' AND local_date BETWEEN %s AND %s AND sku <> '' + GROUP BY sku", + array( $market, $from, $to ) + ); + $sku = array(); + $tf = 0.0; + $ts = 0.0; + foreach ( $rows as $r ) { + if ( (int) $r['units'] > 0 ) { + $sku[ (string) $r['sku'] ] = abs( (float) $r['fees'] ) / (int) $r['units']; + } + $tf += abs( (float) $r['fees'] ); + $ts += (float) $r['sales']; + } + return array( 'sku' => $sku, 'ratio' => $ts > 0 ? $tf / $ts : 0.30 ); + } + + /** @return array date => expense amount */ + private function expenses( string $market, string $from, string $to ): array { + $rows = Db::rows( + "SELECT * FROM {t:expenses} WHERE (market = '' OR market = %s) AND start_date <= %s AND (end_date IS NULL OR end_date >= %s)", + array( $market, $to, $from ) + ); + $out = array(); + foreach ( $rows as $e ) { + $amt = (float) $e['amount']; + if ( 'monthly' === $e['recurrence'] ) { + $per_day = $amt * 12 / 365.25; + $start = max( $from, (string) $e['start_date'] ); + $end = min( $to, (string) ( $e['end_date'] ?: $to ) ); + for ( $d = $start; $d <= $end; $d = SuiteTime::shift( $d, 1 ) ) { + $out[ $d ] = ( $out[ $d ] ?? 0 ) + $per_day; + } + } elseif ( $e['start_date'] >= $from && $e['start_date'] <= $to ) { + $out[ (string) $e['start_date'] ] = ( $out[ (string) $e['start_date'] ] ?? 0 ) + $amt; + } + } + return $out; + } + + private function add( array &$days, array &$skus, string $d, string $sku, array $vals, ?array $meta = null ): void { + if ( ! isset( $days[ $d ] ) ) { + return; + } + if ( ! isset( $skus[ $sku ] ) ) { + $skus[ $sku ] = self::blank_day() + array( 'asin' => '', 'title' => '' ); + } + if ( $meta ) { + $skus[ $sku ]['asin'] = $skus[ $sku ]['asin'] ?: (string) ( $meta['asin'] ?? '' ); + $skus[ $sku ]['title'] = $skus[ $sku ]['title'] ?: (string) ( $meta['title'] ?? '' ); + } + foreach ( $vals as $k => $v ) { + $days[ $d ][ $k ] += $v; + $skus[ $sku ][ $k ] += $v; + } + if ( isset( $vals['units'] ) && isset( $meta['orders'] ) ) { + $skus[ $sku ]['orders'] += (int) $meta['orders']; + } + } + + private static function blank_day(): array { + return array( + 'orders' => 0, 'units' => 0, 'sales' => 0.0, 'promo' => 0.0, 'refunds' => 0.0, 'refund_units' => 0, + 'amazon_fees' => 0.0, 'fees_estimated' => 0.0, 'account_fees' => 0.0, 'ads' => 0.0, 'cogs' => 0.0, + 'reimbursements' => 0.0, 'adjustments' => 0.0, 'expenses' => 0.0, 'tax_collected' => 0.0, 'net_profit' => 0.0, + ); + } + + private static function net( array $r, bool $include_tax ): float { + return $r['sales'] + $r['promo'] + $r['refunds'] + $r['amazon_fees'] + $r['account_fees'] + $r['ads'] + + $r['cogs'] + $r['reimbursements'] + $r['adjustments'] + $r['expenses'] + + ( $include_tax ? $r['tax_collected'] : 0.0 ); + } + + private static function ratios( array $t ): array { + foreach ( $t as $k => $v ) { + if ( is_float( $v ) ) { + $t[ $k ] = round( $v, 2 ); + } + } + $t['margin_pct'] = $t['sales'] > 0 ? round( $t['net_profit'] / $t['sales'] * 100, 2 ) : null; + $t['roi_pct'] = $t['cogs'] < 0 ? round( $t['net_profit'] / -$t['cogs'] * 100, 2 ) : null; + $t['avg_order_value'] = $t['orders'] > 0 ? round( $t['sales'] / $t['orders'], 2 ) : null; + $t['refund_rate_pct'] = $t['units'] > 0 ? round( $t['refund_units'] / $t['units'] * 100, 2 ) : null; + return $t; + } +} diff --git a/sg-commerce-pro/src/Suite/Reimbursements/ReimbursementAuditor.php b/sg-commerce-pro/src/Suite/Reimbursements/ReimbursementAuditor.php new file mode 100644 index 0000000..81e1fb3 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Reimbursements/ReimbursementAuditor.php @@ -0,0 +1,299 @@ + 45 days ago, no return received, no + * reimbursement on the order — Amazon must reimburse or + * charge the buyer back. + * 4. fee_overcharge FBA fulfilment fee per unit jumped > 10 % vs the SKU's + * prior baseline with no listing change — typical of a + * wrong dimension re-measure; request a re-measurement. + * + * Each finding becomes a case with a ready-to-paste Seller Central message. + * Findings are de-duplicated by a stable hash so re-scans never duplicate. + * + * @package SevenGum\Commerce\Suite\Reimbursements + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Suite\Reimbursements; + +use SevenGum\Commerce\Suite\Support\Db; + +defined( 'ABSPATH' ) || exit; + +final class ReimbursementAuditor { + + public const LOST_REASONS = array( 'M' ); + public const FOUND_REASONS = array( 'F' ); + public const DAMAGED_REASONS = array( 'E', 'Q', '6', '7' ); + + /** @return array{found:int, new:int, value:float} */ + public function scan( ?int $now = null ): array { + $now = $now ?? time(); + $stats = array( 'found' => 0, 'new' => 0, 'value' => 0.0 ); + $cases = array_merge( + $this->lost_and_damaged( $now ), + $this->refunds_without_return( $now ), + $this->fee_overcharges( $now ) + ); + foreach ( $cases as $c ) { + $stats['found']++; + $stats['value'] += $c['est_amount']; + $exists = Db::row( 'SELECT id, status FROM {t:reimb_cases} WHERE case_hash = %s', array( $c['case_hash'] ) ); + if ( null === $exists ) { + Db::insert( 'reimb_cases', $c + array( 'status' => 'open', 'created_at' => Db::now(), 'updated_at' => Db::now() ) ); + $stats['new']++; + } elseif ( 'open' === $exists['status'] ) { + Db::update( 'reimb_cases', array( + 'qty' => $c['qty'], 'est_amount' => $c['est_amount'], 'evidence' => $c['evidence'], 'updated_at' => Db::now(), + ), array( 'id' => (int) $exists['id'] ) ); + } + } + // Auto-close open cases Amazon has since reimbursed on its own. + $this->auto_resolve(); + $stats['value'] = round( $stats['value'], 2 ); + return $stats; + } + + public function lost_and_damaged( int $now ): array { + $cutoff = gmdate( 'Y-m-d', $now - 30 * DAY_IN_SECONDS ); + $oldest = gmdate( 'Y-m-d', $now - 540 * DAY_IN_SECONDS ); // 18-month claim window. + $rows = Db::rows( + "SELECT market, fnsku, MAX(sku) AS sku, MAX(asin) AS asin, reason, SUM(qty) AS qty, MIN(event_date) AS first_date, MAX(event_date) AS last_date, + COUNT(*) AS events, MAX(fulfillment_center) AS fc + FROM {t:ledger} + WHERE event_type = 'Adjustments' AND event_date BETWEEN %s AND %s + GROUP BY market, fnsku, reason", + array( $oldest, $cutoff ) + ); + $by = array(); + foreach ( $rows as $r ) { + $k = $r['market'] . '|' . $r['fnsku']; + $by[ $k ] ??= array( 'market' => $r['market'], 'fnsku' => $r['fnsku'], 'sku' => $r['sku'], 'asin' => $r['asin'], 'lost' => 0, 'found' => 0, 'damaged' => 0, 'first' => $r['first_date'], 'last' => $r['last_date'], 'fc' => $r['fc'] ); + $q = (int) $r['qty']; + if ( in_array( $r['reason'], self::LOST_REASONS, true ) ) { + $by[ $k ]['lost'] += -$q; + } elseif ( in_array( $r['reason'], self::FOUND_REASONS, true ) ) { + $by[ $k ]['found'] += $q; + } elseif ( in_array( $r['reason'], self::DAMAGED_REASONS, true ) ) { + $by[ $k ]['damaged'] += -$q; + } + $by[ $k ]['first'] = min( $by[ $k ]['first'], $r['first_date'] ); + $by[ $k ]['last'] = max( $by[ $k ]['last'], $r['last_date'] ); + } + + $out = array(); + foreach ( $by as $g ) { + $reimbursed = Db::rows( + 'SELECT reason, SUM(qty_cash + qty_inventory) AS q FROM {t:reimbursements} WHERE fnsku = %s GROUP BY reason', + array( $g['fnsku'] ) + ); + $r_lost = 0; + $r_dmg = 0; + foreach ( $reimbursed as $r ) { + $reason = strtolower( (string) $r['reason'] ); + if ( str_contains( $reason, 'lost' ) ) { + $r_lost += (int) $r['q']; + } elseif ( str_contains( $reason, 'damage' ) ) { + $r_dmg += (int) $r['q']; + } + } + $unit = $this->unit_value( (string) $g['sku'] ); + $lost_open = $g['lost'] - $g['found'] - $r_lost; + if ( $lost_open > 0 ) { + $out[] = $this->make( 'lost_warehouse', $g, $lost_open, $unit, array( + 'misplaced_units' => $g['lost'], 'found_units' => $g['found'], 'reimbursed_units' => $r_lost, + 'first_event' => $g['first'], 'last_event' => $g['last'], 'fulfillment_center' => $g['fc'], + ) ); + } + $dmg_open = $g['damaged'] - $r_dmg; + if ( $dmg_open > 0 ) { + $out[] = $this->make( 'damaged_warehouse', $g, $dmg_open, $unit, array( + 'damaged_units' => $g['damaged'], 'reimbursed_units' => $r_dmg, + 'first_event' => $g['first'], 'last_event' => $g['last'], 'fulfillment_center' => $g['fc'], + ) ); + } + } + return $out; + } + + public function refunds_without_return( int $now ): array { + $before = gmdate( 'Y-m-d', $now - 45 * DAY_IN_SECONDS ); + $after = gmdate( 'Y-m-d', $now - 540 * DAY_IN_SECONDS ); + $rows = Db::rows( + "SELECT f.market, f.amazon_order_id, MAX(f.sku) AS sku, SUM(f.amount) AS refunded, SUM(f.qty) AS qty, MIN(f.local_date) AS refund_date, MAX(f.currency) AS currency + FROM {t:fin_events} f + WHERE f.event_type = 'Refund' AND f.category = 'refund' AND f.charge_type = 'Principal' AND f.local_date BETWEEN %s AND %s + AND NOT EXISTS (SELECT 1 FROM {t:returns} r WHERE r.amazon_order_id = f.amazon_order_id) + AND NOT EXISTS (SELECT 1 FROM {t:reimbursements} b WHERE b.amazon_order_id = f.amazon_order_id) + AND EXISTS (SELECT 1 FROM {t:orders} o WHERE o.amazon_order_id = f.amazon_order_id AND o.fulfillment_channel = 'AFN') + GROUP BY f.market, f.amazon_order_id", + array( $after, $before ) + ); + $out = array(); + foreach ( $rows as $r ) { + $amt = abs( (float) $r['refunded'] ); + if ( $amt <= 0 ) { + continue; + } + $qty = max( 1, abs( (int) $r['qty'] ) ); + $evidence = array( 'refund_date' => $r['refund_date'], 'refunded_amount' => round( $amt, 2 ), 'units' => $qty ); + $out[] = array( + 'case_hash' => sha1( 'refund_no_return|' . $r['amazon_order_id'] ), + 'kind' => 'refund_no_return', + 'market' => (string) $r['market'], + 'sku' => (string) $r['sku'], + 'fnsku' => '', + 'asin' => '', + 'reference_id' => (string) $r['amazon_order_id'], + 'event_date' => (string) $r['refund_date'], + 'qty' => $qty, + 'est_amount' => round( $amt, 2 ), + 'currency' => (string) $r['currency'], + 'evidence' => wp_json_encode( $evidence ), + 'notes' => self::message( 'refund_no_return', (string) $r['sku'], '', (string) $r['amazon_order_id'], $qty, $evidence ), + ); + } + return $out; + } + + public function fee_overcharges( int $now ): array { + $recent_from = gmdate( 'Y-m-d', $now - 30 * DAY_IN_SECONDS ); + $base_from = gmdate( 'Y-m-d', $now - 120 * DAY_IN_SECONDS ); + $rows = Db::rows( + "SELECT f.market, f.sku, CASE WHEN f.local_date >= %s THEN 'recent' ELSE 'base' END AS period, + SUM(f.amount) AS fees, SUM(p.qty) AS units, MAX(f.currency) AS currency + FROM {t:fin_events} f + INNER JOIN (SELECT amazon_order_id, sku, SUM(qty) AS qty FROM {t:fin_events} + WHERE event_type = 'Shipment' AND charge_type = 'Principal' GROUP BY amazon_order_id, sku) p + ON p.amazon_order_id = f.amazon_order_id AND p.sku = f.sku + WHERE f.event_type = 'Shipment' AND f.charge_type = 'FBAPerUnitFulfillmentFee' AND f.local_date >= %s + GROUP BY f.market, f.sku, period", + array( $recent_from, $base_from ) + ); + $by = array(); + foreach ( $rows as $r ) { + $by[ $r['market'] . '|' . $r['sku'] ][ $r['period'] ] = $r; + } + $out = array(); + foreach ( $by as $key => $p ) { + if ( empty( $p['recent'] ) || empty( $p['base'] ) || (int) $p['recent']['units'] < 5 || (int) $p['base']['units'] < 5 ) { + continue; + } + $old = abs( (float) $p['base']['fees'] ) / (int) $p['base']['units']; + $new = abs( (float) $p['recent']['fees'] ) / (int) $p['recent']['units']; + if ( $old <= 0 || ( $new - $old ) / $old < 0.10 ) { + continue; + } + [ $market, $sku ] = explode( '|', $key, 2 ); + $units = (int) $p['recent']['units']; + $evidence = array( 'baseline_fee_per_unit' => round( $old, 2 ), 'current_fee_per_unit' => round( $new, 2 ), 'units_affected' => $units ); + $out[] = array( + 'case_hash' => sha1( 'fee_overcharge|' . $key . '|' . round( $new, 2 ) ), + 'kind' => 'fee_overcharge', + 'market' => $market, + 'sku' => $sku, + 'fnsku' => '', + 'asin' => '', + 'reference_id' => '', + 'event_date' => gmdate( 'Y-m-d', $now ), + 'qty' => $units, + 'est_amount' => round( ( $new - $old ) * $units, 2 ), + 'currency' => (string) $p['recent']['currency'], + 'evidence' => wp_json_encode( $evidence ), + 'notes' => self::message( 'fee_overcharge', $sku, '', '', $units, $evidence ), + ); + } + return $out; + } + + public function update_case( int $id, array $in ): bool { + $data = array( 'updated_at' => Db::now() ); + if ( isset( $in['status'] ) && in_array( $in['status'], array( 'open', 'filed', 'reimbursed', 'dismissed' ), true ) ) { + $data['status'] = $in['status']; + } + if ( isset( $in['amazon_case_id'] ) ) { + $data['amazon_case_id'] = mb_substr( sanitize_text_field( (string) $in['amazon_case_id'] ), 0, 32 ); + } + return Db::update( 'reimb_cases', $data, array( 'id' => $id ) ) > 0; + } + + public function summary(): array { + $by = Db::rows( + "SELECT kind, status, COUNT(*) AS n, SUM(est_amount) AS value FROM {t:reimb_cases} GROUP BY kind, status" + ); + $recovered = (float) Db::var( 'SELECT COALESCE(SUM(amount),0) FROM {t:reimbursements} WHERE approval_date >= %s', array( gmdate( 'Y-m-d', time() - 365 * DAY_IN_SECONDS ) ) ); + return array( 'by' => $by, 'reimbursed_12m' => round( $recovered, 2 ) ); + } + + private function auto_resolve(): void { + Db::exec( + "UPDATE {t:reimb_cases} SET status = 'reimbursed', updated_at = %s + WHERE status IN ('open','filed') AND kind = 'refund_no_return' + AND reference_id IN (SELECT amazon_order_id FROM {t:reimbursements} WHERE amazon_order_id <> '')", + array( Db::now() ) + ); + } + + /** Average realised sale price per unit over 180 days (Amazon reimburses at estimated sale value). */ + private function unit_value( string $sku ): float { + $v = Db::row( + 'SELECT SUM(item_price) AS s, SUM(qty) AS q FROM {t:order_items} WHERE sku = %s AND local_date >= %s AND qty > 0', + array( $sku, gmdate( 'Y-m-d', time() - 180 * DAY_IN_SECONDS ) ) + ); + return null !== $v && (int) $v['q'] > 0 ? (float) $v['s'] / (int) $v['q'] : 0.0; + } + + private function make( string $kind, array $g, int $qty, float $unit, array $evidence ): array { + $currency = (string) Db::var( 'SELECT currency FROM {t:orders} WHERE market = %s AND currency <> %s LIMIT 1', array( $g['market'], '' ) ); + return array( + 'case_hash' => sha1( $kind . '|' . $g['market'] . '|' . $g['fnsku'] . '|' . $g['first'] ), + 'kind' => $kind, + 'market' => (string) $g['market'], + 'sku' => (string) $g['sku'], + 'fnsku' => (string) $g['fnsku'], + 'asin' => (string) $g['asin'], + 'reference_id' => '', + 'event_date' => (string) $g['last'], + 'qty' => $qty, + // Reimbursement ≈ sale price minus referral & FBA fees; 0.7 is a conservative haircut. + 'est_amount' => round( $qty * $unit * 0.7, 2 ), + 'currency' => $currency, + 'evidence' => wp_json_encode( $evidence ), + 'notes' => self::message( $kind, (string) $g['sku'], (string) $g['fnsku'], '', $qty, $evidence ), + ); + } + + /** Ready-to-paste Seller Central case text. */ + public static function message( string $kind, string $sku, string $fnsku, string $order, int $qty, array $ev ): string { + return match ( $kind ) { + 'lost_warehouse' => sprintf( + "Hello Seller Support,\n\nOur inventory ledger shows %d unit(s) of FNSKU %s (SKU %s) adjusted as misplaced (reason code M) between %s and %s at %s. Only %d unit(s) were subsequently found and %d were reimbursed, leaving %d unit(s) unaccounted for beyond the 30-day reconciliation period.\n\nPer the FBA Inventory Reimbursement Policy, please investigate and reimburse the %d missing unit(s).\n\nThank you.", + (int) $ev['misplaced_units'], $fnsku, $sku, $ev['first_event'], $ev['last_event'], $ev['fulfillment_center'] ?: 'your fulfillment centers', + (int) $ev['found_units'], (int) $ev['reimbursed_units'], $qty, $qty + ), + 'damaged_warehouse' => sprintf( + "Hello Seller Support,\n\nOur inventory ledger shows %d unit(s) of FNSKU %s (SKU %s) adjusted as damaged in an Amazon fulfillment center between %s and %s. %d unit(s) have been reimbursed so far, leaving %d unit(s) unreimbursed.\n\nPer the FBA Inventory Reimbursement Policy for warehouse damage, please reimburse the remaining %d unit(s).\n\nThank you.", + (int) $ev['damaged_units'], $fnsku, $sku, $ev['first_event'], $ev['last_event'], (int) $ev['reimbursed_units'], $qty, $qty + ), + 'refund_no_return' => sprintf( + "Hello Seller Support,\n\nOrder %s (SKU %s) was refunded to the customer on %s for %s, but more than 45 days later no return has been received at a fulfillment center and no reimbursement has been issued.\n\nPer the FBA customer returns policy, please either charge the customer for the unreturned item or reimburse us for %d unit(s).\n\nThank you.", + $order, $sku, $ev['refund_date'], number_format( (float) $ev['refunded_amount'], 2 ), $qty + ), + 'fee_overcharge' => sprintf( + "Hello Seller Support,\n\nThe FBA fulfillment fee for SKU %s increased from %s to %s per unit over the last 30 days (%d units affected) without any change to the product or its packaging. This suggests an incorrect dimension/weight measurement.\n\nPlease re-measure the item and refund the fee difference for all affected units.\n\nThank you.", + $sku, number_format( (float) $ev['baseline_fee_per_unit'], 2 ), number_format( (float) $ev['current_fee_per_unit'], 2 ), $qty + ), + default => '', + }; + } +} diff --git a/sg-commerce-pro/src/Suite/Reports/ReportIngestor.php b/sg-commerce-pro/src/Suite/Reports/ReportIngestor.php new file mode 100644 index 0000000..466486a --- /dev/null +++ b/sg-commerce-pro/src/Suite/Reports/ReportIngestor.php @@ -0,0 +1,293 @@ + $this->orders->ingest_flat_file( $raw, $market ), + 'GET_SALES_AND_TRAFFIC_REPORT' => $this->traffic( $raw, $market, $meta, $demo ), + 'GET_LEDGER_DETAIL_VIEW_DATA' => $this->ledger( $raw, $market, $demo ), + 'GET_FBA_REIMBURSEMENTS_DATA' => $this->reimbursements( $raw, $market, $demo ), + 'GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA' => $this->returns( $raw, $market, $demo ), + 'GET_FBA_INVENTORY_PLANNING_DATA' => $this->inventory_health( $raw, $market, $demo ), + 'GET_SELLER_FEEDBACK_DATA' => $this->feedback( $raw, $market, $demo ), + 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT' => $this->sqp( $raw, $market, $demo ), + default => count( ReportParser::tsv( $raw ) ), + }; + } + + public function traffic( string $raw, string $market, array $meta, bool $demo = false ): int { + $doc = ReportParser::json( $raw ); + $n = 0; + foreach ( (array) ( $doc['salesAndTrafficByDate'] ?? array() ) as $d ) { + $date = substr( (string) ( $d['date'] ?? '' ), 0, 10 ); + if ( '' === $date ) { + continue; + } + Db::upsert( 'traffic_daily', array( + 'market' => $market, + 'report_date' => $date, + 'asin' => '', + 'sku' => '', + 'sessions' => (int) ( $d['trafficByDate']['sessions'] ?? 0 ), + 'page_views' => (int) ( $d['trafficByDate']['pageViews'] ?? 0 ), + 'buy_box_pct' => (float) ( $d['trafficByDate']['buyBoxPercentage'] ?? 0 ), + 'units' => (int) ( $d['salesByDate']['unitsOrdered'] ?? 0 ), + 'order_items' => (int) ( $d['salesByDate']['totalOrderItems'] ?? 0 ), + 'ordered_sales' => (float) ( $d['salesByDate']['orderedProductSales']['amount'] ?? 0 ), + 'is_demo' => $demo ? 1 : 0, + ) ); + $n++; + } + // By-ASIN rows are aggregated over the whole report window; we request one day per report. + $day = substr( (string) ( $meta['data_start'] ?? ( $doc['reportSpecification']['dataStartTime'] ?? '' ) ), 0, 10 ); + if ( '' !== $day ) { + foreach ( (array) ( $doc['salesAndTrafficByAsin'] ?? array() ) as $a ) { + $asin = (string) ( $a['childAsin'] ?? $a['parentAsin'] ?? '' ); + if ( '' === $asin ) { + continue; + } + Db::upsert( 'traffic_daily', array( + 'market' => $market, + 'report_date' => $day, + 'asin' => $asin, + 'sku' => (string) ( $a['sku'] ?? '' ), + 'sessions' => (int) ( $a['trafficByAsin']['sessions'] ?? 0 ), + 'page_views' => (int) ( $a['trafficByAsin']['pageViews'] ?? 0 ), + 'buy_box_pct' => (float) ( $a['trafficByAsin']['buyBoxPercentage'] ?? 0 ), + 'units' => (int) ( $a['salesByAsin']['unitsOrdered'] ?? 0 ), + 'order_items' => (int) ( $a['salesByAsin']['totalOrderItems'] ?? 0 ), + 'ordered_sales' => (float) ( $a['salesByAsin']['orderedProductSales']['amount'] ?? 0 ), + 'is_demo' => $demo ? 1 : 0, + ) ); + $n++; + } + } + return $n; + } + + public function ledger( string $raw, string $market, bool $demo = false ): int { + $n = 0; + foreach ( ReportParser::tsv( $raw ) as $r ) { + $date = self::date( $r['date'] ?? '' ); + if ( null === $date ) { + continue; + } + Db::upsert( 'ledger', array( + 'row_hash' => sha1( wp_json_encode( array( $market, $r['date'] ?? '', $r['fnsku'] ?? '', $r['event-type'] ?? '', $r['reference-id'] ?? '', $r['quantity'] ?? '', $r['fulfillment-center'] ?? '', $r['disposition'] ?? '', $r['reason'] ?? '' ) ) ), + 'market' => $market, + 'event_date' => $date, + 'fnsku' => (string) ( $r['fnsku'] ?? '' ), + 'sku' => (string) ( $r['msku'] ?? $r['sku'] ?? '' ), + 'asin' => (string) ( $r['asin'] ?? '' ), + 'event_type' => (string) ( $r['event-type'] ?? '' ), + 'reference_id' => (string) ( $r['reference-id'] ?? '' ), + 'qty' => (int) ( $r['quantity'] ?? 0 ), + 'fulfillment_center' => (string) ( $r['fulfillment-center'] ?? '' ), + 'disposition' => (string) ( $r['disposition'] ?? '' ), + 'reason' => (string) ( $r['reason'] ?? '' ), + 'is_demo' => $demo ? 1 : 0, + ) ); + $n++; + } + return $n; + } + + public function reimbursements( string $raw, string $market, bool $demo = false ): int { + $n = 0; + foreach ( ReportParser::tsv( $raw ) as $r ) { + $id = (string) ( $r['reimbursement-id'] ?? '' ); + if ( '' === $id ) { + continue; + } + Db::upsert( 'reimbursements', array( + 'reimbursement_id' => $id, + 'market' => $market, + 'approval_date' => self::date( $r['approval-date'] ?? '' ) ?? gmdate( 'Y-m-d' ), + 'case_id' => (string) ( $r['case-id'] ?? '' ), + 'amazon_order_id' => (string) ( $r['amazon-order-id'] ?? '' ), + 'reason' => (string) ( $r['reason'] ?? '' ), + 'sku' => (string) ( $r['sku'] ?? '' ), + 'fnsku' => (string) ( $r['fnsku'] ?? '' ), + 'asin' => (string) ( $r['asin'] ?? '' ), + 'amount' => ReportParser::money( $r['amount-total'] ?? 0 ), + 'currency' => (string) ( $r['currency-unit'] ?? '' ), + 'qty_cash' => (int) ( $r['quantity-reimbursed-cash'] ?? 0 ), + 'qty_inventory' => (int) ( $r['quantity-reimbursed-inventory'] ?? 0 ), + 'is_demo' => $demo ? 1 : 0, + ) ); + $n++; + } + return $n; + } + + public function returns( string $raw, string $market, bool $demo = false ): int { + $n = 0; + foreach ( ReportParser::tsv( $raw ) as $r ) { + $date = self::date( $r['return-date'] ?? '' ); + if ( null === $date ) { + continue; + } + Db::upsert( 'returns', array( + 'row_hash' => sha1( wp_json_encode( array( $market, $r['order-id'] ?? '', $r['fnsku'] ?? '', $r['license-plate-number'] ?? '', $r['return-date'] ?? '' ) ) ), + 'market' => $market, + 'return_date' => $date, + 'amazon_order_id' => (string) ( $r['order-id'] ?? '' ), + 'sku' => (string) ( $r['sku'] ?? '' ), + 'asin' => (string) ( $r['asin'] ?? '' ), + 'fnsku' => (string) ( $r['fnsku'] ?? '' ), + 'qty' => (int) ( $r['quantity'] ?? 0 ), + 'fulfillment_center' => (string) ( $r['fulfillment-center-id'] ?? '' ), + 'disposition' => (string) ( $r['detailed-disposition'] ?? '' ), + 'reason' => (string) ( $r['reason'] ?? '' ), + 'status' => (string) ( $r['status'] ?? '' ), + 'comments' => mb_substr( (string) ( $r['customer-comments'] ?? '' ), 0, 500 ), + 'is_demo' => $demo ? 1 : 0, + ) ); + $n++; + } + return $n; + } + + public function inventory_health( string $raw, string $market, bool $demo = false ): int { + $n = 0; + foreach ( ReportParser::tsv( $raw ) as $r ) { + $sku = (string) ( $r['sku'] ?? '' ); + if ( '' === $sku ) { + continue; + } + $over_365 = 0; + foreach ( $r as $k => $v ) { + if ( 1 === preg_match( '/^inv-age-(365-plus|366|4\d\d|5\d\d|6\d\d|7\d\d)/', $k ) ) { + $over_365 += (int) $v; + } + } + Db::upsert( 'inventory_health', array( + 'market' => $market, + 'snapshot_date' => self::date( $r['snapshot-date'] ?? '' ) ?? gmdate( 'Y-m-d' ), + 'sku' => $sku, + 'asin' => (string) ( $r['asin'] ?? '' ), + 'available' => (int) ( $r['available'] ?? 0 ), + 'age_0_90' => (int) ( $r['inv-age-0-to-90-days'] ?? 0 ), + 'age_91_180' => (int) ( $r['inv-age-91-to-180-days'] ?? 0 ), + 'age_181_270' => (int) ( $r['inv-age-181-to-270-days'] ?? 0 ), + 'age_271_365' => (int) ( $r['inv-age-271-to-365-days'] ?? 0 ), + 'age_365_plus' => $over_365, + 'units_t30' => (int) ( $r['units-shipped-t30'] ?? 0 ), + 'sell_through' => isset( $r['sell-through'] ) && '' !== $r['sell-through'] ? ReportParser::money( $r['sell-through'] ) : null, + 'days_of_supply' => isset( $r['days-of-supply'] ) && '' !== $r['days-of-supply'] ? (int) $r['days-of-supply'] : null, + 'est_storage_cost' => isset( $r['estimated-storage-cost-next-month'] ) && '' !== $r['estimated-storage-cost-next-month'] ? ReportParser::money( $r['estimated-storage-cost-next-month'] ) : null, + 'recommended_action' => mb_substr( (string) ( $r['recommended-action'] ?? '' ), 0, 64 ), + 'is_demo' => $demo ? 1 : 0, + ) ); + $n++; + } + return $n; + } + + public function feedback( string $raw, string $market, bool $demo = false ): int { + $n = 0; + foreach ( ReportParser::tsv( $raw ) as $r ) { + $date = self::date( $r['date'] ?? '' ); + if ( null === $date ) { + continue; + } + $order = (string) ( $r['order-id'] ?? '' ); + $rating = (int) ( $r['rating'] ?? 0 ); + $hash = sha1( $market . '|' . $order . '|' . $date . '|' . $rating ); + $is_new = 0 === (int) Db::var( 'SELECT COUNT(*) FROM {t:feedback} WHERE row_hash = %s', array( $hash ) ); + Db::upsert( 'feedback', array( + 'row_hash' => $hash, + 'market' => $market, + 'feedback_date' => $date, + 'rating' => $rating, + 'comments' => mb_substr( (string) ( $r['comments'] ?? '' ), 0, 2000 ), + 'amazon_order_id' => $order, + 'is_demo' => $demo ? 1 : 0, + ) ); + if ( $is_new && $rating > 0 && $rating <= 2 && ! $demo ) { + SuiteAlerts::raise( 'warning', 'suite_feedback', sprintf( 'Negative seller feedback (%d★) on order %s', $rating, $order ), mb_substr( (string) ( $r['comments'] ?? '' ), 0, 300 ), array( 'market' => $market ) ); + } + $n++; + } + return $n; + } + + public function sqp( string $raw, string $market, bool $demo = false ): int { + $doc = ReportParser::json( $raw ); + $n = 0; + foreach ( (array) ( $doc['dataByAsin'] ?? array() ) as $r ) { + $kw = mb_substr( strtolower( (string) ( $r['searchQueryData']['searchQuery'] ?? '' ) ), 0, 190 ); + if ( '' === $kw || empty( $r['asin'] ) ) { + continue; + } + Db::upsert( 'keyword_metrics', array( + 'market' => $market, + 'asin' => (string) $r['asin'], + 'keyword' => $kw, + 'period_start' => substr( (string) ( $r['startDate'] ?? '' ), 0, 10 ), + 'period_end' => substr( (string) ( $r['endDate'] ?? '' ), 0, 10 ), + 'query_score' => (int) ( $r['searchQueryData']['searchQueryScore'] ?? 0 ), + 'query_volume' => (int) ( $r['searchQueryData']['searchQueryVolume'] ?? 0 ), + 'impressions_total' => (int) ( $r['impressionData']['totalQueryImpressionCount'] ?? 0 ), + 'impressions_asin' => (int) ( $r['impressionData']['asinImpressionCount'] ?? 0 ), + 'clicks_total' => (int) ( $r['clickData']['totalClickCount'] ?? 0 ), + 'clicks_asin' => (int) ( $r['clickData']['asinClickCount'] ?? 0 ), + 'cart_adds_total' => (int) ( $r['cartAddData']['totalCartAddCount'] ?? 0 ), + 'cart_adds_asin' => (int) ( $r['cartAddData']['asinCartAddCount'] ?? 0 ), + 'purchases_total' => (int) ( $r['purchaseData']['totalPurchaseCount'] ?? 0 ), + 'purchases_asin' => (int) ( $r['purchaseData']['asinPurchaseCount'] ?? 0 ), + 'is_demo' => $demo ? 1 : 0, + ) ); + $n++; + } + return $n; + } + + /** Accepts ISO-8601, Y-m-d, and US m/d/Y. */ + public static function date( string $v ): ?string { + $v = trim( $v ); + if ( '' === $v ) { + return null; + } + if ( 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2})/', $v, $m ) ) { + return "{$m[1]}-{$m[2]}-{$m[3]}"; + } + if ( 1 === preg_match( '#^(\d{1,2})/(\d{1,2})/(\d{2,4})#', $v, $m ) ) { + $y = strlen( $m[3] ) === 2 ? '20' . $m[3] : $m[3]; + return sprintf( '%04d-%02d-%02d', (int) $y, (int) $m[1], (int) $m[2] ); + } + $ts = strtotime( $v ); + return false === $ts ? null : gmdate( 'Y-m-d', $ts ); + } +} diff --git a/sg-commerce-pro/src/Suite/Reports/ReportParser.php b/sg-commerce-pro/src/Suite/Reports/ReportParser.php new file mode 100644 index 0000000..b82d751 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Reports/ReportParser.php @@ -0,0 +1,113 @@ += 2 && "\x1f\x8b" === substr( $raw, 0, 2 ) ) { + $out = @gzdecode( $raw ); + if ( false === $out ) { + throw new \RuntimeException( 'Report document could not be decompressed.' ); + } + return $out; + } + return $raw; + } + + /** Normalise encoding to UTF-8 and strip a BOM. */ + public static function to_utf8( string $raw ): string { + if ( str_starts_with( $raw, "\xEF\xBB\xBF" ) ) { + $raw = substr( $raw, 3 ); + } + if ( function_exists( 'mb_check_encoding' ) && ! mb_check_encoding( $raw, 'UTF-8' ) ) { + $converted = function_exists( 'mb_convert_encoding' ) ? mb_convert_encoding( $raw, 'UTF-8', 'Windows-1252' ) : false; + if ( is_string( $converted ) ) { + return $converted; + } + } + return $raw; + } + + /** + * Parse a tab-separated flat file into associative rows keyed by a + * normalised header (lower-case, spaces/underscores → hyphens). + * + * @return array> + */ + public static function tsv( string $raw ): array { + $raw = self::to_utf8( self::maybe_gunzip( $raw ) ); + $lines = preg_split( '/\r\n|\n|\r/', $raw ) ?: array(); + $header = null; + $rows = array(); + foreach ( $lines as $line ) { + if ( '' === trim( $line ) ) { + continue; + } + $cells = explode( "\t", $line ); + if ( null === $header ) { + $header = array_map( array( self::class, 'normalise_key' ), $cells ); + continue; + } + $row = array(); + foreach ( $header as $i => $key ) { + $row[ $key ] = isset( $cells[ $i ] ) ? trim( $cells[ $i ], " \"" ) : ''; + } + $rows[] = $row; + } + return $rows; + } + + /** @return array */ + public static function json( string $raw ): array { + $decoded = json_decode( self::to_utf8( self::maybe_gunzip( $raw ) ), true ); + if ( ! is_array( $decoded ) ) { + throw new \RuntimeException( 'Report document is not valid JSON.' ); + } + return $decoded; + } + + public static function normalise_key( string $key ): string { + $key = strtolower( trim( $key, " \t\"\xEF\xBB\xBF" ) ); + return (string) preg_replace( '/[\s_]+/', '-', $key ); + } + + /** Parse money strings like "1,234.50", "$4.99", "4,99" (EU). */ + public static function money( mixed $v ): float { + if ( is_int( $v ) || is_float( $v ) ) { + return (float) $v; + } + $s = preg_replace( '/[^\d,.\-]/', '', (string) $v ) ?? ''; + if ( '' === $s ) { + return 0.0; + } + // "1.234,56" → EU format; "1,234.56" → US format; "4,99" → EU decimal. + if ( str_contains( $s, ',' ) && str_contains( $s, '.' ) ) { + $s = strrpos( $s, ',' ) > strrpos( $s, '.' ) + ? str_replace( array( '.', ',' ), array( '', '.' ), $s ) + : str_replace( ',', '', $s ); + } elseif ( str_contains( $s, ',' ) ) { + $s = 1 === preg_match( '/,\d{1,2}$/', $s ) ? str_replace( ',', '.', $s ) : str_replace( ',', '', $s ); + } + return (float) $s; + } + + /** Parse percent strings like "12.5%" → 12.5. */ + public static function pct( mixed $v ): float { + return self::money( str_replace( '%', '', (string) $v ) ); + } +} diff --git a/sg-commerce-pro/src/Suite/Reports/ReportPipeline.php b/sg-commerce-pro/src/Suite/Reports/ReportPipeline.php new file mode 100644 index 0000000..be5744e --- /dev/null +++ b/sg-commerce-pro/src/Suite/Reports/ReportPipeline.php @@ -0,0 +1,254 @@ + [label, schedule (daily|weekly|manual), window days, options] + * window 0 = report takes no date range. + */ + public const CATALOG = array( + 'GET_FLAT_FILE_ALL_ORDERS_DATA_BY_ORDER_DATE_GENERAL' => array( 'Orders (all, by order date)', 'daily', 3, array() ), + 'GET_SALES_AND_TRAFFIC_REPORT' => array( 'Business Report — Sales & Traffic', 'daily', 1, array( 'dateGranularity' => 'DAY', 'asinGranularity' => 'CHILD' ) ), + 'GET_FBA_FULFILLMENT_CUSTOMER_RETURNS_DATA' => array( 'FBA customer returns', 'daily', 14, array() ), + 'GET_FBA_INVENTORY_PLANNING_DATA' => array( 'FBA inventory health / aged inventory', 'daily', 0, array() ), + 'GET_SELLER_FEEDBACK_DATA' => array( 'Seller feedback', 'daily', 30, array() ), + 'GET_LEDGER_DETAIL_VIEW_DATA' => array( 'Inventory ledger (detail)', 'weekly', 60, array() ), + 'GET_FBA_REIMBURSEMENTS_DATA' => array( 'FBA reimbursements', 'weekly', 120, array() ), + 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT' => array( 'Brand Analytics — Search Query Performance', 'weekly', 7, array( 'reportPeriod' => 'WEEK' ) ), + ); + + private const MAX_ATTEMPTS = 90; + + public function __construct( + private ReportsClient $reports, + private ReportIngestor $ingestor, + private AdsClient $ads, + private AdsSync $ads_sync, + private Marketplaces $marketplaces, + private Logger $logger, + ) {} + + /** + * Request an SP-API report. Dates are Y-m-d (inclusive) in UTC. + */ + public function request( string $type, string $market, ?string $from = null, ?string $to = null, array $options = array() ): int { + $spec = self::CATALOG[ $type ] ?? array( $type, 'manual', 0, array() ); + $options = $options + $spec[3]; + $start = null !== $from ? $from . 'T00:00:00Z' : null; + $end = null !== $to ? $to . 'T23:59:59Z' : null; + $row = array( + 'report_type' => $type, + 'market' => $market, + 'source' => 'sp', + 'status' => 'requested', + 'data_start' => null !== $from ? $from . ' 00:00:00' : null, + 'data_end' => null !== $to ? $to . ' 23:59:59' : null, + 'options' => wp_json_encode( $options ), + 'requested_at' => Db::now(), + ); + try { + $row['remote_id'] = $this->reports->create( $type, $market, $start, $end, $options ); + } catch ( \Throwable $e ) { + $row['status'] = 'failed'; + $row['error'] = mb_substr( $e->getMessage(), 0, 1000 ); + } + return Db::insert( 'reports', $row ); + } + + /** Request every report whose schedule is due. */ + public function request_scheduled( string $schedule, array $sqp_asins = array() ): int { + $n = 0; + foreach ( $this->marketplaces->enabled_codes() as $market ) { + $today = SuiteTime::today( $market ); + foreach ( self::CATALOG as $type => [ $label, $sched, $window ] ) { + if ( $sched !== $schedule ) { + continue; + } + if ( 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT' === $type ) { + $n += $this->request_sqp( $market, $sqp_asins[ $market ] ?? array() ); + continue; + } + if ( 0 === $window ) { + $this->request( $type, $market ); + } elseif ( 'GET_SALES_AND_TRAFFIC_REPORT' === $type ) { + // One report per day gives per-day per-ASIN rows; restate the last 3 days. + foreach ( array( 1, 2, 3 ) as $back ) { + $d = SuiteTime::shift( $today, -$back ); + $this->request( $type, $market, $d, $d ); + $n++; + } + continue; + } else { + $this->request( $type, $market, SuiteTime::shift( $today, -$window ), SuiteTime::shift( $today, -1 ) ); + } + $n++; + } + } + return $n; + } + + /** Backfill orders + traffic for N days (orders in 30-day chunks). */ + public function backfill( string $market, int $days ): int { + $n = 0; + $today = SuiteTime::today( $market ); + for ( $back = $days; $back > 0; $back -= 30 ) { + $from = SuiteTime::shift( $today, -$back ); + $to = SuiteTime::shift( $today, -max( 1, $back - 29 ) ); + $this->request( 'GET_FLAT_FILE_ALL_ORDERS_DATA_BY_ORDER_DATE_GENERAL', $market, $from, $to ); + $n++; + } + for ( $back = min( $days, 30 ); $back >= 1; $back-- ) { + $d = SuiteTime::shift( $today, -$back ); + $this->request( 'GET_SALES_AND_TRAFFIC_REPORT', $market, $d, $d ); + $n++; + } + return $n; + } + + /** + * SQP requires a full Sunday–Saturday week and ≤ 200 chars of + * space-separated ASINs per request. + */ + public function request_sqp( string $market, array $asins ): int { + $asins = array_values( array_unique( array_filter( $asins ) ) ); + if ( ! $asins ) { + return 0; + } + $today = new \DateTimeImmutable( SuiteTime::today( $market ) ); + $sat = $today->modify( 'last saturday' ); + $sun = $sat->modify( '-6 days' ); + $n = 0; + $chunk = array(); + $flush = function () use ( &$chunk, &$n, $market, $sun, $sat ): void { + if ( $chunk ) { + $this->request( 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT', $market, $sun->format( 'Y-m-d' ), $sat->format( 'Y-m-d' ), array( 'asin' => implode( ' ', $chunk ) ) ); + $n++; + $chunk = array(); + } + }; + foreach ( $asins as $a ) { + if ( strlen( implode( ' ', array_merge( $chunk, array( $a ) ) ) ) > 200 ) { + $flush(); + } + $chunk[] = $a; + } + $flush(); + return $n; + } + + /** Poll in-flight reports; ingest finished ones. */ + public function poll( int $limit = 8 ): array { + $stats = array( 'checked' => 0, 'completed' => 0, 'failed' => 0, 'rows' => 0 ); + $rows = Db::rows( + "SELECT * FROM {t:reports} WHERE status IN ('requested','processing') ORDER BY requested_at ASC LIMIT %d", + array( $limit ) + ); + foreach ( $rows as $r ) { + $stats['checked']++; + $id = (int) $r['id']; + try { + $result = 'ads' === $r['source'] ? $this->poll_ads( $r ) : $this->poll_sp( $r ); + if ( null === $result ) { + $attempts = (int) $r['attempts'] + 1; + Db::update( 'reports', array( + 'status' => $attempts >= self::MAX_ATTEMPTS ? 'failed' : 'processing', + 'attempts' => $attempts, + 'error' => $attempts >= self::MAX_ATTEMPTS ? 'Timed out waiting for Amazon.' : null, + ), array( 'id' => $id ) ); + continue; + } + [ $status, $count, $err ] = $result; + Db::update( 'reports', array( + 'status' => $status, + 'rows_ingested' => $count, + 'error' => $err, + 'completed_at' => Db::now(), + ), array( 'id' => $id ) ); + if ( 'completed' === $status || 'empty' === $status ) { + $stats['completed']++; + $stats['rows'] += $count; + do_action( 'sg_suite_report_ingested', (string) $r['report_type'], (string) $r['market'], $count ); + } else { + $stats['failed']++; + } + } catch ( \Throwable $e ) { + $stats['failed']++; + Db::update( 'reports', array( + 'status' => (int) $r['attempts'] >= 5 ? 'failed' : 'processing', + 'attempts' => (int) $r['attempts'] + 1, + 'error' => mb_substr( $e->getMessage(), 0, 1000 ), + ), array( 'id' => $id ) ); + $this->logger->warning( 'Suite report poll failed', array( 'id' => $id, 'type' => $r['report_type'], 'error' => $e->getMessage() ) ); + } + } + return $stats; + } + + /** @return array{0:string,1:int,2:?string}|null null = still processing */ + private function poll_sp( array $r ): ?array { + $st = $this->reports->get( (string) $r['remote_id'], (string) $r['market'] ); + $status = (string) ( $st['processingStatus'] ?? '' ); + if ( in_array( $status, array( 'IN_QUEUE', 'IN_PROGRESS', '' ), true ) ) { + return null; + } + if ( 'CANCELLED' === $status ) { + return array( 'empty', 0, 'Amazon returned no data for this range.' ); + } + if ( 'FATAL' === $status ) { + $detail = ''; + if ( ! empty( $st['reportDocumentId'] ) ) { + try { + $detail = mb_substr( $this->reports->download( (string) $st['reportDocumentId'], (string) $r['market'] ), 0, 500 ); + } catch ( \Throwable ) { + $detail = ''; + } + } + return array( 'failed', 0, 'FATAL' . ( '' !== $detail ? ': ' . $detail : '' ) ); + } + $doc = (string) ( $st['reportDocumentId'] ?? '' ); + Db::update( 'reports', array( 'document_id' => $doc ), array( 'id' => (int) $r['id'] ) ); + $raw = $this->reports->download( $doc, (string) $r['market'] ); + $count = $this->ingestor->ingest( (string) $r['report_type'], (string) $r['market'], $raw, $r ); + return array( 'completed', $count, null ); + } + + private function poll_ads( array $r ): ?array { + $st = $this->ads->report_status( (string) $r['remote_id'] ); + $status = strtoupper( (string) ( $st['status'] ?? '' ) ); + if ( 'FAILED' === $status ) { + return array( 'failed', 0, (string) ( $st['failureReason'] ?? 'Ads report failed.' ) ); + } + if ( 'COMPLETED' !== $status || empty( $st['url'] ) ) { + return null; + } + $count = $this->ads_sync->ingest( (string) $r['report_type'], $this->ads->download( (string) $st['url'] ) ); + return array( 'completed', $count, null ); + } + + /** Housekeeping: drop report rows older than 90 days. */ + public function prune(): int { + return Db::exec( 'DELETE FROM {t:reports} WHERE requested_at < %s', array( gmdate( 'Y-m-d H:i:s', time() - 90 * DAY_IN_SECONDS ) ) ); + } +} diff --git a/sg-commerce-pro/src/Suite/Reports/ReportsClient.php b/sg-commerce-pro/src/Suite/Reports/ReportsClient.php new file mode 100644 index 0000000..7bfcfbf --- /dev/null +++ b/sg-commerce-pro/src/Suite/Reports/ReportsClient.php @@ -0,0 +1,80 @@ +client->marketplace_info( $market ); + $body = array( + 'reportType' => $type, + 'marketplaceIds' => array( (string) ( $mp['id'] ?? '' ) ), + ); + if ( null !== $start ) { + $body['dataStartTime'] = $start; + } + if ( null !== $end ) { + $body['dataEndTime'] = $end; + } + if ( $options ) { + $body['reportOptions'] = array_map( 'strval', $options ); + } + $res = $this->client->request( 'POST', $market, self::BASE . '/reports', array(), $body, 'reports.create' ); + $id = (string) ( $res['reportId'] ?? '' ); + if ( '' === $id ) { + throw new \RuntimeException( 'createReport returned no reportId.' ); + } + return $id; + } + + /** @return array{processingStatus:string, reportDocumentId?:string} */ + public function get( string $report_id, string $market ): array { + return $this->client->request( 'GET', $market, self::BASE . '/reports/' . rawurlencode( $report_id ), array(), null, 'reports.get' ); + } + + /** Resolve and download a document; returns decompressed raw content. */ + public function download( string $document_id, string $market ): string { + $doc = $this->client->request( 'GET', $market, self::BASE . '/documents/' . rawurlencode( $document_id ), array(), null, 'reports.document' ); + $url = (string) ( $doc['url'] ?? '' ); + if ( '' === $url ) { + throw new \RuntimeException( 'Report document has no download URL.' ); + } + $res = wp_remote_get( $url, array( 'timeout' => 120 ) ); + if ( is_wp_error( $res ) ) { + throw new \RuntimeException( 'Report download failed: ' . $res->get_error_message() ); + } + if ( 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { + throw new \RuntimeException( 'Report download HTTP ' . wp_remote_retrieve_response_code( $res ) ); + } + $body = (string) wp_remote_retrieve_body( $res ); + return 'GZIP' === strtoupper( (string) ( $doc['compressionAlgorithm'] ?? '' ) ) || str_starts_with( $body, "\x1f\x8b" ) + ? ReportParser::maybe_gunzip( $body ) + : $body; + } +} diff --git a/sg-commerce-pro/src/Suite/Research/ProductResearch.php b/sg-commerce-pro/src/Suite/Research/ProductResearch.php new file mode 100644 index 0000000..cccff7b --- /dev/null +++ b/sg-commerce-pro/src/Suite/Research/ProductResearch.php @@ -0,0 +1,251 @@ + 1.0, 'gourmet' => 1.0, 'home' => 1.6, 'kitchen' => 1.4, 'beauty' => 1.2, 'hpc' => 1.3, + 'health' => 1.3, 'sports' => 0.9, 'toy' => 1.0, 'pet' => 0.7, 'office' => 0.6, 'electronics' => 0.9, + 'wireless' => 0.9, 'baby' => 0.5, 'lawn' => 0.8, 'garden' => 0.8, 'home_improvement' => 0.8, + 'apparel' => 1.8, 'fashion' => 1.8, 'shoes' => 1.2, 'automotive' => 0.7, 'books' => 1.5, + ); + + public function __construct( private AmazonClient $client ) {} + + public function search( string $keywords, string $market ): array { + $mp = $this->client->marketplace_info( $market ); + $res = $this->client->request( 'GET', $market, '/catalog/2022-04-01/items', array( + 'keywords' => $keywords, + 'marketplaceIds' => (string) ( $mp['id'] ?? '' ), + 'includedData' => 'summaries,salesRanks,images', + 'pageSize' => 20, + ), null, 'catalog.search' ); + + $items = array(); + foreach ( (array) ( $res['items'] ?? array() ) as $it ) { + $asin = (string) ( $it['asin'] ?? '' ); + $sum = (array) ( $it['summaries'][0] ?? array() ); + [ $rank, $group ] = self::top_rank( (array) ( $it['salesRanks'][0] ?? array() ) ); + $img = ''; + foreach ( (array) ( $it['images'][0]['images'] ?? array() ) as $im ) { + if ( 'MAIN' === ( $im['variant'] ?? '' ) || '' === $img ) { + $img = (string) ( $im['link'] ?? '' ); + } + } + $items[ $asin ] = array( + 'asin' => $asin, + 'title' => (string) ( $sum['itemName'] ?? '' ), + 'brand' => (string) ( $sum['brand'] ?? $sum['brandName'] ?? '' ), + 'category' => (string) ( $sum['browseClassification']['displayName'] ?? $group ), + 'image' => $img, + 'bsr' => $rank, + 'est_units' => null !== $rank ? self::estimate_monthly_units( $rank, $group ) : null, + 'price' => null, + 'offers' => null, + 'est_revenue'=> null, + ); + } + if ( $items ) { + try { + $pr = $this->client->request( 'GET', $market, '/products/pricing/v0/competitivePrice', array( + 'MarketplaceId' => (string) ( $mp['id'] ?? '' ), + 'Asins' => implode( ',', array_slice( array_keys( $items ), 0, 20 ) ), + 'ItemType' => 'Asin', + ), null, 'pricing.competitivePricing' ); + foreach ( (array) ( $pr['payload'] ?? array() ) as $p ) { + $asin = (string) ( $p['ASIN'] ?? '' ); + if ( ! isset( $items[ $asin ] ) ) { + continue; + } + $cp = (array) ( $p['Product']['CompetitivePricing'] ?? array() ); + foreach ( (array) ( $cp['CompetitivePrices'] ?? array() ) as $price ) { + $items[ $asin ]['price'] = (float) ( $price['Price']['LandedPrice']['Amount'] ?? $price['Price']['ListingPrice']['Amount'] ?? 0 ) ?: null; + break; + } + $count = 0; + foreach ( (array) ( $cp['NumberOfOfferListings'] ?? array() ) as $n ) { + $count += (int) ( $n['Count'] ?? 0 ); + } + $items[ $asin ]['offers'] = $count; + } + } catch ( \Throwable ) { + // Pricing is enrichment only; search still returns. + } + } + foreach ( $items as &$i ) { + if ( null !== $i['price'] && null !== $i['est_units'] ) { + $i['est_revenue'] = round( $i['price'] * $i['est_units'], 2 ); + } + } + unset( $i ); + $list = array_values( $items ); + $units = array_filter( array_column( $list, 'est_units' ) ); + $revs = array_filter( array_column( $list, 'est_revenue' ) ); + $prices = array_filter( array_column( $list, 'price' ) ); + return array( + 'items' => $list, + 'summary' => array( + 'results' => count( $list ), + 'avg_price' => $prices ? round( array_sum( $prices ) / count( $prices ), 2 ) : null, + 'avg_units' => $units ? (int) round( array_sum( $units ) / count( $units ) ) : null, + 'total_revenue' => $revs ? round( array_sum( $revs ), 2 ) : null, + 'top3_share_pct' => $revs ? round( array_sum( array_slice( self::rsorted( $revs ), 0, 3 ) ) / max( 1, array_sum( $revs ) ) * 100, 1 ) : null, + 'opportunity' => self::opportunity( $list ), + ), + ); + } + + public function fees( string $asin, float $price, string $market ): array { + $mp = $this->client->marketplace_info( $market ); + $cur = (string) ( $mp['currency'] ?? 'USD' ); + $res = $this->client->request( 'POST', $market, '/products/fees/v0/items/' . rawurlencode( $asin ) . '/feesEstimate', array(), array( + 'FeesEstimateRequest' => array( + 'MarketplaceId' => (string) ( $mp['id'] ?? '' ), + 'IsAmazonFulfilled' => true, + 'PriceToEstimateFees' => array( + 'ListingPrice' => array( 'CurrencyCode' => $cur, 'Amount' => round( $price, 2 ) ), + 'Shipping' => array( 'CurrencyCode' => $cur, 'Amount' => 0 ), + ), + 'Identifier' => 'sg-' . wp_generate_password( 8, false ), + ), + ), 'fees.estimate' ); + $est = (array) ( $res['payload']['FeesEstimateResult'] ?? array() ); + if ( 'Success' !== ( $est['Status'] ?? 'Success' ) ) { + throw new \RuntimeException( (string) ( $est['Error']['Message'] ?? 'Fee estimate failed.' ) ); + } + $details = array(); + $referral = 0.0; + $fba = 0.0; + foreach ( (array) ( $est['FeesEstimate']['FeeDetailList'] ?? array() ) as $d ) { + $amt = (float) ( $d['FinalFee']['Amount'] ?? $d['FeeAmount']['Amount'] ?? 0 ); + $type = (string) ( $d['FeeType'] ?? '' ); + $details[] = array( 'type' => $type, 'amount' => $amt ); + if ( 'ReferralFee' === $type ) { + $referral += $amt; + } elseif ( str_contains( $type, 'FBA' ) ) { + $fba += $amt; + } + } + return array( + 'asin' => $asin, + 'price' => $price, + 'currency' => $cur, + 'total' => (float) ( $est['FeesEstimate']['TotalFeesEstimate']['Amount'] ?? array_sum( array_column( $details, 'amount' ) ) ), + 'referral' => $referral, + 'fba' => $fba, + 'details' => $details, + ); + } + + /** + * Pure profitability calculator (FBA revenue calculator +). + * + * @param array{price:float, unit_cost:float, inbound:float, referral_pct?:float, referral?:float, fba_fee:float, storage_per_unit?:float, tacos_pct?:float, return_pct?:float, cvr_pct?:float} $in + */ + public static function calc( array $in ): array { + $price = max( 0.0, (float) ( $in['price'] ?? 0 ) ); + $referral = isset( $in['referral'] ) && $in['referral'] > 0 ? (float) $in['referral'] : $price * (float) ( $in['referral_pct'] ?? 15 ) / 100; + $fba = (float) ( $in['fba_fee'] ?? 0 ); + $storage = (float) ( $in['storage_per_unit'] ?? 0 ); + $cogs = (float) ( $in['unit_cost'] ?? 0 ) + (float) ( $in['inbound'] ?? 0 ); + $ads = $price * (float) ( $in['tacos_pct'] ?? 0 ) / 100; + // Returns: lose the sale on return_pct of units; referral is mostly refunded, FBA fee is not. + $ret = (float) ( $in['return_pct'] ?? 0 ) / 100; + $returns = $ret * ( $price - $referral * 0.8 ); + $fees = $referral + $fba + $storage; + $profit = $price - $fees - $cogs - $ads - $returns; + $pre_ads = $price - $fees - $cogs - $returns; + $cvr = (float) ( $in['cvr_pct'] ?? 10 ) / 100; + return array( + 'price' => round( $price, 2 ), + 'amazon_fees' => round( $fees, 2 ), + 'referral' => round( $referral, 2 ), + 'cogs' => round( $cogs, 2 ), + 'ads_per_unit' => round( $ads, 2 ), + 'returns_per_unit' => round( $returns, 2 ), + 'profit_per_unit' => round( $profit, 2 ), + 'margin_pct' => $price > 0 ? round( $profit / $price * 100, 2 ) : null, + 'roi_pct' => $cogs > 0 ? round( $profit / $cogs * 100, 2 ) : null, + 'breakeven_acos' => $price > 0 ? round( max( 0, $pre_ads ) / $price * 100, 2 ) : null, + 'max_cpc' => round( max( 0, $pre_ads ) * $cvr, 2 ), + 'breakeven_price' => self::breakeven_price( $in ), + ); + } + + /** Units/month from BSR (power law, display-group adjusted). */ + public static function estimate_monthly_units( int $rank, string $group ): int { + if ( $rank <= 0 ) { + return 0; + } + $factor = 1.0; + $g = strtolower( $group ); + foreach ( self::GROUP_FACTOR as $k => $f ) { + if ( str_contains( $g, $k ) ) { + $factor = $f; + break; + } + } + return (int) max( 0, round( $factor * 417000 * pow( $rank, -0.89 ) ) ); + } + + /** @return array{0:?int, 1:string} */ + private static function top_rank( array $sales_ranks ): array { + foreach ( (array) ( $sales_ranks['displayGroupRanks'] ?? array() ) as $r ) { + return array( (int) ( $r['rank'] ?? 0 ) ?: null, (string) ( $r['websiteDisplayGroup'] ?? $r['title'] ?? '' ) ); + } + foreach ( (array) ( $sales_ranks['classificationRanks'] ?? array() ) as $r ) { + return array( (int) ( $r['rank'] ?? 0 ) ?: null, (string) ( $r['title'] ?? '' ) ); + } + return array( null, '' ); + } + + private static function rsorted( array $v ): array { + rsort( $v ); + return $v; + } + + /** 0–100: demand high, competition (offers, top-3 concentration) low, price in the sweet spot. */ + private static function opportunity( array $items ): ?int { + if ( ! $items ) { + return null; + } + $units = array_filter( array_column( $items, 'est_units' ) ); + $prices = array_filter( array_column( $items, 'price' ) ); + $offers = array_filter( array_column( $items, 'offers' ), static fn( $v ) => null !== $v ); + $demand = $units ? min( 40, ( array_sum( $units ) / count( $units ) ) / 25 ) : 0; + $comp = $offers ? max( 0, 30 - ( array_sum( $offers ) / count( $offers ) ) * 3 ) : 15; + $avg_p = $prices ? array_sum( $prices ) / count( $prices ) : 0; + $price = $avg_p >= 15 && $avg_p <= 60 ? 30 : ( $avg_p >= 10 ? 18 : 8 ); + return (int) round( min( 100, $demand + $comp + $price ) ); + } + + private static function breakeven_price( array $in ): ?float { + $cogs = (float) ( $in['unit_cost'] ?? 0 ) + (float) ( $in['inbound'] ?? 0 ); + $fixed = (float) ( $in['fba_fee'] ?? 0 ) + (float) ( $in['storage_per_unit'] ?? 0 ) + $cogs; + $pct = ( (float) ( $in['referral_pct'] ?? 15 ) + (float) ( $in['tacos_pct'] ?? 0 ) ) / 100; + return $pct < 1 ? round( $fixed / ( 1 - $pct ), 2 ) : null; + } +} diff --git a/sg-commerce-pro/src/Suite/Rest/SuiteController.php b/sg-commerce-pro/src/Suite/Rest/SuiteController.php new file mode 100644 index 0000000..9c42dab --- /dev/null +++ b/sg-commerce-pro/src/Suite/Rest/SuiteController.php @@ -0,0 +1,677 @@ + [methods, handler] + '/suite/overview' => array( 'GET', 'overview' ), + '/suite/profit' => array( 'GET', 'profit' ), + '/suite/orders' => array( 'GET', 'orders' ), + '/suite/costs' => array( 'GET,POST', 'costs' ), + '/suite/expenses' => array( 'GET,POST', 'expenses' ), + '/suite/expenses/(?P\d+)' => array( 'DELETE', 'expense_delete' ), + '/suite/restock' => array( 'GET', 'restock' ), + '/suite/inventory-health' => array( 'GET', 'inventory_health' ), + '/suite/ads/overview' => array( 'GET', 'ads_overview' ), + '/suite/ads/campaigns' => array( 'GET', 'ads_campaigns' ), + '/suite/ads/targets' => array( 'GET', 'ads_targets' ), + '/suite/ads/search-terms' => array( 'GET', 'ads_search_terms' ), + '/suite/ads/actions' => array( 'GET', 'ads_actions' ), + '/suite/ads/optimize' => array( 'POST', 'ads_optimize' ), + '/suite/ads/apply' => array( 'POST', 'ads_apply' ), + '/suite/ads/dismiss' => array( 'POST', 'ads_dismiss' ), + '/suite/ads/sync' => array( 'POST', 'ads_sync' ), + '/suite/ads/test' => array( 'POST', 'ads_test' ), + '/suite/keywords' => array( 'GET,POST', 'keywords' ), + '/suite/keywords/(?P\d+)' => array( 'DELETE', 'keyword_delete' ), + '/suite/keywords/research' => array( 'POST', 'keyword_research' ), + '/suite/keywords/queries' => array( 'GET', 'keyword_queries' ), + '/suite/listings' => array( 'GET', 'listings' ), + '/suite/listings/audit' => array( 'POST', 'listing_audit' ), + '/suite/listings/ai' => array( 'POST', 'listing_ai' ), + '/suite/monitor' => array( 'GET', 'monitor' ), + '/suite/monitor/history' => array( 'GET', 'monitor_history' ), + '/suite/monitor/run' => array( 'POST', 'monitor_run' ), + '/suite/reviews' => array( 'GET', 'reviews' ), + '/suite/reviews/run' => array( 'POST', 'reviews_run' ), + '/suite/reimbursements' => array( 'GET', 'reimbursements' ), + '/suite/reimbursements/scan' => array( 'POST', 'reimbursements_scan' ), + '/suite/reimbursements/(?P\d+)' => array( 'POST', 'reimbursement_update' ), + '/suite/research/search' => array( 'POST', 'research_search' ), + '/suite/research/fees' => array( 'POST', 'research_fees' ), + '/suite/research/calc' => array( 'POST', 'research_calc' ), + '/suite/traffic' => array( 'GET', 'traffic' ), + '/suite/returns' => array( 'GET', 'returns' ), + '/suite/feedback' => array( 'GET', 'feedback' ), + '/suite/alerts' => array( 'GET,POST', 'alerts' ), + '/suite/reports' => array( 'GET,POST', 'reports' ), + '/suite/reports/poll' => array( 'POST', 'reports_poll' ), + '/suite/sync' => array( 'POST', 'sync' ), + '/suite/settings' => array( 'GET,POST', 'settings' ), + '/suite/demo' => array( 'POST,DELETE', 'demo' ), + ); + foreach ( $r as $path => [ $methods, $handler ] ) { + register_rest_route( self::NS, $path, array( + 'methods' => $methods, + 'permission_callback' => static fn() => current_user_can( 'manage_options' ), + 'callback' => function ( \WP_REST_Request $req ) use ( $handler ) { + try { + return rest_ensure_response( $this->{$handler}( $req ) ); + } catch ( \Throwable $e ) { + return new \WP_Error( 'sg_suite_error', $e->getMessage(), array( 'status' => $e instanceof \InvalidArgumentException ? 400 : 500 ) ); + } + }, + ) ); + } + } + + /* ------------------------------------------------------------------ */ + /* Helpers */ + /* ------------------------------------------------------------------ */ + + private function market( \WP_REST_Request $req ): string { + $mp = $this->c->get( Marketplaces::class ); + $m = strtoupper( (string) ( $req['market'] ?? '' ) ); + return $mp->exists( $m ) ? $m : $mp->primary(); + } + + /** @return array{0:string,1:string} */ + private function range( \WP_REST_Request $req, string $market, int $default_days = 30 ): array { + $today = SuiteTime::today( $market ); + $to = SuiteTime::ymd( $req['to'] ?? '', $today ); + $from = SuiteTime::ymd( $req['from'] ?? '', SuiteTime::shift( $to, -( $default_days - 1 ) ) ); + if ( $from > $to ) { + [ $from, $to ] = array( $to, $from ); + } + if ( SuiteTime::days_between( $from, $to ) > 400 ) { + $from = SuiteTime::shift( $to, -399 ); + } + return array( $from, $to ); + } + + private function json( \WP_REST_Request $req ): array { + $p = $req->get_json_params(); + return is_array( $p ) ? $p : $req->get_params(); + } + + /* ------------------------------------------------------------------ */ + /* Dashboard & profit */ + /* ------------------------------------------------------------------ */ + + public function overview( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + $today = SuiteTime::today( $market ); + $profit = $this->c->get( ProfitEngine::class ); + $pnl30 = $profit->pnl( $market, SuiteTime::shift( $today, -29 ), $today ); + $restock = $this->c->get( RestockPlanner::class )->build( $market ); + $board = $this->c->get( ListingMonitor::class )->board(); + $mine = array_filter( $board, static fn( $r ) => (int) $r['is_mine'] ); + $bb_won = array_filter( $mine, static fn( $r ) => (int) $r['buybox_is_mine'] ); + $listing_scores = Db::rows( 'SELECT score FROM {t:listing_audits} WHERE market = %s', array( $market ) ); + $reimb = Db::row( "SELECT COUNT(*) AS n, COALESCE(SUM(est_amount),0) AS v FROM {t:reimb_cases} WHERE status = 'open'" ); + $ads = $this->c->get( AdsInsights::class )->overview( SuiteTime::shift( $today, -29 ), $today ); + $traffic = Db::row( + "SELECT COALESCE(SUM(sessions),0) AS sessions, COALESCE(SUM(units),0) AS units FROM {t:traffic_daily} + WHERE market = %s AND asin = '' AND report_date BETWEEN %s AND %s", + array( $market, SuiteTime::shift( $today, -29 ), $today ) + ); + return array( + 'market' => $market, + 'currency' => $pnl30['currency'], + 'periods' => $profit->dashboard( $market ), + 'series' => $pnl30['series'], + 'top_skus' => array_slice( $pnl30['skus'], 0, 8 ), + 'totals_30' => $pnl30['totals'], + 'restock' => $restock['summary'] + array( 'urgent' => array_slice( array_values( array_filter( $restock['rows'], static fn( $r ) => in_array( $r['status'], array( 'out_of_stock', 'critical', 'reorder_now' ), true ) ) ), 0, 6 ) ), + 'buybox' => array( 'asins' => count( $mine ), 'winning' => count( $bb_won ), 'pct' => $mine ? round( count( $bb_won ) / count( $mine ) * 100, 1 ) : null, 'hijacked' => count( array_filter( $mine, static fn( $r ) => (int) $r['other_sellers'] > 0 ) ) ), + 'listings' => array( 'audited' => count( $listing_scores ), 'avg_score' => $listing_scores ? (int) round( array_sum( array_column( $listing_scores, 'score' ) ) / count( $listing_scores ) ) : null ), + 'reimburse' => array( 'open' => (int) ( $reimb['n'] ?? 0 ), 'value' => round( (float) ( $reimb['v'] ?? 0 ), 2 ) ), + 'ads' => $ads['totals'], + 'conversion' => (int) ( $traffic['sessions'] ?? 0 ) > 0 ? round( (int) $traffic['units'] / (int) $traffic['sessions'] * 100, 2 ) : null, + 'sessions' => (int) ( $traffic['sessions'] ?? 0 ), + 'reviews' => $this->c->get( ReviewRequester::class )->stats( 30 )['by_status'], + 'alerts' => AlertDispatcher::recent( 8 ), + 'unread' => AlertDispatcher::unread_count(), + 'connected' => $this->c->get( AmazonClient::class )->has_credentials(), + 'ads_ready' => $this->c->get( AdsClient::class )->is_ready(), + 'demo' => DemoSeeder::active(), + 'missing_costs' => $pnl30['missing_costs'], + ); + } + + public function profit( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + [ $from, $to ] = $this->range( $req, $market ); + $out = $this->c->get( ProfitEngine::class )->pnl( $market, $from, $to ); + $days = SuiteTime::days_between( $from, $to ); + $prev = $this->c->get( ProfitEngine::class )->pnl( $market, SuiteTime::shift( $from, -$days ), SuiteTime::shift( $from, -1 ), false ); + $out['previous'] = $prev['totals']; + return $out; + } + + public function orders( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + [ $from, $to ] = $this->range( $req, $market, 7 ); + $args = array( $market, $from, $to ); + $where = 'o.market = %s AND o.local_date BETWEEN %s AND %s'; + $q = trim( (string) ( $req['q'] ?? '' ) ); + if ( '' !== $q ) { + $where .= ' AND (o.amazon_order_id LIKE %s OR EXISTS (SELECT 1 FROM {t:order_items} x WHERE x.amazon_order_id = o.amazon_order_id AND (x.sku LIKE %s OR x.asin LIKE %s)))'; + $like = '%' . $GLOBALS['wpdb']->esc_like( $q ) . '%'; + array_push( $args, $like, $like, $like ); + } + $status = (string) ( $req['status'] ?? '' ); + if ( '' !== $status ) { + $where .= ' AND o.status = %s'; + $args[] = $status; + } + $orders = Db::rows( "SELECT o.* FROM {t:orders} o WHERE {$where} ORDER BY o.purchase_date DESC LIMIT 500", $args ); + $ids = array_column( $orders, 'amazon_order_id' ); + $items = array(); + $fees = array(); + if ( $ids ) { + foreach ( Db::rows( 'SELECT * FROM {t:order_items} WHERE amazon_order_id IN (' . Db::in( $ids ) . ')', $ids ) as $it ) { + $items[ $it['amazon_order_id'] ][] = $it; + } + foreach ( Db::rows( "SELECT amazon_order_id, category, SUM(amount) AS amt FROM {t:fin_events} WHERE amazon_order_id IN (" . Db::in( $ids ) . ') GROUP BY amazon_order_id, category', $ids ) as $f ) { + $fees[ $f['amazon_order_id'] ][ $f['category'] ] = (float) $f['amt']; + } + } + $costs = $this->c->get( CostBook::class ); + $unit_fee = $this->c->get( ProfitEngine::class )->fee_per_unit( $market, $to ); + foreach ( $orders as &$o ) { + $o['items'] = $items[ $o['amazon_order_id'] ] ?? array(); + $f = $fees[ $o['amazon_order_id'] ] ?? array(); + $sales = array_sum( array_map( static fn( $i ) => (float) $i['item_price'], $o['items'] ) ); + $promo = array_sum( array_map( static fn( $i ) => (float) $i['promo_discount'], $o['items'] ) ); + $cogs = array_sum( array_map( static fn( $i ) => (int) $i['qty'] * $costs->landed( $market, (string) $i['sku'] ), $o['items'] ) ); + if ( isset( $f['fee'] ) ) { + $fee = $f['fee']; + $o['fees_estimated'] = false; + } else { + $fee = -array_sum( array_map( static fn( $i ) => isset( $unit_fee['sku'][ $i['sku'] ] ) ? $unit_fee['sku'][ $i['sku'] ] * (int) $i['qty'] : $unit_fee['ratio'] * (float) $i['item_price'], $o['items'] ) ); + $o['fees_estimated'] = true; + } + $o['sales'] = round( $sales, 2 ); + $o['fees'] = round( $fee, 2 ); + $o['refund'] = round( (float) ( $f['refund'] ?? 0 ), 2 ); + $o['cogs'] = round( $cogs, 2 ); + $o['profit'] = 'Canceled' === $o['status'] ? 0.0 : round( $sales - $promo + $fee + $o['refund'] - $cogs, 2 ); + } + return array( 'from' => $from, 'to' => $to, 'orders' => $orders ); + } + + public function costs( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + $book = $this->c->get( CostBook::class ); + if ( 'POST' === $req->get_method() ) { + $rows = (array) ( $this->json( $req )['rows'] ?? array() ); + $n = 0; + foreach ( $rows as $row ) { + if ( ! empty( $row['sku'] ) && $book->save( $market, (string) $row['sku'], (array) $row ) ) { + $n++; + } + } + return array( 'saved' => $n ); + } + return array( 'rows' => $book->all( $market ) ); + } + + public function expenses( \WP_REST_Request $req ): array { + if ( 'POST' === $req->get_method() ) { + $in = $this->json( $req ); + $label = sanitize_text_field( (string) ( $in['label'] ?? '' ) ); + if ( '' === $label ) { + throw new \InvalidArgumentException( 'Label is required.' ); + } + $id = Db::insert( 'expenses', array( + 'market' => strtoupper( sanitize_text_field( (string) ( $in['market'] ?? '' ) ) ), + 'label' => mb_substr( $label, 0, 128 ), + 'category' => sanitize_key( (string) ( $in['category'] ?? 'other' ) ) ?: 'other', + 'amount' => max( 0, (float) ( $in['amount'] ?? 0 ) ), + 'recurrence' => 'monthly' === ( $in['recurrence'] ?? '' ) ? 'monthly' : 'once', + 'start_date' => SuiteTime::ymd( $in['start_date'] ?? '', gmdate( 'Y-m-d' ) ), + 'end_date' => ! empty( $in['end_date'] ) ? SuiteTime::ymd( $in['end_date'], gmdate( 'Y-m-d' ) ) : null, + 'sku' => '', + 'created_at' => Db::now(), + ) ); + return array( 'id' => $id ); + } + return array( 'rows' => Db::rows( 'SELECT * FROM {t:expenses} ORDER BY start_date DESC' ) ); + } + + public function expense_delete( \WP_REST_Request $req ): array { + return array( 'deleted' => Db::exec( 'DELETE FROM {t:expenses} WHERE id = %d', array( (int) $req['id'] ) ) ); + } + + /* ------------------------------------------------------------------ */ + /* Inventory */ + /* ------------------------------------------------------------------ */ + + public function restock( \WP_REST_Request $req ): array { + return $this->c->get( RestockPlanner::class )->build( $this->market( $req ) ); + } + + public function inventory_health( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + $snap = (string) Db::var( 'SELECT MAX(snapshot_date) FROM {t:inventory_health} WHERE market = %s', array( $market ) ); + return array( + 'snapshot' => $snap, + 'rows' => '' === $snap ? array() : Db::rows( 'SELECT * FROM {t:inventory_health} WHERE market = %s AND snapshot_date = %s ORDER BY (age_181_270 + age_271_365 + age_365_plus) DESC, available DESC', array( $market, $snap ) ), + ); + } + + /* ------------------------------------------------------------------ */ + /* PPC */ + /* ------------------------------------------------------------------ */ + + public function ads_overview( \WP_REST_Request $req ): array { + [ $from, $to ] = $this->range( $req, $this->market( $req ) ); + return $this->c->get( AdsInsights::class )->overview( $from, $to ) + array( + 'from' => $from, 'to' => $to, + 'ready' => $this->c->get( AdsClient::class )->is_ready(), + 'planned' => (int) Db::var( "SELECT COUNT(*) FROM {t:ads_actions} WHERE status = 'planned'" ), + ); + } + + public function ads_campaigns( \WP_REST_Request $req ): array { + [ $from, $to ] = $this->range( $req, $this->market( $req ) ); + return array( 'rows' => $this->c->get( AdsInsights::class )->campaigns( $from, $to ) ); + } + + public function ads_targets( \WP_REST_Request $req ): array { + [ $from, $to ] = $this->range( $req, $this->market( $req ) ); + return array( 'rows' => $this->c->get( AdsInsights::class )->targets( $from, $to, sanitize_text_field( (string) ( $req['campaign'] ?? '' ) ) ) ); + } + + public function ads_search_terms( \WP_REST_Request $req ): array { + [ $from, $to ] = $this->range( $req, $this->market( $req ) ); + return array( 'rows' => $this->c->get( AdsInsights::class )->search_terms( $from, $to ) ); + } + + public function ads_actions( \WP_REST_Request $req ): array { + $i = $this->c->get( AdsInsights::class ); + return array( 'planned' => $i->actions( 'planned' ), 'history' => $i->history() ); + } + + public function ads_optimize(): array { + return $this->c->get( AdsSync::class )->optimize(); + } + + public function ads_apply( \WP_REST_Request $req ): array { + return $this->c->get( AdsSync::class )->apply( (array) ( $this->json( $req )['ids'] ?? array() ) ); + } + + public function ads_dismiss( \WP_REST_Request $req ): array { + return array( 'dismissed' => $this->c->get( AdsSync::class )->dismiss( (array) ( $this->json( $req )['ids'] ?? array() ) ) ); + } + + public function ads_sync(): array { + $sync = $this->c->get( AdsSync::class ); + $structure = $sync->sync_structure(); + $reports = $sync->request_reports( gmdate( 'Y-m-d', time() - 31 * DAY_IN_SECONDS ), gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ) ); + return array( 'structure' => $structure, 'reports_requested' => count( $reports ) ); + } + + public function ads_test( \WP_REST_Request $req ): array { + $in = $this->json( $req ); + if ( $in ) { + $this->c->get( SuiteSettings::class )->save( $in ); + } + return $this->c->get( AdsClient::class )->test(); + } + + /* ------------------------------------------------------------------ */ + /* Keywords & listings */ + /* ------------------------------------------------------------------ */ + + public function keywords( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + $svc = $this->c->get( KeywordService::class ); + if ( 'POST' === $req->get_method() ) { + $in = $this->json( $req ); + $asin = strtoupper( sanitize_text_field( (string) ( $in['asin'] ?? '' ) ) ); + if ( 1 !== preg_match( '/^[A-Z0-9]{10}$/', $asin ) ) { + throw new \InvalidArgumentException( 'A valid 10-character ASIN is required.' ); + } + $list = is_array( $in['keywords'] ?? null ) ? $in['keywords'] : preg_split( '/[\r\n,]+/', (string) ( $in['keywords'] ?? '' ) ); + return array( 'added' => $svc->add( $market, $asin, (array) $list, 'manual', (int) ( $in['priority'] ?? 2 ) ) ); + } + return array( 'rows' => $svc->tracked( $market, strtoupper( sanitize_text_field( (string) ( $req['asin'] ?? '' ) ) ) ), 'asins' => $this->own_asins( $market ) ); + } + + public function keyword_delete( \WP_REST_Request $req ): array { + return array( 'deleted' => $this->c->get( KeywordService::class )->delete( (int) $req['id'] ) ); + } + + public function keyword_research( \WP_REST_Request $req ): array { + $in = $this->json( $req ); + $asins = array_values( array_filter( array_map( static fn( $a ) => strtoupper( trim( (string) $a ) ), (array) ( $in['asins'] ?? array() ) ) ) ); + return array( 'rows' => $this->c->get( KeywordService::class )->research( $asins ) ); + } + + public function keyword_queries( \WP_REST_Request $req ): array { + return array( 'rows' => $this->c->get( KeywordService::class )->sqp_queries( $this->market( $req ), strtoupper( sanitize_text_field( (string) ( $req['asin'] ?? '' ) ) ) ) ); + } + + public function listings( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + $rows = Db::rows( 'SELECT * FROM {t:listing_audits} WHERE market = %s ORDER BY score ASC', array( $market ) ); + foreach ( $rows as &$r ) { + $r['findings'] = json_decode( (string) $r['findings'], true ) ?: array(); + $r['snapshot'] = json_decode( (string) $r['snapshot'], true ) ?: array(); + } + return array( 'rows' => $rows, 'seller_id_set' => '' !== $this->c->get( SuiteSettings::class )->seller_id() ); + } + + public function listing_audit( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + $sku = sanitize_text_field( (string) ( $this->json( $req )['sku'] ?? '' ) ); + if ( '' === $sku ) { + return array( 'audited' => $this->c->get( SuiteModule::class )->audit_batch( 30 ) ); + } + $asin = (string) Db::var( 'SELECT asin FROM ' . $GLOBALS['wpdb']->prefix . 'sg_products WHERE market = %s AND sku = %s', array( $market, $sku ) ); + $kw = array_column( Db::rows( 'SELECT keyword FROM {t:keywords} WHERE market = %s AND asin = %s', array( $market, $asin ) ), 'keyword' ); + return $this->c->get( ListingAuditor::class )->audit( $market, $sku, $kw ); + } + + /** AI rewrite of title/bullets/backend using the configured AI provider and tracked keywords. */ + public function listing_ai( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + $sku = sanitize_text_field( (string) ( $this->json( $req )['sku'] ?? '' ) ); + $row = Db::row( 'SELECT * FROM {t:listing_audits} WHERE market = %s AND sku = %s', array( $market, $sku ) ); + if ( null === $row ) { + throw new \InvalidArgumentException( 'Audit the listing first.' ); + } + $snap = json_decode( (string) $row['snapshot'], true ) ?: array(); + $kw = array_column( Db::rows( 'SELECT keyword FROM {t:keywords} WHERE market = %s AND asin = %s ORDER BY priority', array( $market, (string) $row['asin'] ) ), 'keyword' ); + $voice = (string) $this->c->get( \SevenGum\Commerce\Database\Repositories\SettingsRepository::class )->get( 'ai_brand_voice', 'modern, clean, playful but confident' ); + $prompt = "You are an Amazon listing copywriter. Brand voice: {$voice}.\n" + . "Rewrite this Amazon listing to maximise conversion and search indexing while following Amazon policy:\n" + . "- Title: start with the brand, max 200 characters, no word more than twice, no !\$?_{}^ characters, no promotional claims.\n" + . "- Exactly 5 bullet points, each 150-250 characters, starting with a short CAPITALISED benefit phrase.\n" + . "- Backend search terms: max 249 bytes, space separated, no commas, no brand names, no words already in the title.\n" + . '- Naturally include these target keywords: ' . implode( ', ', array_slice( $kw, 0, 15 ) ) . "\n\n" + . 'Current title: ' . ( $snap['title'] ?? '' ) . "\n" + . "Current bullets:\n- " . implode( "\n- ", (array) ( $snap['bullets'] ?? array() ) ) . "\n\n" + . "Respond ONLY with JSON: {\"title\": \"...\", \"bullets\": [\"...\"], \"backend\": \"...\"}"; + $provider = $this->c->get( \SevenGum\Commerce\AI\ProviderInterface::class ); + $res = $provider->generate( $prompt, array( 'temperature' => 0.6, 'max_tokens' => 1200 ) ); + $text = (string) ( $res['text'] ?? '' ); + $json = null; + if ( preg_match( '/\{.*\}/s', $text, $m ) ) { + $json = json_decode( $m[0], true ); + } + if ( ! is_array( $json ) ) { + throw new \RuntimeException( 'The AI provider did not return valid JSON. Raw output: ' . mb_substr( $text, 0, 300 ) ); + } + $draft = array_merge( $snap, array( + 'title' => (string) ( $json['title'] ?? '' ), + 'bullets' => array_values( array_map( 'strval', (array) ( $json['bullets'] ?? array() ) ) ), + 'backend' => (string) ( $json['backend'] ?? '' ), + ) ); + return array( 'draft' => $draft, 'score' => ListingAuditor::score( $draft, $kw ), 'provider' => $res['provider'] ?? '', 'model' => $res['model'] ?? '' ); + } + + /* ------------------------------------------------------------------ */ + /* Monitor, reviews, reimbursements */ + /* ------------------------------------------------------------------ */ + + public function monitor(): array { + $mon = $this->c->get( ListingMonitor::class ); + return array( + 'rows' => $mon->board(), + 'hijacks' => Db::rows( "SELECT s.*, (SELECT COUNT(*) FROM {t:offer_sellers} x WHERE x.asin = s.asin AND x.is_me = 1) AS mine FROM {t:offer_sellers} s WHERE s.is_me = 0 AND s.active = 1 AND EXISTS (SELECT 1 FROM {t:offer_sellers} m WHERE m.asin = s.asin AND m.market = s.market AND m.is_me = 1) ORDER BY s.first_seen DESC" ), + 'watching' => count( $mon->watchlist() ), + ); + } + + public function monitor_history( \WP_REST_Request $req ): array { + $mon = $this->c->get( ListingMonitor::class ); + $market = $this->market( $req ); + $asin = strtoupper( sanitize_text_field( (string) ( $req['asin'] ?? '' ) ) ); + return array( 'history' => $mon->history( $market, $asin, max( 7, min( 365, (int) ( $req['days'] ?? 90 ) ) ) ), 'sellers' => $mon->sellers( $market, $asin ) ); + } + + public function monitor_run(): array { + return $this->c->get( ListingMonitor::class )->run(); + } + + public function reviews(): array { + return $this->c->get( ReviewRequester::class )->stats( 30 ) + array( 'enabled' => $this->c->get( SuiteSettings::class )->bool( 'suite_reviews_enabled' ) ); + } + + public function reviews_run(): array { + return $this->c->get( ReviewRequester::class )->run(); + } + + public function reimbursements( \WP_REST_Request $req ): array { + $status = sanitize_key( (string) ( $req['status'] ?? 'open' ) ); + $rows = 'all' === $status + ? Db::rows( 'SELECT * FROM {t:reimb_cases} ORDER BY est_amount DESC LIMIT 500' ) + : Db::rows( 'SELECT * FROM {t:reimb_cases} WHERE status = %s ORDER BY est_amount DESC LIMIT 500', array( $status ) ); + foreach ( $rows as &$r ) { + $r['evidence'] = json_decode( (string) $r['evidence'], true ) ?: array(); + } + return array( + 'rows' => $rows, + 'summary' => $this->c->get( ReimbursementAuditor::class )->summary(), + 'recent' => Db::rows( 'SELECT * FROM {t:reimbursements} ORDER BY approval_date DESC LIMIT 100' ), + ); + } + + public function reimbursements_scan(): array { + return $this->c->get( ReimbursementAuditor::class )->scan(); + } + + public function reimbursement_update( \WP_REST_Request $req ): array { + return array( 'updated' => $this->c->get( ReimbursementAuditor::class )->update_case( (int) $req['id'], $this->json( $req ) ) ); + } + + /* ------------------------------------------------------------------ */ + /* Research */ + /* ------------------------------------------------------------------ */ + + public function research_search( \WP_REST_Request $req ): array { + $kw = sanitize_text_field( (string) ( $this->json( $req )['keywords'] ?? '' ) ); + if ( '' === $kw ) { + throw new \InvalidArgumentException( 'Enter keywords to search.' ); + } + return $this->c->get( ProductResearch::class )->search( $kw, $this->market( $req ) ); + } + + public function research_fees( \WP_REST_Request $req ): array { + $in = $this->json( $req ); + $asin = strtoupper( sanitize_text_field( (string) ( $in['asin'] ?? '' ) ) ); + if ( 1 !== preg_match( '/^[A-Z0-9]{10}$/', $asin ) ) { + throw new \InvalidArgumentException( 'A valid ASIN is required.' ); + } + return $this->c->get( ProductResearch::class )->fees( $asin, (float) ( $in['price'] ?? 0 ), $this->market( $req ) ); + } + + public function research_calc( \WP_REST_Request $req ): array { + return ProductResearch::calc( array_map( 'floatval', array_intersect_key( $this->json( $req ), array_flip( array( + 'price', 'unit_cost', 'inbound', 'referral_pct', 'referral', 'fba_fee', 'storage_per_unit', 'tacos_pct', 'return_pct', 'cvr_pct', + ) ) ) ) ); + } + + /* ------------------------------------------------------------------ */ + /* Traffic, returns, feedback, alerts */ + /* ------------------------------------------------------------------ */ + + public function traffic( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + [ $from, $to ] = $this->range( $req, $market ); + $series = Db::rows( + "SELECT report_date AS date, sessions, page_views, buy_box_pct, units, ordered_sales FROM {t:traffic_daily} + WHERE market = %s AND asin = '' AND report_date BETWEEN %s AND %s ORDER BY report_date", + array( $market, $from, $to ) + ); + $asins = Db::rows( + "SELECT asin, MAX(sku) AS sku, SUM(sessions) AS sessions, SUM(page_views) AS page_views, AVG(buy_box_pct) AS buy_box_pct, + SUM(units) AS units, SUM(ordered_sales) AS sales + FROM {t:traffic_daily} WHERE market = %s AND asin <> '' AND report_date BETWEEN %s AND %s GROUP BY asin ORDER BY SUM(sessions) DESC", + array( $market, $from, $to ) + ); + foreach ( $asins as &$a ) { + $a['cvr'] = (int) $a['sessions'] > 0 ? round( (int) $a['units'] / (int) $a['sessions'] * 100, 2 ) : null; + $a['buy_box_pct'] = round( (float) $a['buy_box_pct'], 1 ); + } + return array( 'from' => $from, 'to' => $to, 'series' => $series, 'asins' => $asins ); + } + + public function returns( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + [ $from, $to ] = $this->range( $req, $market, 90 ); + $a = array( $market, $from, $to ); + return array( + 'by_reason' => Db::rows( 'SELECT reason, SUM(qty) AS units FROM {t:returns} WHERE market = %s AND return_date BETWEEN %s AND %s GROUP BY reason ORDER BY units DESC', $a ), + 'by_sku' => Db::rows( 'SELECT sku, SUM(qty) AS units, SUM(CASE WHEN disposition = %s THEN qty ELSE 0 END) AS sellable FROM {t:returns} WHERE market = %s AND return_date BETWEEN %s AND %s GROUP BY sku ORDER BY units DESC', array_merge( array( 'SELLABLE' ), $a ) ), + 'rows' => Db::rows( 'SELECT * FROM {t:returns} WHERE market = %s AND return_date BETWEEN %s AND %s ORDER BY return_date DESC LIMIT 300', $a ), + ); + } + + public function feedback( \WP_REST_Request $req ): array { + $market = $this->market( $req ); + $rows = Db::rows( 'SELECT * FROM {t:feedback} WHERE market = %s ORDER BY feedback_date DESC LIMIT 300', array( $market ) ); + $n = count( $rows ); + $neg = count( array_filter( $rows, static fn( $r ) => (int) $r['rating'] <= 2 ) ); + return array( 'rows' => $rows, 'count' => $n, 'negative_pct' => $n ? round( $neg / $n * 100, 2 ) : null, 'avg' => $n ? round( array_sum( array_column( $rows, 'rating' ) ) / $n, 2 ) : null ); + } + + public function alerts( \WP_REST_Request $req ): array { + if ( 'POST' === $req->get_method() ) { + return array( 'marked' => AlertDispatcher::mark_all_read() ); + } + return array( 'rows' => AlertDispatcher::recent( 100 ), 'unread' => AlertDispatcher::unread_count() ); + } + + /* ------------------------------------------------------------------ */ + /* Reports, sync, settings, demo */ + /* ------------------------------------------------------------------ */ + + public function reports( \WP_REST_Request $req ): array { + $pipeline = $this->c->get( ReportPipeline::class ); + if ( 'POST' === $req->get_method() ) { + $in = $this->json( $req ); + $type = (string) ( $in['type'] ?? '' ); + if ( ! isset( ReportPipeline::CATALOG[ $type ] ) ) { + throw new \InvalidArgumentException( 'Unsupported report type.' ); + } + $market = $this->market( $req ); + $window = ReportPipeline::CATALOG[ $type ][2]; + $to = SuiteTime::ymd( $in['to'] ?? '', SuiteTime::shift( SuiteTime::today( $market ), -1 ) ); + $from = SuiteTime::ymd( $in['from'] ?? '', SuiteTime::shift( $to, -max( 0, $window - 1 ) ) ); + if ( 'GET_BRAND_ANALYTICS_SEARCH_QUERY_PERFORMANCE_REPORT' === $type ) { + return array( 'requested' => $pipeline->request_sqp( $market, $this->own_asins( $market ) ) ); + } + $id = 0 === $window ? $pipeline->request( $type, $market ) : $pipeline->request( $type, $market, $from, $to ); + return array( 'id' => $id, 'row' => Db::row( 'SELECT * FROM {t:reports} WHERE id = %d', array( $id ) ) ); + } + return array( + 'catalog' => array_map( static fn( $k, $v ) => array( 'type' => $k, 'label' => $v[0], 'schedule' => $v[1], 'window' => $v[2] ), array_keys( ReportPipeline::CATALOG ), ReportPipeline::CATALOG ), + 'rows' => Db::rows( 'SELECT * FROM {t:reports} ORDER BY requested_at DESC LIMIT 200' ), + 'jobs' => get_option( 'sg_suite_job_status', array() ), + ); + } + + public function reports_poll(): array { + return $this->c->get( ReportPipeline::class )->poll( 10 ); + } + + /** Manual "sync now" for a given job. */ + public function sync( \WP_REST_Request $req ): array { + $job = sanitize_key( (string) ( $this->json( $req )['job'] ?? '' ) ); + $m = $this->c->get( SuiteModule::class ); + $market = $this->market( $req ); + return array( 'job' => $job, 'result' => match ( $job ) { + 'orders' => $m->guard( 'orders', fn() => $this->c->get( OrdersSync::class )->run() ), + 'finance' => $m->guard( 'finance', fn() => $this->c->get( FinanceSync::class )->run() ), + 'reports' => $m->guard( 'reports.daily', fn() => $this->c->get( ReportPipeline::class )->request_scheduled( 'daily' ) ), + 'weekly' => $m->guard( 'reports.weekly', fn() => $this->c->get( ReportPipeline::class )->request_scheduled( 'weekly', $m->sqp_asins() ) ), + 'backfill' => $m->guard( 'backfill', function () use ( $market ) { + FinanceSync::reset_cursors(); + return $this->c->get( ReportPipeline::class )->backfill( $market, $this->c->get( SuiteSettings::class )->int( 'suite_backfill_days' ) ); + } ), + 'poll' => $m->guard( 'pipeline', fn() => $this->c->get( ReportPipeline::class )->poll( 10 ) ), + 'daily' => $m->daily() ?? 'done', + default => throw new \InvalidArgumentException( 'Unknown job.' ), + } ); + } + + public function settings( \WP_REST_Request $req ): array { + $s = $this->c->get( SuiteSettings::class ); + if ( 'POST' === $req->get_method() ) { + $saved = $s->save( $this->json( $req ) ); + return array( 'saved' => $saved, 'settings' => $s->export() ); + } + return array( + 'settings' => $s->export(), + 'connected' => $this->c->get( AmazonClient::class )->has_credentials(), + 'jobs' => get_option( 'sg_suite_job_status', array() ), + 'next_runs' => array_map( static fn( $h ) => ( $t = wp_next_scheduled( $h ) ) ? gmdate( 'c', $t ) : null, array_combine( array_keys( SuiteModule::HOOKS ), array_keys( SuiteModule::HOOKS ) ) ), + 'ads_hosts' => array_keys( AdsClient::HOSTS ), + ); + } + + public function demo( \WP_REST_Request $req ): array { + $seeder = $this->c->get( DemoSeeder::class ); + if ( 'DELETE' === $req->get_method() ) { + $seeder->clear(); + return array( 'demo' => false ); + } + return array( 'demo' => true, 'counts' => $seeder->seed( $this->market( $req ) ) ); + } + + /** @return string[] */ + private function own_asins( string $market ): array { + global $wpdb; + return array_values( array_filter( (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT asin FROM {$wpdb->prefix}sg_products WHERE market = %s AND asin <> '' ORDER BY asin", $market ) ) ) ); // phpcs:ignore + } +} diff --git a/sg-commerce-pro/src/Suite/Reviews/ReviewRequester.php b/sg-commerce-pro/src/Suite/Reviews/ReviewRequester.php new file mode 100644 index 0000000..4ced5b9 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Reviews/ReviewRequester.php @@ -0,0 +1,149 @@ +settings->int( 'suite_reviews_delay_days' ) ); + // Delivery estimate: latest_delivery, else earliest_delivery, else purchase + 7 days. + $ready_before = gmdate( 'Y-m-d H:i:s', $now - $delay * DAY_IN_SECONDS ); + $too_old = gmdate( 'Y-m-d H:i:s', $now - 29 * DAY_IN_SECONDS ); + $skip_refunds = $this->settings->bool( 'suite_reviews_skip_refunded' ); + + $sql = "SELECT o.amazon_order_id, o.market, + COALESCE(o.latest_delivery, o.earliest_delivery, DATE_ADD(o.purchase_date, INTERVAL 7 DAY)) AS delivered + FROM {t:orders} o + LEFT JOIN {t:solicitations} s ON s.amazon_order_id = o.amazon_order_id + WHERE o.status = 'Shipped' AND o.is_demo = 0 AND s.id IS NULL + AND COALESCE(o.latest_delivery, o.earliest_delivery, DATE_ADD(o.purchase_date, INTERVAL 7 DAY)) <= %s + AND COALESCE(o.latest_delivery, o.earliest_delivery, DATE_ADD(o.purchase_date, INTERVAL 7 DAY)) >= %s"; + if ( $skip_refunds ) { + $sql .= " AND NOT EXISTS (SELECT 1 FROM {t:fin_events} f WHERE f.amazon_order_id = o.amazon_order_id AND f.event_type = 'Refund') + AND NOT EXISTS (SELECT 1 FROM {t:returns} r WHERE r.amazon_order_id = o.amazon_order_id)"; + } + $sql .= ' ORDER BY delivered ASC LIMIT %d'; + return Db::rows( $sql, array( $ready_before, $too_old, $limit ) ); + } + + /** @return array{sent:int, ineligible:int, errors:int} */ + public function run(): array { + $stats = array( 'sent' => 0, 'ineligible' => 0, 'errors' => 0 ); + if ( ! $this->settings->bool( 'suite_reviews_enabled' ) || ! $this->client->has_credentials() ) { + return $stats; + } + $sent_today = (int) Db::var( + "SELECT COUNT(*) FROM {t:solicitations} WHERE status = 'sent' AND attempted_at >= %s", + array( gmdate( 'Y-m-d 00:00:00' ) ) + ); + $budget = max( 0, $this->settings->int( 'suite_reviews_daily_cap' ) - $sent_today ); + // Each order needs 2 calls at 1 rps; cap per tick to stay inside cron time limits. + foreach ( $this->candidates( min( $budget, 40 ) ) as $o ) { + $r = $this->request_one( (string) $o['amazon_order_id'], (string) $o['market'] ); + $stats[ $r ]++; + if ( 'errors' === $r && $stats['errors'] >= 3 ) { + break; + } + } + return $stats; + } + + /** @return 'sent'|'ineligible'|'errors' */ + public function request_one( string $order_id, string $market ): string { + $mp = $this->client->marketplace_info( $market ); + $q = array( 'marketplaceIds' => (string) ( $mp['id'] ?? '' ) ); + $base = '/solicitations/v1/orders/' . rawurlencode( $order_id ); + try { + $actions = $this->client->request( 'GET', $market, $base, $q, null, 'solicitations' ); + $available = false; + foreach ( (array) ( $actions['_links']['actions'] ?? array() ) as $a ) { + if ( str_contains( (string) ( $a['href'] ?? '' ), self::ACTION ) || self::ACTION === ( $a['name'] ?? '' ) ) { + $available = true; + } + } + if ( ! $available ) { + $this->record( $order_id, $market, 'ineligible', 'Amazon reports no review request available (outside window or already requested).' ); + return 'ineligible'; + } + $this->client->request( 'POST', $market, $base . '/solicitations/' . self::ACTION, $q, array(), 'solicitations' ); + $this->record( $order_id, $market, 'sent', '' ); + return 'sent'; + } catch ( \Throwable $e ) { + $msg = $e->getMessage(); + // 403 here means the order is not eligible (e.g. already requested in Seller Central). + $status = str_contains( $msg, '403' ) || str_contains( $msg, 'not eligible' ) ? 'ineligible' : 'error'; + $this->record( $order_id, $market, $status, mb_substr( $msg, 0, 250 ) ); + return 'ineligible' === $status ? 'ineligible' : 'errors'; + } + } + + public function record( string $order_id, string $market, string $status, string $message, bool $demo = false ): void { + Db::upsert( 'solicitations', array( + 'market' => $market, + 'amazon_order_id' => $order_id, + 'status' => $status, + 'message' => $message, + 'attempted_at' => Db::now(), + 'is_demo' => $demo ? 1 : 0, + ) ); + } + + /** Allow errored orders to be retried. */ + public function retry_errors(): int { + return Db::exec( "DELETE FROM {t:solicitations} WHERE status = 'error'" ); + } + + public function stats( int $days = 30 ): array { + $since = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); + $by = array( 'sent' => 0, 'ineligible' => 0, 'error' => 0 ); + foreach ( Db::rows( 'SELECT status, COUNT(*) AS n FROM {t:solicitations} WHERE attempted_at >= %s GROUP BY status', array( $since ) ) as $r ) { + $by[ (string) $r['status'] ] = (int) $r['n']; + } + $daily = Db::rows( + "SELECT DATE(attempted_at) AS d, SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) AS sent FROM {t:solicitations} + WHERE attempted_at >= %s GROUP BY DATE(attempted_at) ORDER BY d", + array( $since ) + ); + return array( + 'by_status' => $by, + 'daily' => $daily, + 'pending' => count( $this->candidates( 500 ) ), + 'recent' => Db::rows( 'SELECT * FROM {t:solicitations} ORDER BY attempted_at DESC LIMIT 100' ), + ); + } +} diff --git a/sg-commerce-pro/src/Suite/SuiteModule.php b/sg-commerce-pro/src/Suite/SuiteModule.php new file mode 100644 index 0000000..1eb0b58 --- /dev/null +++ b/sg-commerce-pro/src/Suite/SuiteModule.php @@ -0,0 +1,309 @@ + 'sg_fifteen_minutes', + 'sg_suite_hourly' => 'hourly', + 'sg_suite_daily' => 'daily', + ); + + public function __construct( private Container $container ) {} + + public function id(): string { + return 'suite'; + } + + public function register(): void { + $this->bind(); + + add_action( 'admin_init', array( $this, 'ensure_installed' ), 2 ); + add_action( 'admin_menu', array( $this, 'menu' ), 20 ); + add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); + add_action( 'rest_api_init', function (): void { + $this->container->get( SuiteController::class )->routes(); + } ); + + add_action( 'sg_suite_tick', array( $this, 'tick' ) ); + add_action( 'sg_suite_hourly', array( $this, 'hourly' ) ); + add_action( 'sg_suite_daily', array( $this, 'daily' ) ); + add_action( 'sg_suite_report_ingested', array( $this, 'after_report' ), 10, 3 ); + } + + private function bind(): void { + $c = $this->container; + $c->singleton( SuiteSettings::class, static fn( Container $c ) => new SuiteSettings( $c->get( SettingsRepository::class ) ) ); + $c->singleton( CostBook::class, static fn( Container $c ) => new CostBook( $c->get( SuiteSettings::class ) ) ); + $c->singleton( ReportsClient::class, static fn( Container $c ) => new ReportsClient( $c->get( AmazonClient::class ) ) ); + $c->singleton( OrdersSync::class, static fn( Container $c ) => new OrdersSync( $c->get( AmazonClient::class ), $c->get( Marketplaces::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) ); + $c->singleton( ReportIngestor::class, static fn( Container $c ) => new ReportIngestor( $c->get( OrdersSync::class ) ) ); + $c->singleton( AdsClient::class, static fn( Container $c ) => new AdsClient( $c->get( SuiteSettings::class ), $c->get( CacheManager::class ), $c->get( Logger::class ) ) ); + $c->singleton( AdsSync::class, static fn( Container $c ) => new AdsSync( $c->get( AdsClient::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) ); + $c->singleton( AdsInsights::class, static fn() => new AdsInsights() ); + $c->singleton( ReportPipeline::class, static fn( Container $c ) => new ReportPipeline( + $c->get( ReportsClient::class ), $c->get( ReportIngestor::class ), $c->get( AdsClient::class ), + $c->get( AdsSync::class ), $c->get( Marketplaces::class ), $c->get( Logger::class ) + ) ); + $c->singleton( FinanceSync::class, static fn( Container $c ) => new FinanceSync( $c->get( AmazonClient::class ), $c->get( Marketplaces::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) ); + $c->singleton( ProfitEngine::class, static fn( Container $c ) => new ProfitEngine( $c->get( CostBook::class ), $c->get( Marketplaces::class ), $c->get( SuiteSettings::class ) ) ); + $c->singleton( RestockPlanner::class, static fn( Container $c ) => new RestockPlanner( $c->get( CostBook::class ), $c->get( SuiteSettings::class ) ) ); + $c->singleton( KeywordService::class, static fn( Container $c ) => new KeywordService( $c->get( AdsClient::class ) ) ); + $c->singleton( ListingAuditor::class, static fn( Container $c ) => new ListingAuditor( $c->get( AmazonClient::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) ); + $c->singleton( ListingMonitor::class, static fn( Container $c ) => new ListingMonitor( $c->get( AmazonClient::class ), $c->get( Marketplaces::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) ); + $c->singleton( ReviewRequester::class, static fn( Container $c ) => new ReviewRequester( $c->get( AmazonClient::class ), $c->get( SuiteSettings::class ), $c->get( Logger::class ) ) ); + $c->singleton( ReimbursementAuditor::class, static fn() => new ReimbursementAuditor() ); + $c->singleton( ProductResearch::class, static fn( Container $c ) => new ProductResearch( $c->get( AmazonClient::class ) ) ); + $c->singleton( DemoSeeder::class, static fn( Container $c ) => new DemoSeeder( $c->get( ReimbursementAuditor::class ), $c->get( AdsSync::class ) ) ); + $c->singleton( SuiteController::class, static fn( Container $c ) => new SuiteController( $c ) ); + } + + /** Create tables + schedules for sites that updated without re-activating. */ + public function ensure_installed(): void { + SuiteSchema::maybe_install(); + self::schedule(); + } + + public static function schedule(): void { + foreach ( self::HOOKS as $hook => $recurrence ) { + if ( ! wp_next_scheduled( $hook ) ) { + wp_schedule_event( time() + 120, $recurrence, $hook ); + } + } + } + + public static function unschedule(): void { + foreach ( array_keys( self::HOOKS ) as $hook ) { + wp_clear_scheduled_hook( $hook ); + } + } + + public function menu(): void { + add_menu_page( + 'Amazon Seller Suite', + 'Amazon Suite', + 'manage_options', + self::PAGE, + array( $this, 'render' ), + 'dashicons-chart-area', + 57 + ); + $sections = array( + '' => 'Dashboard', + 'profit' => 'Profit & P&L', + 'orders' => 'Orders', + 'restock' => 'Inventory & Restock', + 'ppc' => 'PPC Manager', + 'keywords' => 'Keywords', + 'listings' => 'Listing Optimizer', + 'monitor' => 'Hijacker & Buy Box', + 'reviews' => 'Review Requests', + 'reimbursements' => 'Reimbursements', + 'research' => 'Product Research', + 'reports' => 'Reports Center', + 'settings' => 'Suite Settings', + ); + foreach ( $sections as $route => $label ) { + add_submenu_page( self::PAGE, $label, $label, 'manage_options', self::PAGE . ( '' === $route ? '' : '#/' . $route ), array( $this, 'render' ) ); + } + } + + public function assets( string $hook ): void { + if ( ! str_contains( $hook, self::PAGE ) ) { + return; + } + wp_enqueue_style( 'sg-suite', SG_COMMERCE_URL . 'assets/css/suite.css', array(), SG_COMMERCE_VERSION ); + wp_enqueue_script( 'sg-suite', SG_COMMERCE_URL . 'assets/js/suite.js', array(), SG_COMMERCE_VERSION, true ); + $mp = $this->container->get( Marketplaces::class ); + $markets = array(); + foreach ( $mp->enabled_codes() as $code ) { + $m = $mp->get( $code ); + $markets[] = array( 'code' => $code, 'currency' => $m['currency'] ?? 'USD', 'country' => $m['country'] ?? $code, 'tld' => $m['tld'] ?? 'com' ); + } + wp_localize_script( 'sg-suite', 'SGSuite', array( + 'root' => esc_url_raw( rest_url( 'sg-commerce/v1/suite' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'markets' => $markets, + 'primary' => $mp->primary(), + 'connected' => $this->container->get( AmazonClient::class )->has_credentials(), + 'ads' => $this->container->get( AdsClient::class )->is_ready(), + 'demo' => DemoSeeder::active(), + 'version' => SG_COMMERCE_VERSION, + 'coreUrl' => admin_url( 'admin.php?page=sg-commerce-amazon' ), + ) ); + } + + public function render(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to access this page.', 'sg-commerce' ) ); + } + require SG_COMMERCE_DIR . 'admin/views/suite.php'; + } + + /* ------------------------------------------------------------------ */ + /* Schedules */ + /* ------------------------------------------------------------------ */ + + private function can_sync(): bool { + return $this->container->get( SuiteSettings::class )->bool( 'suite_sync_enabled' ) + && $this->container->get( AmazonClient::class )->has_credentials(); + } + + public function tick(): void { + $this->guard( 'pipeline', fn() => $this->container->get( ReportPipeline::class )->poll( 8 ) ); + if ( ! $this->can_sync() ) { + return; + } + $this->guard( 'orders', fn() => $this->container->get( OrdersSync::class )->run() ); + if ( $this->container->get( SuiteSettings::class )->bool( 'suite_monitor_enabled' ) ) { + $this->guard( 'monitor', fn() => $this->container->get( ListingMonitor::class )->run() ); + } + } + + public function hourly(): void { + if ( ! $this->can_sync() ) { + return; + } + $this->guard( 'finance', fn() => $this->container->get( FinanceSync::class )->run() ); + $this->guard( 'reviews', fn() => $this->container->get( ReviewRequester::class )->run() ); + } + + public function daily(): void { + $pipeline = $this->container->get( ReportPipeline::class ); + if ( $this->can_sync() ) { + $this->guard( 'reports.daily', fn() => $pipeline->request_scheduled( 'daily' ) ); + $last_weekly = (int) get_option( 'sg_suite_last_weekly', 0 ); + if ( time() - $last_weekly > 6 * DAY_IN_SECONDS ) { + $this->guard( 'reports.weekly', fn() => $pipeline->request_scheduled( 'weekly', $this->sqp_asins() ) ); + update_option( 'sg_suite_last_weekly', time(), false ); + } + $this->guard( 'listings', fn() => $this->audit_batch( 25 ) ); + } + $ads = $this->container->get( AdsClient::class ); + $settings = $this->container->get( SuiteSettings::class ); + if ( $settings->bool( 'suite_ads_enabled' ) && $ads->is_ready() ) { + $sync = $this->container->get( AdsSync::class ); + $this->guard( 'ads.structure', fn() => $sync->sync_structure() ); + $this->guard( 'ads.reports', fn() => $sync->request_reports( gmdate( 'Y-m-d', time() - 8 * DAY_IN_SECONDS ), gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ) ) ); + } + $this->guard( 'reimbursements', fn() => $this->container->get( ReimbursementAuditor::class )->scan() ); + $this->guard( 'prune', function () use ( $pipeline ) { + $pipeline->prune(); + $this->container->get( ListingMonitor::class )->prune(); + return true; + } ); + } + + /** Re-plan PPC once the targeting/search-term data for the day lands. */ + public function after_report( string $type, string $market, int $rows ): void { + if ( ! in_array( $type, array( 'spTargeting', 'spSearchTerm' ), true ) ) { + return; + } + if ( get_transient( 'sg_suite_ads_optimized' ) ) { + return; + } + set_transient( 'sg_suite_ads_optimized', 1, 6 * HOUR_IN_SECONDS ); + $this->guard( 'ads.optimize', fn() => $this->container->get( AdsSync::class )->optimize() ); + } + + /** @return array market => own ASINs */ + public function sqp_asins(): array { + global $wpdb; + $out = array(); + $rows = $wpdb->get_results( "SELECT DISTINCT market, asin FROM {$wpdb->prefix}sg_products WHERE asin <> '' AND sku NOT LIKE 'DEMO-%'", ARRAY_A ) ?: array(); // phpcs:ignore + foreach ( $rows as $r ) { + $out[ (string) $r['market'] ][] = (string) $r['asin']; + } + return $out; + } + + public function audit_batch( int $limit ): int { + global $wpdb; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT p.market, p.sku, p.asin FROM {$wpdb->prefix}sg_products p + LEFT JOIN " . SuiteSchema::table( 'listing_audits' ) . " a ON a.market = p.market AND a.sku = p.sku + WHERE p.sku NOT LIKE %s ORDER BY a.audited_at IS NOT NULL, a.audited_at ASC LIMIT %d", + 'DEMO-%', $limit + ), ARRAY_A ) ?: array(); // phpcs:ignore + $auditor = $this->container->get( ListingAuditor::class ); + $n = 0; + foreach ( $rows as $r ) { + $kw = array_column( Db::rows( 'SELECT keyword FROM {t:keywords} WHERE market = %s AND asin = %s', array( $r['market'], $r['asin'] ) ), 'keyword' ); + try { + $auditor->audit( (string) $r['market'], (string) $r['sku'], $kw ); + $n++; + } catch ( \Throwable $e ) { + $this->container->get( Logger::class )->warning( 'Listing audit failed', array( 'sku' => $r['sku'], 'error' => $e->getMessage() ) ); + if ( str_contains( $e->getMessage(), 'Seller ID' ) ) { + break; + } + } + } + return $n; + } + + /** Run a job, log failures, record last-run status for the Settings screen. */ + public function guard( string $job, callable $fn ): mixed { + $status = get_option( 'sg_suite_job_status', array() ); + $status = is_array( $status ) ? $status : array(); + try { + $result = $fn(); + $status[ $job ] = array( 'at' => time(), 'ok' => true, 'result' => is_scalar( $result ) || is_array( $result ) ? $result : null ); + update_option( 'sg_suite_job_status', $status, false ); + return $result; + } catch ( \Throwable $e ) { + $status[ $job ] = array( 'at' => time(), 'ok' => false, 'error' => mb_substr( $e->getMessage(), 0, 300 ) ); + update_option( 'sg_suite_job_status', $status, false ); + $this->container->get( Logger::class )->error( "Suite job {$job} failed: " . $e->getMessage() ); + return null; + } + } +} diff --git a/sg-commerce-pro/src/Suite/SuiteSchema.php b/sg-commerce-pro/src/Suite/SuiteSchema.php new file mode 100644 index 0000000..0be06d6 --- /dev/null +++ b/sg-commerce-pro/src/Suite/SuiteSchema.php @@ -0,0 +1,538 @@ +prefix . 'sg_suite_' . $short; + } + + public static function maybe_install(): void { + if ( (int) get_option( self::OPTION, 0 ) < self::VERSION ) { + self::install(); + } + } + + public static function install(): void { + global $wpdb; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + $c = $wpdb->get_charset_collate(); + $t = static fn( string $s ): string => self::table( $s ); + + dbDelta( "CREATE TABLE {$t('orders')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + amazon_order_id VARCHAR(32) NOT NULL, + purchase_date DATETIME NOT NULL, + local_date DATE NOT NULL, + last_update DATETIME DEFAULT NULL, + status VARCHAR(32) DEFAULT '' NOT NULL, + fulfillment_channel VARCHAR(8) DEFAULT '' NOT NULL, + sales_channel VARCHAR(64) DEFAULT '' NOT NULL, + order_total DECIMAL(14,4) DEFAULT 0 NOT NULL, + currency VARCHAR(3) DEFAULT '' NOT NULL, + units INT DEFAULT 0 NOT NULL, + is_business TINYINT(1) DEFAULT 0 NOT NULL, + is_prime TINYINT(1) DEFAULT 0 NOT NULL, + ship_country VARCHAR(2) DEFAULT '' NOT NULL, + earliest_delivery DATETIME DEFAULT NULL, + latest_delivery DATETIME DEFAULT NULL, + items_synced TINYINT(1) DEFAULT 0 NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY order_id (amazon_order_id), + KEY market_date (market, local_date), + KEY status (status), + KEY items_synced (items_synced) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('order_items')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + amazon_order_id VARCHAR(32) NOT NULL, + order_item_id VARCHAR(64) NOT NULL, + local_date DATE NOT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + title VARCHAR(255) DEFAULT '' NOT NULL, + qty INT DEFAULT 0 NOT NULL, + item_price DECIMAL(14,4) DEFAULT 0 NOT NULL, + item_tax DECIMAL(14,4) DEFAULT 0 NOT NULL, + promo_discount DECIMAL(14,4) DEFAULT 0 NOT NULL, + status VARCHAR(32) DEFAULT '' NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY order_item (amazon_order_id, order_item_id), + KEY market_sku_date (market, sku, local_date), + KEY local_date (local_date) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('fin_events')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) DEFAULT '' NOT NULL, + event_hash CHAR(40) NOT NULL, + posted_date DATETIME NOT NULL, + local_date DATE NOT NULL, + event_type VARCHAR(48) NOT NULL, + amazon_order_id VARCHAR(32) DEFAULT '' NOT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + charge_type VARCHAR(64) DEFAULT '' NOT NULL, + category VARCHAR(16) NOT NULL, + amount DECIMAL(14,4) DEFAULT 0 NOT NULL, + currency VARCHAR(3) DEFAULT '' NOT NULL, + qty INT DEFAULT 0 NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY event_hash (event_hash), + KEY market_date_cat (market, local_date, category), + KEY order_id (amazon_order_id), + KEY sku_date (sku, local_date) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('reports')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + report_type VARCHAR(96) NOT NULL, + market VARCHAR(2) DEFAULT '' NOT NULL, + source VARCHAR(8) DEFAULT 'sp' NOT NULL, + remote_id VARCHAR(64) DEFAULT '' NOT NULL, + document_id VARCHAR(128) DEFAULT '' NOT NULL, + status VARCHAR(16) DEFAULT 'requested' NOT NULL, + data_start DATETIME DEFAULT NULL, + data_end DATETIME DEFAULT NULL, + options LONGTEXT, + rows_ingested INT DEFAULT 0 NOT NULL, + attempts INT DEFAULT 0 NOT NULL, + error TEXT, + requested_at DATETIME NOT NULL, + completed_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY status (status, requested_at), + KEY type_time (report_type, requested_at) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('sku_costs')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + sku VARCHAR(64) NOT NULL, + unit_cost DECIMAL(14,4) DEFAULT 0 NOT NULL, + inbound_per_unit DECIMAL(14,4) DEFAULT 0 NOT NULL, + prep_per_unit DECIMAL(14,4) DEFAULT 0 NOT NULL, + other_per_unit DECIMAL(14,4) DEFAULT 0 NOT NULL, + lead_time_days INT DEFAULT 0 NOT NULL, + moq INT DEFAULT 0 NOT NULL, + case_pack INT DEFAULT 0 NOT NULL, + supplier VARCHAR(128) DEFAULT '' NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY market_sku (market, sku) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('expenses')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) DEFAULT '' NOT NULL, + label VARCHAR(128) NOT NULL, + category VARCHAR(32) DEFAULT 'other' NOT NULL, + amount DECIMAL(14,4) DEFAULT 0 NOT NULL, + recurrence VARCHAR(8) DEFAULT 'once' NOT NULL, + start_date DATE NOT NULL, + end_date DATE DEFAULT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + created_at DATETIME NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + KEY start_date (start_date) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('ads_campaigns')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + profile_id VARCHAR(32) NOT NULL, + campaign_id VARCHAR(32) NOT NULL, + name VARCHAR(255) DEFAULT '' NOT NULL, + state VARCHAR(16) DEFAULT '' NOT NULL, + targeting_type VARCHAR(16) DEFAULT '' NOT NULL, + daily_budget DECIMAL(14,4) DEFAULT 0 NOT NULL, + bidding_strategy VARCHAR(48) DEFAULT '' NOT NULL, + start_date DATE DEFAULT NULL, + updated_at DATETIME NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY campaign (profile_id, campaign_id) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('ads_targets')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + profile_id VARCHAR(32) NOT NULL, + target_id VARCHAR(32) NOT NULL, + kind VARCHAR(8) DEFAULT 'keyword' NOT NULL, + campaign_id VARCHAR(32) NOT NULL, + ad_group_id VARCHAR(32) DEFAULT '' NOT NULL, + keyword_text VARCHAR(255) DEFAULT '' NOT NULL, + match_type VARCHAR(32) DEFAULT '' NOT NULL, + state VARCHAR(16) DEFAULT '' NOT NULL, + bid DECIMAL(10,2) DEFAULT NULL, + updated_at DATETIME NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY target (profile_id, target_id), + KEY campaign (campaign_id) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('ads_daily')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + profile_id VARCHAR(32) NOT NULL, + report_date DATE NOT NULL, + level VARCHAR(8) NOT NULL, + campaign_id VARCHAR(32) NOT NULL, + ad_group_id VARCHAR(32) DEFAULT '' NOT NULL, + target_id VARCHAR(32) DEFAULT '' NOT NULL, + keyword_text VARCHAR(255) DEFAULT '' NOT NULL, + match_type VARCHAR(32) DEFAULT '' NOT NULL, + impressions INT DEFAULT 0 NOT NULL, + clicks INT DEFAULT 0 NOT NULL, + cost DECIMAL(14,4) DEFAULT 0 NOT NULL, + sales DECIMAL(14,4) DEFAULT 0 NOT NULL, + orders INT DEFAULT 0 NOT NULL, + units INT DEFAULT 0 NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY row_key (profile_id, report_date, level, campaign_id, target_id), + KEY date_level (report_date, level) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('ads_search_terms')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + profile_id VARCHAR(32) NOT NULL, + row_hash CHAR(40) NOT NULL, + report_date DATE NOT NULL, + campaign_id VARCHAR(32) NOT NULL, + ad_group_id VARCHAR(32) DEFAULT '' NOT NULL, + target_id VARCHAR(32) DEFAULT '' NOT NULL, + keyword_text VARCHAR(255) DEFAULT '' NOT NULL, + match_type VARCHAR(32) DEFAULT '' NOT NULL, + search_term VARCHAR(255) NOT NULL, + impressions INT DEFAULT 0 NOT NULL, + clicks INT DEFAULT 0 NOT NULL, + cost DECIMAL(14,4) DEFAULT 0 NOT NULL, + sales DECIMAL(14,4) DEFAULT 0 NOT NULL, + orders INT DEFAULT 0 NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY row_hash (row_hash), + KEY report_date (report_date), + KEY term (search_term(64)) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('ads_actions')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + profile_id VARCHAR(32) DEFAULT '' NOT NULL, + kind VARCHAR(16) NOT NULL, + entity_id VARCHAR(64) DEFAULT '' NOT NULL, + campaign_id VARCHAR(32) DEFAULT '' NOT NULL, + ad_group_id VARCHAR(32) DEFAULT '' NOT NULL, + label VARCHAR(255) DEFAULT '' NOT NULL, + old_value VARCHAR(64) DEFAULT '' NOT NULL, + new_value VARCHAR(64) DEFAULT '' NOT NULL, + reason TEXT, + payload LONGTEXT, + status VARCHAR(12) DEFAULT 'planned' NOT NULL, + error TEXT, + created_at DATETIME NOT NULL, + applied_at DATETIME DEFAULT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + KEY status (status, created_at), + KEY kind (kind) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('keywords')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + asin VARCHAR(20) NOT NULL, + keyword VARCHAR(190) NOT NULL, + priority TINYINT DEFAULT 2 NOT NULL, + source VARCHAR(16) DEFAULT 'manual' NOT NULL, + created_at DATETIME NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY market_asin_kw (market, asin, keyword), + KEY asin (asin) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('keyword_metrics')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + asin VARCHAR(20) NOT NULL, + keyword VARCHAR(190) NOT NULL, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + query_score INT DEFAULT 0 NOT NULL, + query_volume INT DEFAULT 0 NOT NULL, + impressions_total INT DEFAULT 0 NOT NULL, + impressions_asin INT DEFAULT 0 NOT NULL, + clicks_total INT DEFAULT 0 NOT NULL, + clicks_asin INT DEFAULT 0 NOT NULL, + cart_adds_total INT DEFAULT 0 NOT NULL, + cart_adds_asin INT DEFAULT 0 NOT NULL, + purchases_total INT DEFAULT 0 NOT NULL, + purchases_asin INT DEFAULT 0 NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY metric_key (market, asin, keyword, period_start), + KEY period (period_start) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('asin_snapshots')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + asin VARCHAR(20) NOT NULL, + captured_at DATETIME NOT NULL, + is_mine TINYINT(1) DEFAULT 0 NOT NULL, + my_price DECIMAL(14,4) DEFAULT NULL, + buybox_price DECIMAL(14,4) DEFAULT NULL, + buybox_seller VARCHAR(32) DEFAULT '' NOT NULL, + buybox_is_mine TINYINT(1) DEFAULT 0 NOT NULL, + lowest_price DECIMAL(14,4) DEFAULT NULL, + offer_count INT DEFAULT 0 NOT NULL, + fba_offer_count INT DEFAULT 0 NOT NULL, + bsr INT DEFAULT NULL, + bsr_category VARCHAR(128) DEFAULT '' NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + KEY asin_time (market, asin, captured_at), + KEY captured (captured_at) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('offer_sellers')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + asin VARCHAR(20) NOT NULL, + seller_id VARCHAR(32) NOT NULL, + is_me TINYINT(1) DEFAULT 0 NOT NULL, + is_fba TINYINT(1) DEFAULT 0 NOT NULL, + last_price DECIMAL(14,4) DEFAULT NULL, + first_seen DATETIME NOT NULL, + last_seen DATETIME NOT NULL, + active TINYINT(1) DEFAULT 1 NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY seller_asin (market, asin, seller_id), + KEY active (active, last_seen) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('solicitations')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + amazon_order_id VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + message VARCHAR(255) DEFAULT '' NOT NULL, + attempted_at DATETIME NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY order_id (amazon_order_id), + KEY status_time (status, attempted_at) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('reimb_cases')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + case_hash CHAR(40) NOT NULL, + kind VARCHAR(32) NOT NULL, + market VARCHAR(2) DEFAULT '' NOT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + fnsku VARCHAR(20) DEFAULT '' NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + reference_id VARCHAR(64) DEFAULT '' NOT NULL, + event_date DATE DEFAULT NULL, + qty INT DEFAULT 0 NOT NULL, + est_amount DECIMAL(14,4) DEFAULT 0 NOT NULL, + currency VARCHAR(3) DEFAULT '' NOT NULL, + status VARCHAR(16) DEFAULT 'open' NOT NULL, + amazon_case_id VARCHAR(32) DEFAULT '' NOT NULL, + evidence LONGTEXT, + notes TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY case_hash (case_hash), + KEY status (status, kind) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('ledger')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + row_hash CHAR(40) NOT NULL, + market VARCHAR(2) DEFAULT '' NOT NULL, + event_date DATE NOT NULL, + fnsku VARCHAR(20) DEFAULT '' NOT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + event_type VARCHAR(32) DEFAULT '' NOT NULL, + reference_id VARCHAR(64) DEFAULT '' NOT NULL, + qty INT DEFAULT 0 NOT NULL, + fulfillment_center VARCHAR(16) DEFAULT '' NOT NULL, + disposition VARCHAR(32) DEFAULT '' NOT NULL, + reason VARCHAR(16) DEFAULT '' NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY row_hash (row_hash), + KEY fnsku_date (fnsku, event_date), + KEY type_reason (event_type, reason) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('reimbursements')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + reimbursement_id VARCHAR(32) NOT NULL, + market VARCHAR(2) DEFAULT '' NOT NULL, + approval_date DATE NOT NULL, + case_id VARCHAR(32) DEFAULT '' NOT NULL, + amazon_order_id VARCHAR(32) DEFAULT '' NOT NULL, + reason VARCHAR(64) DEFAULT '' NOT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + fnsku VARCHAR(20) DEFAULT '' NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + amount DECIMAL(14,4) DEFAULT 0 NOT NULL, + currency VARCHAR(3) DEFAULT '' NOT NULL, + qty_cash INT DEFAULT 0 NOT NULL, + qty_inventory INT DEFAULT 0 NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY reimb (reimbursement_id, fnsku), + KEY order_id (amazon_order_id), + KEY approval (approval_date) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('returns')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + row_hash CHAR(40) NOT NULL, + market VARCHAR(2) DEFAULT '' NOT NULL, + return_date DATE NOT NULL, + amazon_order_id VARCHAR(32) DEFAULT '' NOT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + fnsku VARCHAR(20) DEFAULT '' NOT NULL, + qty INT DEFAULT 0 NOT NULL, + fulfillment_center VARCHAR(16) DEFAULT '' NOT NULL, + disposition VARCHAR(32) DEFAULT '' NOT NULL, + reason VARCHAR(64) DEFAULT '' NOT NULL, + status VARCHAR(48) DEFAULT '' NOT NULL, + comments VARCHAR(500) DEFAULT '' NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY row_hash (row_hash), + KEY order_id (amazon_order_id), + KEY sku_date (sku, return_date) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('traffic_daily')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + report_date DATE NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + sku VARCHAR(64) DEFAULT '' NOT NULL, + sessions INT DEFAULT 0 NOT NULL, + page_views INT DEFAULT 0 NOT NULL, + buy_box_pct DECIMAL(6,2) DEFAULT 0 NOT NULL, + units INT DEFAULT 0 NOT NULL, + order_items INT DEFAULT 0 NOT NULL, + ordered_sales DECIMAL(14,4) DEFAULT 0 NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY day_asin (market, report_date, asin), + KEY report_date (report_date) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('listing_audits')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + sku VARCHAR(64) NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + score INT DEFAULT 0 NOT NULL, + status VARCHAR(32) DEFAULT '' NOT NULL, + content_hash CHAR(40) DEFAULT '' NOT NULL, + findings LONGTEXT, + snapshot LONGTEXT, + audited_at DATETIME NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY market_sku (market, sku) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('inventory_health')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + market VARCHAR(2) NOT NULL, + snapshot_date DATE NOT NULL, + sku VARCHAR(64) NOT NULL, + asin VARCHAR(20) DEFAULT '' NOT NULL, + available INT DEFAULT 0 NOT NULL, + age_0_90 INT DEFAULT 0 NOT NULL, + age_91_180 INT DEFAULT 0 NOT NULL, + age_181_270 INT DEFAULT 0 NOT NULL, + age_271_365 INT DEFAULT 0 NOT NULL, + age_365_plus INT DEFAULT 0 NOT NULL, + units_t30 INT DEFAULT 0 NOT NULL, + sell_through DECIMAL(8,2) DEFAULT NULL, + days_of_supply INT DEFAULT NULL, + est_storage_cost DECIMAL(14,4) DEFAULT NULL, + recommended_action VARCHAR(64) DEFAULT '' NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY snap_sku (market, snapshot_date, sku) + ) {$c};" ); + + dbDelta( "CREATE TABLE {$t('feedback')} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + row_hash CHAR(40) NOT NULL, + market VARCHAR(2) DEFAULT '' NOT NULL, + feedback_date DATE NOT NULL, + rating TINYINT DEFAULT 0 NOT NULL, + comments TEXT, + amazon_order_id VARCHAR(32) DEFAULT '' NOT NULL, + is_demo TINYINT(1) DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY row_hash (row_hash), + KEY rating_date (rating, feedback_date) + ) {$c};" ); + + update_option( self::OPTION, self::VERSION, false ); + } + + /** Drop every suite table (used by uninstall + tests). */ + public static function drop_all(): void { + global $wpdb; + foreach ( self::TABLES as $short ) { + $wpdb->query( 'DROP TABLE IF EXISTS ' . self::table( $short ) ); // phpcs:ignore WordPress.DB.PreparedSQL + } + delete_option( self::OPTION ); + } +} diff --git a/sg-commerce-pro/src/Suite/SuiteSettings.php b/sg-commerce-pro/src/Suite/SuiteSettings.php new file mode 100644 index 0000000..02f7a51 --- /dev/null +++ b/sg-commerce-pro/src/Suite/SuiteSettings.php @@ -0,0 +1,152 @@ + [type, default] */ + public const SCHEMA = array( + // Sync + 'suite_sync_enabled' => array( 'bool', true ), + 'suite_backfill_days' => array( 'int', 60 ), + 'suite_order_items_per_run' => array( 'int', 25 ), + // Profit + 'suite_default_unit_cost' => array( 'float', 0.0 ), + 'suite_include_tax' => array( 'bool', false ), + // Restock + 'suite_lead_time_days' => array( 'int', 30 ), + 'suite_safety_days' => array( 'int', 14 ), + 'suite_target_cover_days' => array( 'int', 60 ), + 'suite_overstock_days' => array( 'int', 180 ), + // Ads + 'suite_ads_enabled' => array( 'bool', false ), + 'suite_ads_region' => array( 'string', 'na' ), + 'suite_ads_profile_id' => array( 'string', '' ), + 'suite_ads_target_acos' => array( 'float', 30.0 ), + 'suite_ads_lookback_days' => array( 'int', 14 ), + 'suite_ads_min_clicks' => array( 'int', 12 ), + 'suite_ads_max_step_pct' => array( 'float', 20.0 ), + 'suite_ads_min_bid' => array( 'float', 0.20 ), + 'suite_ads_max_bid' => array( 'float', 4.00 ), + 'suite_ads_raise_low_impr' => array( 'bool', true ), + 'suite_ads_neg_min_clicks' => array( 'int', 15 ), + 'suite_ads_harvest_min_orders'=> array( 'int', 2 ), + 'suite_ads_harvest_ad_group' => array( 'string', '' ), + 'suite_ads_harvest_campaign' => array( 'string', '' ), + 'suite_ads_auto_apply' => array( 'bool', false ), + // Reviews + 'suite_reviews_enabled' => array( 'bool', false ), + 'suite_reviews_delay_days' => array( 'int', 7 ), + 'suite_reviews_daily_cap' => array( 'int', 150 ), + 'suite_reviews_skip_refunded' => array( 'bool', true ), + // Monitor + 'suite_monitor_enabled' => array( 'bool', true ), + 'suite_monitor_price_drop_pct'=> array( 'float', 10.0 ), + 'suite_monitor_extra_asins' => array( 'string', '' ), + // Alerts + 'suite_alert_email' => array( 'string', '' ), + 'suite_alert_email_levels' => array( 'string', 'critical' ), + ); + + public function __construct( private SettingsRepository $repo ) {} + + public function get( string $key ): mixed { + if ( ! isset( self::SCHEMA[ $key ] ) ) { + return null; + } + [ $type, $default ] = self::SCHEMA[ $key ]; + return self::cast( $type, $this->repo->get( $key, $default ) ); + } + + public function int( string $key ): int { return (int) $this->get( $key ); } + public function float( string $key ): float { return (float) $this->get( $key ); } + public function bool( string $key ): bool { return (bool) $this->get( $key ); } + public function string( string $key ): string { return (string) $this->get( $key ); } + + /** All public settings + secret presence flags (safe for REST). */ + public function export(): array { + $out = array(); + foreach ( array_keys( self::SCHEMA ) as $k ) { + $out[ $k ] = $this->get( $k ); + } + foreach ( self::SECRETS as $s ) { + $out[ 'has_' . $s ] = $this->has_secret( $s ); + } + $out['seller_id_set'] = '' !== (string) $this->repo->get( 'amazon_seller_id', '' ); + return $out; + } + + /** + * Persist from untrusted input. Unknown keys are ignored; values are + * cast to their declared type. Empty secrets keep the stored value. + * + * @return string[] keys saved + */ + public function save( array $input ): array { + $values = array(); + foreach ( self::SCHEMA as $k => [ $type ] ) { + if ( array_key_exists( $k, $input ) ) { + $v = self::cast( $type, $input[ $k ] ); + if ( 'string' === $type ) { + $v = sanitize_text_field( (string) $v ); + } + $values[ $k ] = $v; + } + } + if ( $values ) { + $this->repo->update_many( $values ); + } + $saved = array_keys( $values ); + foreach ( self::SECRETS as $s ) { + $v = isset( $input[ $s ] ) ? trim( (string) $input[ $s ] ) : ''; + if ( '' !== $v ) { + $this->repo->set( $s, $v ); + $saved[] = $s; + } + } + if ( isset( $input['amazon_seller_id'] ) && '' !== trim( (string) $input['amazon_seller_id'] ) ) { + $this->repo->set( 'amazon_seller_id', sanitize_text_field( (string) $input['amazon_seller_id'] ) ); + $saved[] = 'amazon_seller_id'; + } + return $saved; + } + + public function secret( string $key ): string { + return (string) $this->repo->get( $key, '' ); + } + + public function has_secret( string $key ): bool { + return $this->repo->has( $key ); + } + + public function seller_id(): string { + return (string) $this->repo->get( 'amazon_seller_id', '' ); + } + + private static function cast( string $type, mixed $v ): mixed { + return match ( $type ) { + 'int' => (int) $v, + 'float' => (float) $v, + 'bool' => is_string( $v ) ? in_array( strtolower( $v ), array( '1', 'true', 'yes', 'on' ), true ) : (bool) $v, + default => is_scalar( $v ) ? (string) $v : '', + }; + } +} diff --git a/sg-commerce-pro/src/Suite/Support/Db.php b/sg-commerce-pro/src/Suite/Support/Db.php new file mode 100644 index 0000000..5abb4ac --- /dev/null +++ b/sg-commerce-pro/src/Suite/Support/Db.php @@ -0,0 +1,136 @@ +replace(). + * + * @package SevenGum\Commerce\Suite\Support + */ + +declare( strict_types = 1 ); + +namespace SevenGum\Commerce\Suite\Support; + +use SevenGum\Commerce\Suite\SuiteSchema; + +defined( 'ABSPATH' ) || exit; + +final class Db { + + /** + * @param string $short Suite table short name. + * @param array $row Column => value. + * @param string[] $keep Columns NOT overwritten on conflict. + */ + public static function upsert( string $short, array $row, array $keep = array() ): bool { + global $wpdb; + if ( ! $row ) { + return false; + } + $table = SuiteSchema::table( $short ); + $cols = array_keys( $row ); + $ph = array(); + $values = array(); + foreach ( $row as $v ) { + if ( null === $v ) { + $ph[] = 'NULL'; + continue; + } + if ( is_int( $v ) || is_bool( $v ) ) { + $ph[] = '%d'; + $values[] = (int) $v; + } elseif ( is_float( $v ) ) { + $ph[] = '%s'; + $values[] = self::num( $v ); + } else { + $ph[] = '%s'; + $values[] = (string) $v; + } + } + $updates = array(); + foreach ( $cols as $col ) { + if ( ! in_array( $col, $keep, true ) ) { + $updates[] = "`{$col}` = VALUES(`{$col}`)"; + } + } + $sql = "INSERT INTO {$table} (`" . implode( '`,`', $cols ) . '`) VALUES (' . implode( ',', $ph ) . ')'; + if ( $updates ) { + $sql .= ' ON DUPLICATE KEY UPDATE ' . implode( ', ', $updates ); + } + $prepared = $values ? $wpdb->prepare( $sql, $values ) : $sql; // phpcs:ignore WordPress.DB.PreparedSQL + return false !== $wpdb->query( $prepared ); // phpcs:ignore WordPress.DB.PreparedSQL + } + + /** Upsert many rows; returns count written. */ + public static function upsert_many( string $short, array $rows, array $keep = array() ): int { + $n = 0; + foreach ( $rows as $row ) { + if ( self::upsert( $short, $row, $keep ) ) { + $n++; + } + } + return $n; + } + + /** Prepared SELECT returning ARRAY_A rows. `{t:name}` expands to a suite table. */ + public static function rows( string $sql, array $args = array() ): array { + global $wpdb; + $sql = self::expand( $sql ); + $res = $wpdb->get_results( $args ? $wpdb->prepare( $sql, $args ) : $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL + return is_array( $res ) ? $res : array(); + } + + public static function row( string $sql, array $args = array() ): ?array { + $rows = self::rows( $sql, $args ); + return $rows[0] ?? null; + } + + public static function var( string $sql, array $args = array() ): mixed { + global $wpdb; + $sql = self::expand( $sql ); + return $wpdb->get_var( $args ? $wpdb->prepare( $sql, $args ) : $sql ); // phpcs:ignore WordPress.DB.PreparedSQL + } + + public static function exec( string $sql, array $args = array() ): int { + global $wpdb; + $sql = self::expand( $sql ); + $res = $wpdb->query( $args ? $wpdb->prepare( $sql, $args ) : $sql ); // phpcs:ignore WordPress.DB.PreparedSQL + return is_int( $res ) ? $res : 0; + } + + public static function insert( string $short, array $row ): int { + global $wpdb; + $wpdb->insert( SuiteSchema::table( $short ), $row ); + return (int) $wpdb->insert_id; + } + + public static function update( string $short, array $data, array $where ): int { + global $wpdb; + $res = $wpdb->update( SuiteSchema::table( $short ), $data, $where ); + return is_int( $res ) ? $res : 0; + } + + /** Replace `{t:orders}` placeholders with real table names. */ + public static function expand( string $sql ): string { + return (string) preg_replace_callback( + '/\{t:([a-z_]+)\}/', + static fn( array $m ): string => SuiteSchema::table( $m[1] ), + $sql + ); + } + + /** Placeholder list for IN (...) clauses. */ + public static function in( array $values, string $ph = '%s' ): string { + return implode( ',', array_fill( 0, max( 1, count( $values ) ), $ph ) ); + } + + public static function num( float $v ): string { + return number_format( $v, 4, '.', '' ); + } + + public static function now(): string { + return gmdate( 'Y-m-d H:i:s' ); + } +} diff --git a/sg-commerce-pro/src/Suite/Support/SuiteAlerts.php b/sg-commerce-pro/src/Suite/Support/SuiteAlerts.php new file mode 100644 index 0000000..6db3316 --- /dev/null +++ b/sg-commerce-pro/src/Suite/Support/SuiteAlerts.php @@ -0,0 +1,47 @@ + 'America/Los_Angeles', 'CA' => 'America/Los_Angeles', 'MX' => 'America/Mexico_City', + 'BR' => 'America/Sao_Paulo', 'UK' => 'Europe/London', 'IE' => 'Europe/Dublin', + 'DE' => 'Europe/Berlin', 'FR' => 'Europe/Paris', 'IT' => 'Europe/Rome', 'ES' => 'Europe/Madrid', + 'NL' => 'Europe/Amsterdam', 'BE' => 'Europe/Brussels', 'SE' => 'Europe/Stockholm', + 'PL' => 'Europe/Warsaw', 'TR' => 'Europe/Istanbul', 'AE' => 'Asia/Dubai', 'SA' => 'Asia/Riyadh', + 'IN' => 'Asia/Kolkata', 'JP' => 'Asia/Tokyo', 'AU' => 'Australia/Sydney', 'SG' => 'Asia/Singapore', + ); + + public static function tz( string $market ): \DateTimeZone { + return new \DateTimeZone( self::TZ[ strtoupper( $market ) ] ?? 'UTC' ); + } + + /** ISO-8601 / MySQL UTC timestamp → 'Y-m-d' in marketplace time. */ + public static function local_date( string $utc, string $market ): string { + try { + $dt = new \DateTimeImmutable( '' === $utc ? 'now' : $utc, new \DateTimeZone( 'UTC' ) ); + } catch ( \Exception ) { + $dt = new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) ); + } + return $dt->setTimezone( self::tz( $market ) )->format( 'Y-m-d' ); + } + + /** ISO-8601 → MySQL DATETIME in UTC. */ + public static function to_mysql( ?string $iso ): ?string { + if ( null === $iso || '' === $iso ) { + return null; + } + try { + return ( new \DateTimeImmutable( $iso ) )->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); + } catch ( \Exception ) { + return null; + } + } + + /** Today's date in the marketplace timezone. */ + public static function today( string $market ): string { + return ( new \DateTimeImmutable( 'now', self::tz( $market ) ) )->format( 'Y-m-d' ); + } + + /** Shift a Y-m-d date by N days. */ + public static function shift( string $ymd, int $days ): string { + return ( new \DateTimeImmutable( $ymd ) )->modify( sprintf( '%+d days', $days ) )->format( 'Y-m-d' ); + } + + /** Inclusive day count between two Y-m-d dates. */ + public static function days_between( string $from, string $to ): int { + $a = new \DateTimeImmutable( $from ); + $b = new \DateTimeImmutable( $to ); + return (int) $a->diff( $b )->days + 1; + } + + /** Validate Y-m-d; fall back to $default. */ + public static function ymd( mixed $value, string $default ): string { + $v = is_string( $value ) ? $value : ''; + return 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $v ) ? $v : $default; + } +} diff --git a/sg-commerce-pro/src/Testing/Sandbox.php b/sg-commerce-pro/src/Testing/Sandbox.php new file mode 100644 index 0000000..e69bb9f --- /dev/null +++ b/sg-commerce-pro/src/Testing/Sandbox.php @@ -0,0 +1,162 @@ + array( + array( + 'marketplace' => array( + 'id' => 'ATVPDKIKX0DER', + 'name' => 'Amazon.com', + 'countryCode' => 'US', + 'domainName' => 'www.amazon.com', + ), + 'participation' => array( + 'isParticipating' => true, + 'hasSuspendedListings' => false, + ), + ), + ), + ); + } + + // FBA inventory. + if ( str_contains( $path, '/fba/inventory/v1/summaries' ) ) { + return array( + 'payload' => array( + 'inventorySummaries' => self::mock_inventory_rows(), + ), + ); + } + + // Competitive pricing. + if ( str_contains( $path, '/products/pricing/v0/items/' ) && str_contains( $path, '/offers' ) ) { + return array( + 'payload' => self::mock_offers_response(), + ); + } + + // MCF preview. + if ( str_contains( $path, '/mfn/v0/fulfillmentOrders/preview' ) ) { + return array( + 'payload' => array( + 'fulfillmentPreviews' => array( + array( + 'isFulfillable' => true, + 'marketplaceId' => 'ATVPDKIKX0DER', + 'shippingSpeedCategory' => 'Standard', + 'estimatedFees' => array( + array( 'name' => 'FBAPerUnitFulfillmentFee', + 'amount' => array( 'value' => 3.22, 'currencyCode' => 'USD' ) ), + ), + ), + ), + ), + ); + } + + // MCF create. + if ( 'POST' === $method && str_contains( $path, '/mfn/v0/fulfillmentOrders' ) && ! str_contains( $path, 'preview' ) ) { + return array( 'payload' => array() ); + } + + // MCF get. + if ( 'GET' === $method && str_contains( $path, '/mfn/v0/fulfillmentOrders/' ) ) { + return array( + 'payload' => array( + 'fulfillmentOrder' => array( + 'fulfillmentOrderStatus' => 'COMPLETE', + ), + 'fulfillmentShipments' => array( + array( + 'shippingDate' => gmdate( 'c', time() - DAY_IN_SECONDS ), + 'estimatedArrivalDate' => gmdate( 'c', time() + DAY_IN_SECONDS ), + 'fulfillmentShipmentPackage' => array( + array( 'trackingNumber' => 'SANDBOX-TRK-' . mt_rand( 10000, 99999 ), + 'carrierCode' => 'UPS' ), + ), + ), + ), + ), + ); + } + + // Default empty. + return array( 'payload' => array() ); + } + + private static function mock_inventory_rows(): array { + $flavors = array( 'cookie', 'mint', 'cinnamon', 'ginger', 'blueberry', 'pineapple' ); + $out = array(); + foreach ( $flavors as $i => $f ) { + $out[] = array( + 'asin' => 'B0SG' . str_pad( (string) ( $i * 111 ), 6, '0', STR_PAD_LEFT ), + 'sellerSku' => 'SG-' . strtoupper( $f ) . '-01', + 'productName' => 'Seven Gum ' . ucfirst( $f ) . ' 30-pc', + 'totalQuantity' => 50 + ( $i * 10 ), + 'fulfillableQuantity' => 40 + ( $i * 8 ), + ); + } + return $out; + } + + private static function mock_offers_response(): array { + return array( + 'Summary' => array( + 'BuyBoxPrices' => array( + array( + 'ListingPrice' => array( 'Amount' => 4.99, 'CurrencyCode' => 'USD' ), + ), + ), + 'NumberOfOffers' => array( array( 'OfferCount' => 3 ) ), + ), + 'Offers' => array( + array( + 'SellerId' => 'A1SELLER', + 'IsBuyBoxWinner' => true, + 'ListingPrice' => array( 'Amount' => 4.99, 'CurrencyCode' => 'USD' ), + ), + ), + ); + } +} diff --git a/sg-commerce-pro/src/Widget/Shortcode.php b/sg-commerce-pro/src/Widget/Shortcode.php new file mode 100644 index 0000000..727d64b --- /dev/null +++ b/sg-commerce-pro/src/Widget/Shortcode.php @@ -0,0 +1,156 @@ + '', + 'market' => 'auto', // Geotargeting module resolves 'auto' to visitor's market. + 'show' => 'image,name,desc,price,stock,cta', + 'style' => 'card', + ), + is_array( $atts ) ? $atts : array(), + 'sg_product' + ); + + $sku = sanitize_text_field( (string) $atts['sku'] ); + $market = strtoupper( sanitize_text_field( (string) $atts['market'] ) ); + if ( 'AUTO' === $market || '' === $market ) { + // Geotargeting filter didn't resolve (module disabled) — fall back to primary. + $market = $this->marketplaces->primary(); + } + + if ( '' === $sku ) { + return '
[sg_product] requires a sku attribute.
'; + } + if ( ! $this->marketplaces->exists( $market ) ) { + return sprintf( + '
Invalid market code: %s
', + esc_html( $market ) + ); + } + + $data = $this->fetch( $sku, $market ); + if ( null === $data ) { + return sprintf( + '
Product %s not found in our catalog.
', + esc_html( $sku ) + ); + } + + return $this->render_html( $data, $atts ); + } + + private function fetch( string $sku, string $market ): ?array { + $key = $market . '|' . $sku; + if ( array_key_exists( $key, $this->cache ) ) { + return $this->cache[ $key ]; + } + $row = $this->products->find_by_sku( $market, $sku ); + if ( null === $row ) { + $this->cache[ $key ] = null; + return null; + } + $mp = $this->marketplaces->get( $market ); + $lang = (string) ( $mp['language'] ?? 'en' ); + + // Prefer the localized AI description, fall back to English. + $description = $this->copywriter->find_description( (int) $row['id'], $lang ) + ?? $this->copywriter->find_description( (int) $row['id'], 'en' ) + ?? ''; + + $data = array( + 'id' => (int) $row['id'], + 'sku' => (string) $row['sku'], + 'asin' => (string) $row['asin'], + 'market' => (string) $row['market'], + 'name' => (string) $row['product_name'], + 'image_url' => (string) $row['image_url'], + 'stock' => (int) $row['fulfillable_qty'], + 'in_stock' => (int) $row['fulfillable_qty'] > 0, + 'price' => null !== $row['buybox_price'] ? (float) $row['buybox_price'] : null, + 'currency' => (string) $row['buybox_currency'] ?: ( $mp['currency'] ?? '' ), + 'description' => $description, + 'amazon_url' => '' !== $row['asin'] ? $this->marketplaces->product_url( (string) $row['asin'], $market ) : '', + ); + $this->cache[ $key ] = $data; + return $data; + } + + private function render_html( array $d, array $atts ): string { + $show = array_map( 'trim', explode( ',', (string) $atts['show'] ) ); + $style = in_array( $atts['style'], array( 'default', 'compact', 'card' ), true ) ? $atts['style'] : 'card'; + + ob_start(); + ?> +
+ + +
+ <?php echo esc_attr( $d['name'] ); ?> +
+ + +
+ +

+ + + +

+ + +
+ + + + + + + + + + + + +
+ + + + + + +
+
+ container; + + $c->singleton( Shortcode::class, static fn( Container $c ) => + new Shortcode( + $c->get( ProductRepository::class ), + $c->get( Marketplaces::class ), + $c->get( Copywriter::class ) + ) + ); + + add_shortcode( 'sg_product', function ( $atts ): string { + return $this->container->get( Shortcode::class )->render( $atts ); + } ); + + add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_assets' ) ); + } + + public function maybe_enqueue_assets(): void { + global $post; + // Only load on pages containing our shortcode. + if ( ! $post || ! has_shortcode( (string) $post->post_content, 'sg_product' ) ) { + return; + } + wp_enqueue_style( + 'sg-commerce-widget', + SG_COMMERCE_URL . 'assets/css/widget.css', + array(), + SG_COMMERCE_VERSION + ); + wp_enqueue_script( + 'sg-commerce-widget', + SG_COMMERCE_URL . 'assets/js/widget.js', + array(), + SG_COMMERCE_VERSION, + true + ); + wp_localize_script( + 'sg-commerce-widget', + 'SG_COMMERCE', + array( 'rest' => esc_url_raw( rest_url( 'sg-commerce/v1/' ) ) ) + ); + } +} diff --git a/sg-commerce-pro/tests/integration-smoke.php b/sg-commerce-pro/tests/integration-smoke.php new file mode 100644 index 0000000..fd31119 --- /dev/null +++ b/sg-commerce-pro/tests/integration-smoke.php @@ -0,0 +1,161 @@ + 'administrator', 'number' => 1 ) ); +wp_set_current_user( $admins[0]->ID ); + +$fail = 0; +$call = static function ( string $method, string $route, array $params = array() ) use ( &$fail ) { + $req = new WP_REST_Request( $method, '/sg-commerce/v1/suite' . $route ); + if ( 'GET' === $method ) { + $req->set_query_params( $params ); + } else { + $req->set_header( 'content-type', 'application/json' ); + $req->set_body( wp_json_encode( $params ) ); + } + $t = microtime( true ); + $res = rest_do_request( $req ); + $ms = (int) ( ( microtime( true ) - $t ) * 1000 ); + $ok = $res->get_status() < 300; + if ( ! $ok ) { + $fail++; + } + printf( "%-4s %-34s %d %5dms %s\n", $method, $route, $res->get_status(), $ms, $ok ? '' : wp_json_encode( $res->get_data() ) ); + return $res->get_data(); +}; +$assert = static function ( bool $cond, string $msg ) use ( &$fail ) { + if ( ! $cond ) { + $fail++; + echo "ASSERT FAILED: {$msg}\n"; + } +}; + +$seed = $call( 'POST', '/demo', array() ); +echo 'seed counts: ' . wp_json_encode( $seed['counts'] ?? array() ) . "\n"; +$assert( ( $seed['counts']['orders'] ?? 0 ) > 500, 'demo orders seeded' ); + +$o = $call( 'GET', '/overview' ); +$assert( isset( $o['periods']['mtd']['sales'] ), 'overview has MTD sales' ); +$assert( ( $o['totals_30']['sales'] ?? 0 ) > 0, '30-day sales > 0' ); +$assert( ( $o['totals_30']['amazon_fees'] ?? 0 ) < 0, 'fees negative' ); +$assert( ( $o['totals_30']['cogs'] ?? 0 ) < 0, 'cogs negative' ); +echo 'overview 30d: ' . wp_json_encode( array_intersect_key( $o['totals_30'], array_flip( array( 'orders', 'units', 'sales', 'refunds', 'amazon_fees', 'fees_estimated', 'account_fees', 'ads', 'cogs', 'expenses', 'net_profit', 'margin_pct', 'roi_pct', 'tacos_pct' ) ) ) ) . "\n"; +echo 'forecast: ' . wp_json_encode( $o['periods']['forecast'] ) . "\n"; + +$p = $call( 'GET', '/profit', array( 'from' => gmdate( 'Y-m-d', strtotime( '-60 days' ) ) ) ); +$assert( count( $p['skus'] ?? array() ) >= 18, 'profit by sku rows' ); +$assert( count( $p['series'] ?? array() ) >= 60, 'profit daily series' ); +$assert( ! empty( $p['fee_breakdown'] ), 'fee breakdown' ); + +$ord = $call( 'GET', '/orders' ); +$assert( count( $ord['orders'] ?? array() ) > 50, 'orders list' ); +$call( 'GET', '/orders', array( 'q' => 'SPEARMINT' ) ); + +$c = $call( 'GET', '/costs' ); +$assert( count( $c['rows'] ?? array() ) >= 19, 'costs rows' ); +$call( 'POST', '/costs', array( 'rows' => array( array( 'sku' => 'DEMO-P1-LEMON', 'unit_cost' => 1.2, 'inbound_per_unit' => 0.1, 'lead_time_days' => 40 ) ) ) ); +$e = $call( 'POST', '/expenses', array( 'label' => 'Test expense', 'amount' => 10, 'recurrence' => 'once' ) ); +$call( 'GET', '/expenses' ); +$call( 'DELETE', '/expenses/' . (int) ( $e['id'] ?? 0 ) ); + +$r = $call( 'GET', '/restock' ); +$assert( ( $r['summary']['skus'] ?? 0 ) >= 19, 'restock skus' ); +$statuses = array_count_values( array_column( $r['rows'] ?? array(), 'status' ) ); +echo 'restock statuses: ' . wp_json_encode( $statuses ) . "\n"; +$assert( isset( $statuses['out_of_stock'] ), 'restock has OOS sku' ); +$call( 'GET', '/inventory-health' ); + +$a = $call( 'GET', '/ads/overview' ); +$assert( ( $a['totals']['cost'] ?? 0 ) > 0, 'ads spend' ); +$call( 'GET', '/ads/campaigns' ); +$call( 'GET', '/ads/targets' ); +$call( 'GET', '/ads/search-terms' ); +$plan = $call( 'POST', '/ads/optimize' ); +echo 'ppc plan: ' . wp_json_encode( $plan ) . "\n"; +$assert( ( $plan['bids'] ?? 0 ) + ( $plan['terms'] ?? 0 ) > 0, 'optimizer produced actions' ); +$acts = $call( 'GET', '/ads/actions' ); +$kinds = array_count_values( array_column( $acts['planned'] ?? array(), 'kind' ) ); +echo 'planned kinds: ' . wp_json_encode( $kinds ) . "\n"; +$ids = array_slice( array_map( 'intval', array_column( array_filter( $acts['planned'], static fn( $x ) => 'bid' === $x['kind'] ), 'id' ) ), 0, 2 ); +$ap = $call( 'POST', '/ads/apply', array( 'ids' => $ids ) ); +$assert( ( $ap['applied'] ?? 0 ) === count( $ids ), 'demo actions applied' ); + +$k = $call( 'GET', '/keywords' ); +$assert( count( $k['rows'] ?? array() ) >= 10, 'tracked keywords' ); +$call( 'POST', '/keywords', array( 'asin' => 'B0DEMO0001', 'keywords' => "test keyword one\ntest keyword two" ) ); +$call( 'GET', '/keywords/queries', array( 'asin' => 'B0DEMO0001' ) ); +$call( 'POST', '/keywords/research', array( 'asins' => array( 'B0DEMO0001' ) ) ); + +$l = $call( 'GET', '/listings' ); +$assert( count( $l['rows'] ?? array() ) >= 19, 'listing audits' ); +echo 'listing scores: ' . implode( ',', array_column( $l['rows'], 'score' ) ) . "\n"; + +$m = $call( 'GET', '/monitor' ); +$assert( count( $m['hijacks'] ?? array() ) >= 1, 'hijacker detected in demo' ); +$call( 'GET', '/monitor/history', array( 'asin' => 'B0DEMO0006' ) ); + +$rv = $call( 'GET', '/reviews' ); +echo 'reviews: ' . wp_json_encode( $rv['by_status'] ?? array() ) . "\n"; + +$rb = $call( 'GET', '/reimbursements' ); +$kinds = array_count_values( array_column( $rb['rows'] ?? array(), 'kind' ) ); +echo 'reimbursement cases: ' . wp_json_encode( $kinds ) . ' value=' . array_sum( array_column( $rb['rows'], 'est_amount' ) ) . "\n"; +$assert( count( $rb['rows'] ?? array() ) > 0, 'reimbursement cases found' ); +if ( ! empty( $rb['rows'][0]['id'] ) ) { + $call( 'POST', '/reimbursements/' . (int) $rb['rows'][0]['id'], array( 'status' => 'filed', 'amazon_case_id' => '1234567890' ) ); +} + +$calc = $call( 'POST', '/research/calc', array( 'price' => 16.99, 'unit_cost' => 4.2, 'inbound' => 0.5, 'referral_pct' => 15, 'fba_fee' => 4.02, 'tacos_pct' => 10, 'return_pct' => 2 ) ); +echo 'calc: ' . wp_json_encode( $calc ) . "\n"; +$assert( abs( ( $calc['profit_per_unit'] ?? 0 ) - 3.5 ) < 1.5, 'calc profit sane' ); + +$call( 'GET', '/traffic' ); +$call( 'GET', '/returns' ); +$call( 'GET', '/feedback' ); +$call( 'GET', '/alerts' ); +$call( 'GET', '/reports' ); +$s = $call( 'GET', '/settings' ); +$assert( ! isset( $s['settings']['ads_client_secret'] ), 'no secrets in settings payload' ); +$call( 'POST', '/settings', array( 'suite_ads_target_acos' => '25', 'ads_client_secret' => 'shh-secret-value' ) ); +$s2 = $call( 'GET', '/settings' ); +$assert( true === ( $s2['settings']['has_ads_client_secret'] ?? null ), 'secret stored flag' ); +$assert( ! str_contains( wp_json_encode( $s2 ), 'shh-secret-value' ), 'secret never echoed' ); +$raw = get_option( 'sg_commerce_secret_ads_client_secret' ); +$assert( is_string( $raw ) && ! str_contains( $raw, 'shh-secret-value' ), 'secret encrypted at rest' ); + +// Permission check: anonymous must be rejected. +wp_set_current_user( 0 ); +$res = rest_do_request( new WP_REST_Request( 'GET', '/sg-commerce/v1/suite/overview' ) ); +$assert( in_array( $res->get_status(), array( 401, 403 ), true ), 'anonymous rejected' ); +wp_set_current_user( $admins[0]->ID ); + +// Remove the keywords this test added (they are not demo-flagged). +\SevenGum\Commerce\Suite\Support\Db::exec( 'DELETE FROM {t:keywords} WHERE keyword LIKE %s', array( 'test keyword%' ) ); +$al = $call( 'GET', '/alerts' ); +$assert( count( $al['rows'] ?? array() ) >= 5, 'demo alerts seeded' ); + +$call( 'DELETE', '/demo' ); +global $wpdb; +$left = 0; +foreach ( \SevenGum\Commerce\Suite\SuiteSchema::TABLES as $t ) { + if ( in_array( $t, array( 'reports', 'sku_costs' ), true ) ) { + continue; + } + $left += (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . \SevenGum\Commerce\Suite\SuiteSchema::table( $t ) . ' WHERE is_demo = 1' ); +} +$left += (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}sg_products WHERE sku LIKE 'DEMO-%'" ); +$left += (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}sg_alerts WHERE context LIKE '%\"demo\":true%'" ); +$assert( 0 === $left, "demo fully cleared (left={$left})" ); + +echo $fail ? "\nFAILED: {$fail}\n" : "\nALL OK\n"; diff --git a/sg-commerce-pro/tests/unit.php b/sg-commerce-pro/tests/unit.php new file mode 100644 index 0000000..a3cf6eb --- /dev/null +++ b/sg-commerce-pro/tests/unit.php @@ -0,0 +1,266 @@ + $c, 'CurrencyAmount' => $v ); +} + +/* -------------------------------------------------------------------- */ +echo "FinancialEventFlattener\n"; +$events = array( + 'ShipmentEventList' => array( array( + 'AmazonOrderId' => '111-1', 'PostedDate' => '2026-09-10T12:00:00Z', 'MarketplaceName' => 'Amazon.com', + 'ShipmentItemList' => array( array( + 'SellerSKU' => 'SKU-A', 'QuantityShipped' => 2, + 'ItemChargeList' => array( + array( 'ChargeType' => 'Principal', 'ChargeAmount' => money( 33.98 ) ), + array( 'ChargeType' => 'Tax', 'ChargeAmount' => money( 2.46 ) ), + ), + 'ItemFeeList' => array( + array( 'FeeType' => 'Commission', 'FeeAmount' => money( -5.10 ) ), + array( 'FeeType' => 'FBAPerUnitFulfillmentFee', 'FeeAmount' => money( -8.04 ) ), + ), + 'PromotionList' => array( array( 'PromotionType' => 'PromotionMetaDataDefinitionValue', 'PromotionAmount' => money( -3.40 ) ) ), + 'ItemTaxWithheldList' => array( array( 'TaxesWithheld' => array( array( 'ChargeType' => 'MarketplaceFacilitatorTax-Principal', 'ChargeAmount' => money( -2.46 ) ) ) ) ), + ) ), + ) ), + 'RefundEventList' => array( array( + 'AmazonOrderId' => '111-0', 'PostedDate' => '2026-09-11T08:00:00Z', + 'ShipmentItemAdjustmentList' => array( array( + 'SellerSKU' => 'SKU-A', 'QuantityShipped' => 1, + 'ItemChargeAdjustmentList' => array( array( 'ChargeType' => 'Principal', 'ChargeAmount' => money( -16.99 ) ) ), + 'ItemFeeAdjustmentList' => array( + array( 'FeeType' => 'Commission', 'FeeAmount' => money( 2.04 ) ), + array( 'FeeType' => 'RefundCommission', 'FeeAmount' => money( -0.51 ) ), + ), + ) ), + ) ), + 'ServiceFeeEventList' => array( array( 'FeeList' => array( array( 'FeeType' => 'FBAStorageFee', 'FeeAmount' => money( -42.17 ) ) ) ) ), + 'AdjustmentEventList' => array( array( + 'AdjustmentType' => 'WAREHOUSE_DAMAGE', 'PostedDate' => '2026-09-12T00:00:00Z', + 'AdjustmentItemList' => array( array( 'SellerSKU' => 'SKU-A', 'Quantity' => 2, 'TotalAmount' => money( 19.80 ) ) ), + ) ), + 'ProductAdsPaymentEventList' => array( array( 'postedDate' => '2026-09-13T00:00:00Z', 'transactionType' => 'Charge', 'invoiceId' => 'INV1', 'transactionValue' => money( 250.00 ) ) ), +); +$lines = FinancialEventFlattener::flatten( $events, '2026-09-30T00:00:00Z' ); +$by = array(); +foreach ( $lines as $l ) { + $by[ $l['category'] ] = ( $by[ $l['category'] ] ?? 0 ) + $l['amount']; +} +check( 'revenue = principal only', near( $by['revenue'] ?? 0, 33.98 ) ); +check( 'tax nets to zero (collected + withheld)', near( $by['tax'] ?? 1, 0.0 ) ); +check( 'fees include item, refund and storage fees', near( $by['fee'] ?? 0, -5.10 - 8.04 + 2.04 - 0.51 - 42.17 ) ); +check( 'promotion captured', near( $by['promo'] ?? 0, -3.40 ) ); +check( 'refund principal negative', near( $by['refund'] ?? 0, -16.99 ) ); +check( 'warehouse damage is a reimbursement', near( $by['reimbursement'] ?? 0, 19.80 ) ); +check( 'ads invoice negative', near( $by['ads'] ?? 0, -250.0 ) ); +$units = array_sum( array_map( static fn( $l ) => 'revenue' === $l['category'] ? $l['qty'] : 0, $lines ) ); +check( 'units attributed once on Principal', 2 === $units, (string) $units ); +$refund_units = array_sum( array_map( static fn( $l ) => 'refund' === $l['category'] ? $l['qty'] : 0, $lines ) ); +check( 'refund units negative', -1 === $refund_units ); +$storage = array_values( array_filter( $lines, static fn( $l ) => 'FBAStorageFee' === $l['charge_type'] ) ); +check( 'ServiceFeeEvent without PostedDate uses window end', '2026-09-30T00:00:00Z' === ( $storage[0]['posted'] ?? '' ) ); +check( 'payout identity: Σ amount == net', near( array_sum( array_column( $lines, 'amount' ) ), 33.98 + 2.46 - 2.46 - 5.10 - 8.04 - 3.40 - 16.99 + 2.04 - 0.51 - 42.17 + 19.80 - 250.0 ) ); +check( 'hash is deterministic', FinancialEventFlattener::hash( $lines[0], 0 ) === FinancialEventFlattener::hash( $lines[0], 0 ) ); +check( 'hash ordinal disambiguates duplicates', FinancialEventFlattener::hash( $lines[0], 0 ) !== FinancialEventFlattener::hash( $lines[0], 1 ) ); +check( 'adjustment categories', 'fee' === FinancialEventFlattener::adjustment_category( 'FBAStorageFeeAdjustment' ) && 'adjustment' === FinancialEventFlattener::adjustment_category( 'ReserveDebit' ) ); + +/* -------------------------------------------------------------------- */ +echo "ReportParser / ReportIngestor::date\n"; +$tsv = "amazon-order-id\tsku\tquantity\titem-price\n111-1\tSKU-A\t2\t33.98\r\n111-2\tSKU-B\t1\t\"4.99\"\n"; +$rows = ReportParser::tsv( $tsv ); +check( 'tsv rows', 2 === count( $rows ) ); +check( 'tsv header normalised', isset( $rows[0]['amazon-order-id'] ) && '33.98' === $rows[0]['item-price'] ); +check( 'tsv strips quotes', '4.99' === $rows[1]['item-price'] ); +check( 'gzip transparently decoded', 2 === count( ReportParser::tsv( gzencode( $tsv ) ) ) ); +check( 'Windows-1252 converted to UTF-8', 'Café' === ReportParser::tsv( "name\n" . "Caf\xE9" )[0]['name'] ); +check( 'BOM stripped from header', isset( ReportParser::tsv( "\xEF\xBB\xBFDate\tFNSKU\n1\t2" )[0]['date'] ) ); +check( 'header with spaces → hyphens', isset( ReportParser::tsv( "Event Type\tReference ID\nA\tB" )[0]['event-type'] ) ); +check( 'money US', near( ReportParser::money( '$1,234.50' ), 1234.50 ) ); +check( 'money EU', near( ReportParser::money( '1.234,50' ), 1234.50 ) ); +check( 'money EU decimal', near( ReportParser::money( '4,99' ), 4.99 ) ); +check( 'money negative', near( ReportParser::money( '-12.30' ), -12.30 ) ); +check( 'json gzip', 1 === ReportParser::json( gzencode( '[{"a":1}]' ) )[0]['a'] ); +check( 'date ISO', '2026-09-01' === ReportIngestor::date( '2026-09-01T07:00:00+00:00' ) ); +check( 'date US m/d/Y', '2026-09-05' === ReportIngestor::date( '09/05/2026' ) ); +check( 'date empty', null === ReportIngestor::date( '' ) ); + +/* -------------------------------------------------------------------- */ +echo "BidOptimizer\n"; +$opt = new BidOptimizer( array( 'target_acos' => 30, 'min_clicks' => 12, 'max_step_pct' => 20, 'min_bid' => 0.2, 'max_bid' => 4, 'raise_low_impr' => true, 'neg_min_clicks' => 15, 'harvest_min_orders' => 2, 'avg_order_value' => 17 ) ); +$base = array( 'kind' => 'keyword', 'campaign_id' => 'C1', 'ad_group_id' => 'AG1', 'state' => 'ENABLED' ); +$plan = $opt->bids( array( + $base + array( 'target_id' => 'hi-acos', 'label' => 'a', 'bid' => 1.50, 'impressions' => 5000, 'clicks' => 100, 'cost' => 120, 'sales' => 200, 'orders' => 10 ), + $base + array( 'target_id' => 'lo-acos', 'label' => 'b', 'bid' => 0.50, 'impressions' => 5000, 'clicks' => 50, 'cost' => 25, 'sales' => 300, 'orders' => 15 ), + $base + array( 'target_id' => 'no-orders', 'label' => 'c', 'bid' => 1.00, 'impressions' => 3000, 'clicks' => 14, 'cost' => 9, 'sales' => 0, 'orders' => 0 ), + $base + array( 'target_id' => 'bleeder', 'label' => 'd', 'bid' => 1.00, 'impressions' => 3000, 'clicks' => 40, 'cost' => 40, 'sales' => 0, 'orders' => 0 ), + $base + array( 'target_id' => 'invisible', 'label' => 'e', 'bid' => 0.40, 'impressions' => 20, 'clicks' => 0, 'cost' => 0, 'sales' => 0, 'orders' => 0 ), + $base + array( 'target_id' => 'on-target', 'label' => 'f', 'bid' => 0.60, 'impressions' => 5000, 'clicks' => 100, 'cost' => 60, 'sales' => 200, 'orders' => 10 ), + array_merge( $base, array( 'state' => 'PAUSED' ) ) + array( 'target_id' => 'paused', 'label' => 'g', 'bid' => 1.0, 'impressions' => 0, 'clicks' => 50, 'cost' => 50, 'sales' => 0, 'orders' => 0 ), +) ); +$a = array(); +foreach ( $plan as $p ) { + $a[ $p['entity_id'] ] = $p; +} +check( 'high ACoS → bid cut, capped at -20%', isset( $a['hi-acos'] ) && near( (float) $a['hi-acos']['new_value'], 1.20 ) ); +check( 'low ACoS → bid raised, capped at +20%', isset( $a['lo-acos'] ) && near( (float) $a['lo-acos']['new_value'], 0.60 ) ); +check( 'no orders after min clicks → cut', isset( $a['no-orders'] ) && near( (float) $a['no-orders']['new_value'], 0.80 ) ); +check( 'heavy bleeder (≥2× break-even CPA) → pause', 'pause' === ( $a['bleeder']['kind'] ?? '' ) ); +check( 'low impressions → raise half step', isset( $a['invisible'] ) && near( (float) $a['invisible']['new_value'], 0.44 ) ); +check( 'on-target keyword left alone', ! isset( $a['on-target'] ) ); +check( 'paused keywords ignored', ! isset( $a['paused'] ) ); +$clamp = ( new BidOptimizer( array( 'target_acos' => 90, 'max_step_pct' => 50, 'max_bid' => 1.0 ) ) )->bids( array( + $base + array( 'target_id' => 'x', 'label' => 'x', 'bid' => 0.9, 'impressions' => 900, 'clicks' => 10, 'cost' => 9, 'sales' => 100, 'orders' => 5 ), +) ); +check( 'bids clamp to max_bid', near( (float) ( $clamp[0]['new_value'] ?? 0 ), 1.0 ) ); + +$terms = $opt->search_terms( array( + array( 'search_term' => 'Sugar Free Gum Bulk', 'campaign_id' => 'AUTO', 'ad_group_id' => 'AGA', 'keyword_text' => 'close-match', 'match_type' => 'AUTO', 'impressions' => 900, 'clicks' => 20, 'cost' => 15, 'sales' => 85, 'orders' => 5 ), + array( 'search_term' => 'nicotine gum', 'campaign_id' => 'BROAD', 'ad_group_id' => 'AGB', 'keyword_text' => 'gum', 'match_type' => 'BROAD', 'impressions' => 900, 'clicks' => 22, 'cost' => 18, 'sales' => 0, 'orders' => 0 ), + array( 'search_term' => 'spearmint gum', 'campaign_id' => 'AUTO', 'ad_group_id' => 'AGA', 'keyword_text' => 'close-match', 'match_type' => 'AUTO', 'impressions' => 900, 'clicks' => 20, 'cost' => 10, 'sales' => 80, 'orders' => 4 ), + array( 'search_term' => 'b0abcdefgh', 'campaign_id' => 'AUTO', 'ad_group_id' => 'AGA', 'keyword_text' => 'substitutes', 'match_type' => 'AUTO', 'impressions' => 900, 'clicks' => 20, 'cost' => 10, 'sales' => 80, 'orders' => 4 ), + array( 'search_term' => 'bubble gum machine', 'campaign_id' => 'BROAD', 'ad_group_id' => 'AGB', 'keyword_text' => 'gum', 'match_type' => 'BROAD', 'impressions' => 900, 'clicks' => 30, 'cost' => 20, 'sales' => 0, 'orders' => 0 ), +), array( 'spearmint gum' => true ), array( 'AGB|bubble gum machine' => true ), 'EXACT-C', 'EXACT-AG' ); +$k = array(); +foreach ( $terms as $t ) { + $k[ $t['entity_id'] ] = $t; +} +check( 'converting term harvested to exact destination', 'harvest' === ( $k['sugar free gum bulk']['kind'] ?? '' ) && 'EXACT-AG' === $k['sugar free gum bulk']['ad_group_id'] ); +check( 'harvest bid = CPC × 1.1', near( (float) $k['sugar free gum bulk']['payload']['bid'], 0.83 ) ); +check( 'non-converting term negated in its source ad group', 'negative' === ( $k['nicotine gum']['kind'] ?? '' ) && 'AGB' === $k['nicotine gum']['ad_group_id'] ); +check( 'already-exact keyword not re-harvested', ! isset( $k['spearmint gum'] ) ); +check( 'ASIN term → product-target harvest', 'harvest_asin' === ( $k['b0abcdefgh']['kind'] ?? '' ) ); +check( 'existing negative not duplicated', ! isset( $k['bubble gum machine'] ) ); + +$budgets = $opt->budgets( array( + array( 'campaign_id' => 'capped-good', 'name' => 'A', 'state' => 'ENABLED', 'daily_budget' => 20, 'days' => 7, 'cost' => 135, 'sales' => 700 ), + array( 'campaign_id' => 'capped-bad', 'name' => 'B', 'state' => 'ENABLED', 'daily_budget' => 20, 'days' => 7, 'cost' => 138, 'sales' => 200 ), + array( 'campaign_id' => 'fine', 'name' => 'C', 'state' => 'ENABLED', 'daily_budget' => 50, 'days' => 7, 'cost' => 100, 'sales' => 600 ), +) ); +$b = array_column( $budgets, 'new_value', 'entity_id' ); +check( 'profitable capped campaign → +20% budget', near( (float) ( $b['capped-good'] ?? 0 ), 24.0 ) ); +check( 'unprofitable capped campaign → -15% budget', near( (float) ( $b['capped-bad'] ?? 0 ), 17.0 ) ); +check( 'uncapped campaign untouched', ! isset( $b['fine'] ) ); + +/* -------------------------------------------------------------------- */ +echo "RestockPlanner::plan\n"; +$in = static fn( array $o ) => $o + array( 'sku' => 'S', 'asin' => 'A', 'title' => 'T', 'fulfillable' => 0, 'reserved' => 0, 'inbound' => 0, 'units_7' => 0, 'units_30' => 0, 'units_90' => 0, 'oos_days_30' => 0, 'lead_time' => 30, 'moq' => 0, 'case_pack' => 0, 'unit_cost' => 2.0, 'price' => 10.0 ); +$plan = RestockPlanner::plan( array( + $in( array( 'sku' => 'steady', 'fulfillable' => 300, 'units_7' => 70, 'units_30' => 300, 'units_90' => 900, 'case_pack' => 24, 'moq' => 100 ) ), + $in( array( 'sku' => 'oos', 'fulfillable' => 0, 'units_7' => 0, 'units_30' => 150, 'units_90' => 600, 'oos_days_30' => 15 ) ), + $in( array( 'sku' => 'fat', 'fulfillable' => 5000, 'units_7' => 14, 'units_30' => 60, 'units_90' => 180 ) ), + $in( array( 'sku' => 'gap', 'fulfillable' => 20, 'inbound' => 2000, 'units_7' => 70, 'units_30' => 300, 'units_90' => 900 ) ), + $in( array( 'sku' => 'dead', 'fulfillable' => 40 ) ), +), '2026-10-01', array( 'safety_days' => 14, 'target_cover' => 60, 'overstock_days' => 180 ) ); +$r = array_column( $plan['rows'], null, 'sku' ); +check( 'velocity = 0.5·v7 + 0.3·v30 + 0.2·v90', near( $r['steady']['velocity'], 10.0 ) ); +check( 'days of cover', 30 === $r['steady']['days_of_cover'] ); +check( 'steady SKU below reorder point → critical', 'critical' === $r['steady']['status'], $r['steady']['status'] ); +// need = 10 × (30 + 60) − 300 = 600 → case pack 24 → 600 +check( 'order qty rounded up to case pack', 600 === $r['steady']['order_qty'], (string) $r['steady']['order_qty'] ); +check( 'stock-out date', '2026-10-31' === $r['steady']['stockout_date'] ); +// v30 = 150 / (30 − 15 OOS days) = 10 → 0.3·10 + 0.2·6.67 = 4.33 (vs 2.83 if OOS days were counted). +check( 'OOS SKU flagged + velocity excludes stock-out days', 'out_of_stock' === $r['oos']['status'] && near( $r['oos']['velocity'], 4.333 ) ); +check( 'lost sales per day for OOS', $r['oos']['lost_sales_day'] > 40 ); +check( 'overstock detected', 'overstock' === $r['fat']['status'] && 0 === $r['fat']['order_qty'] ); +check( 'inbound gap → stockout_risk', 'stockout_risk' === $r['gap']['status'], $r['gap']['status'] ); +check( 'no sales → no_sales, no order', 'no_sales' === $r['dead']['status'] && 0 === $r['dead']['order_qty'] ); +check( 'urgency ordering', 'oos' === $plan['rows'][0]['sku'] ); +check( 'summary PO value', near( $plan['summary']['order_value'], array_sum( array_column( $plan['rows'], 'order_value' ) ) ) ); + +/* -------------------------------------------------------------------- */ +echo "ListingAuditor::score\n"; +$good = array( + 'asin' => 'B0X', 'brand' => '7 SEVEN', 'product_type' => 'GUM', 'status' => array( 'BUYABLE', 'DISCOVERABLE' ), 'issues' => array(), + 'title' => '7 SEVEN Sugar-Free Gum, Spearmint, 50 Pieces, Xylitol Sweetened, Aspartame-Free, Long-Lasting Flavor, Vegan', + 'bullets' => array_fill( 0, 5, 'LONG-LASTING FLAVOR — Our slow-release flavor beads keep every piece fresh for up to 45 minutes, so one piece covers your whole commute or meeting without fading.' ), + 'description' => str_repeat( 'Clean, confident chewing gum with xylitol sweetness and long-lasting flavor. ', 18 ), + 'backend' => 'sugarless chewing gum xylitol mints breath freshener keto vegan office travel bottle aspartame free dental gum fresh breath dry mouth', + 'images' => array_fill( 0, 7, 'x.jpg' ), +); +$s = ListingAuditor::score( $good, array( 'spearmint gum', 'xylitol gum' ) ); +check( 'good listing scores ≥ 90', $s['score'] >= 90, (string) $s['score'] ); +check( 'keyword coverage 100%', 100 === $s['keyword_coverage']['pct'] ); +$bad = array_merge( $good, array( + 'title' => 'Gum Gum Gum!! Best Seller Sugar Free GUM GUM', 'bullets' => array( 'Short 😀' ), 'description' => '', + 'backend' => str_repeat( 'gum ', 70 ) . 'B0ABCDEFGH', 'images' => array( 'x.jpg' ), + 'status' => array( 'DISCOVERABLE' ), 'issues' => array( array( 'code' => '90220', 'severity' => 'ERROR', 'message' => 'missing weight' ) ), +) ); +$s2 = ListingAuditor::score( $bad, array( 'spearmint gum' ) ); +$msgs = implode( ' | ', array_column( $s2['findings'], 'message' ) ); +check( 'bad listing scores < 40', $s2['score'] < 40, (string) $s2['score'] ); +check( 'flags repeated words', str_contains( $msgs, 'more than twice' ) ); +check( 'flags banned characters', str_contains( $msgs, 'decorative characters' ) ); +check( 'flags promotional claims', str_contains( $msgs, 'best seller' ) ); +check( 'flags emoji in bullets', str_contains( $msgs, 'emoji' ) ); +check( 'flags backend > 249 bytes', str_contains( $msgs, '249 bytes' ) ); +check( 'flags ASIN in backend', str_contains( $msgs, 'ASINs' ) ); +check( 'flags suppression', 'suppressed' === $s2['status'] ); +check( 'grade F', 'F' === $s2['grade'] ); +check( 'claims ignore substrings (wholesale ≠ sale)', array() === ListingAuditor::claims( 'wholesale pack' ) ); +check( 'extract() maps Listings API attributes', 'Hello' === ListingAuditor::extract( array( + 'summaries' => array( array( 'asin' => 'B0X', 'status' => array( 'BUYABLE' ) ) ), + 'attributes' => array( 'item_name' => array( array( 'value' => 'Hello' ) ), 'bullet_point' => array( array( 'value' => 'b1' ), array( 'value' => 'b2' ) ), + 'other_product_image_locator_1' => array( array( 'media_location' => 'i1' ) ), 'main_product_image_locator' => array( array( 'media_location' => 'm' ) ) ), +) )['title'] ); + +/* -------------------------------------------------------------------- */ +echo "ProductResearch\n"; +$c = ProductResearch::calc( array( 'price' => 20, 'unit_cost' => 4, 'inbound' => 1, 'referral_pct' => 15, 'fba_fee' => 5, 'tacos_pct' => 10, 'return_pct' => 0, 'cvr_pct' => 10 ) ); +// 20 − 3 (referral) − 5 (FBA) − 5 (COGS) − 2 (ads) = 5 +check( 'profit per unit', near( $c['profit_per_unit'], 5.0 ) ); +check( 'margin %', near( $c['margin_pct'], 25.0 ) ); +check( 'ROI %', near( $c['roi_pct'], 100.0 ) ); +check( 'break-even ACoS = pre-ad margin', near( $c['breakeven_acos'], 35.0 ) ); +check( 'max CPC = pre-ad profit × CVR', near( $c['max_cpc'], 0.70 ) ); +check( 'break-even price', near( $c['breakeven_price'], 10 / 0.75 ) ); +check( 'BSR estimate decreases with rank', ProductResearch::estimate_monthly_units( 100, 'grocery' ) > ProductResearch::estimate_monthly_units( 10000, 'grocery' ) ); +check( 'BSR group factor applied', ProductResearch::estimate_monthly_units( 1000, 'home' ) > ProductResearch::estimate_monthly_units( 1000, 'baby' ) ); + +echo "\n{$pass} passed, {$fail} failed\n"; +exit( $fail ? 1 : 0 ); diff --git a/sg-commerce-pro/uninstall.php b/sg-commerce-pro/uninstall.php new file mode 100644 index 0000000..a59db49 --- /dev/null +++ b/sg-commerce-pro/uninstall.php @@ -0,0 +1,123 @@ +prefix}sg_products", + "{$wpdb->prefix}sg_price_history", + "{$wpdb->prefix}sg_descriptions", + "{$wpdb->prefix}sg_competitors", + "{$wpdb->prefix}sg_rules", + "{$wpdb->prefix}sg_alerts", + "{$wpdb->prefix}sg_audit", + "{$wpdb->prefix}sg_mcf_orders", + "{$wpdb->prefix}sg_comparisons", + "{$wpdb->prefix}sg_injection_rules", + "{$wpdb->prefix}sg_settlements", + "{$wpdb->prefix}sg_inbound_plans", + "{$wpdb->prefix}sg_product_ean", + ); + // v4.0 — every Amazon Seller Suite table shares the sg_suite_ prefix. + $suite_tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . 'sg_suite_' ) . '%' ) ); + $tables = array_merge( $tables, (array) $suite_tables ); + foreach ( $tables as $t ) { + $wpdb->query( "DROP TABLE IF EXISTS {$t}" ); + } + + $option_keys = array( + 'sg_commerce_settings', + 'sg_commerce_schema_version', + 'sg_commerce_installed_at', + 'sg_commerce_activated_at', + 'sg_commerce_default_rules_seeded', + 'sg_commerce_last_cron_sync', + ); + foreach ( $option_keys as $key ) { + delete_option( $key ); + } + + // Encrypted secrets — one option each. + $secret_keys = array( + 'amazon_client_id', + 'amazon_client_secret', + 'amazon_refresh_token', + 'amazon_seller_id', + 'openai_api_key', + 'anthropic_api_key', + 'ollama_auth_token', + 'ads_client_id', + 'ads_client_secret', + 'ads_refresh_token', + ); + foreach ( $secret_keys as $sk ) { + delete_option( 'sg_commerce_secret_' . $sk ); + } + + // Catch-all: any other core option added over time (carton templates, sandbox flag, + // notices, API-key flashes…) shares the sg_commerce_ prefix. + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'sg_commerce_' ) . '%' ) ); + + // Suite options (cursors, job status, demo flag, schema version). + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'sg_suite_' ) . '%' ) ); + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( '_transient_sg_suite_' ) . '%', $wpdb->esc_like( '_transient_timeout_sg_suite_' ) . '%' ) ); + + // Transients. + $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_sg_commerce/%' OR option_name LIKE '_transient_timeout_sg_commerce/%'" ); + + // Cron. + $crons = array( + 'sg_commerce_hourly_sync', + 'sg_commerce_daily_analytics', + 'sg_commerce_repricer_tick', + 'sg_commerce_competitor_scan', + 'sg_commerce_ai_queue_process', + 'sg_commerce_mcf_refresh', + 'sg_commerce_pii_purge', + 'sg_commerce_sqs_poll', + 'sg_commerce_ai_describe', + 'sg_commerce_ai_translate', + 'sg_suite_tick', + 'sg_suite_hourly', + 'sg_suite_daily', + ); + foreach ( $crons as $hook ) { + wp_clear_scheduled_hook( $hook ); + } + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( '', array(), 'sg_commerce' ); + } + + // Log directory. + $uploads = wp_upload_dir( null, false ); + if ( ! empty( $uploads['basedir'] ) ) { + $log_dir = trailingslashit( $uploads['basedir'] ) . 'sg-commerce-logs'; + if ( is_dir( $log_dir ) ) { + array_map( 'unlink', glob( $log_dir . '/*' ) ?: array() ); + @rmdir( $log_dir ); + } + } +} + +if ( is_multisite() ) { + $sites = get_sites( array( 'fields' => 'ids', 'number' => 0 ) ); + foreach ( (array) $sites as $blog_id ) { + switch_to_blog( (int) $blog_id ); + sg_commerce_uninstall_one_site(); + restore_current_blog(); + } +} else { + sg_commerce_uninstall_one_site(); +}