costLines([ $this->row('platform', '955859844397', '3767501840732782900', 20, 2800), $this->row('internal', 'DSG005016N11V01', '3767501840732782900', 20, 2800), ]); $this->assertSame(1, $placeholders); $this->assertSame(0, $bundles); $this->assertSame('DSG005016N11V01', $lines[0]['sku']); $this->assertSame(20.0, $lines[0]['qty']); } public function test_bundle_parent_is_removed_when_component_amount_matches(): void { [$lines, $placeholders, $bundles] = $this->costLines([ $this->row('bundle-parent', '运营组合套装12994', '3832980288011632985', 2, 625.26), $this->row( 'bundle-child', 'SJ038017N1410V02', '3832980288011632985', 2, 625.26, '运营组合套装12994', ), ]); $this->assertSame(0, $placeholders); $this->assertSame(1, $bundles); $this->assertSame('SJ038017N1410V02', $lines[0]['sku']); $this->assertSame(2.0, $lines[0]['qty']); } public function test_bundle_amount_difference_of_one_cent_is_reconciled(): void { [$lines, , $bundles] = $this->costLines([ $this->row('bundle-parent', '运营组合套装12994', '3832980288011632985', 1, 100), $this->row( 'bundle-child', 'SJ038017N1410V02', '3832980288011632985', 1, 100.01, '运营组合套装12994', ), ]); $this->assertSame(1, $bundles); $this->assertSame('SJ038017N1410V02', $lines[0]['sku']); } public function test_bundle_parent_remains_and_components_are_held_when_amount_does_not_match(): void { [$lines, , $bundles, , , $stats] = $this->costLines([ $this->row('bundle-parent', '运营组合套装12994', '3832980288011632985', 2, 696), $this->row( 'bundle-child', 'SJ038017N1410V02', '3832980288011632985', 2, 625.26, '运营组合套装12994', ), ]); $this->assertSame(0, $bundles); $this->assertSame(['运营组合套装12994'], array_column($lines, 'sku')); $this->assertTrue($lines[0]['force_cost_pending']); $this->assertSame('amount_mismatch', $lines[0]['bundle_resolution_status']); $this->assertSame(1, $stats['bundle_stats']['amount_mismatch_groups']); $this->assertSame(1, $stats['bundle_stats']['held_component_rows']); } public function test_non_chinese_structured_combo_keeps_legacy_parent_removal(): void { [$lines, , $bundles, , , $stats] = $this->costLines([ $this->row('structured-parent', 'ZH-SJ038017N1410V02-1', '3832980288011632985', 1, 100), $this->row( 'structured-child', 'SJ038017N1410V02', '3832980288011632985', 1, 50, 'ZH-SJ038017N1410V02-1', ), ]); $this->assertSame(1, $bundles); $this->assertSame(['SJ038017N1410V02'], array_column($lines, 'sku')); $this->assertSame(0, $stats['bundle_stats']['amount_mismatch_groups']); $this->assertSame(0, $stats['bundle_stats']['held_component_rows']); } public function test_bundle_parent_without_components_remains_pending(): void { [$lines, , , , , $stats] = $this->costLines([ $this->row('bundle-parent', 'MKH-运营组合套装260706009', '3832980288011632985', 1, 2021), ]); $this->assertSame(['MKH-运营组合套装260706009'], array_column($lines, 'sku')); $this->assertTrue($lines[0]['force_cost_pending']); $this->assertSame('missing_components', $lines[0]['bundle_resolution_status']); $this->assertSame(1, $stats['bundle_stats']['missing_component_groups']); } public function test_zero_bundle_placeholder_without_components_is_dropped(): void { [$lines, , , , , $stats] = $this->costLines([ $this->row( 'bundle-placeholder', '运营组合套装13011', '3832980288011632985', 0, 0, '', ['sku_type' => 'combine', 'base_price' => 398], ), ]); $this->assertSame([], $lines); $this->assertSame(0, $stats['bundle_stats']['missing_component_groups']); } public function test_zero_bundle_placeholder_with_mismatched_components_keeps_components_priced(): void { [$lines, , , , , $stats] = $this->costLines([ $this->row( 'bundle-placeholder', '运营组合套装13011', '3832980288011632985', 0, 0, '', ['sku_type' => 'combine', 'base_price' => 899], ), $this->row('bundle-child', 'SJ038016N1410V01', '3832980288011632985', 1, 420), ]); $this->assertSame(['SJ038016N1410V01'], array_column($lines, 'sku')); $this->assertSame(1, $stats['bundle_stats']['zero_placeholder_ignored_groups']); $this->assertSame(0, $stats['bundle_stats']['amount_mismatch_groups']); $this->assertSame(0, $stats['bundle_stats']['held_component_rows']); } public function test_inferred_bundle_components_reconcile_against_parent_base_amount(): void { [$lines, , $bundles, , , $stats] = $this->costLines([ $this->row( 'bundle-parent', '运营自行组合替换', '3832980288011632985', 0, 0, '', ['sku_type' => 'normal', 'base_price' => 559.88], ), $this->row('bundle-child-a', '34093J', '3832980288011632985', 5, 1314.87), $this->row('bundle-child-b', 'CZ002625N68V01', '3832980288011632985', 5, 1484.53), ]); $this->assertSame(1, $bundles); $this->assertSame(['34093J', 'CZ002625N68V01'], array_column($lines, 'sku')); $this->assertSame(1, $stats['bundle_stats']['reconciled_groups']); $this->assertSame(2, $stats['bundle_stats']['inferred_component_rows']); } public function test_stable_history_components_are_expanded_when_parent_amount_matches(): void { $profileKey = "jst\0京东-HX旗舰店104\0" . "10227805997572\0" . "10035321954709\0" . 'MKH-运营组合套装260706009'; [$lines, , $bundles, , , $stats] = $this->costLines([ $this->row( 'bundle-parent', 'MKH-运营组合套装260706009', '10035321954709', 1, 2021, '', [ 'store_name' => '京东-HX旗舰店104', 'shop_sku_id' => '10227805997572', 'sku_type' => 'combine', ], ), ], [ $profileKey => [ 'components' => [ 'DNZ038572N1437V01' => ['qty' => 1, 'amount' => 357.84], 'GBGJ-SJZ002103N1261V01' => ['qty' => 1, 'amount' => 935.82], 'GBGJ-ZWJ024903AN1407V01' => ['qty' => 1, 'amount' => 363.67], 'GBGJ-ZWJ024903BN1407V01' => ['qty' => 1, 'amount' => 363.67], ], ], ]); $this->assertSame(1, $bundles); $this->assertSame( [ 'DNZ038572N1437V01', 'GBGJ-SJZ002103N1261V01', 'GBGJ-ZWJ024903AN1407V01', 'GBGJ-ZWJ024903BN1407V01', ], array_column($lines, 'sku'), ); $this->assertSame(1, $stats['bundle_stats']['history_auto_split_groups']); $this->assertSame(4, $stats['bundle_stats']['history_auto_split_component_rows']); } public function test_history_components_remain_pending_when_parent_amount_differs(): void { $profileKey = "jst\0京东-HX旗舰店104\0" . "10227805997572\0" . "10035321954709\0" . 'MKH-运营组合套装260706009'; [$lines, , $bundles, , , $stats] = $this->costLines([ $this->row( 'bundle-parent', 'MKH-运营组合套装260706009', '10035321954709', 1, 2000, '', [ 'store_name' => '京东-HX旗舰店104', 'shop_sku_id' => '10227805997572', 'sku_type' => 'combine', ], ), ], [ $profileKey => [ 'components' => [ 'DNZ038572N1437V01' => ['qty' => 1, 'amount' => 357.84], 'GBGJ-SJZ002103N1261V01' => ['qty' => 1, 'amount' => 935.82], 'GBGJ-ZWJ024903AN1407V01' => ['qty' => 1, 'amount' => 363.67], 'GBGJ-ZWJ024903BN1407V01' => ['qty' => 1, 'amount' => 363.67], ], ], ]); $this->assertSame(0, $bundles); $this->assertSame(['MKH-运营组合套装260706009'], array_column($lines, 'sku')); $this->assertSame('history_amount_mismatch', $lines[0]['bundle_resolution_status']); $this->assertSame(1, $stats['bundle_stats']['history_profile_amount_mismatch_groups']); } public function test_imported_after_sales_components_split_a_bundle_when_amounts_match(): void { $profileKey = "imported_after_sales\0天猫-亿家达旗舰店\0" . "5120748758529014849\0" . '运营组合套装12037'; [$lines, , $bundles, , , $stats] = $this->costLines([ $this->row( 'bundle-parent', '运营组合套装12037', '1030426322500', 1, 429.05, '', [ 'store_name' => '天猫-亿家达旗舰店', 'online_order_no' => '5120748758529014849', 'shop_sku_id' => '6208843810051', 'shop_i_id' => '1030426322500', 'sku_type' => 'combine', ], ), ], [ $profileKey => [ 'source' => 'imported_after_sales', 'component_codes' => [ 'SJ038020N1410V02', 'SJ038051N1410V01', 'SJ038052N1410V01', 'SJ038056N1410V01', ], 'components' => [ 'SJ038020N1410V02' => ['qty' => 1, 'amount' => 385.43], 'SJ038051N1410V01' => ['qty' => 1, 'amount' => 14.54], 'SJ038052N1410V01' => ['qty' => 1, 'amount' => 14.54], 'SJ038056N1410V01' => ['qty' => 1, 'amount' => 14.54], ], ], ]); $codes = array_column($lines, 'sku'); sort($codes, SORT_STRING); $this->assertSame(1, $bundles); $this->assertSame([ 'SJ038020N1410V02', 'SJ038051N1410V01', 'SJ038052N1410V01', 'SJ038056N1410V01', ], $codes); $this->assertSame(1, $stats['bundle_stats']['imported_after_sales_split_groups']); $this->assertSame(1, $stats['bundle_stats']['imported_after_sales_split_parent_rows']); $this->assertSame(4, $stats['bundle_stats']['imported_after_sales_split_component_rows']); $this->assertSame(0, $stats['bundle_stats']['history_auto_split_groups']); } public function test_imported_after_sales_components_remain_pending_when_parent_amount_differs(): void { $profileKey = "imported_after_sales\0天猫-亿家达旗舰店\0" . "5120748758529014849\0" . '运营组合套装12037'; [$lines, , $bundles, , , $stats] = $this->costLines([ $this->row( 'bundle-parent', '运营组合套装12037', '1030426322500', 1, 429.05, '', [ 'store_name' => '天猫-亿家达旗舰店', 'online_order_no' => '5120748758529014849', 'shop_sku_id' => '6208843810051', 'shop_i_id' => '1030426322500', 'sku_type' => 'combine', ], ), ], [ $profileKey => [ 'source' => 'imported_after_sales', 'components' => [ 'SJ038020N1410V02' => ['qty' => 1, 'amount' => 400], ], ], ]); $this->assertSame(0, $bundles); $this->assertSame(['运营组合套装12037'], array_column($lines, 'sku')); $this->assertSame('imported_amount_mismatch', $lines[0]['bundle_resolution_status']); $this->assertSame(1, $stats['bundle_stats']['imported_after_sales_amount_mismatch_groups']); } public function test_history_components_keep_precedence_over_imported_after_sales_components(): void { $parent = $this->row( 'bundle-parent', '运营组合套装12037', '1030426322500', 1, 429.05, '', [ 'store_name' => '天猫-亿家达旗舰店', 'online_order_no' => '5120748758529014849', 'shop_sku_id' => '6208843810051', 'shop_i_id' => '1030426322500', 'sku_type' => 'combine', ], ); $importedKey = "imported_after_sales\0天猫-亿家达旗舰店\0" . "5120748758529014849\0" . '运营组合套装12037'; $historyKey = "jst\0天猫-亿家达旗舰店\0" . "6208843810051\0" . "1030426322500\0" . '运营组合套装12037'; [$lines, , $bundles, , , $stats] = $this->costLines([$parent], [ $historyKey => [ 'components' => [ 'HISTORY-COMPONENT' => ['qty' => 1, 'amount' => 429.05], ], ], $importedKey => [ 'source' => 'imported_after_sales', 'components' => [ 'IMPORTED-COMPONENT' => ['qty' => 1, 'amount' => 429.05], ], ], ]); $this->assertSame(1, $bundles); $this->assertSame(['HISTORY-COMPONENT'], array_column($lines, 'sku')); $this->assertSame(1, $stats['bundle_stats']['history_auto_split_groups']); $this->assertSame(0, $stats['bundle_stats']['imported_after_sales_split_groups']); } 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_non_numeric_variant_parent_is_removed_when_quantity_and_amount_match(): void { [$lines, $placeholders, $bundles, $plusParents, $variantParents] = $this->costLines([ $this->row('variant-parent', 'TESTP001', '3767501840732782900', 1, 91), $this->row('variant-child', 'TESTP001N1150V01', '3767501840732782900', 1, 91), ]); $this->assertSame(0, $placeholders); $this->assertSame(0, $bundles); $this->assertSame(0, $plusParents); $this->assertSame(1, $variantParents); $this->assertSame(['TESTP001N1150V01'], array_column($lines, 'sku')); } public function test_non_numeric_variant_parent_remains_when_amounts_do_not_match(): void { [$lines, , , , $variantParents] = $this->costLines([ $this->row('variant-parent', 'TESTP001', '3767501840732782900', 1, 91), $this->row('variant-child', 'TESTP001N1150V01', '3767501840732782900', 1, 90), ]); $this->assertSame(0, $variantParents); $this->assertContains('TESTP001', array_column($lines, 'sku')); $this->assertContains('TESTP001N1150V01', array_column($lines, 'sku')); } public function test_non_numeric_variant_parent_does_not_cross_platform_item_groups(): void { [$lines, , , , $variantParents] = $this->costLines([ $this->row('variant-parent', 'TESTP001', '3767501840732782900', 1, 91), $this->row('variant-child', 'TESTP001N1150V01', '3767501840732782901', 1, 91), ]); $this->assertSame(0, $variantParents); $this->assertContains('TESTP001', array_column($lines, 'sku')); $this->assertContains('TESTP001N1150V01', array_column($lines, 'sku')); } public function test_ctg_parent_uses_the_same_generic_placeholder_rule(): void { [$lines, , , , $variantParents] = $this->costLines([ $this->row('ctg-parent', 'CTG022516', '3767501840732782900', 1, 91), $this->row('ctg-child', 'CTG022516N1150V01', '3767501840732782900', 1, 91), ]); $this->assertSame(1, $variantParents); $this->assertSame(['CTG022516N1150V01'], array_column($lines, 'sku')); } public function test_strict_chinese_suffix_alias_keeps_the_formal_sku(): void { [$lines, , , , $variantParents, $stats] = $this->costLines([ $this->row('alias-placeholder', 'ZSJ002020N1100V01非', '10034936120452', 1, 39), $this->row('alias-formal', 'ZSJ002020N1100V01', '10034936120452', 1, 39), ]); $this->assertSame(0, $variantParents); $this->assertSame(1, $stats['chinese_alias_replaced_rows']); $this->assertSame(['ZSJ002020N1100V01非'], array_keys($stats['chinese_alias_placeholder_codes'])); $this->assertSame(['ZSJ002020N1100V01'], array_keys($stats['chinese_alias_formal_codes'])); $this->assertSame(['ZSJ002020N1100V01'], array_column($lines, 'sku')); } public function test_chinese_suffix_alias_is_not_removed_when_amounts_differ(): void { [$lines, , , , $variantParents, $stats] = $this->costLines([ $this->row('alias-placeholder', 'ZSJ002020N1100V01非', '10034936120452', 1, 39), $this->row('alias-formal', 'ZSJ002020N1100V01', '10034936120452', 1, 29), ]); $this->assertSame(0, $variantParents); $this->assertSame(0, $stats['chinese_alias_replaced_rows']); $this->assertContains('ZSJ002020N1100V01非', array_column($lines, 'sku')); $this->assertContains('ZSJ002020N1100V01', array_column($lines, 'sku')); } public function test_chinese_suffix_alias_requires_a_unique_formal_relation(): void { [$lines, , , , , $stats] = $this->costLines([ $this->row('alias-placeholder-a', 'ZSJ002020N1100V01非', '10034936120452', 1, 39), $this->row('alias-placeholder-b', 'ZSJ002020N1100V01非', '10034936120452', 1, 39), $this->row('alias-formal', 'ZSJ002020N1100V01', '10034936120452', 1, 39), ]); $this->assertSame(0, $stats['chinese_alias_replaced_rows']); $this->assertContains('ZSJ002020N1100V01非', array_column($lines, 'sku')); $this->assertContains('ZSJ002020N1100V01', array_column($lines, 'sku')); } public function test_bundle_named_parent_is_not_removed_by_generic_alias_rule(): void { [$lines, , , , $variantParents] = $this->costLines([ $this->row('bundle-parent', '运营组合套装12994', '3767501840732782900', 1, 91), $this->row('bundle-child', '运营组合套装12994-扩展', '3767501840732782900', 1, 91), $this->row('mkh-bundle-parent', 'MKH-运营组合套装12994', '3767501840732782901', 1, 91), $this->row('mkh-bundle-child', 'MKH-运营组合套装12994-扩展', '3767501840732782901', 1, 91), ]); $this->assertSame(0, $variantParents); $this->assertSame( [ '运营组合套装12994', '运营组合套装12994-扩展', 'MKH-运营组合套装12994', 'MKH-运营组合套装12994-扩展', ], array_column($lines, 'sku'), ); } public function test_planting_prefix_rows_are_excluded_before_alias_matching(): void { [$lines, , , , , $stats] = $this->costLines([ $this->row('planting', '种菜GBSFY-SJZ002802N1439V01', '3767501840732782900', 1, 215), $this->row('formal', 'GBSFY-SJZ002802N1439V01', '3767501840732782900', 1, 215), ]); $this->assertSame(1, $stats['planting_prefix_excluded_rows']); $this->assertSame(['种菜GBSFY-SJZ002802N1439V01'], array_keys($stats['planting_prefix_codes'])); $this->assertSame(['GBSFY-SJZ002802N1439V01'], array_column($lines, 'sku')); } public function test_readable_chinese_accessory_code_is_kept_as_is(): void { [$lines, , , , , $stats] = $this->costLines([ $this->row('accessory', '胡桃色舒美特实木面1把', '3767501840732782900', 1, 173), ]); $this->assertSame(0, $stats['chinese_alias_replaced_rows']); $this->assertSame(['胡桃色舒美特实木面1把'], array_column($lines, 'sku')); } public function test_goods_profit_current_order_split_replaces_base_code_when_amount_matches(): void { $profileKey = "jst\0" . "8986587\0" . "3533103677164802\0" . 'GOODS-ITEM-1'; [$lines, , , , , $stats] = $this->costLines([ $this->row('base', 'SJ000608', 'GOODS-ITEM-1', 1, 76.76), $this->row('component', 'SJ000632N1225V01', 'GOODS-ITEM-1', 1, 76.76), ], [], [ $profileKey => [ 'source' => 'goods_profit_current_order', 'components' => [ 'SJ000632N1225V01' => ['qty' => 1, 'amount' => 76.76], ], ], ]); $this->assertSame(['SJ000632N1225V01'], array_column($lines, 'sku')); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_groups']); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_parent_rows']); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_component_rows']); } public function test_goods_profit_current_order_split_expands_numeric_code_to_multiple_components(): void { $profileKey = "jst\0" . "8986587\0" . "3533103677164802\0" . 'GOODS-ITEM-2'; [$lines, , , , , $stats] = $this->costLines([ $this->row('numeric', '10218486752933', 'GOODS-ITEM-2', 1, 643.33), ], [], [ $profileKey => [ 'source' => 'goods_profit_current_order', 'components' => [ 'XG028321AN1235V01' => ['qty' => 1, 'amount' => 321.66], 'XG028321BN1235V01' => ['qty' => 1, 'amount' => 321.67], ], ], ]); $codes = array_column($lines, 'sku'); sort($codes, SORT_STRING); $this->assertSame(['XG028321AN1235V01', 'XG028321BN1235V01'], $codes); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_groups']); $this->assertSame(2, $stats['bundle_stats']['goods_profit_split_component_rows']); } public function test_goods_profit_bundle_parent_ignores_stale_order_mirror_components(): void { $profileKey = "jst\0" . "8986587\0" . "3533103677164802\0" . 'GOODS-ITEM-BUNDLE'; $official = [ 'SJ038067N1410V01' => ['qty' => 1, 'amount' => 6.97], 'SJ038064N1410V01' => ['qty' => 1, 'amount' => 6.97], 'SJ038057N1410V01' => ['qty' => 1, 'amount' => 3.49], 'SJ038054N1410V01' => ['qty' => 1, 'amount' => 3.48], 'SJ038046N1410V01' => ['qty' => 1, 'amount' => 3.49], 'SJ038043N1410V01' => ['qty' => 1, 'amount' => 3.48], 'SJ038013N1410V01' => ['qty' => 1, 'amount' => 485.93], 'SJ038009N1410V01' => ['qty' => 2, 'amount' => 135.19], ]; $rows = [ $this->row('bundle-parent', '运营组合套装12607', 'GOODS-ITEM-BUNDLE', 1, 649), ]; foreach ($official as $code => $component) { $rows[] = $this->row( 'official-' . $code, $code, 'GOODS-ITEM-BUNDLE', $component['qty'], $component['amount'], '运营组合套装12607', ); } $rows[] = $this->row('stale-12', 'SJ038012N1410V01', 'GOODS-ITEM-BUNDLE', 2, 485.93, '运营组合套装12607'); $rows[] = $this->row('stale-16', 'SJ038016N1410V01', 'GOODS-ITEM-BUNDLE', 1, 135.19, '运营组合套装12607'); [$lines, , , , , $stats] = $this->costLines($rows, [], [ $profileKey => [ 'source' => 'goods_profit_current_order', 'components' => $official, ], ]); $codes = array_column($lines, 'sku'); sort($codes, SORT_STRING); $expected = array_keys($official); sort($expected, SORT_STRING); $this->assertSame($expected, $codes); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_groups']); $this->assertSame(8, $stats['bundle_stats']['goods_profit_split_component_rows']); $this->assertSame(649.0, round(array_sum(array_column($lines, 'amount')), 2)); } public function test_goods_profit_bundle_parent_ignores_zero_quantity_stale_parent_rows(): void { $profileKey = "jst\0" . "8986587\0" . "3533103677164802\0" . 'GOODS-ITEM-ZERO-PARENTS'; $components = [ 'SJ038020N1410V02' => ['qty' => 1, 'amount' => 377.31], 'SJ038069N1410V01' => ['qty' => 3, 'amount' => 42.69], ]; [$lines, , , , , $stats] = $this->costLines([ $this->row('old-parent-1', '运营组合套装12630', 'GOODS-ITEM-ZERO-PARENTS', 0, 0), $this->row('old-parent-2', '运营组合套装13014', 'GOODS-ITEM-ZERO-PARENTS', 0, 0), $this->row('current-parent', '运营组合套装13015', 'GOODS-ITEM-ZERO-PARENTS', 1, 420), ], [], [ $profileKey => [ 'source' => 'goods_profit_current_order', 'components' => $components, ], ]); $codes = array_column($lines, 'sku'); sort($codes, SORT_STRING); $this->assertSame(['SJ038020N1410V02', 'SJ038069N1410V01'], $codes); $this->assertSame(420.0, round(array_sum(array_column($lines, 'amount')), 2)); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_groups']); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_parent_rows']); } public function test_goods_profit_bundle_combines_split_parent_branches_before_reconcile(): void { $components = [ 'DNZ038526N1280V01' => ['qty' => 2, 'amount' => 1111.84], 'GBGJ-DNZ038401N1440V01' => ['qty' => 2, 'amount' => 954.58], 'GBGJ-ZWJ024903AN1407V01' => ['qty' => 2, 'amount' => 633.90], 'GBGJ-ZWJ024903BN1407V01' => ['qty' => 2, 'amount' => 633.90], ]; $common = [ 'shop_sku_id' => 'SPLIT-SKU', ]; [$lines, , , , , $stats] = $this->costLines([ $this->row('split-root', 'MKH-运营组合套装260706016', 'SPLIT-ITEM', 2, 3334.22, '', $common + [ 'o_id' => 'SPLIT-ROOT', ]), $this->row('split-child-1', 'MKH-运营组合套装260706016', 'SPLIT-ITEM', 1, 1667.11, '', $common + [ 'o_id' => 'SPLIT-CHILD-1', 'parent_o_id' => 'SPLIT-ROOT', ]), $this->row('split-child-2', 'MKH-运营组合套装260706016', 'SPLIT-ITEM', 1, 1667.11, '', $common + [ 'o_id' => 'SPLIT-CHILD-2', 'parent_o_id' => 'SPLIT-ROOT', ]), ], [], [ "jst\0SPLIT-CHILD-1\0SPLIT-SKU\0SPLIT-ITEM" => [ 'source' => 'goods_profit_current_order', 'components' => $components, ], "jst\0SPLIT-CHILD-2\0SPLIT-SKU\0SPLIT-ITEM" => [ 'source' => 'goods_profit_current_order', 'components' => $components, ], ]); $bySku = collect($lines)->keyBy('sku'); $this->assertCount(4, $lines); $this->assertSame(2.0, $bySku['DNZ038526N1280V01']['qty']); $this->assertSame(3334.22, round(array_sum(array_column($lines, 'amount')), 2)); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_groups']); $this->assertSame(3, $stats['bundle_stats']['goods_profit_split_parent_rows']); } public function test_goods_profit_bundle_uses_unique_component_group_from_stale_shop_sku(): void { $components = [ 'SJ038020N1410V02' => ['qty' => 1, 'amount' => 377.31], 'SJ038069N1410V01' => ['qty' => 3, 'amount' => 42.69], ]; $common = [ 'o_id' => 'CROSS-SKU-ORDER', 'shop_i_id' => 'CROSS-SKU-ITEM', ]; [$lines, , , , , $stats] = $this->costLines([ $this->row('old-parent', '运营组合套装12630', 'CROSS-SKU-ITEM', 0, 0, '', $common + [ 'shop_sku_id' => 'OLD-SKU', ]), $this->row('component-1', 'SJ038020N1410V02', 'CROSS-SKU-ITEM', 1, 377.31, '运营组合套装12630', $common + [ 'shop_sku_id' => 'OLD-SKU', ]), $this->row('component-2', 'SJ038069N1410V01', 'CROSS-SKU-ITEM', 3, 42.69, '运营组合套装12630', $common + [ 'shop_sku_id' => 'OLD-SKU', ]), $this->row('current-parent', '运营组合套装13011', 'CROSS-SKU-ITEM', 1, 420, '', $common + [ 'shop_sku_id' => 'NEW-SKU', ]), $this->row('separate-item', 'SN021702N230V01', 'CROSS-SKU-ITEM', 1, 49.97, '', [ 'o_id' => 'CROSS-SKU-CHILD', 'parent_o_id' => 'CROSS-SKU-ORDER', 'shop_i_id' => 'CROSS-SKU-ITEM', 'shop_sku_id' => 'OTHER-SKU', ]), ], [], [ "jst\0CROSS-SKU-ORDER\0OLD-SKU\0CROSS-SKU-ITEM" => [ 'source' => 'goods_profit_current_order', 'components' => $components, ], ]); $codes = array_column($lines, 'sku'); sort($codes, SORT_STRING); $this->assertSame([ 'SJ038020N1410V02', 'SJ038069N1410V01', 'SN021702N230V01', ], $codes); $this->assertSame(469.97, round(array_sum(array_column($lines, 'amount')), 2)); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_groups']); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_parent_rows']); } public function test_goods_profit_current_order_split_is_not_applied_when_amount_differs(): void { $profileKey = "jst\0" . "8986587\0" . "3533103677164802\0" . 'GOODS-ITEM-3'; [$lines, , , , , $stats] = $this->costLines([ $this->row('base', 'SJ000608', 'GOODS-ITEM-3', 1, 76.76), ], [], [ $profileKey => [ 'source' => 'goods_profit_current_order', 'components' => [ 'SJ000632N1225V01' => ['qty' => 1, 'amount' => 70], ], ], ]); $this->assertSame(['SJ000608'], array_column($lines, 'sku')); $this->assertSame(0, $stats['bundle_stats']['goods_profit_split_groups']); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_mismatch_groups']); } public function test_goods_profit_zero_placeholder_is_removed_when_one_positive_component_matches_quantity(): void { $profileKey = "jst\0" . "8986587\0" . "3533103677164802\0" . 'ZERO-ITEM'; [$lines, , , , , $stats] = $this->costLines([ $this->row('zero', '1954871230495', 'ZERO-ITEM', 1, 0), $this->row('positive', 'MKHDNZ0409260600891', 'POSITIVE-ITEM', 1, 415.18), ], [], [ $profileKey => [ 'source' => 'goods_profit_zero_placeholder', 'components' => [], 'remove_only' => true, ], ]); $this->assertSame(['MKHDNZ0409260600891'], array_column($lines, 'sku')); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_groups']); $this->assertSame(1, $stats['bundle_stats']['goods_profit_split_parent_rows']); $this->assertSame(0, $stats['bundle_stats']['goods_profit_split_component_rows']); } 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']); $this->assertSame(0, $result['afs_replaced_rows']); } public function test_afs_mapping_is_used_when_history_has_no_internal_sku(): void { [$lines] = $this->costLines([ $this->row('platform', '905560897216', '3779397502214734671', 1, 297.52), ]); $command = new SyncStoreReceiptProductCost(); $method = new ReflectionMethod($command, 'mapPlatformSkus'); $method->setAccessible(true); $afsMappings = [ "抖音-佰汇家具店\0" . "3533103677164802\0" . '3779397502214734671' => [ 'SG009135N1063V01', ], ]; $result = $method->invoke( $command, $lines, new StoreReceiptPlatformSkuMapper([]), $afsMappings, '抖音-佰汇家具店', ); $this->assertSame(1, $result['auto_replaced_rows']); $this->assertSame(1, $result['afs_replaced_rows']); $this->assertSame('SG009135N1063V01', $result['lines'][0]['sku']); $this->assertSame('unique_afs', $result['lines'][0]['platform_mapping_status']); $this->assertSame('afs', $result['lines'][0]['platform_mapping_source']); } public function test_afs_multiple_candidates_remain_pending_and_history_ambiguity_is_not_overridden(): void { [$lines] = $this->costLines([ $this->row('platform', '905560897216', '3779397502214734671', 1, 297.52), ]); $command = new SyncStoreReceiptProductCost(); $method = new ReflectionMethod($command, 'mapPlatformSkus'); $method->setAccessible(true); $key = "抖音-佰汇家具店\0" . "3533103677164802\0" . '3779397502214734671'; $ambiguousAfs = $method->invoke( $command, $lines, new StoreReceiptPlatformSkuMapper([]), [$key => ['SG009105N1063V01', 'SG009135N1063V01']], '抖音-佰汇家具店', ); $this->assertSame(0, $ambiguousAfs['auto_replaced_rows']); $this->assertSame(1, $ambiguousAfs['ambiguous_rows']); $this->assertSame('905560897216', $ambiguousAfs['lines'][0]['sku']); $this->assertSame('ambiguous_afs', $ambiguousAfs['lines'][0]['platform_mapping_status']); $history = new StoreReceiptPlatformSkuMapper([ [ 'shop_sku_id' => '3533103677164802', 'shop_i_id' => '3779397502214734671', 'sku_id' => 'SG009105N1063V01', 'i_id' => 'SG0091', 'properties_value' => '', ], [ 'shop_sku_id' => '3533103677164802', 'shop_i_id' => '3779397502214734671', 'sku_id' => 'SG009135N1063V01', 'i_id' => 'SG0091', 'properties_value' => '', ], ]); $historyWins = $method->invoke( $command, $lines, $history, [$key => ['SG009135N1063V01']], '抖音-佰汇家具店', ); $this->assertSame(0, $historyWins['auto_replaced_rows']); $this->assertSame('ambiguous', $historyWins['lines'][0]['platform_mapping_status']); } public function test_yunfei_and_bcj_are_excluded_globally_and_from_bundle_components(): void { $command = new SyncStoreReceiptProductCost(); $constant = (new \ReflectionClass($command))->getReflectionConstant('EXCLUDED_SKUS'); $excludedSkus = $constant->getValue(); $this->assertContains('YUNFEI', $excludedSkus); $this->assertContains('BCJ', $excludedSkus); $method = new ReflectionMethod($command, 'expandComboSku'); $method->setAccessible(true); $components = $method->invoke( null, 'MKH-YUNFEI-1-BCJ-1-SG006367N1028V01-2', 3.0, ); $this->assertSame([['SG006367N1028V01', 6.0, true]], $components); } public function test_priced_line_always_calculates_cost_without_unit_mismatch_override(): void { $command = new SyncStoreReceiptProductCost(); $method = new ReflectionMethod($command, 'calculateLineCost'); $method->setAccessible(true); $result = $method->invoke($command, 9524.0, 58.0); $this->assertSame(9524.0, $result['quantity']); $this->assertSame(58.0, $result['unit_price']); $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>,1:int,2:int,3:int,4:int,5:array} */ private function costLines( array $rows, array $bundleProfiles = [], array $goodsProfitSplits = [], ): array { $command = new SyncStoreReceiptProductCost(); $method = new ReflectionMethod($command, 'costLines'); $method->setAccessible(true); $stats = []; $lines = $method->invokeArgs($command, [$rows, &$stats, $bundleProfiles, $goodsProfitSplits]); return [ $lines, $stats['platform_placeholder_excluded_rows'], $stats['bundle_parent_excluded_rows'], $stats['plus_parent_excluded_rows'], $stats['generic_variant_parent_excluded_rows'], $stats, ]; } /** @return array */ private function row( string $rowKey, string $sku, string $shopItemId, float $quantity, float $amount, string $bundleParent = '', array $overrides = [], ): array { return array_replace([ 'source' => 'jst', 'platform' => '京喜账单', 'store_name' => '京喜-八方进宝工厂店', 'sku' => $sku, 'sku_type' => str_contains($sku, '组合') ? 'combine' : 'normal', 'base_price' => $amount, 'item_name' => $sku, 'qty' => $quantity, 'amount' => $amount, 'warehouse' => '简尚-新苏北仓', 'o_id' => '8986587', 'parent_o_id' => '', 'row_key' => $rowKey, '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, ], $overrides); } }