diff --git a/app/Business/StoreReceiptPlatformSkuMapper.php b/app/Business/StoreReceiptPlatformSkuMapper.php new file mode 100644 index 00000000..cf93f42f --- /dev/null +++ b/app/Business/StoreReceiptPlatformSkuMapper.php @@ -0,0 +1,84 @@ +> */ + private array $internalSkus = []; + + /** @var array> */ + private array $internalProperties = []; + + /** @var array */ + private array $historyRows = []; + + /** @param iterable> $historyRows */ + public function __construct(iterable $historyRows) + { + foreach ($historyRows as $row) { + $shopSkuId = trim((string) $this->value($row, 'shop_sku_id')); + $shopItemId = trim((string) $this->value($row, 'shop_i_id')); + $skuId = trim((string) $this->value($row, 'sku_id')); + $itemId = trim((string) $this->value($row, 'i_id')); + if ($shopSkuId === '' || $shopItemId === '' || $skuId === '') { + continue; + } + + // 平台原始行的 i_id 等于 shop_i_id;内部拆分行的 i_id 是内部款式编码。 + if ($itemId === '' || $itemId === $shopItemId) { + continue; + } + + $key = $this->key($shopSkuId, $shopItemId); + $this->internalSkus[$key][$skuId] = ($this->internalSkus[$key][$skuId] ?? 0) + 1; + $property = trim((string) $this->value($row, 'properties_value')); + if ($property !== '') { + $this->internalProperties[$key][$property] = ($this->internalProperties[$key][$property] ?? 0) + 1; + } + $this->historyRows[$key] = ($this->historyRows[$key] ?? 0) + 1; + } + } + + /** + * @return array{ + * status:string, + * internal_sku:?string, + * candidates:list, + * history_rows:int, + * internal_properties:list + * } + */ + public function resolve(string $shopSkuId, string $shopItemId): array + { + $key = $this->key(trim($shopSkuId), trim($shopItemId)); + $skus = array_keys($this->internalSkus[$key] ?? []); + sort($skus); + $properties = array_keys($this->internalProperties[$key] ?? []); + sort($properties); + + return [ + 'status' => count($skus) === 1 ? 'unique' : ($skus === [] ? 'missing' : 'ambiguous'), + 'internal_sku' => count($skus) === 1 ? $skus[0] : null, + 'candidates' => $skus, + 'history_rows' => (int) ($this->historyRows[$key] ?? 0), + 'internal_properties' => $properties, + ]; + } + + private function key(string $shopSkuId, string $shopItemId): string + { + return $shopSkuId . "\0" . $shopItemId; + } + + private function value(object|array $row, string $key): mixed + { + return is_array($row) ? ($row[$key] ?? null) : ($row->{$key} ?? null); + } +} diff --git a/app/Console/Commands/SyncStoreReceiptProductCost.php b/app/Console/Commands/SyncStoreReceiptProductCost.php index b855944f..6a4fac23 100644 --- a/app/Console/Commands/SyncStoreReceiptProductCost.php +++ b/app/Console/Commands/SyncStoreReceiptProductCost.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Business\StoreReceiptPlatformSkuMapper; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -186,7 +187,7 @@ class SyncStoreReceiptProductCost extends Command } $this->info('订单侧候选单号:' . count($candidates)); - /** @var array> $lines */ + /** @var array> $lines */ $lines = []; $excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'"; foreach (self::SOURCES as $source) { @@ -199,7 +200,8 @@ class SyncStoreReceiptProductCost extends Command . " o.o_id as o_id, o.link_o_id as parent_o_id, " . " CONCAT(o.o_id, '-', i.oi_id) as row_key, " . " i.sku_id as sku_id, i.qty as qty, i.amount as amount, o.status as status, " - . " i.shop_i_id as shop_i_id, i.src_combine_sku_id as src_combine_sku_id + . " i.shop_i_id as shop_i_id, i.shop_sku_id as shop_sku_id, " + . " i.properties_value as properties_value, i.src_combine_sku_id as src_combine_sku_id FROM tmp_cost_order_nos t STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id @@ -261,12 +263,15 @@ class SyncStoreReceiptProductCost extends Command 'status' => (string) ($row->status ?? ''), 'row_key' => (string) $row->row_key, 'shop_i_id' => trim((string) ($row->shop_i_id ?? '')), + 'shop_sku_id' => trim((string) ($row->shop_sku_id ?? '')), + 'properties_value' => trim((string) ($row->properties_value ?? '')), 'src_combine_sku_id' => mb_strtoupper(trim((string) ($row->src_combine_sku_id ?? ''))), ]; } } $this->info('订单侧命中核心单号:' . count($lines)); + $platformSkuMapper = $this->platformSkuMapper($mirror, $lines); $shortNames = $this->warehouseShortNames(); $prices = $this->weightedAverageUnitPrices($mirror, $reportMonth); // 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。 @@ -297,6 +302,9 @@ class SyncStoreReceiptProductCost extends Command $matchedPriceRows = 0; $placeholderExcludedRows = 0; $bundleParentExcludedRows = 0; + $autoReplacedRows = 0; + $mappingAmbiguousRows = 0; + $mappingMissingRows = 0; $pendingCostRows = []; foreach ($billByCore as $core => $bill) { @@ -314,6 +322,11 @@ class SyncStoreReceiptProductCost extends Command ); $placeholderExcludedRows += $excludedPlaceholders; $bundleParentExcludedRows += $excludedBundleParents; + $mapped = $this->mapPlatformSkus($costLines, $platformSkuMapper); + $costLines = $mapped['lines']; + $autoReplacedRows += $mapped['auto_replaced_rows']; + $mappingAmbiguousRows += $mapped['ambiguous_rows']; + $mappingMissingRows += $mapped['missing_rows']; foreach ($costLines as $line) { $short = $shortNames['by_provider'][$line['warehouse']] ?? ''; @@ -373,10 +386,20 @@ class SyncStoreReceiptProductCost extends Command 'warehouse' => $line['warehouse'] !== '' ? $line['warehouse'] : null, 'warehouse_short_name' => $short !== '' ? $short : null, 'product_code' => $line['sku'], + 'original_platform_sku' => $line['original_platform_sku'] ?? null, + 'shop_sku_id' => $line['shop_sku_id'] ?? null, + 'shop_i_id' => $line['shop_i_id'] ?? null, + 'properties_value' => $line['properties_value'] ?? null, + 'mapping_status' => $line['platform_mapping_status'] ?? null, + 'mapping_candidates' => $line['platform_mapping_candidates'] ?? [], 'product_quantity' => $quantity, 'reason' => $short === '' ? '未匹配仓库简称' - : '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价', + : (($line['platform_mapping_status'] ?? null) === 'ambiguous' + ? '平台 shop_sku_id 对应多个历史内部商品编码' + : (($line['platform_mapping_status'] ?? null) === 'missing' + ? '平台 shop_sku_id 未找到历史内部商品编码' + : '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价')), 'code_type' => $this->pendingCodeType((string) $line['sku']), ]; } @@ -437,6 +460,12 @@ class SyncStoreReceiptProductCost extends Command $missingPriceRows, number_format((float) $pendingCostQuantity, 2), )); + $this->info(sprintf( + ' 平台数字 SKU 自动替换:%d 行;多候选保留:%d 行;无历史映射保留:%d 行', + $autoReplacedRows, + $mappingAmbiguousRows, + $mappingMissingRows, + )); $result = [ 'status' => 'success', @@ -450,6 +479,9 @@ class SyncStoreReceiptProductCost extends Command 'placeholder_excluded_rows' => $placeholderExcludedRows, 'bundle_parent_excluded_rows' => $bundleParentExcludedRows, 'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows, + 'auto_replaced_platform_sku_rows' => $autoReplacedRows, + 'platform_sku_mapping_ambiguous_rows' => $mappingAmbiguousRows, + 'platform_sku_mapping_missing_rows' => $mappingMissingRows, 'pending_cost_rows' => $missingPriceRows, 'pending_cost_quantity' => round((float) $pendingCostQuantity, 4), 'unit_mismatch_rows' => $mismatchRows, @@ -498,6 +530,152 @@ class SyncStoreReceiptProductCost extends Command ]); } + /** + * @param array>> $lines + */ + private function platformSkuMapper($mirror, array $lines): StoreReceiptPlatformSkuMapper + { + $candidates = []; + foreach ($lines as $orderLines) { + foreach ($orderLines as $line) { + $sku = mb_strtoupper(trim((string) ($line['sku'] ?? ''))); + $shopSkuId = trim((string) ($line['shop_sku_id'] ?? '')); + $shopItemId = trim((string) ($line['shop_i_id'] ?? '')); + if ( + preg_match('/^\d+$/', $sku) === 1 + && $shopSkuId !== '' + && $shopItemId !== '' + ) { + $candidates[$shopSkuId . "\0" . $shopItemId] = [ + 'shop_sku_id' => $shopSkuId, + 'shop_i_id' => $shopItemId, + ]; + } + } + } + + if ($candidates === []) { + return new StoreReceiptPlatformSkuMapper([]); + } + + $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_platform_sku_keys'); + $mirror->statement( + 'CREATE TEMPORARY TABLE tmp_platform_sku_keys (' + . 'shop_sku_id varchar(255) NOT NULL, ' + . 'shop_i_id varchar(255) NOT NULL, ' + . 'PRIMARY KEY (shop_sku_id, shop_i_id)' + . ') ENGINE=InnoDB', + ); + + $historyRows = []; + try { + foreach (array_chunk(array_values($candidates), 500) as $chunk) { + $mirror->table('tmp_platform_sku_keys')->insertOrIgnore($chunk); + } + + foreach (self::SOURCES as $source) { + $rows = $mirror->select( + "SELECT i.shop_sku_id, i.shop_i_id, i.sku_id, i.i_id, i.properties_value + FROM {$source['items']} i + STRAIGHT_JOIN tmp_platform_sku_keys k + ON k.shop_sku_id = i.shop_sku_id + AND k.shop_i_id = i.shop_i_id + WHERE COALESCE(i.i_id, '') <> '' + AND i.i_id <> i.shop_i_id", + ); + foreach ($rows as $row) { + $historyRows[] = $row; + } + } + } catch (Throwable $exception) { + $this->warn('平台 SKU 历史映射读取失败,本批数字 SKU 保持待核验:' . $exception->getMessage()); + } finally { + $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_platform_sku_keys'); + } + + $this->info(sprintf( + '平台数字 SKU 候选:%d 个;历史内部商品映射记录:%d 条', + count($candidates), + count($historyRows), + )); + + return new StoreReceiptPlatformSkuMapper($historyRows); + } + + /** + * @param list> $lines + * @return array{ + * lines:list>, + * auto_replaced_rows:int, + * ambiguous_rows:int, + * missing_rows:int + * } + */ + private function mapPlatformSkus( + array $lines, + StoreReceiptPlatformSkuMapper $mapper, + ): array { + $mappedLines = []; + foreach ($lines as $line) { + $sku = mb_strtoupper(trim((string) $line['sku'])); + $shopSkuId = trim((string) ($line['shop_sku_id'] ?? '')); + $shopItemId = trim((string) ($line['shop_i_id'] ?? '')); + $line['platform_mapping_status'] = null; + $line['platform_mapping_candidates'] = []; + $line['original_platform_sku'] = null; + + if ( + preg_match('/^\d+$/', $sku) === 1 + && $shopSkuId !== '' + && $shopItemId !== '' + ) { + $resolution = $mapper->resolve($shopSkuId, $shopItemId); + $line['platform_mapping_status'] = $resolution['status']; + $line['platform_mapping_candidates'] = $resolution['candidates']; + $line['original_platform_sku'] = $sku; + if ($resolution['status'] === 'unique' && $resolution['internal_sku'] !== null) { + $line['sku'] = mb_strtoupper($resolution['internal_sku']); + $line['auto_mapped_platform_sku'] = true; + } + } + + $key = $line['warehouse'] . "\0" . $line['sku']; + if (! isset($mappedLines[$key])) { + $mappedLines[$key] = $line; + continue; + } + + $mappedLines[$key]['qty'] += $line['qty']; + $mappedLines[$key]['amount'] += $line['amount']; + if (! empty($line['auto_mapped_platform_sku'])) { + $mappedLines[$key]['auto_mapped_platform_sku'] = true; + $mappedLines[$key]['original_platform_sku'] = $line['original_platform_sku']; + $mappedLines[$key]['platform_mapping_status'] = 'unique'; + $mappedLines[$key]['platform_mapping_candidates'] = $line['platform_mapping_candidates']; + } + } + + $autoReplacedRows = 0; + $ambiguousRows = 0; + $missingRows = 0; + foreach ($mappedLines as $line) { + if (! empty($line['auto_mapped_platform_sku'])) { + $autoReplacedRows++; + } elseif (($line['platform_mapping_status'] ?? null) === 'ambiguous') { + $ambiguousRows++; + } elseif (($line['platform_mapping_status'] ?? null) === 'missing') { + $missingRows++; + } + } + + return [ + 'lines' => array_values($mappedLines), + 'auto_replaced_rows' => $autoReplacedRows, + 'ambiguous_rows' => $ambiguousRows, + 'missing_rows' => $missingRows, + ]; + } + private function pendingCodeType(string $code): string { $code = mb_strtoupper(trim($code)); @@ -532,8 +710,8 @@ class SyncStoreReceiptProductCost extends Command * 如果带仓库的行全部是 Cancelled,则整单不参与商品成本; * 4) 排除 Cancelled 行,同一订单的多条末级行按「发货仓库 + 商品编码」汇总数量。 * - * @param list $rows - * @return list + * @param list $rows + * @return list */ private function costLines( array $rows, @@ -657,10 +835,16 @@ class SyncStoreReceiptProductCost extends Command // 8) 汇总到「发货仓库 + 商品编码」。 $aggregated = []; foreach ($unique as $row) { - $key = $row['warehouse'] . "\0" . $row['sku']; + $isPlatformSku = preg_match('/^\d+$/', (string) $row['sku']) === 1; + $key = $row['warehouse'] + . "\0" . $row['sku'] + . ($isPlatformSku ? "\0" . ($row['shop_sku_id'] ?? '') : ''); $aggregated[$key] = [ 'sku' => $row['sku'], 'warehouse' => $row['warehouse'], + 'shop_i_id' => (string) ($row['shop_i_id'] ?? ''), + 'shop_sku_id' => (string) ($row['shop_sku_id'] ?? ''), + 'properties_value' => (string) ($row['properties_value'] ?? ''), 'qty' => ($aggregated[$key]['qty'] ?? 0.0) + $row['qty'], 'amount' => ($aggregated[$key]['amount'] ?? 0.0) + $row['amount'], ]; diff --git a/resources/views/business/pages/financial-operations/product-costs.blade.php b/resources/views/business/pages/financial-operations/product-costs.blade.php index 97bb5d63..2d6809c9 100644 --- a/resources/views/business/pages/financial-operations/product-costs.blade.php +++ b/resources/views/business/pages/financial-operations/product-costs.blade.php @@ -112,6 +112,7 @@ 常州简尚家居有限公司对应映射表里的「配件单仓」行), 商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」 (采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。 + 平台数字 SKU 会按平台 SKU ID + 商品 ID 回查历史已拆分订单,仅唯一内部编码自动替换;多候选保持待核验。 同一订单下已有内部商品编码时,平台数字商品占位行不参与成本;组合装拆分后,组合装主行不参与成本,只取组件行。 拆单只取最末级订单行(没有任何下级的叶子行),主单和中间汇总层不参与、避免重复计数; 同一单分多仓发货的按末级实际仓库分行;虚拟条码(X0000x)不计入; diff --git a/tests/Unit/Business/StoreReceiptPlatformSkuMapperTest.php b/tests/Unit/Business/StoreReceiptPlatformSkuMapperTest.php new file mode 100644 index 00000000..4f27fb81 --- /dev/null +++ b/tests/Unit/Business/StoreReceiptPlatformSkuMapperTest.php @@ -0,0 +1,86 @@ +row('958530694526', '3768947181063635112', '3768947181063635112'), + $this->row('SG006367N1028V01', 'SG0063', '3768947181063635112'), + $this->row('SG006367N1028V01', 'SG0063', '3768947181063635112'), + ]); + + $result = $mapper->resolve('3533103677164802', '3768947181063635112'); + + $this->assertSame('unique', $result['status']); + $this->assertSame('SG006367N1028V01', $result['internal_sku']); + $this->assertSame(2, $result['history_rows']); + $this->assertSame(['经典北美黑胡桃色'], $result['internal_properties']); + } + + public function test_multiple_internal_skus_remain_ambiguous(): void + { + $mapper = new StoreReceiptPlatformSkuMapper([ + $this->row('SG001', 'SGSTYLE1', '3768947181063635112'), + $this->row('SG002', 'SGSTYLE2', '3768947181063635112'), + ]); + + $result = $mapper->resolve('3533103677164802', '3768947181063635112'); + + $this->assertSame('ambiguous', $result['status']); + $this->assertNull($result['internal_sku']); + $this->assertSame(['SG001', 'SG002'], $result['candidates']); + } + + public function test_missing_history_stays_unmapped(): void + { + $mapper = new StoreReceiptPlatformSkuMapper([ + $this->row('958530694526', '3768947181063635112', '3768947181063635112'), + ]); + + $result = $mapper->resolve('3533103677164802', '3768947181063635112'); + + $this->assertSame('missing', $result['status']); + $this->assertNull($result['internal_sku']); + $this->assertSame([], $result['candidates']); + } + + public function test_mapping_requires_the_same_platform_spec_id(): void + { + $mapper = new StoreReceiptPlatformSkuMapper([ + $this->row( + 'SG006367N1028V01', + 'SG0063', + '3768947181063635112', + '3533103677164802', + ), + ]); + + $result = $mapper->resolve('3533103677164999', '3768947181063635112'); + + $this->assertSame('missing', $result['status']); + $this->assertNull($result['internal_sku']); + } + + /** @return array */ + private function row( + string $skuId, + string $itemId, + string $shopItemId, + string $shopSkuId = '3533103677164802', + ): array + { + return [ + 'shop_sku_id' => $shopSkuId, + 'shop_i_id' => $shopItemId, + 'sku_id' => $skuId, + 'i_id' => $itemId, + 'properties_value' => $itemId === 'SG0063' ? '经典北美黑胡桃色' : '', + ]; + } +} diff --git a/tests/Unit/StoreReceiptProductCostLineTest.php b/tests/Unit/StoreReceiptProductCostLineTest.php index c530064f..d69774fe 100644 --- a/tests/Unit/StoreReceiptProductCostLineTest.php +++ b/tests/Unit/StoreReceiptProductCostLineTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use App\Business\StoreReceiptPlatformSkuMapper; use App\Console\Commands\SyncStoreReceiptProductCost; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -41,6 +42,37 @@ class StoreReceiptProductCostLineTest extends TestCase $this->assertSame(2.0, $lines[0]['qty']); } + public function test_unique_platform_sku_is_replaced_after_line_filtering(): void + { + [$lines] = $this->costLines([ + $this->row( + 'platform', + '958530694526', + '3768947181063635112', + 1, + 93.25, + ), + ]); + $mapper = new StoreReceiptPlatformSkuMapper([ + [ + 'shop_sku_id' => '3533103677164802', + 'shop_i_id' => '3768947181063635112', + 'sku_id' => 'SG006367N1028V01', + 'i_id' => 'SG0063', + 'properties_value' => '经典北美黑胡桃色', + ], + ]); + $command = new SyncStoreReceiptProductCost(); + $method = new ReflectionMethod($command, 'mapPlatformSkus'); + $method->setAccessible(true); + $result = $method->invoke($command, $lines, $mapper); + + $this->assertSame(1, $result['auto_replaced_rows']); + $this->assertSame('SG006367N1028V01', $result['lines'][0]['sku']); + $this->assertSame('958530694526', $result['lines'][0]['original_platform_sku']); + $this->assertSame('unique', $result['lines'][0]['platform_mapping_status']); + } + /** @return array{0:list>,1:int,2:int} */ private function costLines(array $rows): array { @@ -74,6 +106,8 @@ class StoreReceiptProductCostLineTest extends TestCase 'row_key' => $rowKey, 'status' => 'Sent', 'shop_i_id' => $shopItemId, + 'shop_sku_id' => '3533103677164802', + 'properties_value' => '颜色分类:六格-90*30*75cm-木纹胡桃色?赠送后背板', 'src_combine_sku_id' => $bundleParent, ]; }