227 lines
12 KiB
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);
|
|
}
|
|
}
|
|
}
|