商品成本:按91料号映射规格条码
将91基础料号映射为唯一规格条码后继续沿用三级价格链计算成本;多规格候选保持待核验。
This commit is contained in:
parent
50ab5974fb
commit
dd8dfddacf
@ -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 '平台数字编码未映射内部商品';
|
||||
|
||||
@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user