'支付宝-测试账户', 'online_real_store' => '天猫_测试店', 'bill_item' => $item, 'online_order_no' => $order, 'occurred_at' => '2026-08-01 10:00:00', 'income_amount' => $income, 'expense_amount' => $expense, ], $extra); } private function aggregate( iterable $rows, array $planting = [], array $plantingReimbursement = [], array $plantingUnreimbursed = [], ?AccountLedgerRemarkMap $map = null, ): array { $map ??= new class extends AccountLedgerRemarkMap { public function remark(?string $storeName, ?string $tabPlatform, ?string $billItem, array $businessRemarks = [], string $goodsName = ''): ?string { return $billItem; } }; return (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke( new SyncStoreReceiptOrderMatches(), $rows, ['label' => '淘系支付宝'], 'online_order_no', 'occurred_at', 'income_amount', 'expense_amount', 'online_real_store', $planting, $plantingReimbursement, $plantingUnreimbursed, $map, '淘宝', ); } public function test_one_pass_keeps_payments_promotions_refunds_and_first_bill_metadata(): void { $bills = [ $this->bill('优惠收入', '260630-10001', 0, 3), $this->bill('订单收入', '260630-10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']), $this->bill('订单收入', '260630-10001', 25), $this->bill('冲减收入', '260630-10001', 0, 2), $this->bill('违约赔付(冲减收入)', '260630-10001', 0, 5), $this->bill('退款', '260630-10001', 0, 7), $this->bill('优惠收入', '20002', 4), $this->bill('退款', '20002', 0, 1), $this->bill('冲减收入', '', 0, 8), $this->bill('冲减收入', '', 3), $this->bill('退款', '', 0, 2), $this->bill('订单收入', '30003', 0), $this->bill('不计入', '40004', 999), ]; // A generator cannot be rewound. Promotions precede their eventual payment. $result = $this->aggregate((function () use ($bills) { yield from $bills; })(), ['10001' => true]); $key = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店'); $this->assertCount(2, $result['groups']); $paid = $result['groups'][$key . '|260630-10001']; $this->assertSame(2, $paid['payment_count']); $this->assertSame(125.0, $paid['income_amount']); $this->assertSame(-3.0, $paid['discount_amount']); $this->assertSame(-2.0, $paid['offset_amount']); $this->assertSame(-5.0, $paid['default_compensation_amount']); $this->assertSame(-7.0, $paid['refund_amount']); $this->assertSame(125.0, $paid['bill_items']['income']['订单收入']['amount']); $this->assertSame(2, $paid['bill_items']['income']['订单收入']['line_count']); $this->assertSame(-3.0, $paid['bill_items']['discount']['优惠收入']['amount']); $this->assertSame(-2.0, $paid['bill_items']['offset']['冲减收入']['amount']); $this->assertSame(-5.0, $paid['bill_items']['default_compensation']['违约赔付(冲减收入)']['amount']); $this->assertSame(-7.0, $paid['bill_items']['refund']['退款']['amount']); $this->assertTrue($paid['planting_seen']); $this->assertSame('260630-10001', $paid['online_order_no']); $this->assertSame('2026-08-01', $paid['occurred_date']); $this->assertSame('天猫_测试店', $paid['store_name']); $this->assertSame(0.0, $result['groups'][$key . '|30003']['income_amount']); $this->assertSame(3, $result['paymentTotal']); $this->assertSame(13, $result['billCount']); $this->assertSame(9, $result['moneyRemarkRows']); $this->assertSame(125.0, $result['incomeTotal']); $this->assertSame(1.0, $result['discountTotal']); $this->assertSame(-7.0, $result['offsetTotal']); $this->assertSame(-5.0, $result['defaultCompensationTotal']); $this->assertSame(-10.0, $result['refundTotal']); $this->assertSame(-3.0, $result['attachedDiscount']); $this->assertSame(-2.0, $result['attachedOffset']); $this->assertSame(-5.0, $result['attachedDefaultCompensation']); $this->assertSame(3, $result['unattachedRows']); $this->assertSame([$key . '|20002', $key . '||冲减收入', $key . '||退款'], array_keys($result['promoOnlyGroups'])); $this->assertSame(4.0, $result['promoOnlyGroups'][$key . '|20002']['discount_amount']); $this->assertSame(-1.0, $result['promoOnlyGroups'][$key . '|20002']['refund_amount']); $this->assertSame(-5.0, $result['promoOnlyGroups'][$key . '||冲减收入']['offset_amount']); $this->assertSame(-2.0, $result['promoOnlyGroups'][$key . '||退款']['refund_amount']); $this->assertSame( -5.0, $result['promoOnlyGroups'][$key . '||冲减收入']['bill_items']['offset']['冲减收入']['amount'], ); $this->assertSame( 2, $result['promoOnlyGroups'][$key . '||冲减收入']['bill_items']['offset']['冲减收入']['line_count'], ); } public function test_same_core_with_different_date_prefixes_stays_as_two_orders(): void { $result = $this->aggregate([ $this->bill('订单收入', '260627-475634091573139', 13.91), $this->bill('订单收入', '260713-475634091573139', 29.51), ]); $shop = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店'); $this->assertCount(2, $result['groups']); $first = $result['groups'][$shop . '|260627-475634091573139']; $second = $result['groups'][$shop . '|260713-475634091573139']; $this->assertSame('475634091573139', $first['order_core_no']); $this->assertSame('475634091573139', $second['order_core_no']); $this->assertSame('260627-475634091573139', $first['order_identity']); $this->assertSame('260713-475634091573139', $second['order_identity']); $this->assertSame(13.91, $first['income_amount']); $this->assertSame(29.51, $second['income_amount']); } public function test_order_side_exact_full_number_wins_and_ambiguous_core_fallback_never_sums(): void { $command = new SyncStoreReceiptOrderMatches(); $method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'matchOrderSide'); $shop = SyncStoreReceiptOrderMatches::normalizeShop('拼多多-欧意朗家具官方旗舰店'); $core = '475634091573139'; $first = '260627-475634091573139'; $second = '260713-475634091573139'; $amounts = [ $shop . '|' . $first => 13.91, $shop . '|' . $second => 29.51, ]; $orderSeen = [ $shop . '|' . $first => true, $shop . '|' . $second => true, ]; $identitiesByCore = [$core => [$shop => [$first => true, $second => true]]]; $coresByCore = [$core => [$shop => true]]; $exact = $method->invoke( $command, $first, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额' ); $this->assertSame(['已匹配', 13.91, ''], $exact); $ambiguous = $method->invoke( $command, $core, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额' ); $this->assertSame(['未匹配', null, '订单侧同号多条'], $ambiguous); } public function test_empty_order_promotions_remain_separate_even_with_an_empty_order_payment(): void { $result = $this->aggregate([ $this->bill('订单收入', '', 12), $this->bill('优惠收入', '', 2, 0, ['occurred_at' => '2026-08-03 10:00:00']), $this->bill('优惠收入', '50005', 3), $this->bill('优惠收入', '50005', 4, 0, ['online_real_store' => '另一家店']), ]); $this->assertCount(1, $result['groups']); $this->assertCount(3, $result['promoOnlyGroups']); $first = reset($result['promoOnlyGroups']); $this->assertSame('2026-08-03', $first['occurred_date']); $this->assertSame(2.0, $first['discount_amount']); } public function test_promo_only_row_carries_default_compensation_separately(): void { $row = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'promoOnlyRow'))->invoke( null, '2026-08-01', '得物', [ 'order_identity' => '110206461460184356', 'order_core_no' => '110206461460184356', 'shop_key' => '得物-墨尚思', 'bill_item' => '违约赔付', 'occurred_date' => '2026-08-02', 'store_name' => '得物-墨尚思', 'online_order_no' => '110206461460184356', 'discount_seen' => false, 'discount_amount' => 0.0, 'offset_seen' => false, 'offset_amount' => 0.0, 'default_compensation_seen' => true, 'default_compensation_amount' => -25.0, 'refund_seen' => false, 'refund_amount' => 0.0, ], now(), ); $this->assertNull($row['offset_amount']); $this->assertSame(-25.0, $row['default_compensation_amount']); } public function test_business_remark_and_goods_name_are_forwarded_once_per_bill(): void { $map = $this->createMock(AccountLedgerRemarkMap::class); $map->expects($this->once())->method('remark')->with( '支付宝-测试账户', '淘宝', '动态项目', ['活动优惠'], '商品关键词', )->willReturn('优惠收入'); $result = $this->aggregate([$this->bill('动态项目', '', 1.5, 0, [ 'custom_remark' => '活动优惠', 'second_remark' => '活动优惠', 'goods_name' => '商品关键词', ])], [], [], [], $map); $this->assertSame(1.5, $result['discountTotal']); $this->assertSame([], $result['groups']); $this->assertCount(1, $result['promoOnlyGroups']); $this->assertSame(0, $this->aggregate([])['billCount']); } public function test_metric_details_are_rebuilt_from_collected_bill_items(): void { $schema = Schema::connection('business_mirror'); if (! $schema->hasTable('store_receipt_order_match_metric_details')) { $schema->create('store_receipt_order_match_metric_details', function (Blueprint $table): void { $table->date('report_month'); $table->string('detail_key'); $table->string('row_key'); $table->string('platform'); $table->string('metric'); $table->string('bill_item'); $table->decimal('amount', 20, 4)->default(0); $table->unsignedInteger('line_count')->default(1); $table->dateTime('imported_at'); }); } DB::connection('business_mirror')->table('store_receipt_order_match_metric_details')->delete(); (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'replaceMetricDetails'))->invoke( new SyncStoreReceiptOrderMatches(), '2026-09-01', '京东账单', [ [ 'shop_key' => '京东-店铺A', 'order_identity' => 'ORDER-A', 'bill_items' => [ 'offset' => [ '价款扣减' => ['amount' => -60.0, 'line_count' => 1], '售后扣减' => ['amount' => -40.0, 'line_count' => 1], ], ], ], ], [ [ 'shop_key' => '京东-店铺B', 'order_identity' => '', 'bill_item' => '平台扣款', 'bill_items' => [ 'offset' => [ '平台扣款' => ['amount' => -25.0, 'line_count' => 2], ], ], ], ], now(), ); $rows = DB::connection('business_mirror') ->table('store_receipt_order_match_metric_details') ->orderBy('bill_item') ->get(); $this->assertCount(3, $rows); $this->assertEqualsWithDelta(-125.0, (float) $rows->sum('amount'), 0.001); $this->assertSame('京东账单', $rows->first()->platform); $this->assertSame('offset', $rows->first()->metric); } public function test_planting_label_prefers_reimbursed_and_uses_unreimbursed_fallback(): void { $method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'plantingLabel'); $this->assertSame('种菜报销', $method->invoke(null, [ 'planting_reimbursement_seen' => true, 'planting_unreimbursed_seen' => true, ])); $this->assertSame('种菜未报销', $method->invoke(null, [ 'planting_reimbursement_seen' => false, 'planting_unreimbursed_seen' => true, ])); $this->assertNull($method->invoke(null, [])); } public function test_stream_uses_bound_filter_and_sql_limit_in_sqlite(): void { $db = DB::connection('business_mirror'); $db->getSchemaBuilder()->create('receipt_memory_test', function (\Illuminate\Database\Schema\Blueprint $table): void { $table->integer('id'); $table->string('name'); }); $db->table('receipt_memory_test')->insert([['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b'], ['id' => 3, 'name' => 'c']]); $query = $db->table('receipt_memory_test')->where('id', '>', 1)->orderBy('id')->limit(1); $rows = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'streamBillRows'))->invoke(new SyncStoreReceiptOrderMatches(), $query); $this->assertSame([2], array_map(static fn (object $row) => $row->id, iterator_to_array($rows))); } public function test_other_source_columns_and_missing_optional_columns_keep_their_amounts(): void { $map = $this->createMock(AccountLedgerRemarkMap::class); $map->expects($this->once())->method('remark')->with('京东-测试店', '京东', '货款(收)', [], '')->willReturn('订单收入'); $result = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke( new SyncStoreReceiptOrderMatches(), [(object) ['store_name' => '京东-测试店', 'order_no' => '70007', 'bill_date' => '2026-08-09', 'income' => 12.5, 'bill_item' => '货款(收)']], ['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], [], [], $map, '京东', ); $group = reset($result['groups']); $this->assertSame(12.5, $result['incomeTotal']); $this->assertSame('2026-08-09', $group['occurred_date']); $this->assertSame('京东-测试店', $group['store_name']); $this->assertSame([], $result['promoOnlyGroups']); } public function test_mysql_stream_uses_separate_connection_and_closes_it_after_consumer_error(): void { $normal = $this->createMock(MySqlConnection::class); $normal->method('getConfig')->willReturn(['driver' => 'mysql', 'options' => [\PDO::ATTR_TIMEOUT => 3]]); $query = new \Illuminate\Database\Query\Builder($normal, new \Illuminate\Database\Query\Grammars\MySqlGrammar(), new \Illuminate\Database\Query\Processors\MySqlProcessor()); $query->from('receipt_memory_test')->where('id', '>', 1)->limit(1); $stream = $this->createMock(MySqlConnection::class); $stream->expects($this->once())->method('cursor')->with($query->toSql(), [1])->willReturnCallback(function () { yield (object) ['id' => 2]; }); $stream->expects($this->once())->method('disconnect'); $normal->expects($this->never())->method('setReadPdo'); $normal->expects($this->never())->method('disconnect'); $factory = $this->createMock(ConnectionFactory::class); $factory->expects($this->once())->method('make')->with($this->callback(function (array $config): bool { return $config['options'][\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] === false && $config['options'][\PDO::ATTR_TIMEOUT] === 3; }), 'receipt_bill_stream')->willReturn($stream); $this->app->instance('db.factory', $factory); $rows = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'streamBillRows'))->invoke(new SyncStoreReceiptOrderMatches(), $query); try { foreach ($rows as $row) { $this->assertSame(2, $row->id); throw new \RuntimeException('consumer aborted'); } } catch (\RuntimeException $exception) { $this->assertSame('consumer aborted', $exception->getMessage()); } finally { unset($rows); } } }