diff --git a/app/Console/Commands/SyncStoreReceiptProductCost.php b/app/Console/Commands/SyncStoreReceiptProductCost.php index 8e74c8d5..1e668f8e 100644 --- a/app/Console/Commands/SyncStoreReceiptProductCost.php +++ b/app/Console/Commands/SyncStoreReceiptProductCost.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use InvalidArgumentException; use Modules\EcommerceFinance\Repositories\GoodsProfitAfsRepository; +use Modules\EcommerceFinance\Repositories\ProductMaterialBarcodeMappingRepository; use Throwable; /** @@ -321,6 +322,20 @@ class SyncStoreReceiptProductCost extends Command count($catalogPrices) )); + // 91. 基础料号先按条码打印导入记录映射内部商品编码。 + // 多个不同目标编码时保持原编码待核验,不能按相似度或时间随机选择。 + $materialCodes91 = $this->materialCodes91($lines); + $materialBarcodeMappings = app(ProductMaterialBarcodeMappingRepository::class) + ->resolve($materialCodes91); + $this->info(sprintf( + '91. 基础料号:%d 个;唯一映射 %d 个;多候选 %d 个;未找到映射 %d 个;映射源返回 %d 条', + count($materialCodes91), + count($materialBarcodeMappings['unique']), + count($materialBarcodeMappings['ambiguous']), + count($materialBarcodeMappings['missing']), + $materialBarcodeMappings['source_rows'], + )); + $this->pruneTarget($connection, $reportMonth, $platform); $target = DB::connection($connection)->table(self::TARGET_TABLE); @@ -344,6 +359,12 @@ class SyncStoreReceiptProductCost extends Command $afsReplacedRows = 0; $mappingAmbiguousRows = 0; $mappingMissingRows = 0; + $materialMappedRows = 0; + $materialMappingAmbiguousRows = 0; + $materialMappingMissingRows = 0; + $materialMappedCollapsedRows = 0; + $materialMappedQuantity = 0.0; + $materialMappedCost = 0.0; $pendingCostRows = []; $placeholderCodeSets = [ 'chinese_alias_placeholder_codes' => [], @@ -400,6 +421,13 @@ class SyncStoreReceiptProductCost extends Command $bundleRollup[$bundleMetric] += $bundleValue; } } + $materialMapped = $this->map91MaterialSkus($costLines, $materialBarcodeMappings); + $costLines = $materialMapped['lines']; + $materialMappedRows += $materialMapped['mapped_rows']; + $materialMappingAmbiguousRows += $materialMapped['ambiguous_rows']; + $materialMappingMissingRows += $materialMapped['missing_rows']; + $materialMappedCollapsedRows += $materialMapped['collapsed_rows']; + $mapped = $this->mapPlatformSkus( $costLines, $platformSkuMappings['history'], @@ -472,6 +500,10 @@ class SyncStoreReceiptProductCost extends Command 'properties_value' => $line['properties_value'] ?? null, 'mapping_status' => $line['platform_mapping_status'] ?? null, 'mapping_candidates' => $line['platform_mapping_candidates'] ?? [], + 'original_material_code' => $line['original_material_code'] ?? null, + 'normalized_material_code' => $line['normalized_material_code'] ?? null, + 'material_mapping_status' => $line['material_mapping_status'] ?? null, + 'material_mapping_candidates' => $line['material_mapping_candidates'] ?? [], 'bundle_resolution_status' => $line['bundle_resolution_status'] ?? null, 'bundle_component_rows' => (int) ($line['bundle_component_rows'] ?? 0), 'bundle_component_amount' => $line['bundle_component_amount'] ?? null, @@ -487,13 +519,17 @@ class SyncStoreReceiptProductCost extends Command ) ? '组合装组件金额与主行金额不一致,组件行暂不计成本' : '组合装未找到可计价组件') + : (($line['material_mapping_status'] ?? null) === 'ambiguous' + ? '91 基础料号在条码映射库对应多个商品编码,未自动替换' + : (($line['material_mapping_status'] ?? null) === 'missing' + ? '91 基础料号在条码映射库未找到商品编码' : ($short === '' ? '未匹配仓库简称' : (in_array(($line['platform_mapping_status'] ?? null), ['ambiguous', 'ambiguous_afs'], true) ? '平台 shop_sku_id 在历史订单或售后镜像中对应多个内部商品编码' : (($line['platform_mapping_status'] ?? null) === 'missing' ? '平台 shop_sku_id 未找到历史内部商品编码' - : '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价'))), + : '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价'))))), 'code_type' => $forceBundlePending ? (in_array( ($line['bundle_resolution_status'] ?? ''), @@ -502,12 +538,19 @@ class SyncStoreReceiptProductCost extends Command ) ? '组合装组件金额不一致' : '组合装未找到可用组件') - : $this->pendingCodeType((string) $line['sku']), + : $this->pendingCodeType( + (string) $line['sku'], + $line['material_mapping_status'] ?? null, + ), ]; } else { $matchedPriceRows++; $quantityTotal += $quantity; $costTotal += (float) $cost; + if (($line['material_mapping_status'] ?? null) === 'unique') { + $materialMappedQuantity += $quantity; + $materialMappedCost += (float) $cost; + } } $buffer[] = [ @@ -571,6 +614,15 @@ class SyncStoreReceiptProductCost extends Command $mappingAmbiguousRows, $mappingMissingRows, )); + $this->info(sprintf( + ' 91. 基础料号替换:%d 行(映射后合并 %d 行);多候选保留:%d 行;未找到映射保留:%d 行;已映射成本:%s 件 / %s 元', + $materialMappedRows, + $materialMappedCollapsedRows, + $materialMappingAmbiguousRows, + $materialMappingMissingRows, + number_format($materialMappedQuantity, 2), + number_format($materialMappedCost, 2), + )); $this->info(sprintf( ' 组合装回溯:对平 %d 组,剔除主行 %d 行,保留组件 %d 行(识别明确指向 %d 行、同规格兼容 %d 行);缺组件 %d 组,金额不一致 %d 组,暂不计组件 %d 行,忽略零值空占位 %d 组', $bundleRollup['reconciled_groups'], @@ -651,6 +703,19 @@ class SyncStoreReceiptProductCost extends Command 'afs_fallback_replaced_platform_sku_rows' => $afsReplacedRows, 'platform_sku_mapping_ambiguous_rows' => $mappingAmbiguousRows, 'platform_sku_mapping_missing_rows' => $mappingMissingRows, + 'material_91_mapping' => [ + 'candidate_codes' => $materialCodes91, + 'unique_mapping_codes' => $materialBarcodeMappings['unique'], + 'ambiguous_codes' => $materialBarcodeMappings['ambiguous'], + 'missing_codes' => $materialBarcodeMappings['missing'], + 'source_rows' => $materialBarcodeMappings['source_rows'], + 'mapped_rows' => $materialMappedRows, + 'mapped_collapsed_rows' => $materialMappedCollapsedRows, + 'ambiguous_rows' => $materialMappingAmbiguousRows, + 'missing_rows' => $materialMappingMissingRows, + 'mapped_quantity' => round($materialMappedQuantity, 4), + 'mapped_cost' => round($materialMappedCost, 2), + ], 'pending_cost_rows' => $missingPriceRows, 'pending_cost_quantity' => round((float) $pendingCostQuantity, 4), 'missing_warehouse_rows' => $missingWarehouseRows, @@ -1090,6 +1155,125 @@ class SyncStoreReceiptProductCost extends Command ]; } + /** + * 从订单侧原始行和可解析组合装中收集 91. 基础料号,供一次批量只读查询。 + * + * @param array>> $lines + * @return list + */ + private function materialCodes91(array $lines): array + { + $codes = []; + foreach ($lines as $orderLines) { + foreach ($orderLines as $line) { + $expanded = ! empty($line['force_cost_pending']) + ? [[(string) $line['sku'], (float) $line['qty'], false]] + : self::expandComboSku((string) $line['sku'], (float) $line['qty']); + foreach ($expanded as [$code]) { + $materialCode = ProductMaterialBarcodeMappingRepository::normalizeMaterialCode((string) $code); + if ($materialCode !== null) { + $codes[$materialCode] = true; + } + } + } + } + + $codes = array_keys($codes); + sort($codes, SORT_STRING); + + return $codes; + } + + /** + * 只替换唯一映射;多候选或未找到时保留原编码。 + * 替换后按「发货仓库 + 商品编码」合并,避免同一条码的不同尾缀重复写行。 + * + * @param list> $lines + * @param array{unique:array,ambiguous:array>,missing:list,source_rows:int} $mappings + * @return array{ + * lines:list>, + * mapped_rows:int, + * ambiguous_rows:int, + * missing_rows:int, + * collapsed_rows:int + * } + */ + private function map91MaterialSkus(array $lines, array $mappings): array + { + $mappedLines = []; + $mappedRows = 0; + $ambiguousRows = 0; + $missingRows = 0; + $collapsedRows = 0; + + foreach ($lines as $line) { + $originalSku = mb_strtoupper(trim((string) $line['sku'])); + $materialCode = ProductMaterialBarcodeMappingRepository::normalizeMaterialCode($originalSku); + $line['material_mapping_status'] = null; + $line['material_mapping_candidates'] = []; + $line['original_material_code'] = null; + $line['normalized_material_code'] = $materialCode; + + if ($materialCode !== null) { + if (isset($mappings['unique'][$materialCode])) { + $line['sku'] = $mappings['unique'][$materialCode]; + $line['original_material_code'] = $originalSku; + $line['material_mapping_status'] = 'unique'; + $line['material_mapping_candidates'] = [$line['sku']]; + $mappedRows++; + } elseif (isset($mappings['ambiguous'][$materialCode])) { + $line['original_material_code'] = $originalSku; + $line['material_mapping_status'] = 'ambiguous'; + $line['material_mapping_candidates'] = $mappings['ambiguous'][$materialCode]; + $ambiguousRows++; + } else { + $line['original_material_code'] = $originalSku; + $line['material_mapping_status'] = 'missing'; + $missingRows++; + } + } + + $key = (string) ($line['warehouse'] ?? '') . "\0" . (string) $line['sku']; + if (! isset($mappedLines[$key])) { + $mappedLines[$key] = $line; + continue; + } + + $existing = $mappedLines[$key]; + $collapsedRows++; + $existing['qty'] += $line['qty']; + $existing['amount'] += $line['amount']; + if (($line['material_mapping_status'] ?? null) === 'unique') { + $existing['material_mapping_status'] = 'unique'; + $existing['material_mapping_candidates'] = $line['material_mapping_candidates']; + $existing['original_material_code'] = $line['original_material_code']; + $existing['normalized_material_code'] = $line['normalized_material_code']; + } + foreach ([ + 'force_cost_pending', + 'bundle_resolution_status', + 'bundle_component_rows', + 'bundle_component_amount', + 'bundle_expected_amount', + 'bundle_amount_difference', + 'bundle_component_codes', + ] as $bundleField) { + if (! empty($line[$bundleField])) { + $existing[$bundleField] = $line[$bundleField]; + } + } + $mappedLines[$key] = $existing; + } + + return [ + 'lines' => array_values($mappedLines), + 'mapped_rows' => $mappedRows, + 'ambiguous_rows' => $ambiguousRows, + 'missing_rows' => $missingRows, + 'collapsed_rows' => $collapsedRows, + ]; + } + /** * @param list> $lines * @return array{ @@ -1390,8 +1574,15 @@ class SyncStoreReceiptProductCost extends Command ]; } - private function pendingCodeType(string $code): string + private function pendingCodeType(string $code, ?string $materialMappingStatus = null): string { + if ($materialMappingStatus === 'ambiguous') { + return '91 基础料号存在多个条码候选'; + } + if ($materialMappingStatus === 'missing') { + return '91 基础料号未找到条码映射'; + } + $code = mb_strtoupper(trim($code)); if (preg_match('/^\d+$/', $code) === 1) { return '平台数字编码未映射内部商品'; diff --git a/config/upstreams.php b/config/upstreams.php index 25aad75c..354a9786 100644 --- a/config/upstreams.php +++ b/config/upstreams.php @@ -52,6 +52,10 @@ return [ 'host' => env('UPSTREAM_TUNNEL_JS_DATA_MIDDLE_PLATFORM_HOST'), 'port' => env('UPSTREAM_TUNNEL_JS_DATA_MIDDLE_PLATFORM_PORT'), ], + 'product_barcode_catalog' => [ + 'host' => env('UPSTREAM_TUNNEL_PRODUCT_BARCODE_CATALOG_HOST'), + 'port' => env('UPSTREAM_TUNNEL_PRODUCT_BARCODE_CATALOG_PORT'), + ], ], 'local_configuration' => [ @@ -76,5 +80,9 @@ return [ 'config' => 'js_data_middle_platform', 'use_pdo_dblib' => true, ], + 'product_barcode_catalog' => [ + 'label' => '商品条码料号映射库', + 'config' => 'upstream_pipeline.defaults', + ], ], ]; diff --git a/docs/商品成本占位行规则清单.md b/docs/商品成本占位行规则清单.md index 566a540a..d4d04e49 100644 --- a/docs/商品成本占位行规则清单.md +++ b/docs/商品成本占位行规则清单.md @@ -32,6 +32,7 @@ | MKH-运营组合套装 | 商品编码以 `MKH-运营组合套装` 开头 | 不进入中文后缀或通用父子别名清洗,交给组合装组件规则 | 组件缺失时待核验 | | 零值组合占位 | 主行数量和金额均为 0,且没有可对平组件 | 不生成商品成本行 | 组件若存在但金额无法对平,忽略空主行,组件按原价格路径计价 | | 人工可读配件 | `原木色广岛`、`原木色舒美特实木面1把`、`胡桃色舒美特实木面1把` | 保持原编码,按现有价格来源计价 | 无有效价格时待核验,禁止自动删中文 | +| 91. 基础料号 | 商品编码匹配 `91.C数字.数字`,可带规格尾缀,例如 `91.C46324.01-N1280` | 去掉尾缀后查 `BarcodePrintImportRecord`:先由 `materialNo` 找基础 SKU,再取该 SKU 的 `updates.barcode`;无条码时才回退基础 SKU | 多候选或未找到映射时保留原编码并进入待核验 | | 其他中文别名 | 不属于上述分类,且找不到唯一正式编码或组件关系 | 不改写编码 | 保留原行待核验 | 中文后缀规则的严格匹配键为: @@ -69,6 +70,26 @@ 历史父编码查询使用订单明细的 `sku_id`、`src_combine_sku_id` 索引;平台 SKU 售后镜像查询与数字 SKU 映射合并执行。 +### 2.3 91. 基础料号映射 + +只处理 `91.` 开头且符合 `91.C数字.数字` 格式的商品编码。查询前去掉第一个 `-` 及其后的规格尾缀: + +```text +91.C46324.01-N1280 → 91.C46324.01 +91.C45340.01-N09 → 91.C45340.01 +91.C49784.01-N1280-1 → 91.C49784.01 +``` + +映射来源是只读来源 `product_barcode_catalog` 的 `public."BarcodePrintImportRecord"`: + +1. 按 `updates->>'materialNo'` 匹配规范化基础料号; +2. 基础 SKU 优先取 `normalizedSku`,为空时回退 `skuCode`; +3. 目标商品编码优先取同一基础 SKU 的 `updates.barcode`;没有规格条码时才回退基础 SKU; +4. 同一基础料号仅有一个不同规格条码时自动替换; +5. 同号多个规格条码时保留原编码,候选全部写入待核验清单,禁止按时间、相似度或记录顺序选择; +6. 替换后仍按「月加权平均单价 → 线上代发价格 → 普通商品资料成本价」计算成本; +7. 不同尾缀映射到同一目标编码时,按「发货仓库 + 目标商品编码」合并数量,避免重复成本行。 + ## 3. 价格匹配 商品单价按以下顺序匹配: @@ -128,6 +149,8 @@ pending_cost_quantity - 组合主行与组件金额误差超过 0.01 元时,主行保留待核验,组件不同时计入正常成本。 - 数量和金额均为 0 的空组合占位不得生成新的待核验行。 - 人工可读配件编码保持原样。 +- `91.` 基础料号只在唯一映射时替换;多候选和缺失映射不得自动改写。 +- `91.` 映射替换后的目标编码继续走原有三级价格来源,映射本身不直接赋值单价。 - 找不到唯一正式编码、仓库或组件关系的行继续进入待核验清单。 -实现入口:`app/Console/Commands/SyncStoreReceiptProductCost.php`;对应单元测试:`tests/Unit/StoreReceiptProductCostLineTest.php`。 +实现入口:`app/Console/Commands/SyncStoreReceiptProductCost.php`;映射仓储:`modules/EcommerceFinance/Repositories/ProductMaterialBarcodeMappingRepository.php`;对应单元测试:`tests/Unit/StoreReceiptProductCostLineTest.php`。 diff --git a/modules/EcommerceFinance/Repositories/ProductMaterialBarcodeMappingRepository.php b/modules/EcommerceFinance/Repositories/ProductMaterialBarcodeMappingRepository.php new file mode 100644 index 00000000..bbd28f16 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/ProductMaterialBarcodeMappingRepository.php @@ -0,0 +1,137 @@ + $materialCodes + * @return array{ + * unique:array, + * ambiguous:array>, + * missing:list, + * source_rows:int + * } + */ + public function resolve(array $materialCodes, ?UpstreamReadOnlyQuery $query = null): array + { + $normalized = []; + foreach ($materialCodes as $code) { + $materialCode = self::normalizeMaterialCode((string) $code); + if ($materialCode !== null) { + $normalized[$materialCode] = true; + } + } + $materialCodes = array_keys($normalized); + sort($materialCodes, SORT_STRING); + + if ($materialCodes === []) { + return [ + 'unique' => [], + 'ambiguous' => [], + 'missing' => [], + 'source_rows' => 0, + ]; + } + + $query ??= new UpstreamReadOnlyQuery(self::SOURCE_KEY, 15); + $candidates = []; + $sourceRows = 0; + + try { + foreach (array_chunk($materialCodes, 200) as $chunk) { + $placeholders = implode(', ', array_fill(0, count($chunk), '?')); + $rows = $query->select( + 'WITH material_skus AS (' + . 'SELECT DISTINCT ' + . "TRIM(updates->>'materialNo') AS material_no, " + . 'COALESCE(NULLIF(BTRIM("normalizedSku"), \'\'), NULLIF(BTRIM("skuCode"), \'\')) AS sku_code ' + . 'FROM public."BarcodePrintImportRecord" ' + . "WHERE TRIM(updates->>'materialNo') IN ({$placeholders}) " + . 'AND COALESCE(NULLIF(BTRIM("normalizedSku"), \'\'), NULLIF(BTRIM("skuCode"), \'\')) IS NOT NULL' + . '), barcode_matches AS (' + . 'SELECT DISTINCT material_skus.material_no, ' + . "NULLIF(BTRIM(product_rows.updates->>'barcode'), '') AS barcode " + . 'FROM material_skus ' + . 'JOIN public."BarcodePrintImportRecord" product_rows ' + . 'ON COALESCE(NULLIF(BTRIM(product_rows."normalizedSku"), \'\'), ' + . 'NULLIF(BTRIM(product_rows."skuCode"), \'\')) = material_skus.sku_code ' + . "WHERE NULLIF(BTRIM(product_rows.updates->>'barcode'), '') IS NOT NULL" + . ') ' + . 'SELECT barcode_matches.material_no, barcode_matches.barcode AS mapped_sku ' + . 'FROM barcode_matches ' + . 'UNION ' + . 'SELECT material_skus.material_no, material_skus.sku_code ' + . 'FROM material_skus ' + . 'WHERE NOT EXISTS (' + . 'SELECT 1 FROM barcode_matches ' + . 'WHERE barcode_matches.material_no = material_skus.material_no' + . ')', + $chunk, + ); + foreach ($rows as $row) { + $sourceRows++; + $materialCode = self::normalizeMaterialCode((string) ($row['material_no'] ?? '')); + $mappedSku = mb_strtoupper(trim((string) ($row['mapped_sku'] ?? ''))); + if ($materialCode === null || $mappedSku === '') { + continue; + } + $candidates[$materialCode][$mappedSku] = true; + } + } + } catch (Throwable $exception) { + throw new RuntimeException( + '91 基础料号条码映射读取失败,请检查商品条码映射库只读连接:' . $exception->getMessage(), + 0, + $exception, + ); + } + + $unique = []; + $ambiguous = []; + $missing = []; + foreach ($materialCodes as $materialCode) { + $mappedSkus = array_keys($candidates[$materialCode] ?? []); + sort($mappedSkus, SORT_STRING); + if (count($mappedSkus) === 1) { + $unique[$materialCode] = $mappedSkus[0]; + } elseif (count($mappedSkus) > 1) { + $ambiguous[$materialCode] = $mappedSkus; + } else { + $missing[] = $materialCode; + } + } + + return [ + 'unique' => $unique, + 'ambiguous' => $ambiguous, + 'missing' => $missing, + 'source_rows' => $sourceRows, + ]; + } +} diff --git a/tests/Unit/EcommerceFinance/ProductMaterialBarcodeMappingRepositoryTest.php b/tests/Unit/EcommerceFinance/ProductMaterialBarcodeMappingRepositoryTest.php new file mode 100644 index 00000000..368e40c9 --- /dev/null +++ b/tests/Unit/EcommerceFinance/ProductMaterialBarcodeMappingRepositoryTest.php @@ -0,0 +1,63 @@ +assertSame( + '91.C46324.01', + ProductMaterialBarcodeMappingRepository::normalizeMaterialCode(' 91.c46324.01-N1280 '), + ); + $this->assertSame( + '91.C45340.01', + ProductMaterialBarcodeMappingRepository::normalizeMaterialCode('91.C45340.01-N09'), + ); + $this->assertSame( + '91.C49784.01', + ProductMaterialBarcodeMappingRepository::normalizeMaterialCode('91.C49784.01-N1280-1'), + ); + $this->assertNull(ProductMaterialBarcodeMappingRepository::normalizeMaterialCode('GBGJ-SJ025102')); + $this->assertNull(ProductMaterialBarcodeMappingRepository::normalizeMaterialCode('91.OTHER')); + } + + public function test_unique_mapping_is_selected_and_ambiguous_or_missing_mapping_is_not_guessed(): void + { + $query = $this->createMock(UpstreamReadOnlyQuery::class); + $query->expects($this->once()) + ->method('select') + ->with( + $this->callback(static fn (string $sql): bool => str_contains( + $sql, + 'FROM public."BarcodePrintImportRecord"', + ) && str_contains($sql, "updates->>'barcode'") + && substr_count($sql, '?') === 3), + ['91.C45340.01', '91.C45438.01', '91.C49784.01'], + ) + ->willReturn([ + ['material_no' => '91.C45340.01', 'mapped_sku' => 'GBGJ-SJ025102N09V01'], + ['material_no' => '91.C45340.01', 'mapped_sku' => 'GBGJ-SJ025102N09V01'], + ['material_no' => '91.C45438.01', 'mapped_sku' => 'GBGJ-DNZ036601N09V01'], + ['material_no' => '91.C45438.01', 'mapped_sku' => 'MKHDNZ0409260601235'], + ]); + + $result = (new ProductMaterialBarcodeMappingRepository())->resolve([ + '91.C45340.01-N09', + '91.C45438.01-N09', + '91.C49784.01-N1280-1', + 'GBGJ-SJ025102', + ], $query); + + $this->assertSame(['91.C45340.01' => 'GBGJ-SJ025102N09V01'], $result['unique']); + $this->assertSame([ + '91.C45438.01' => ['GBGJ-DNZ036601N09V01', 'MKHDNZ0409260601235'], + ], $result['ambiguous']); + $this->assertSame(['91.C49784.01'], $result['missing']); + $this->assertSame(4, $result['source_rows']); + } +} diff --git a/tests/Unit/StoreReceiptProductCostLineTest.php b/tests/Unit/StoreReceiptProductCostLineTest.php index bc8337b5..e0feb1e2 100644 --- a/tests/Unit/StoreReceiptProductCostLineTest.php +++ b/tests/Unit/StoreReceiptProductCostLineTest.php @@ -560,6 +560,62 @@ class StoreReceiptProductCostLineTest extends TestCase $this->assertSame(552392.0, $result['cost']); } + public function test_91_material_suffixes_use_unique_mapping_and_collapse_duplicate_targets(): void + { + [$lines] = $this->costLines([ + $this->row('material-a', '91.C45340.01-N09', '3767501840732782900', 1, 59), + $this->row('material-b', '91.C45340.01-N1280', '3767501840732782900', 2, 118), + $this->row('material-ambiguous', '91.C45438.01-N09', '3767501840732782901', 1, 59), + $this->row('regular', 'REGULAR-SKU', '3767501840732782902', 1, 59), + ]); + $command = new SyncStoreReceiptProductCost(); + $method = new ReflectionMethod($command, 'map91MaterialSkus'); + $method->setAccessible(true); + + $result = $method->invoke($command, $lines, [ + 'unique' => ['91.C45340.01' => 'GBGJ-SJ025102N09V01'], + 'ambiguous' => [ + '91.C45438.01' => ['GBGJ-DNZ036601N09V01', 'MKHDNZ0409260601235'], + ], + 'missing' => [], + 'source_rows' => 2, + ]); + + $this->assertSame(2, $result['mapped_rows']); + $this->assertSame(1, $result['ambiguous_rows']); + $this->assertSame(0, $result['missing_rows']); + $this->assertSame(1, $result['collapsed_rows']); + + $bySku = collect($result['lines'])->keyBy('sku'); + $this->assertSame(3.0, $bySku['GBGJ-SJ025102N09V01']['qty']); + $this->assertSame('unique', $bySku['GBGJ-SJ025102N09V01']['material_mapping_status']); + $this->assertSame('91.C45340.01', $bySku['GBGJ-SJ025102N09V01']['normalized_material_code']); + $this->assertSame('91.C45438.01-N09', $bySku['91.C45438.01-N09']['sku']); + $this->assertSame('ambiguous', $bySku['91.C45438.01-N09']['material_mapping_status']); + } + + public function test_91_material_candidates_include_structured_combo_components(): void + { + $command = new SyncStoreReceiptProductCost(); + $method = new ReflectionMethod($command, 'materialCodes91'); + $method->setAccessible(true); + + $codes = $method->invoke($command, [ + 'ORDER-1' => [ + $this->row('direct', '91.C46324.01-N1280', '3767501840732782900', 1, 59), + $this->row( + 'combo', + 'ZH-91.C45340.01-N09-2-GBGJ-SJ025102-1', + '3767501840732782901', + 1, + 177, + ), + ], + ]); + + $this->assertSame(['91.C45340.01', '91.C46324.01'], $codes); + } + /** @return array{0:list>,1:int,2:int,3:int,4:int,5:array} */ private function costLines(array $rows, array $bundleProfiles = []): array {