'支付宝-测试账户', '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 = [], ?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, [], [], $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, 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(-7.0, $paid['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(12, $result['billCount']); $this->assertSame(8, $result['moneyRemarkRows']); $this->assertSame(125.0, $result['incomeTotal']); $this->assertSame(1.0, $result['discountTotal']); $this->assertSame(-7.0, $result['offsetTotal']); $this->assertSame(-10.0, $result['refundTotal']); $this->assertSame(-3.0, $result['attachedDiscount']); $this->assertSame(-2.0, $result['attachedOffset']); $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']); } 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_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_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); } } }