php-commerce/Services/SkuParserService.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

192 lines
5.4 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Core\Mod\Commerce\Data\BundleItem;
use Core\Mod\Commerce\Data\ParsedItem;
use Core\Mod\Commerce\Data\SkuOption;
use Core\Mod\Commerce\Data\SkuParseResult;
/**
* Parse compound SKU strings into structured data.
*
* Format: SKU-<opt>~<val>*<qty>[-<opt>~<val>*<qty>]...
*
* Separators:
* - Option separator (within an item)
* ~ Value indicator (option~value)
* * Quantity indicator (optional, default 1)
* , Item separator (multiple items)
* | Bundle separator (grouped items for discount)
*
* Examples:
* LAPTOP-ram~16gb-ssd~512gb Single item with options
* LAPTOP-ram~16gb-cover~black*2 Item with quantity on option
* LAPTOP-ram~16gb,MOUSE,PAD Multiple separate items
* LAPTOP-ram~16gb|MOUSE|PAD Bundle (discount lookup)
*
* One scan tells you everything. No lookups. No mistakes.
*/
class SkuParserService
{
/**
* Parse a compound SKU string into structured data.
*/
public function parse(string $compoundSku): SkuParseResult
{
$compoundSku = trim($compoundSku);
if ($compoundSku === '') {
return new SkuParseResult([]);
}
// Split by comma for multiple items
$segments = explode(',', $compoundSku);
$parsedItems = [];
foreach ($segments as $segment) {
$segment = trim($segment);
if ($segment === '') {
continue;
}
// Check for bundle separator
if (str_contains($segment, '|')) {
$bundleParts = explode('|', $segment);
$bundleItems = [];
foreach ($bundleParts as $part) {
$part = trim($part);
if ($part !== '') {
$bundleItems[] = $this->parseItem($part);
}
}
if (! empty($bundleItems)) {
$parsedItems[] = new BundleItem(
items: $bundleItems,
hash: $this->hashBundle($bundleItems)
);
}
} else {
$parsedItems[] = $this->parseItem($segment);
}
}
return new SkuParseResult($parsedItems);
}
/**
* Parse a single item: SKU-opt~val*qty-opt~val*qty
*/
protected function parseItem(string $item): ParsedItem
{
// First hyphen separates base SKU from options
// But base SKU might contain hyphens if it's a lineage SKU (ORGORG-WBUTS-PROD)
// So we need to find where options start (first segment with ~)
$parts = explode('-', $item);
$baseParts = [];
$optionParts = [];
$inOptions = false;
foreach ($parts as $part) {
if (! $inOptions && ! str_contains($part, '~')) {
$baseParts[] = $part;
} else {
$inOptions = true;
$optionParts[] = $part;
}
}
$baseSku = implode('-', $baseParts);
$options = [];
// Parse each option: opt~val*qty
foreach ($optionParts as $optPart) {
$option = $this->parseOption($optPart);
if ($option !== null) {
$options[] = $option;
}
}
return new ParsedItem(
baseSku: strtoupper($baseSku),
options: $options
);
}
/**
* Parse a single option: opt~val*qty
*/
protected function parseOption(string $optString): ?SkuOption
{
// Match: code~value*quantity (quantity optional)
if (! preg_match('/^([a-z_][a-z0-9_]*)~([^*]+)(?:\*(\d+))?$/i', $optString, $matches)) {
return null;
}
return new SkuOption(
code: strtolower($matches[1]),
value: $matches[2],
quantity: isset($matches[3]) ? (int) $matches[3] : 1
);
}
/**
* Hash bundle for discount lookup (strips human choices).
*
* @param array<ParsedItem> $items
*/
protected function hashBundle(array $items): string
{
$baseSkus = collect($items)
->map(fn (ParsedItem $item) => strtoupper($item->baseSku))
->sort()
->implode('|');
return hash('sha256', $baseSkus);
}
/**
* Validate compound SKU format.
*
* @return array{valid: bool, errors: array}
*/
public function validate(string $compoundSku): array
{
$errors = [];
if (strlen($compoundSku) > 1024) {
$errors[] = 'Compound SKU exceeds maximum length of 1024 characters.';
}
// Try to parse and check for issues
$result = $this->parse($compoundSku);
if ($result->count() === 0) {
$errors[] = 'No valid items found in SKU string.';
}
// Check each item has a base SKU
foreach ($result->collect() as $item) {
if ($item instanceof BundleItem) {
foreach ($item->items as $bundleItem) {
if (empty($bundleItem->baseSku)) {
$errors[] = 'Bundle contains item with empty base SKU.';
}
}
} elseif (empty($item->baseSku)) {
$errors[] = 'Item has empty base SKU.';
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
}