SharingBetFactsTest.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Console\Commands\RecalculateShareRebates;
  4. use App\Services\Sharing\BetFactSynchronizer;
  5. use Carbon\Carbon;
  6. use Illuminate\Config\Repository;
  7. use Illuminate\Database\Capsule\Manager;
  8. use Illuminate\Database\Schema\Blueprint;
  9. use Illuminate\Events\Dispatcher;
  10. use Illuminate\Foundation\Application;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Facades\Facade;
  13. use Illuminate\Support\Facades\Schema;
  14. use PHPUnit\Framework\TestCase;
  15. class SharingBetFactsTest extends TestCase
  16. {
  17. protected function setUp(): void
  18. {
  19. parent::setUp();
  20. Carbon::setTestNow(Carbon::parse('2026-10-24 12:00:00', 'Asia/Shanghai'));
  21. Facade::clearResolvedInstances();
  22. $app = new Application(dirname(__DIR__, 2));
  23. $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai']]));
  24. Facade::setFacadeApplication($app);
  25. $db = new Manager($app);
  26. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  27. $db->setEventDispatcher(new Dispatcher($app)); $db->setAsGlobal(); $db->bootEloquent();
  28. $app->instance('db', $db->getDatabaseManager());
  29. $app->bind('db.schema', fn () => $db->schema());
  30. Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->integer('from')->default(1); });
  31. DB::table('users')->insert([['id' => 1, 'member_id' => '10001', 'from' => -1], ['id' => 2, 'member_id' => '20002', 'from' => 2]]);
  32. Schema::create('lhc_order', function (Blueprint $t) {
  33. $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('type'); $t->integer('lottery_status');
  34. $t->decimal('amount', 24, 10); $t->decimal('total_amount', 24, 10)->nullable(); $t->bigInteger('created_at'); $t->bigInteger('updated_at');
  35. $t->decimal('coupon_discount', 14, 2)->default(0);
  36. });
  37. Schema::create('lhc_order_item', function (Blueprint $t) {
  38. $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('lottery_status');
  39. $t->decimal('win_amount', 24, 10)->nullable(); $t->decimal('profit_and_loss', 24, 10)->nullable(); $t->bigInteger('updated_at');
  40. $t->decimal('coupon_discount', 14, 2)->default(0);
  41. });
  42. Schema::create('sport_game_order', function (Blueprint $t) {
  43. $t->id(); $t->string('member_id'); $t->integer('status'); $t->decimal('amount', 24, 10); $t->decimal('win_amount', 24, 10)->default(0); $t->timestamps();
  44. $t->decimal('coupon_discount', 14, 2)->default(0);
  45. });
  46. Schema::create('sport_order', function (Blueprint $t) {
  47. $t->id(); $t->string('user_id'); $t->integer('status'); $t->integer('pay_status'); $t->integer('return_status')->default(0);
  48. $t->integer('settlement_status'); $t->decimal('amount', 24, 10); $t->decimal('win_amount', 24, 10)->default(0);
  49. $t->decimal('return_amount', 24, 10)->default(0); $t->bigInteger('create_time'); $t->bigInteger('update_time');
  50. $t->bigInteger('settlement_time'); $t->bigInteger('return_operation_time')->nullable();
  51. });
  52. Schema::create('third_game_orders', function (Blueprint $t) {
  53. $t->id(); $t->string('order_key'); $t->unsignedBigInteger('user_id')->nullable(); $t->string('member_id');
  54. $t->integer('game_type'); $t->integer('status'); $t->decimal('bet_amount', 24, 10)->nullable(); $t->decimal('settled_amount', 24, 10)->nullable();
  55. $t->string('currency'); $t->dateTime('bet_time'); $t->dateTime('last_update_time');
  56. });
  57. Schema::create('balance_logs', function (Blueprint $t) {
  58. $t->id(); $t->string('member_id'); $t->unsignedBigInteger('related_id'); $t->string('change_type'); $t->decimal('amount', 24, 10);
  59. });
  60. Schema::create('share_bet_facts', function (Blueprint $t) {
  61. $t->id(); $t->string('source'); $t->string('source_id'); $t->unsignedBigInteger('user_id'); $t->string('member_id');
  62. $t->string('category'); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
  63. foreach (['bet_amount', 'net_profit', 'effective_amount'] as $col) $t->decimal($col, 24, 10);
  64. $t->boolean('settled'); $t->boolean('voided'); $t->string('currency'); $t->string('source_version'); $t->timestamps(); $t->unique(['source', 'source_id']);
  65. });
  66. Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); });
  67. DB::table('share_system_state')->insert(['key' => 'relations_lock', 'value' => '1']);
  68. Schema::create('rebates', function (Blueprint $t) {
  69. $t->id(); $t->string('member_id'); $t->date('date'); $t->decimal('betting_amount', 24, 2);
  70. $t->decimal('effective_betting_amount', 24, 2); $t->decimal('profit', 24, 2)->default(0); $t->integer('status'); $t->timestamps();
  71. });
  72. }
  73. protected function tearDown(): void
  74. {
  75. DB::disconnect(); Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); Carbon::setTestNow();
  76. parent::tearDown();
  77. }
  78. private function lottery(int $status = 2): void
  79. {
  80. $time = Carbon::parse('2026-10-24 00:10:00', 'Asia/Shanghai')->timestamp;
  81. DB::table('lhc_order')->insert(['id' => 10, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'type' => 5,
  82. 'lottery_status' => $status, 'amount' => 100, 'total_amount' => 200, 'created_at' => $time, 'updated_at' => $time + 3600]);
  83. DB::table('lhc_order_item')->insert([
  84. ['id' => 101, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'lottery_status' => 2, 'win_amount' => 150, 'profit_and_loss' => 50, 'updated_at' => $time + 3600],
  85. ['id' => 102, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'lottery_status' => 1, 'win_amount' => 0, 'profit_and_loss' => -100, 'updated_at' => $time + 3600],
  86. ]);
  87. }
  88. public function test_preview_then_sync_uses_child_bets_and_repeated_sync_is_idempotent(): void
  89. {
  90. $this->lottery();
  91. $sync = new BetFactSynchronizer();
  92. $this->assertSame(2, $sync->sync()['created']);
  93. $this->assertSame(0, DB::table('share_bet_facts')->count());
  94. $this->assertSame(2, $sync->sync(null, null, true)['created']);
  95. $this->assertEquals(150, DB::table('share_bet_facts')->sum('effective_amount'));
  96. $this->assertSame('2026-10-24', DB::table('share_bet_facts')->first()->bet_date);
  97. $this->assertSame(['2026-10-24'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true));
  98. $again = $sync->sync(null, null, true);
  99. $this->assertSame(0, $again['created']); $this->assertSame(0, $again['updated']);
  100. $this->assertSame(2, DB::table('share_bet_facts')->count());
  101. }
  102. public function test_coupon_orders_use_paid_stake_for_net_profit_and_effective_bet(): void
  103. {
  104. // 2 numbers x 100 with a 60 coupon split 30/30: paid 70 each, payouts still on the full 100.
  105. $this->lottery();
  106. DB::table('lhc_order')->where('id', 10)->update(['coupon_discount' => 60]);
  107. DB::table('lhc_order_item')->update(['coupon_discount' => 30]);
  108. DB::table('sport_game_order')->insert([
  109. ['id' => 1, 'member_id' => '10001', 'status' => 2, 'amount' => 100, 'win_amount' => 200, 'coupon_discount' => 50, 'created_at' => '2026-10-24 10:00:00', 'updated_at' => '2026-10-24 11:00:00'],
  110. ['id' => 2, 'member_id' => '10001', 'status' => 1, 'amount' => 100, 'win_amount' => 0, 'coupon_discount' => 50, 'created_at' => '2026-10-24 10:00:00', 'updated_at' => '2026-10-24 11:00:00'],
  111. ]);
  112. $result = (new BetFactSynchronizer())->sync(null, null, true);
  113. $this->assertSame([], $result['errors']);
  114. $fact = fn (string $source, string $id) => DB::table('share_bet_facts')->where('source', $source)->where('source_id', $id)->first();
  115. $won = $fact('lhc_order_item', '101'); $lost = $fact('lhc_order_item', '102');
  116. $this->assertEquals(70, $won->bet_amount); $this->assertEquals(80, $won->net_profit); $this->assertEquals(70, $won->effective_amount);
  117. $this->assertEquals(-70, $lost->net_profit); $this->assertEquals(70, $lost->effective_amount);
  118. $this->assertEquals(150, $fact('sport_game_order', '1')->net_profit); $this->assertEquals(50, $fact('sport_game_order', '1')->effective_amount);
  119. $this->assertEquals(-50, $fact('sport_game_order', '2')->net_profit); $this->assertEquals(50, $fact('sport_game_order', '2')->effective_amount);
  120. // A refund of the paid total (not the gross 200) is a consistent full refund.
  121. DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 1, 'updated_at' => Carbon::now()->timestamp]);
  122. DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 140]);
  123. $this->assertSame([], (new BetFactSynchronizer())->sync(null, null, true)['errors']);
  124. $this->assertSame(2, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->where('voided', 1)->count());
  125. }
  126. public function test_refunded_orders_mark_previous_facts_void_and_preserve_unfinished_facts(): void
  127. {
  128. $this->lottery();
  129. $sync = new BetFactSynchronizer(); $sync->sync(null, null, true);
  130. // Failed draw legacy refund is stored with loss status; its positive refund log is authoritative.
  131. DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 1, 'updated_at' => Carbon::now()->timestamp]);
  132. DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 200]);
  133. $this->assertSame(2, $sync->sync(null, null, true)['updated']);
  134. $this->assertEquals(0, DB::table('share_bet_facts')->sum('effective_amount'));
  135. $this->assertSame(2, DB::table('share_bet_facts')->where('voided', 1)->count());
  136. DB::table('sport_game_order')->insert(['member_id' => '10001', 'status' => 0, 'amount' => 100, 'created_at' => '2026-10-24 10:00:00']);
  137. $sync->sync(null, null, true);
  138. $pending = DB::table('share_bet_facts')->where('source', 'sport_game_order')->first();
  139. $this->assertEquals(0, $pending->effective_amount); $this->assertEquals(0, $pending->settled);
  140. }
  141. public function test_orders_settled_without_touching_their_timestamp_still_settle_their_facts(): void
  142. {
  143. $placed = Carbon::parse('2026-10-24 11:00:00', 'Asia/Shanghai')->timestamp;
  144. DB::table('lhc_order')->insert(['id' => 10, 'member_id' => '10001', 'ordernum' => 'slow-draw', 'type' => 1,
  145. 'lottery_status' => 0, 'amount' => 100, 'total_amount' => 200, 'created_at' => $placed, 'updated_at' => $placed]);
  146. DB::table('lhc_order_item')->insert([
  147. ['id' => 101, 'member_id' => '10001', 'ordernum' => 'slow-draw', 'lottery_status' => 0, 'updated_at' => $placed],
  148. ['id' => 102, 'member_id' => '10001', 'ordernum' => 'slow-draw', 'lottery_status' => 0, 'updated_at' => $placed],
  149. ]);
  150. DB::table('sport_order')->insert(['id' => 30, 'user_id' => '10001', 'status' => 1, 'pay_status' => 1, 'settlement_status' => 0,
  151. 'amount' => 100, 'create_time' => $placed, 'update_time' => $placed, 'settlement_time' => 0]);
  152. $sync = new BetFactSynchronizer();
  153. $this->assertSame(3, $sync->sync(null, null, true)['created']);
  154. $this->assertSame(3, DB::table('share_bet_facts')->where('settled', 0)->count());
  155. Carbon::setTestNow(Carbon::parse('2026-10-24 13:00:00', 'Asia/Shanghai'));
  156. $idle = $sync->sync(null, null, true);
  157. $this->assertSame(0, $idle['scanned'], 'open bets whose source is still open are not re-read');
  158. // melbet LhcSettlementService / FootballSettlement: Model::where()->update() leaves updated_at / update_time unchanged.
  159. DB::table('lhc_order_item')->where('id', 101)->update(['lottery_status' => 2, 'win_amount' => 150]);
  160. DB::table('lhc_order_item')->where('id', 102)->update(['lottery_status' => 1]);
  161. DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 2]);
  162. DB::table('sport_order')->where('id', 30)->update(['settlement_status' => 1, 'return_amount' => 50, 'settlement_time' => $placed + 7200]);
  163. Carbon::setTestNow(Carbon::parse('2026-10-24 13:01:00', 'Asia/Shanghai'));
  164. $result = $sync->sync(null, null, true);
  165. $this->assertSame([], $result['errors']);
  166. $this->assertSame(3, $result['updated']);
  167. $this->assertSame(0, DB::table('share_bet_facts')->where('settled', 0)->count());
  168. $this->assertEquals(150, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->sum('effective_amount'));
  169. $this->assertEquals(50, DB::table('share_bet_facts')->where('source', 'sport_order')->value('effective_amount'));
  170. $this->assertSame(0, $sync->sync(null, null, true)['scanned'], 'settled facts are not re-read again');
  171. }
  172. public function test_finished_orders_whose_facts_stay_open_are_retried_hourly_not_every_run(): void
  173. {
  174. $placed = Carbon::parse('2026-10-24 11:00:00', 'Asia/Shanghai')->timestamp;
  175. DB::table('lhc_order')->insert(['id' => 10, 'member_id' => '10001', 'ordernum' => 'stuck', 'type' => 1,
  176. 'lottery_status' => 0, 'amount' => 100, 'total_amount' => 100, 'created_at' => $placed, 'updated_at' => $placed]);
  177. DB::table('lhc_order_item')->insert(['id' => 101, 'member_id' => '10001', 'ordernum' => 'stuck', 'lottery_status' => 0, 'updated_at' => $placed]);
  178. $sync = new BetFactSynchronizer(); $sync->sync(null, null, true);
  179. // Parent is final but its only child bet never settled: the fact cannot leave the open state.
  180. DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 2]);
  181. Carbon::setTestNow(Carbon::parse('2026-10-24 13:00:00', 'Asia/Shanghai'));
  182. $this->assertSame(1, $sync->sync(null, null, true)['unchanged']);
  183. Carbon::setTestNow(Carbon::parse('2026-10-24 13:30:00', 'Asia/Shanghai'));
  184. $this->assertSame(0, $sync->sync(null, null, true)['scanned'], 'waits before retrying');
  185. Carbon::setTestNow(Carbon::parse('2026-10-24 14:01:00', 'Asia/Shanghai'));
  186. DB::table('lhc_order_item')->where('id', 101)->update(['lottery_status' => 1]);
  187. $this->assertSame(1, $sync->sync(null, null, true)['updated'], 'retried after the backoff');
  188. $this->assertSame([], json_decode(DB::table('share_system_state')->where('key', 'facts_repair_backoff:lhc_order')->value('value'), true));
  189. }
  190. public function test_football_partial_returns_third_net_profit_and_demo_exclusion(): void
  191. {
  192. $time = Carbon::parse('2026-10-24 01:00:00', 'Asia/Shanghai')->timestamp;
  193. DB::table('sport_order')->insert(['user_id' => '10001', 'status' => 1, 'pay_status' => 1, 'settlement_status' => 1,
  194. 'amount' => 100, 'win_amount' => 0, 'return_amount' => 50, 'create_time' => $time, 'update_time' => $time + 3600, 'settlement_time' => $time + 3600]);
  195. foreach ([1 => '10001', 2 => '20002'] as $id => $member) {
  196. DB::table('third_game_orders')->insert(['user_id' => $id, 'member_id' => $member, 'order_key' => hash('sha256', (string) $id),
  197. 'game_type' => 4, 'status' => 1, 'bet_amount' => 100, 'settled_amount' => 80, 'currency' => 'CNY',
  198. 'bet_time' => '2026-10-24 01:00:00', 'last_update_time' => '2026-10-24 02:00:00']);
  199. }
  200. $result = (new BetFactSynchronizer())->sync(null, null, true);
  201. $this->assertSame([], $result['errors']); $this->assertSame(2, $result['created']);
  202. $this->assertEquals(50, DB::table('share_bet_facts')->where('source', 'sport_order')->value('effective_amount'));
  203. $this->assertEquals(80, DB::table('share_bet_facts')->where('source', 'third_game_orders')->value('effective_amount'));
  204. $this->assertSame(0, DB::table('share_bet_facts')->where('user_id', 2)->count());
  205. DB::table('third_game_orders')->where('user_id', 1)->update(['status' => 3, 'game_type' => 0, 'bet_amount' => null,
  206. 'settled_amount' => null, 'last_update_time' => '2026-10-24 12:00:00']);
  207. $sync = (new BetFactSynchronizer())->sync(null, null, true);
  208. $this->assertSame([], $sync['errors']); $this->assertSame(1, $sync['updated']);
  209. $cancelled = DB::table('share_bet_facts')->where('source', 'third_game_orders')->first();
  210. $this->assertEquals(0, $cancelled->effective_amount); $this->assertEquals(1, $cancelled->voided);
  211. $this->assertSame('third:4', $cancelled->category);
  212. }
  213. public function test_history_recalculation_is_preview_by_default_and_never_changes_paid_or_ambiguous_dates(): void
  214. {
  215. $this->lottery();
  216. DB::table('rebates')->insert([
  217. ['id' => 1, 'member_id' => '10001', 'date' => '2026-10-23', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 0],
  218. ['id' => 2, 'member_id' => '10001', 'date' => '2026-10-24', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 0],
  219. ['id' => 3, 'member_id' => '10001', 'date' => '2026-10-22', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 1],
  220. ]);
  221. $command = new RecalculateShareRebates(); $sync = new BetFactSynchronizer();
  222. $preview = $command->recalculate($sync);
  223. $this->assertSame(1, $preview['changed']); $this->assertSame(1, $preview['skipped']); $this->assertSame(1, $preview['paid_untouched']);
  224. $this->assertEquals(200, DB::table('rebates')->where('id', 1)->value('effective_betting_amount'));
  225. $written = $command->recalculate($sync, true); $this->assertSame(1, $written['changed']);
  226. $this->assertEquals(150, DB::table('rebates')->where('id', 1)->value('effective_betting_amount'));
  227. $this->assertEquals(200, DB::table('rebates')->where('id', 2)->value('effective_betting_amount'));
  228. $this->assertEquals(200, DB::table('rebates')->where('id', 3)->value('effective_betting_amount'));
  229. }
  230. public function test_legacy_injected_bets_are_never_a_source_and_partial_refund_is_flagged_for_review(): void
  231. {
  232. Schema::create('bets', function (Blueprint $t) { $t->id(); $t->integer('status'); $t->decimal('amount', 24, 10); });
  233. DB::table('bets')->insert(['status' => 2, 'amount' => 100000]);
  234. $this->lottery();
  235. DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 100]);
  236. $result = (new BetFactSynchronizer())->sync(null, null, true);
  237. $this->assertSame(1, $result['scanned']); $this->assertSame(1, count($result['errors']));
  238. $this->assertStringContainsString('refund requires review', $result['errors'][0]['reason']);
  239. $this->assertSame(0, DB::table('share_bet_facts')->count());
  240. $this->assertNull(DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'));
  241. }
  242. public function test_incremental_cursor_includes_new_orders_with_old_dates_and_retries_updated_sources(): void
  243. {
  244. $sync = new BetFactSynchronizer(); $sync->sync(null, null, true);
  245. DB::table('sport_game_order')->insert(['id' => 20, 'member_id' => '10001', 'status' => 2, 'amount' => 100,
  246. 'win_amount' => 150, 'created_at' => '2026-10-01 10:00:00', 'updated_at' => '2026-10-01 11:00:00']);
  247. $this->assertSame(1, $sync->sync(null, null, true)['created']);
  248. DB::table('sport_game_order')->where('id', 20)->update(['status' => 3, 'updated_at' => '2026-10-24 12:00:00']);
  249. $this->assertSame(1, $sync->sync(null, null, true)['updated']);
  250. $this->assertEquals(0, DB::table('share_bet_facts')->value('effective_amount'));
  251. $this->assertSame(['2026-10-01'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true));
  252. }
  253. public function test_legacy_lottery_orders_without_child_bets_are_skipped_and_cursor_advances(): void
  254. {
  255. $time = Carbon::parse('2026-05-01 10:00:00', 'Asia/Shanghai')->timestamp;
  256. DB::table('lhc_order')->insert(['id' => 5, 'member_id' => '10001', 'ordernum' => 'legacy-lottery', 'type' => 5,
  257. 'lottery_status' => 2, 'amount' => 100, 'created_at' => $time, 'updated_at' => $time]);
  258. $this->lottery();
  259. $sync = new BetFactSynchronizer();
  260. $this->assertSame('Lottery order has no identifiable child bets', $sync->sync()['errors'][0]['reason']);
  261. app('config')->set('sharing.lottery_legacy_max_id', 5);
  262. $result = $sync->sync(null, null, true);
  263. $this->assertSame([], $result['errors']); $this->assertSame(1, $result['scanned']); $this->assertSame(2, $result['created']);
  264. $this->assertNotNull(DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'));
  265. }
  266. public function test_second_child_failure_rolls_back_all_facts_dirty_dates_and_source_cursor(): void
  267. {
  268. $this->lottery();
  269. DB::statement("CREATE TRIGGER reject_second_fact BEFORE INSERT ON bot_share_bet_facts WHEN NEW.source_id='102' BEGIN SELECT RAISE(ABORT,'second child failed'); END");
  270. $result = (new BetFactSynchronizer())->sync(null, null, true);
  271. $this->assertSame(1, count($result['errors'])); $this->assertSame(0, $result['created']);
  272. $this->assertSame(0, DB::table('share_bet_facts')->count());
  273. $this->assertNull(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'));
  274. $this->assertNull(DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'));
  275. $this->assertSame(0, DB::transactionLevel());
  276. }
  277. public function test_write_reloads_source_after_chunk_read_and_preview_never_creates_the_guard(): void
  278. {
  279. $this->lottery();
  280. DB::table('share_system_state')->where('key', 'relations_lock')->delete();
  281. $preview = (new BetFactSynchronizer())->sync();
  282. $this->assertSame(2, $preview['created']); $this->assertSame(0, DB::table('share_system_state')->count());
  283. DB::table('share_system_state')->insert(['key' => 'relations_lock', 'value' => '1']);
  284. $changed = false;
  285. DB::listen(function ($event) use (&$changed) {
  286. if (!$changed && preg_match('/from "bot_lhc_order" (where "id" is not null )?order by "id"/', $event->sql)) {
  287. $changed = true;
  288. DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 4, 'updated_at' => Carbon::now()->timestamp]);
  289. }
  290. });
  291. $result = (new BetFactSynchronizer())->sync(null, null, true);
  292. $this->assertTrue($changed); $this->assertSame([], $result['errors']); $this->assertSame(2, $result['created']);
  293. $this->assertEquals(0, DB::table('share_bet_facts')->sum('effective_amount'));
  294. $this->assertSame(2, DB::table('share_bet_facts')->where('voided', 1)->count());
  295. }
  296. public function test_untrusted_source_quarantines_only_identifiable_old_facts_and_repair_restores_them(): void
  297. {
  298. $this->lottery();
  299. DB::table('sport_game_order')->insert(['member_id' => '10001', 'status' => 2, 'amount' => 100, 'win_amount' => 180,
  300. 'created_at' => '2026-10-24 10:00:00', 'updated_at' => '2026-10-24 11:00:00']);
  301. $sync = new BetFactSynchronizer(); $sync->sync(null, null, true);
  302. $original = DB::table('share_bet_facts')->where('source', 'lhc_order_item')->orderBy('source_id')->get();
  303. $cursor = DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value');
  304. DB::table('share_system_state')->where('key', 'facts_dirty_dates')->update(['value' => '[]']);
  305. DB::table('lhc_order')->where('id', 10)->update(['updated_at' => Carbon::now()->timestamp]);
  306. DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 100]);
  307. $bad = $sync->sync(null, null, true);
  308. $this->assertSame(1, count($bad['errors'])); $this->assertSame(2, $bad['errors'][0]['isolated_facts']);
  309. $this->assertSame(2, $bad['updated']);
  310. $this->assertEquals(0, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->sum('effective_amount'));
  311. $this->assertSame(2, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->where('settled', 0)->where('voided', 0)->count());
  312. $this->assertEquals(80, DB::table('share_bet_facts')->where('source', 'sport_game_order')->value('effective_amount'));
  313. $quarantined = DB::table('share_bet_facts')->where('source', 'lhc_order_item')->orderBy('source_id')->get();
  314. foreach ($original as $i => $old) {
  315. $this->assertEquals($old->bet_amount, $quarantined[$i]->bet_amount);
  316. $this->assertEquals($old->net_profit, $quarantined[$i]->net_profit);
  317. $this->assertNotSame($old->source_version, $quarantined[$i]->source_version);
  318. }
  319. $this->assertSame($cursor, DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'));
  320. $this->assertSame(['2026-10-24'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true));
  321. DB::table('balance_logs')->where('change_type', '投注退分')->delete();
  322. $fixed = $sync->sync(null, null, true);
  323. $this->assertSame([], $fixed['errors']); $this->assertSame(2, $fixed['updated']);
  324. $this->assertEquals(150, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->sum('effective_amount'));
  325. $this->assertSame(2, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->where('settled', 1)->count());
  326. }
  327. }