商品成本:按历史唯一规格映射平台数字SKU #482

Merged
chenxi merged 2 commits from feature/chenxi-platform-sku-auto-mapping into main 2026-10-24 10:18:22 +08:00
5 changed files with 395 additions and 6 deletions

View File

@ -0,0 +1,84 @@
<?php
namespace App\Business;
/**
* 平台数字 SKU → 历史已拆分内部商品编码。
*
* 平台 shop_sku_id 已包含平台规格维度;仅当同一 shop_sku_id + shop_i_id
* 的历史内部编码唯一时才允许替换,多候选保持待核验。
*/
class StoreReceiptPlatformSkuMapper
{
/** @var array<string,array<string,int>> */
private array $internalSkus = [];
/** @var array<string,array<string,int>> */
private array $internalProperties = [];
/** @var array<string,int> */
private array $historyRows = [];
/** @param iterable<object|array<string,mixed>> $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<string>,
* history_rows:int,
* internal_properties:list<string>
* }
*/
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);
}
}

View File

@ -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<string,list<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,src_combine_sku_id:string}>> $lines */
/** @var array<string,list<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,shop_sku_id:string,properties_value:string,src_combine_sku_id:string}>> $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<string,list<array<string,mixed>>> $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<array<string,mixed>> $lines
* @return array{
* lines:list<array<string,mixed>>,
* 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<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,src_combine_sku_id:string}> $rows
* @return list<array{sku:string,qty:float,amount:float,warehouse:string}>
* @param list<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,shop_sku_id:string,properties_value:string,src_combine_sku_id:string}> $rows
* @return list<array{sku:string,qty:float,amount:float,warehouse:string,shop_i_id:string,shop_sku_id:string,properties_value:string}>
*/
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'],
];

View File

@ -112,6 +112,7 @@
常州简尚家居有限公司对应映射表里的「配件单仓」行),
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」
(采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。
平台数字 SKU 会按平台 SKU ID + 商品 ID 回查历史已拆分订单,仅唯一内部编码自动替换;多候选保持待核验。
同一订单下已有内部商品编码时,平台数字商品占位行不参与成本;组合装拆分后,组合装主行不参与成本,只取组件行。
拆单只取最末级订单行(没有任何下级的叶子行),主单和中间汇总层不参与、避免重复计数;
同一单分多仓发货的按末级实际仓库分行;虚拟条码(X0000x)不计入;

View File

@ -0,0 +1,86 @@
<?php
namespace Tests\Unit\Business;
use App\Business\StoreReceiptPlatformSkuMapper;
use PHPUnit\Framework\TestCase;
class StoreReceiptPlatformSkuMapperTest extends TestCase
{
public function test_unique_historical_internal_sku_is_mapped(): void
{
$mapper = new StoreReceiptPlatformSkuMapper([
$this->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<string,string> */
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' ? '经典北美黑胡桃色' : '',
];
}
}

View File

@ -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<array<string,mixed>>,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,
];
}