From 9a9e01461c69e847f138f8a37d342d5bce7200c8 Mon Sep 17 00:00:00 2001 From: chenxi Date: Sat, 24 Oct 2026 15:55:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=95=86=E5=93=81=E6=88=90=E6=9C=AC=EF=BC=9A?= =?UTF-8?q?=E5=89=94=E9=99=A4=E5=8F=AF=E6=A0=B8=E5=AF=B9=E7=9A=84=E5=8A=A0?= =?UTF-8?q?=E5=8F=B7=E7=BB=84=E5=90=88=E7=88=B6=E8=A1=8C=20(#497)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在组件金额合计与父行一致时剔除加号组合父行,其余继续待核验。 --- .../Commands/SyncStoreReceiptProductCost.php | 77 ++++++++++++++++--- .../product-costs.blade.php | 2 + .../Unit/StoreReceiptProductCostLineTest.php | 36 ++++++++- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/app/Console/Commands/SyncStoreReceiptProductCost.php b/app/Console/Commands/SyncStoreReceiptProductCost.php index d3eab0b8..5d9e8aa8 100644 --- a/app/Console/Commands/SyncStoreReceiptProductCost.php +++ b/app/Console/Commands/SyncStoreReceiptProductCost.php @@ -180,7 +180,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) { @@ -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 $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 $rows + * @param list $rows * @return list */ 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; 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 02d442cf..87b39d83 100644 --- a/resources/views/business/pages/financial-operations/product-costs.blade.php +++ b/resources/views/business/pages/financial-operations/product-costs.blade.php @@ -117,6 +117,8 @@ 拆单只取最末级订单行(没有任何下级的叶子行),主单和中间汇总层不参与、避免重复计数; 同一单分多仓发货的按末级实际仓库分行;虚拟条码(X0000x)不计入; ZH-、MKH- 开头的组合装按「组件条码 + 数量」拆分,无法解析的组合装保留原编码; + 商品编码含「+」的组合父行,在同一订单、outer_oi_id、shop_sku_id 下有多条组件且金额合计一致时剔除,仅保留组件行; + 组件缺失或金额合计不一致时保留父行待核验。 套餐/服务、运费及指定配件、组合装占位编码 TCXJ、FUWU、AZF、DF-PJZY0001、 JIAXIANGJIANCAIJIAJUZIZHUANG964A、YUNFEI、BCJ 全局不取数(组合装里拆出的这些编码同样剔除)。 发货仓库默认排除「常州简尚家居有限公司」「简尚-苏州福利品仓」「简尚-自动发货仓」, diff --git a/tests/Unit/StoreReceiptProductCostLineTest.php b/tests/Unit/StoreReceiptProductCostLineTest.php index 8c1f1b91..6f037d1d 100644 --- a/tests/Unit/StoreReceiptProductCostLineTest.php +++ b/tests/Unit/StoreReceiptProductCostLineTest.php @@ -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>,1:int,2:int} */ + /** @return array{0:list>,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 */ @@ -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, ];