CaiWuXiTong/tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php

227 lines
12 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use App\Console\Commands\SyncStoreReceiptOrderMatches;
use Illuminate\Database\Connectors\ConnectionFactory;
use Illuminate\Database\MySqlConnection;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use ReflectionMethod;
use Tests\TestCase;
class StoreReceiptMemoryTest extends TestCase
{
private function bill(string $item, string $order, float $income, float $expense = 0, array $extra = []): object
{
return (object) array_merge([
'store_name' => '支付宝-测试账户', '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);
}
}
}