商品成本:按91料号映射规格条码

将91基础料号映射为唯一规格条码后继续沿用三级价格链计算成本;多规格候选保持待核验。
This commit is contained in:
chenxi 2026-10-25 03:28:05 +08:00
parent 50ab5974fb
commit dd8dfddacf
6 changed files with 482 additions and 4 deletions

View File

@ -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<string,list<array<string,mixed>>> $lines
* @return list<string>
*/
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<array<string,mixed>> $lines
* @param array{unique:array<string,string>,ambiguous:array<string,list<string>>,missing:list<string>,source_rows:int} $mappings
* @return array{
* lines:list<array<string,mixed>>,
* 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<array<string,mixed>> $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 '平台数字编码未映射内部商品';

View File

@ -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',
],
],
];

View File

@ -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`。

View File

@ -0,0 +1,137 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use RuntimeException;
use Throwable;
/**
* 商品成本使用的基础料号 → 内部商品编码映射。
*
* 只读取条码打印导入记录的固定字段;数据库账号和连接参数由系统设置中的
* product_barcode_catalog 来源提供,不进入仓库或业务数据。
*/
class ProductMaterialBarcodeMappingRepository
{
public const SOURCE_KEY = 'product_barcode_catalog';
/**
* 「91.C数字.数字」是条码映射库里的基础料号,后面的规格尾缀不参与查询。
*/
public static function normalizeMaterialCode(string $code): ?string
{
$code = mb_strtoupper(trim($code));
if (preg_match('/^(91\.C\d+\.\d+)(?:-.*)?$/i', $code, $matches) !== 1) {
return null;
}
return $matches[1];
}
/**
* @param list<string> $materialCodes
* @return array{
* unique:array<string,string>,
* ambiguous:array<string,list<string>>,
* missing:list<string>,
* 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,
];
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\ProductMaterialBarcodeMappingRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use PHPUnit\Framework\TestCase;
class ProductMaterialBarcodeMappingRepositoryTest extends TestCase
{
public function test_only_91_material_codes_are_normalized_and_suffixes_are_removed(): void
{
$this->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']);
}
}

View File

@ -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<array<string,mixed>>,1:int,2:int,3:int,4:int,5:array<string,mixed>} */
private function costLines(array $rows, array $bundleProfiles = []): array
{