商品成本:剔除可核对的加号组合父行 (#497)

仅在组件金额合计与父行一致时剔除加号组合父行,其余继续待核验。
This commit is contained in:
chenxi 2026-10-24 15:55:41 +08:00
parent c78ce5e00c
commit 9a9e01461c
3 changed files with 101 additions and 14 deletions

View File

@ -180,7 +180,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,shop_sku_id:string,properties_value: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,outer_oi_id:string,properties_value:string,src_combine_sku_id:string}>> $lines */
$lines = [];
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
foreach (self::SOURCES as $source) {
@ -194,6 +194,7 @@ class SyncStoreReceiptProductCost extends Command
. " 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.shop_sku_id as shop_sku_id, "
. " i.outer_oi_id as outer_oi_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
@ -257,6 +258,7 @@ class SyncStoreReceiptProductCost extends Command
'row_key' => (string) $row->row_key,
'shop_i_id' => trim((string) ($row->shop_i_id ?? '')),
'shop_sku_id' => trim((string) ($row->shop_sku_id ?? '')),
'outer_oi_id' => trim((string) ($row->outer_oi_id ?? '')),
'properties_value' => trim((string) ($row->properties_value ?? '')),
'src_combine_sku_id' => mb_strtoupper(trim((string) ($row->src_combine_sku_id ?? ''))),
];
@ -294,6 +296,7 @@ class SyncStoreReceiptProductCost extends Command
$matchedPriceRows = 0;
$placeholderExcludedRows = 0;
$bundleParentExcludedRows = 0;
$plusParentExcludedRows = 0;
$autoReplacedRows = 0;
$mappingAmbiguousRows = 0;
$mappingMissingRows = 0;
@ -307,13 +310,16 @@ class SyncStoreReceiptProductCost extends Command
}
$excludedPlaceholders = 0;
$excludedBundleParents = 0;
$excludedPlusParents = 0;
$costLines = $this->costLines(
$lines[$core] ?? [],
$excludedPlaceholders,
$excludedBundleParents,
$excludedPlusParents,
);
$placeholderExcludedRows += $excludedPlaceholders;
$bundleParentExcludedRows += $excludedBundleParents;
$plusParentExcludedRows += $excludedPlusParents;
$mapped = $this->mapPlatformSkus($costLines, $platformSkuMapper);
$costLines = $mapped['lines'];
$autoReplacedRows += $mapped['auto_replaced_rows'];
@ -427,10 +433,11 @@ class SyncStoreReceiptProductCost extends Command
));
$pendingCostQuantity = array_sum(array_column($pendingCostRows, 'product_quantity'));
$this->info(sprintf(
' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;待核验成本:%d 行 / %s 件',
' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;排除加号组合父行:%d 行;待核验成本:%d 行 / %s 件',
$matchedPriceRows,
$placeholderExcludedRows,
$bundleParentExcludedRows,
$plusParentExcludedRows,
$missingPriceRows,
number_format((float) $pendingCostQuantity, 2),
));
@ -452,7 +459,8 @@ class SyncStoreReceiptProductCost extends Command
'normal_matched_rows' => $matchedPriceRows,
'placeholder_excluded_rows' => $placeholderExcludedRows,
'bundle_parent_excluded_rows' => $bundleParentExcludedRows,
'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows,
'plus_parent_excluded_rows' => $plusParentExcludedRows,
'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows + $plusParentExcludedRows,
'auto_replaced_platform_sku_rows' => $autoReplacedRows,
'platform_sku_mapping_ambiguous_rows' => $mappingAmbiguousRows,
'platform_sku_mapping_missing_rows' => $mappingMissingRows,
@ -664,6 +672,21 @@ class SyncStoreReceiptProductCost extends Command
];
}
/** @param array<string,mixed> $row */
private function plusCombinationGroupKey(array $row): ?string
{
$outerItemId = trim((string) ($row['outer_oi_id'] ?? ''));
$shopSkuId = trim((string) ($row['shop_sku_id'] ?? ''));
if ($outerItemId === '' || $shopSkuId === '') {
return null;
}
return (string) $row['source']
. "\0" . (string) $row['o_id']
. "\0" . $outerItemId
. "\0" . $shopSkuId;
}
private function pendingCodeType(string $code): string
{
$code = mb_strtoupper(trim($code));
@ -698,17 +721,19 @@ 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,shop_sku_id:string,properties_value:string,src_combine_sku_id:string}> $rows
* @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,outer_oi_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,
?int &$placeholderExcludedRows = null,
?int &$bundleParentExcludedRows = null,
?int &$plusParentExcludedRows = null,
): array
{
$placeholderExcludedRows = 0;
$bundleParentExcludedRows = 0;
$plusParentExcludedRows = 0;
if ($rows === []) {
return [];
}
@ -720,7 +745,37 @@ class SyncStoreReceiptProductCost extends Command
$unique[$key] = $row;
}
// 2) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行
// 2) 上游有些平台组合会同时返回「+ 拼接父行」和多条组件行。
// 只有同一订单、outer_oi_id、shop_sku_id 下至少有两条组件,且组件金额
// 合计与父行一致时,父行才是可确认的汇总占位,才允许排除。
$plusParents = [];
$plusComponents = [];
foreach ($unique as $key => $row) {
$groupKey = $this->plusCombinationGroupKey($row);
if ($groupKey === null) {
continue;
}
if (str_contains((string) $row['sku'], '+')) {
$plusParents[$key] = $groupKey;
continue;
}
$plusComponents[$groupKey]['rows'] = ($plusComponents[$groupKey]['rows'] ?? 0) + 1;
$plusComponents[$groupKey]['amount'] = ($plusComponents[$groupKey]['amount'] ?? 0.0)
+ (float) $row['amount'];
}
foreach ($plusParents as $key => $groupKey) {
$component = $plusComponents[$groupKey] ?? null;
if (
$component !== null
&& $component['rows'] >= 2
&& abs(round((float) $component['amount'], 2) - round((float) $unique[$key]['amount'], 2)) <= 0.01
) {
$plusParentExcludedRows++;
unset($unique[$key]);
}
}
// 3) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行
// 只是汇总占位,不能与组件同时计入成本。
$bundleParents = [];
foreach ($unique as $row) {
@ -738,7 +793,7 @@ class SyncStoreReceiptProductCost extends Command
}
}
// 3) 平台原始商品编码通常是一长串数字。同一订单、同一 shop_i_id
// 4) 平台原始商品编码通常是一长串数字。同一订单、同一 shop_i_id
// 如果已经有内部商品编码,则数字行只是平台占位,不参与成本。
$internalSiblings = [];
foreach ($unique as $row) {
@ -761,7 +816,7 @@ class SyncStoreReceiptProductCost extends Command
}
}
// 4) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行:
// 5) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行:
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
$expanded = [];
@ -780,7 +835,7 @@ class SyncStoreReceiptProductCost extends Command
return [];
}
// 5) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。
// 6) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。
$parentIds = [];
foreach ($unique as $row) {
$parent = trim((string) ($row['parent_o_id'] ?? ''));
@ -799,7 +854,7 @@ class SyncStoreReceiptProductCost extends Command
}
}
// 6) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。
// 7) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。
$hasWarehouse = false;
foreach ($unique as $row) {
if (trim((string) $row['warehouse']) !== '') {
@ -814,13 +869,13 @@ class SyncStoreReceiptProductCost extends Command
);
}
// 7) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。
// 8) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。
$unique = array_filter(
$unique,
static fn (array $row): bool => strtoupper(trim((string) ($row['status'] ?? ''))) !== 'CANCELLED'
);
// 8) 汇总到「发货仓库 + 商品编码」。
// 9) 汇总到「发货仓库 + 商品编码」。
$aggregated = [];
foreach ($unique as $row) {
$isPlatformSku = preg_match('/^\d+$/', (string) $row['sku']) === 1;

View File

@ -117,6 +117,8 @@
拆单只取最末级订单行(没有任何下级的叶子行),主单和中间汇总层不参与、避免重复计数;
同一单分多仓发货的按末级实际仓库分行;虚拟条码(X0000x)不计入;
ZH-、MKH- 开头的组合装按「组件条码 + 数量」拆分,无法解析的组合装保留原编码;
商品编码含「+」的组合父行,在同一订单、outer_oi_id、shop_sku_id 下有多条组件且金额合计一致时剔除,仅保留组件行;
组件缺失或金额合计不一致时保留父行待核验。
套餐/服务、运费及指定配件、组合装占位编码 TCXJ、FUWU、AZF、DF-PJZY0001、
JIAXIANGJIANCAIJIAJUZIZHUANG964A、YUNFEI、BCJ 全局不取数(组合装里拆出的这些编码同样剔除)。
发货仓库默认排除「常州简尚家居有限公司」「简尚-苏州福利品仓」「简尚-自动发货仓」,

View File

@ -42,6 +42,34 @@ class StoreReceiptProductCostLineTest extends TestCase
$this->assertSame(2.0, $lines[0]['qty']);
}
public function test_plus_parent_is_removed_when_component_amounts_match(): void
{
[$lines, $placeholders, $bundles, $plusParents] = $this->costLines([
$this->row('plus-parent', 'GBGJ-A+V01+GBGJ-B+V01', '993830851777', 1, 648.06),
$this->row('plus-child-a', 'GBGJ-AV01', '993830851777', 1, 450.03),
$this->row('plus-child-b', 'GBGJ-BV01', '993830851777', 1, 198.03),
]);
$this->assertSame(0, $placeholders);
$this->assertSame(0, $bundles);
$this->assertSame(1, $plusParents);
$this->assertSame(['GBGJ-AV01', 'GBGJ-BV01'], array_column($lines, 'sku'));
}
public function test_plus_parent_remains_when_component_amounts_do_not_match(): void
{
[$lines, $placeholders, $bundles, $plusParents] = $this->costLines([
$this->row('plus-parent', 'GBGJ-A+V01+GBGJ-B+V01', '993830851777', 1, 1142),
$this->row('plus-child-a', 'GBGJ-AV01', '993830851777', 1, 255.20),
$this->row('plus-child-b', 'GBGJ-BV01', '993830851777', 1, 328.56),
]);
$this->assertSame(0, $placeholders);
$this->assertSame(0, $bundles);
$this->assertSame(0, $plusParents);
$this->assertContains('GBGJ-A+V01+GBGJ-B+V01', array_column($lines, 'sku'));
}
public function test_unique_platform_sku_is_replaced_after_line_filtering(): void
{
[$lines] = $this->costLines([
@ -106,7 +134,7 @@ class StoreReceiptProductCostLineTest extends TestCase
$this->assertSame(552392.0, $result['cost']);
}
/** @return array{0:list<array<string,mixed>>,1:int,2:int} */
/** @return array{0:list<array<string,mixed>>,1:int,2:int,3:int} */
private function costLines(array $rows): array
{
$command = new SyncStoreReceiptProductCost();
@ -114,9 +142,10 @@ class StoreReceiptProductCostLineTest extends TestCase
$method->setAccessible(true);
$placeholders = 0;
$bundles = 0;
$lines = $method->invokeArgs($command, [$rows, &$placeholders, &$bundles]);
$plusParents = 0;
$lines = $method->invokeArgs($command, [$rows, &$placeholders, &$bundles, &$plusParents]);
return [$lines, $placeholders, $bundles];
return [$lines, $placeholders, $bundles, $plusParents];
}
/** @return array<string,mixed> */
@ -140,6 +169,7 @@ class StoreReceiptProductCostLineTest extends TestCase
'status' => 'Sent',
'shop_i_id' => $shopItemId,
'shop_sku_id' => '3533103677164802',
'outer_oi_id' => 'OUTER-TEST-1',
'properties_value' => '颜色分类:六格-90*30*75cm-木纹胡桃色?赠送后背板',
'src_combine_sku_id' => $bundleParent,
];