Support bundle title parsing into single inventory products
This commit is contained in:
236
order-import.php
236
order-import.php
@@ -582,18 +582,8 @@ function get_item_components(PDO $pdo, int $sellableItemId): array
|
|||||||
|
|
||||||
function resolve_product_id_fallback(PDO $pdo, string $articleNumber, string $title): ?int
|
function resolve_product_id_fallback(PDO $pdo, string $articleNumber, string $title): ?int
|
||||||
{
|
{
|
||||||
$articleNumber = trim($articleNumber);
|
|
||||||
$title = trim($title);
|
$title = trim($title);
|
||||||
|
|
||||||
if ($articleNumber !== '') {
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM public.product WHERE sku = :sku ORDER BY id LIMIT 1');
|
|
||||||
$stmt->execute([':sku' => $articleNumber]);
|
|
||||||
$id = $stmt->fetchColumn();
|
|
||||||
if ($id !== false) {
|
|
||||||
return (int) $id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($title !== '') {
|
if ($title !== '') {
|
||||||
$stmt = $pdo->prepare('SELECT id FROM public.product WHERE lower(name) = lower(:name) ORDER BY id LIMIT 1');
|
$stmt = $pdo->prepare('SELECT id FROM public.product WHERE lower(name) = lower(:name) ORDER BY id LIMIT 1');
|
||||||
$stmt->execute([':name' => $title]);
|
$stmt->execute([':name' => $title]);
|
||||||
@@ -603,42 +593,188 @@ function resolve_product_id_fallback(PDO $pdo, string $articleNumber, string $ti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business fallback for known product families where Wix title differs from ERP product label.
|
return null;
|
||||||
$search = normalize_match_key($articleNumber . ' ' . $title);
|
}
|
||||||
if ($search !== '') {
|
|
||||||
$familyPatterns = [];
|
|
||||||
|
|
||||||
if (str_contains($search, 'lion') || str_contains($search, 'mane')) {
|
function detect_product_family_key(string $normalizedName): ?string
|
||||||
$familyPatterns[] = '%lionsmane%';
|
{
|
||||||
$familyPatterns[] = '%lion%';
|
if (str_contains($normalizedName, 'lion') && str_contains($normalizedName, 'mane')) {
|
||||||
}
|
return 'lionsmane';
|
||||||
if (str_contains($search, 'shiitake')) {
|
}
|
||||||
$familyPatterns[] = '%shiitake%';
|
if (str_contains($normalizedName, 'chaga')) {
|
||||||
}
|
return 'chaga';
|
||||||
if (str_contains($search, 'chaga')) {
|
}
|
||||||
$familyPatterns[] = '%chaga%';
|
if (str_contains($normalizedName, 'reishi')) {
|
||||||
}
|
return 'reishi';
|
||||||
if (str_contains($search, 'reishi')) {
|
}
|
||||||
$familyPatterns[] = '%reishi%';
|
if (str_contains($normalizedName, 'shiitake')) {
|
||||||
}
|
return 'shiitake';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($familyPatterns as $pattern) {
|
function title_contains_family(string $normalizedTitle, string $familyKey): bool
|
||||||
$stmt = $pdo->prepare(
|
{
|
||||||
'SELECT id
|
if ($familyKey === 'lionsmane') {
|
||||||
FROM public.product
|
return str_contains($normalizedTitle, 'lion') && str_contains($normalizedTitle, 'mane');
|
||||||
WHERE lower(name) LIKE :pattern
|
}
|
||||||
ORDER BY id
|
return str_contains($normalizedTitle, $familyKey);
|
||||||
LIMIT 1'
|
}
|
||||||
);
|
|
||||||
$stmt->execute([':pattern' => $pattern]);
|
function infer_components_from_title(PDO $pdo, string $title): array
|
||||||
$id = $stmt->fetchColumn();
|
{
|
||||||
if ($id !== false) {
|
$normalizedTitle = normalize_match_key($title);
|
||||||
return (int) $id;
|
if ($normalizedTitle === '') {
|
||||||
}
|
return [];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
$products = $pdo->query("SELECT id, name FROM public.product WHERE status = 'active' ORDER BY id")->fetchAll();
|
||||||
|
$components = [];
|
||||||
|
$usedProductIds = [];
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$productId = (int) $product['id'];
|
||||||
|
$productNameNorm = normalize_match_key((string) ($product['name'] ?? ''));
|
||||||
|
$familyKey = detect_product_family_key($productNameNorm);
|
||||||
|
if ($familyKey === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title_contains_family($normalizedTitle, $familyKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($usedProductIds[$productId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$components[] = [
|
||||||
|
'product_id' => $productId,
|
||||||
|
'qty_per_item' => 1.0,
|
||||||
|
];
|
||||||
|
$usedProductIds[$productId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $components;
|
||||||
|
}
|
||||||
|
|
||||||
|
function find_alias_sellable_item_id(PDO $pdo, string $articleNumber, string $title): ?int
|
||||||
|
{
|
||||||
|
$articleNumber = trim($articleNumber);
|
||||||
|
$title = trim($title);
|
||||||
|
$titleNorm = normalize_title_key($title);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT sellable_item_id
|
||||||
|
FROM public.external_item_alias
|
||||||
|
WHERE source_system = 'wix'
|
||||||
|
AND (
|
||||||
|
(:article_number <> '' AND external_article_number = :article_number)
|
||||||
|
OR (:title_norm <> '' AND title_normalized = :title_norm)
|
||||||
|
OR (:title <> '' AND lower(external_title) = lower(:title))
|
||||||
|
)
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
':article_number' => $articleNumber,
|
||||||
|
':title_norm' => $titleNorm,
|
||||||
|
':title' => $title,
|
||||||
|
]);
|
||||||
|
$id = $stmt->fetchColumn();
|
||||||
|
return $id === false ? null : (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure_sellable_mapping_from_title_components(
|
||||||
|
PDO $pdo,
|
||||||
|
string $articleNumber,
|
||||||
|
string $title,
|
||||||
|
array $components
|
||||||
|
): array {
|
||||||
|
if ($components === []) {
|
||||||
|
throw new RuntimeException('Cannot create sellable mapping without components');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sellableItemId = find_alias_sellable_item_id($pdo, $articleNumber, $title);
|
||||||
|
$createdSellable = false;
|
||||||
|
|
||||||
|
if ($sellableItemId === null || !sellable_item_exists($pdo, $sellableItemId)) {
|
||||||
|
$itemCodeSeed = trim($articleNumber) !== '' ? trim($articleNumber) : $title;
|
||||||
|
$itemCode = ensure_unique_sellable_item_code($pdo, $itemCodeSeed);
|
||||||
|
$displayName = trim($title) !== '' ? trim($title) : $itemCode;
|
||||||
|
|
||||||
|
$insertSellable = $pdo->prepare(
|
||||||
|
'INSERT INTO public.sellable_item (item_code, display_name, status, created_at, updated_at)
|
||||||
|
VALUES (:item_code, :display_name, \'active\', NOW(), NOW())
|
||||||
|
RETURNING id'
|
||||||
|
);
|
||||||
|
$insertSellable->execute([
|
||||||
|
':item_code' => $itemCode,
|
||||||
|
':display_name' => $displayName,
|
||||||
|
]);
|
||||||
|
$id = $insertSellable->fetchColumn();
|
||||||
|
if ($id === false) {
|
||||||
|
throw new RuntimeException('Could not create sellable_item from title components');
|
||||||
|
}
|
||||||
|
$sellableItemId = (int) $id;
|
||||||
|
$createdSellable = true;
|
||||||
|
} else {
|
||||||
|
$updateName = $pdo->prepare(
|
||||||
|
'UPDATE public.sellable_item
|
||||||
|
SET display_name = :display_name, updated_at = NOW()
|
||||||
|
WHERE id = :id'
|
||||||
|
);
|
||||||
|
$updateName->execute([
|
||||||
|
':display_name' => trim($title) !== '' ? trim($title) : "AUTO-ITEM-{$sellableItemId}",
|
||||||
|
':id' => $sellableItemId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$syncComponent = $pdo->prepare(
|
||||||
|
'INSERT INTO public.sellable_item_component (sellable_item_id, product_id, qty_per_item, created_at, updated_at)
|
||||||
|
VALUES (:sellable_item_id, :product_id, :qty_per_item, NOW(), NOW())
|
||||||
|
ON CONFLICT (sellable_item_id, product_id)
|
||||||
|
DO UPDATE SET qty_per_item = EXCLUDED.qty_per_item, updated_at = NOW()'
|
||||||
|
);
|
||||||
|
$componentIds = [];
|
||||||
|
foreach ($components as $component) {
|
||||||
|
$productId = (int) $component['product_id'];
|
||||||
|
$qtyPerItem = (float) $component['qty_per_item'];
|
||||||
|
if ($productId <= 0 || $qtyPerItem <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$syncComponent->execute([
|
||||||
|
':sellable_item_id' => $sellableItemId,
|
||||||
|
':product_id' => $productId,
|
||||||
|
':qty_per_item' => $qtyPerItem,
|
||||||
|
]);
|
||||||
|
$componentIds[] = $productId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($componentIds !== []) {
|
||||||
|
$placeholders = [];
|
||||||
|
$params = [':sellable_item_id' => $sellableItemId];
|
||||||
|
foreach ($componentIds as $idx => $productId) {
|
||||||
|
$key = ':product_id_' . $idx;
|
||||||
|
$placeholders[] = $key;
|
||||||
|
$params[$key] = $productId;
|
||||||
|
}
|
||||||
|
$deleteStmt = $pdo->prepare(
|
||||||
|
'DELETE FROM public.sellable_item_component
|
||||||
|
WHERE sellable_item_id = :sellable_item_id
|
||||||
|
AND product_id NOT IN (' . implode(', ', $placeholders) . ')'
|
||||||
|
);
|
||||||
|
$deleteStmt->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$aliasChanged = ensure_alias_points_to_sellable_item($pdo, $sellableItemId, $articleNumber, $title);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sellableItemId' => $sellableItemId,
|
||||||
|
'createdSellableItem' => $createdSellable,
|
||||||
|
'aliasCreatedOrUpdated' => $aliasChanged,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sellable_item_exists(PDO $pdo, int $sellableItemId): bool
|
function sellable_item_exists(PDO $pdo, int $sellableItemId): bool
|
||||||
@@ -1289,13 +1425,25 @@ try {
|
|||||||
$lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null;
|
$lineTotal = $unitPrice !== null ? round($qty * $unitPrice, 2) : null;
|
||||||
$sellableItemId = resolve_sellable_item_id($pdo, $articleNumber, $title);
|
$sellableItemId = resolve_sellable_item_id($pdo, $articleNumber, $title);
|
||||||
$autoMappingMeta = null;
|
$autoMappingMeta = null;
|
||||||
|
if ($sellableItemId === null) {
|
||||||
|
$inferredComponents = infer_components_from_title($pdo, $title);
|
||||||
|
if ($inferredComponents !== []) {
|
||||||
|
$autoMappingMeta = ensure_sellable_mapping_from_title_components(
|
||||||
|
$pdo,
|
||||||
|
$articleNumber,
|
||||||
|
$title,
|
||||||
|
$inferredComponents
|
||||||
|
);
|
||||||
|
$sellableItemId = (int) $autoMappingMeta['sellableItemId'];
|
||||||
|
}
|
||||||
|
}
|
||||||
if ($sellableItemId === null) {
|
if ($sellableItemId === null) {
|
||||||
$fallbackProductId = resolve_product_id_fallback($pdo, $articleNumber, $title);
|
$fallbackProductId = resolve_product_id_fallback($pdo, $articleNumber, $title);
|
||||||
if ($fallbackProductId !== null) {
|
if ($fallbackProductId !== null) {
|
||||||
$autoMappingMeta = ensure_sellable_mapping_from_product_fallback(
|
$autoMappingMeta = ensure_sellable_mapping_from_product_fallback(
|
||||||
$pdo,
|
$pdo,
|
||||||
$fallbackProductId,
|
$fallbackProductId,
|
||||||
$articleNumber,
|
'',
|
||||||
$title
|
$title
|
||||||
);
|
);
|
||||||
$sellableItemId = (int) $autoMappingMeta['sellableItemId'];
|
$sellableItemId = (int) $autoMappingMeta['sellableItemId'];
|
||||||
@@ -1325,7 +1473,7 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
if (is_array($autoMappingMeta)) {
|
if (is_array($autoMappingMeta)) {
|
||||||
$inventory['linesMappedViaFallbackProduct']++;
|
$inventory['linesMappedViaFallbackProduct']++;
|
||||||
$inventory['warnings'][] = "Line {$lineNo} auto-mapped from product fallback (sellable_item_id={$sellableItemId})";
|
$inventory['warnings'][] = "Line {$lineNo} auto-mapped from title/product fallback (sellable_item_id={$sellableItemId})";
|
||||||
}
|
}
|
||||||
$inventory['linesMapped']++;
|
$inventory['linesMapped']++;
|
||||||
$allocationResult = allocate_line_inventory(
|
$allocationResult = allocate_line_inventory(
|
||||||
|
|||||||
Reference in New Issue
Block a user