php-commerce/Services/ProductCatalogService.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
Align commerce module with the monorepo module structure by updating
all namespaces to use the Core\Mod\Commerce convention. This change
supports the recent monorepo separation and ensures consistency with
other modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:23:12 +00:00

397 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Core\Mod\Commerce\Models\Entity;
use Core\Mod\Commerce\Models\Product;
use Core\Mod\Commerce\Models\ProductAssignment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
/**
* Product Catalog Service - Manages master catalog and entity assignments.
*
* M1 entities own products. M2/M3 entities access products via assignments.
* Each assignment can have overrides for price, content, and visibility.
*/
class ProductCatalogService
{
/**
* Create a product (M1 only).
*
* @throws \InvalidArgumentException
*/
public function createProduct(Entity $owner, array $data): Product
{
if (! $owner->isM1()) {
throw new \InvalidArgumentException(
"Only M1 (Master) entities can own products. Entity '{$owner->code}' is {$owner->type}."
);
}
$data['owner_entity_id'] = $owner->id;
$data['sku'] = strtoupper($data['sku'] ?? Product::generateSku($owner->code));
return Product::create($data);
}
/**
* Update a product.
*/
public function updateProduct(Product $product, array $data): Product
{
// SKU is immutable after creation
unset($data['sku'], $data['owner_entity_id']);
$product->update($data);
return $product->fresh();
}
/**
* Delete a product (soft delete).
*/
public function deleteProduct(Product $product): bool
{
return $product->delete();
}
/**
* Assign a product to an M2/M3 entity.
*
* @throws \InvalidArgumentException
*/
public function assignProduct(
Entity $entity,
Product $product,
array $overrides = []
): ProductAssignment {
if ($entity->isM1()) {
throw new \InvalidArgumentException(
'M1 entities own products directly. Use createProduct() instead.'
);
}
// Check if assignment already exists
$existing = ProductAssignment::where('entity_id', $entity->id)
->where('product_id', $product->id)
->first();
if ($existing) {
return $this->updateAssignment($existing, $overrides);
}
return ProductAssignment::create([
'entity_id' => $entity->id,
'product_id' => $product->id,
...$overrides,
]);
}
/**
* Update an assignment's overrides.
*/
public function updateAssignment(
ProductAssignment $assignment,
array $overrides
): ProductAssignment {
$assignment->update($overrides);
return $assignment->fresh();
}
/**
* Remove a product assignment.
*/
public function removeAssignment(ProductAssignment $assignment): bool
{
return $assignment->delete();
}
/**
* Get all products for an entity.
*
* For M1: Returns owned products
* For M2/M3: Returns assigned products
*/
public function getProductsForEntity(
Entity $entity,
bool $activeOnly = true
): Collection {
if ($entity->isM1()) {
$query = Product::forOwner($entity->id);
if ($activeOnly) {
$query->active()->visible();
}
return $query->orderBy('sort_order')->get();
}
// M2/M3: Get assigned products
$query = ProductAssignment::forEntity($entity->id)
->with('product');
if ($activeOnly) {
$query->active()->withActiveProducts();
}
return $query->orderBy('sort_order')->get();
}
/**
* Get a product for an entity with effective values.
*
* Returns array with effective price, name, description, etc.
*/
public function getEffectiveProduct(Entity $entity, Product $product): array
{
if ($entity->isM1()) {
return [
'product' => $product,
'assignment' => null,
'sku' => $entity->buildSku($product->sku),
'price' => $product->price,
'name' => $product->name,
'description' => $product->description,
'image' => $product->image_url,
'available_stock' => $product->stock_quantity,
];
}
$assignment = ProductAssignment::where('entity_id', $entity->id)
->where('product_id', $product->id)
->first();
if (! $assignment) {
return null;
}
return [
'product' => $product,
'assignment' => $assignment,
'sku' => $assignment->getFullSku(),
'price' => $assignment->getEffectivePrice(),
'name' => $assignment->getEffectiveName(),
'description' => $assignment->getEffectiveDescription(),
'image' => $assignment->getEffectiveImage(),
'available_stock' => $assignment->getAvailableStock(),
];
}
/**
* Bulk assign products to an entity.
*/
public function bulkAssign(
Entity $entity,
array $productIds,
array $defaultOverrides = []
): int {
$count = 0;
DB::transaction(function () use ($entity, $productIds, $defaultOverrides, &$count) {
foreach ($productIds as $productId) {
$product = Product::find($productId);
if ($product) {
$this->assignProduct($entity, $product, $defaultOverrides);
$count++;
}
}
});
return $count;
}
/**
* Copy assignments from one entity to another.
*
* Useful for setting up new M3 entities with same products as parent M2.
*/
public function copyAssignments(
Entity $source,
Entity $target,
bool $includeOverrides = true
): int {
$assignments = ProductAssignment::forEntity($source->id)->get();
$count = 0;
DB::transaction(function () use ($assignments, $target, $includeOverrides, &$count) {
foreach ($assignments as $assignment) {
$overrides = $includeOverrides ? [
'sku_suffix' => $assignment->sku_suffix,
'price_override' => $assignment->price_override,
'price_tier_overrides' => $assignment->price_tier_overrides,
'margin_percent' => $assignment->margin_percent,
'fixed_margin' => $assignment->fixed_margin,
'name_override' => $assignment->name_override,
'description_override' => $assignment->description_override,
'image_override' => $assignment->image_override,
'is_featured' => $assignment->is_featured,
'can_discount' => $assignment->can_discount,
'min_price' => $assignment->min_price,
'max_price' => $assignment->max_price,
] : [];
$this->assignProduct($target, $assignment->product, $overrides);
$count++;
}
});
return $count;
}
/**
* Search products by name/SKU.
*/
public function searchProducts(
Entity $owner,
string $query,
int $limit = 20
): Collection {
return Product::forOwner($owner->id)
->where(function ($q) use ($query) {
$q->where('name', 'like', "%{$query}%")
->orWhere('sku', 'like', "%{$query}%");
})
->active()
->visible()
->limit($limit)
->get();
}
/**
* Get products by category.
*/
public function getByCategory(
Entity $entity,
string $category,
bool $activeOnly = true
): Collection {
if ($entity->isM1()) {
$query = Product::forOwner($entity->id)
->inCategory($category);
if ($activeOnly) {
$query->active()->visible();
}
return $query->orderBy('sort_order')->get();
}
$query = ProductAssignment::forEntity($entity->id)
->with('product')
->whereHas('product', fn ($q) => $q->inCategory($category));
if ($activeOnly) {
$query->active()->withActiveProducts();
}
return $query->orderBy('sort_order')->get();
}
/**
* Get featured products for an entity.
*/
public function getFeaturedProducts(Entity $entity, int $limit = 10): Collection
{
if ($entity->isM1()) {
return Product::forOwner($entity->id)
->active()
->visible()
->featured()
->limit($limit)
->get();
}
return ProductAssignment::forEntity($entity->id)
->with('product')
->active()
->featured()
->withActiveProducts()
->limit($limit)
->get();
}
/**
* Get product statistics for an entity.
*/
public function getProductStats(Entity $entity): array
{
if ($entity->isM1()) {
$products = Product::forOwner($entity->id);
return [
'total' => $products->count(),
'active' => $products->clone()->active()->count(),
'featured' => $products->clone()->featured()->count(),
'in_stock' => $products->clone()->inStock()->count(),
'out_of_stock' => $products->clone()->where('stock_status', Product::STOCK_OUT)->count(),
'low_stock' => $products->clone()->where('stock_status', Product::STOCK_LOW)->count(),
];
}
$assignments = ProductAssignment::forEntity($entity->id);
return [
'total' => $assignments->count(),
'active' => $assignments->clone()->active()->count(),
'featured' => $assignments->clone()->featured()->count(),
];
}
/**
* Resolve SKU to product for an entity.
*
* Parses SKU lineage (M1-M2-SKU) and finds the corresponding product.
*/
public function resolveSku(string $fullSku): ?array
{
// Parse SKU format: M1-M2-SKU or M1-M2-M3-SKU
$parts = explode('-', $fullSku);
if (count($parts) < 2) {
return null;
}
// Last part is the base SKU
$baseSku = array_pop($parts);
// Find product by base SKU
$product = Product::where('sku', $baseSku)->first();
if (! $product) {
// Try with combined last parts (SKU might have dashes)
for ($i = count($parts) - 1; $i >= 1; $i--) {
$testSku = implode('-', array_slice($parts, $i)).'-'.$baseSku;
$product = Product::where('sku', $testSku)->first();
if ($product) {
$parts = array_slice($parts, 0, $i);
break;
}
}
}
if (! $product) {
return null;
}
// Build entity path from remaining parts
$entityPath = implode('/', $parts);
$entity = Entity::where('path', $entityPath)->first();
if (! $entity) {
return null;
}
return [
'product' => $product,
'entity' => $entity,
'assignment' => $entity->isM1()
? null
: ProductAssignment::where('entity_id', $entity->id)
->where('product_id', $product->id)
->first(),
];
}
}