CaiWuXiTong/tests/Unit/StoreReceiptProductCostLineTest.php

1059 lines
44 KiB
PHP

<?php
namespace Tests\Unit;
use App\Business\StoreReceiptPlatformSkuMapper;
use App\Console\Commands\SyncStoreReceiptProductCost;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
class StoreReceiptProductCostLineTest extends TestCase
{
public function test_platform_placeholder_is_removed_when_internal_sku_exists(): void
{
[$lines, $placeholders, $bundles] = $this->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<array<string,mixed>>,1:int,2:int,3:int,4:int,5:array<string,mixed>} */
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<string,mixed> */
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);
}
}