- 业绩明细改为可点击金额指标卡 - 明细弹层支持账单名称汇总与平台/店铺明细双页签 - 支持平台、店铺、账单名称筛选与金额合计校验 - 新增账单项目明细镜像及同步一致性校验
357 lines
18 KiB
PHP
357 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\EcommerceFinance;
|
|
|
|
use App\Console\Commands\SyncStoreReceiptOrderMatches;
|
|
use Illuminate\Database\Connectors\ConnectionFactory;
|
|
use Illuminate\Database\MySqlConnection;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
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 = [],
|
|
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);
|
|
}
|
|
}
|
|
}
|