Add WooCommerce product and category management.
Deploy Ladill Woo Manager / deploy (push) Successful in 55s
Deploy Ladill Woo Manager / deploy (push) Successful in 55s
Sync catalog via plugin webhooks and REST proxy, with list/create/edit UI and updated logo from monolith assets. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Woo;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\WooCategory;
|
||||
use App\Models\WooStore;
|
||||
use App\Services\Woo\CatalogSyncService;
|
||||
use App\Services\Woo\CatalogWriteService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private CatalogWriteService $write,
|
||||
private CatalogSyncService $sync,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$storeFilter = $request->query('store');
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
|
||||
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
|
||||
|
||||
$categories = WooCategory::query()
|
||||
->whereIn('woo_store_id', $storeIds)
|
||||
->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter))
|
||||
->when($search !== '', fn ($q) => $q->where('name', 'like', '%'.$search.'%'))
|
||||
->with('store')
|
||||
->orderBy('name')
|
||||
->paginate(30)
|
||||
->withQueryString();
|
||||
|
||||
$stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get();
|
||||
|
||||
return view('woo.categories.index', compact('categories', 'stores', 'storeFilter', 'search'));
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$stores = $this->activeStores($request);
|
||||
$storeId = (int) $request->query('store', $stores->first()?->id ?? 0);
|
||||
$parentCategories = $storeId > 0
|
||||
? WooCategory::query()->where('woo_store_id', $storeId)->orderBy('name')->get()
|
||||
: collect();
|
||||
|
||||
return view('woo.categories.form', [
|
||||
'category' => new WooCategory,
|
||||
'stores' => $stores,
|
||||
'parentCategories' => $parentCategories,
|
||||
'selectedStoreId' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $this->validateCategory($request);
|
||||
$store = $this->authorizedStore($request, (int) $validated['woo_store_id']);
|
||||
|
||||
$this->write->createCategory($store, $validated);
|
||||
|
||||
return redirect()->route('woo.categories.index', ['store' => $store->id])
|
||||
->with('success', 'Category saved.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, WooCategory $category): View
|
||||
{
|
||||
$category->load('store');
|
||||
abort_if($category->store?->user_id !== $request->user()->id, 403);
|
||||
|
||||
$stores = $this->activeStores($request);
|
||||
$parentCategories = $category->store
|
||||
? $category->store->categories()
|
||||
->where('external_id', '!=', $category->external_id)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
return view('woo.categories.form', [
|
||||
'category' => $category,
|
||||
'stores' => $stores,
|
||||
'parentCategories' => $parentCategories,
|
||||
'selectedStoreId' => $category->woo_store_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, WooCategory $category): RedirectResponse
|
||||
{
|
||||
$category->load('store');
|
||||
abort_if($category->store?->user_id !== $request->user()->id, 403);
|
||||
|
||||
$validated = $this->validateCategory($request);
|
||||
$this->write->updateCategory($category, $validated);
|
||||
|
||||
return redirect()->route('woo.categories.index', ['store' => $category->woo_store_id])
|
||||
->with('success', 'Category updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, WooCategory $category): RedirectResponse
|
||||
{
|
||||
$category->load('store');
|
||||
abort_if($category->store?->user_id !== $request->user()->id, 403);
|
||||
|
||||
$storeId = $category->woo_store_id;
|
||||
$this->write->deleteCategory($category);
|
||||
|
||||
return redirect()->route('woo.categories.index', ['store' => $storeId])
|
||||
->with('success', 'Category removed.');
|
||||
}
|
||||
|
||||
public function sync(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'store' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$store = $this->authorizedStore($request, (int) $validated['store']);
|
||||
$count = $this->sync->syncCategories($store);
|
||||
|
||||
return back()->with('success', sprintf(
|
||||
'Synced %d categories from %s.',
|
||||
$count,
|
||||
$store->site_name ?? $store->site_url,
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function validateCategory(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'woo_store_id' => ['required', 'integer'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:10000'],
|
||||
'parent_external_id' => ['nullable', 'integer', 'min:0'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizedStore(Request $request, int $storeId): WooStore
|
||||
{
|
||||
return WooStore::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereKey($storeId)
|
||||
->where('status', WooStore::STATUS_ACTIVE)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
/** @return \Illuminate\Support\Collection<int, WooStore> */
|
||||
private function activeStores(Request $request)
|
||||
{
|
||||
return WooStore::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('status', WooStore::STATUS_ACTIVE)
|
||||
->orderBy('site_name')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Woo;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\WooOrder;
|
||||
use App\Models\WooProduct;
|
||||
use App\Models\WooCategory;
|
||||
use App\Models\WooStore;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -22,6 +24,8 @@ class DashboardController extends Controller
|
||||
WooOrder::FULFILLMENT_DELIVERED,
|
||||
WooOrder::FULFILLMENT_CANCELLED,
|
||||
])->count(),
|
||||
'products' => WooProduct::query()->whereIn('woo_store_id', $storeIds)->count(),
|
||||
'categories' => WooCategory::query()->whereIn('woo_store_id', $storeIds)->count(),
|
||||
];
|
||||
|
||||
$recent = WooOrder::query()
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Woo;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\WooProduct;
|
||||
use App\Models\WooStore;
|
||||
use App\Services\Woo\CatalogSyncService;
|
||||
use App\Services\Woo\CatalogWriteService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private CatalogWriteService $write,
|
||||
private CatalogSyncService $sync,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
$storeFilter = $request->query('store');
|
||||
$statusFilter = $request->query('status');
|
||||
|
||||
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
|
||||
|
||||
$products = WooProduct::query()
|
||||
->whereIn('woo_store_id', $storeIds)
|
||||
->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter))
|
||||
->when($statusFilter, fn ($q) => $q->where('status', $statusFilter))
|
||||
->when($search !== '', function ($query) use ($search) {
|
||||
$like = '%'.$search.'%';
|
||||
$query->where(function ($inner) use ($like) {
|
||||
$inner->where('name', 'like', $like)
|
||||
->orWhere('sku', 'like', $like);
|
||||
});
|
||||
})
|
||||
->with('store')
|
||||
->latest('updated_at')
|
||||
->paginate(20)
|
||||
->withQueryString();
|
||||
|
||||
$stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get();
|
||||
|
||||
return view('woo.products.index', compact('products', 'stores', 'search', 'storeFilter', 'statusFilter'));
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$stores = $this->activeStores($request);
|
||||
|
||||
return view('woo.products.form', [
|
||||
'product' => new WooProduct(['status' => WooProduct::STATUS_DRAFT]),
|
||||
'stores' => $stores,
|
||||
'categories' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $this->validateProduct($request);
|
||||
$store = $this->authorizedStore($request, (int) $validated['woo_store_id']);
|
||||
|
||||
$this->write->createProduct($store, $validated);
|
||||
|
||||
return redirect()->route('woo.products.index', ['store' => $store->id])
|
||||
->with('success', 'Product saved.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, WooProduct $product): View
|
||||
{
|
||||
$product->load('store');
|
||||
abort_if($product->store?->user_id !== $request->user()->id, 403);
|
||||
|
||||
$stores = $this->activeStores($request);
|
||||
$categories = $product->store
|
||||
? $product->store->categories()->orderBy('name')->get()
|
||||
: collect();
|
||||
|
||||
return view('woo.products.form', compact('product', 'stores', 'categories'));
|
||||
}
|
||||
|
||||
public function update(Request $request, WooProduct $product): RedirectResponse
|
||||
{
|
||||
$product->load('store');
|
||||
abort_if($product->store?->user_id !== $request->user()->id, 403);
|
||||
|
||||
$validated = $this->validateProduct($request, $product);
|
||||
$this->write->updateProduct($product, $validated);
|
||||
|
||||
return redirect()->route('woo.products.index', ['store' => $product->woo_store_id])
|
||||
->with('success', 'Product updated.');
|
||||
}
|
||||
|
||||
public function sync(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'store' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$store = $this->authorizedStore($request, (int) $validated['store']);
|
||||
$counts = $this->sync->syncAll($store);
|
||||
|
||||
return back()->with('success', sprintf(
|
||||
'Synced %d categories and %d products from %s.',
|
||||
$counts['categories'],
|
||||
$counts['products'],
|
||||
$store->site_name ?? $store->site_url,
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function validateProduct(Request $request, ?WooProduct $product = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'woo_store_id' => ['required', 'integer'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'sku' => ['nullable', 'string', 'max:120'],
|
||||
'status' => ['required', Rule::in(array_keys(WooProduct::STATUSES))],
|
||||
'regular_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'sale_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'manage_stock' => ['sometimes', 'boolean'],
|
||||
'stock_quantity' => ['nullable', 'integer', 'min:0'],
|
||||
'short_description' => ['nullable', 'string', 'max:5000'],
|
||||
'description' => ['nullable', 'string', 'max:20000'],
|
||||
'category_external_ids' => ['nullable', 'array'],
|
||||
'category_external_ids.*' => ['integer', 'min:1'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizedStore(Request $request, int $storeId): WooStore
|
||||
{
|
||||
return WooStore::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereKey($storeId)
|
||||
->where('status', WooStore::STATUS_ACTIVE)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
/** @return \Illuminate\Support\Collection<int, WooStore> */
|
||||
private function activeStores(Request $request)
|
||||
{
|
||||
return WooStore::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('status', WooStore::STATUS_ACTIVE)
|
||||
->orderBy('site_name')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\WooStore;
|
||||
use App\Services\Woo\CategoryIngestService;
|
||||
use App\Services\Woo\OrderIngestService;
|
||||
use App\Services\Woo\ProductIngestService;
|
||||
use App\Services\Woo\WooWebhookVerifier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -12,7 +14,9 @@ class WooWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private WooWebhookVerifier $verifier,
|
||||
private OrderIngestService $ingest,
|
||||
private OrderIngestService $orders,
|
||||
private ProductIngestService $products,
|
||||
private CategoryIngestService $categories,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, string $storePublicId): JsonResponse
|
||||
@@ -41,11 +45,56 @@ class WooWebhookController extends Controller
|
||||
return response()->json(['ignored' => true]);
|
||||
}
|
||||
|
||||
$order = $this->ingest->ingest($store, $payload);
|
||||
return match (true) {
|
||||
str_starts_with($topic, 'order.') => $this->handleOrder($store, $payload),
|
||||
str_starts_with($topic, 'product_category.') => $this->handleCategory($store, $topic, $payload),
|
||||
str_starts_with($topic, 'product.') => $this->handleProduct($store, $topic, $payload),
|
||||
default => response()->json(['ignored' => true]),
|
||||
};
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function handleOrder(WooStore $store, array $payload): JsonResponse
|
||||
{
|
||||
$order = $this->orders->ingest($store, $payload);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function handleProduct(WooStore $store, string $topic, array $payload): JsonResponse
|
||||
{
|
||||
if ($topic === 'product.deleted') {
|
||||
$this->products->delete($store, (int) ($payload['id'] ?? 0));
|
||||
|
||||
return response()->json(['ok' => true, 'deleted' => true]);
|
||||
}
|
||||
|
||||
$product = $this->products->ingest($store, $payload);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function handleCategory(WooStore $store, string $topic, array $payload): JsonResponse
|
||||
{
|
||||
if ($topic === 'product_category.deleted') {
|
||||
$this->categories->delete($store, (int) ($payload['id'] ?? 0));
|
||||
|
||||
return response()->json(['ok' => true, 'deleted' => true]);
|
||||
}
|
||||
|
||||
$category = $this->categories->ingest($store, $payload);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user