water_promotion_test.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. <?php
  2. declare(strict_types=1);
  3. /** Real business transactions. All fixture rows, policy changes and rewards roll back. */
  4. require dirname(__DIR__) . '/vendor/autoload.php';
  5. require __DIR__ . '/support/MiniApiContract.php';
  6. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  7. \support\App::loadAllConfig(['route']);
  8. date_default_timezone_set('Asia/Shanghai');
  9. use app\miniapi\auth\Principal;
  10. use app\miniapi\model\MiniGrant;
  11. use app\miniapi\model\MiniUser;
  12. use app\miniapi\model\MiniEnterpriseBalance;
  13. use app\miniapi\service\CheckoutService;
  14. use app\miniapi\service\Access;
  15. use app\miniapi\service\EnterpriseQuery;
  16. use app\miniapi\service\PromotionService;
  17. use plugin\saiadmin\app\controller\water\WaterController;
  18. use plugin\saiadmin\app\model\water\WaterAccount;
  19. use plugin\saiadmin\app\model\water\WaterDelivery;
  20. use plugin\saiadmin\app\model\water\WaterLedger;
  21. use plugin\saiadmin\app\model\water\WaterModel;
  22. use plugin\saiadmin\app\model\water\WaterOrder;
  23. use plugin\saiadmin\app\model\water\WaterPromotionCode;
  24. use plugin\saiadmin\app\model\water\WaterPromotionReferral;
  25. use plugin\saiadmin\app\model\water\WaterPromotionSetting;
  26. use plugin\saiadmin\app\model\water\WaterSku;
  27. use plugin\saiadmin\app\service\WaterBusiness;
  28. use plugin\saiadmin\app\service\WaterDispatch;
  29. use plugin\saiadmin\app\service\promotion\PromotionPolicy;
  30. use plugin\saiadmin\app\service\promotion\PromotionQuery;
  31. use plugin\saiadmin\app\service\promotion\PromotionReferrals;
  32. use plugin\saiadmin\app\service\promotion\PromotionRewards;
  33. use support\Request;
  34. $checks = 0;
  35. function check(bool $ok, string $label): void
  36. {
  37. global $checks;
  38. ++$checks;
  39. if (!$ok) {
  40. throw new RuntimeException($label);
  41. }
  42. }
  43. function rejects(callable $operation, string $label): void
  44. {
  45. try {
  46. $operation();
  47. } catch (\plugin\saiadmin\exception\ApiException | \app\miniapi\exception\ApiException $e) {
  48. check(true, $label);
  49. return;
  50. }
  51. throw new RuntimeException('Expected rejection: ' . $label);
  52. }
  53. $db = (new WaterSku())->getConnection();
  54. $models = [WaterPromotionSetting::class, WaterPromotionCode::class, WaterPromotionReferral::class, WaterAccount::class, WaterLedger::class, WaterOrder::class, WaterDelivery::class];
  55. $snapshot = fn () => array_map(fn ($model) => $model::query()->orderBy('id')->get()->toJson(), $models);
  56. $before = $snapshot();
  57. $business = new WaterBusiness();
  58. $referrals = new PromotionReferrals();
  59. $rewards = new PromotionRewards();
  60. $tag = 'PROMO-CHECK-' . bin2hex(random_bytes(5));
  61. $now = date('Y-m-d H:i:s');
  62. $enterprise = fn ($suffix) => $business->save('enterprise', [
  63. 'name' => $tag . $suffix, 'contact' => '回滚夹具', 'phone' => '13812345678',
  64. 'address' => '回滚地址', 'region' => $tag,
  65. ], 1)['id'];
  66. $db->beginTransaction();
  67. try {
  68. $giftSku = $business->save('sku', ['name' => $tag . '-gift', 'liters' => 18.9], 1)['id'];
  69. $purchaseSku = $business->save('sku', ['name' => $tag . '-purchase', 'liters' => 18.9], 1)['id'];
  70. $package = $business->save('package', ['name' => $tag, 'sku_id' => $purchaseSku, 'buckets' => 20, 'price_cents' => 2000], 1)['id'];
  71. WaterPromotionSetting::query()->where('id', 1)->update(['enabled' => false]);
  72. $inviter = $enterprise('inviter');
  73. $invited = $enterprise('invited');
  74. $other = $enterprise('other');
  75. rejects(fn () => $referrals->code($inviter), 'disabled activity cannot generate codes');
  76. $policy = PromotionPolicy::current();
  77. $draft = ['enabled' => true, 'sku_id' => $giftSku, 'quantity' => 3, 'min_amount_cents' => 1000, 'revision' => $policy['revision']];
  78. rejects(fn () => PromotionPolicy::save(array_replace($draft, ['enabled' => 'true']), 1), 'strict boolean');
  79. rejects(fn () => PromotionPolicy::save(array_replace($draft, ['quantity' => 0]), 1), 'no zero rewards when enabled');
  80. rejects(fn () => PromotionPolicy::save(array_replace($draft, ['sku_id' => 2147483647]), 1), 'valid enabled SKU required');
  81. $policy = PromotionPolicy::save($draft, 1);
  82. rejects(fn () => PromotionPolicy::save($draft, 1), 'stale policy revision');
  83. $code = $referrals->code($inviter)['invite_code'];
  84. check(strlen($code) === 20 && $referrals->code($inviter)['invite_code'] === $code, 'stable random enterprise code');
  85. $otherCode = $referrals->code($other)['invite_code'];
  86. rejects(fn () => $referrals->bind($inviter, $code, 0), 'self invitation');
  87. rejects(fn () => $referrals->bind($invited, 'not-a-code', 0), 'invalid invitation code');
  88. $user = MiniUser::query()->create(['appid' => $tag, 'openid' => 'invitee', 'nickname' => '', 'status' => 1, 'created_at' => $now, 'updated_at' => $now]);
  89. $grant = MiniGrant::query()->create(['user_id' => $user->id, 'role' => 'enterprise_admin', 'resource_id' => $invited, 'status' => 1, 'created_by' => 1, 'created_at' => $now, 'updated_at' => $now]);
  90. $access = new Access(new Principal((string) $user->id));
  91. $mini = new PromotionService($access);
  92. rejects(fn () => $mini->code($inviter, ['request_key' => $tag . '-unauthorized']), 'other enterprise cannot generate invitation');
  93. $payload = ['invite_code' => $code, 'request_key' => $tag . '-bind'];
  94. $id = $mini->bind($invited, $payload)['id'];
  95. check($mini->bind($invited, $payload)['replayed'], 'same command replays binding');
  96. rejects(fn () => $mini->bind($invited, array_replace($payload, ['invite_code' => $otherCode])), 'changed idempotency payload');
  97. $grant->status = 2;
  98. $grant->save();
  99. rejects(fn () => $mini->bind($invited, $payload), 'revoked grant denies replay');
  100. $grant->status = 1;
  101. $grant->save();
  102. $grant->role = 'enterprise_member';
  103. $grant->save();
  104. rejects(fn () => $mini->code($invited, ['request_key' => $tag . '-member-code']), 'ordinary member cannot generate codes');
  105. rejects(fn () => $mini->bind($invited, $payload), 'ordinary member cannot bind an invitation');
  106. $grant->role = 'enterprise_admin';
  107. $grant->save();
  108. check($referrals->bind($invited, $code, (int) $user->id)['replayed'], 'same enterprise binding across requests');
  109. rejects(fn () => $referrals->bind($invited, $otherCode, (int) $user->id), 'attribution immutable');
  110. $inviteeCode = $referrals->code($invited)['invite_code'];
  111. rejects(fn () => $referrals->bind($inviter, $inviteeCode, 0), 'reciprocal invitation blocked');
  112. $sameAdmin = $enterprise('same-admin');
  113. MiniGrant::query()->create(['user_id' => $user->id, 'role' => 'enterprise_admin', 'resource_id' => $sameAdmin, 'status' => 1, 'created_by' => 1, 'created_at' => $now, 'updated_at' => $now]);
  114. rejects(fn () => $referrals->bind($sameAdmin, $inviteeCode, (int) $user->id), 'shared administrator rejected');
  115. // Changing/closing the campaign does not rewrite an existing binding's promise.
  116. PromotionPolicy::save(array_replace($draft, ['enabled' => false, 'quantity' => 9, 'revision' => $policy['revision']]), 1);
  117. WaterDelivery::query()->create([
  118. 'enterprise_id' => $invited, 'sku_id' => $purchaseSku, 'quantity' => 1, 'delivered_qty' => 1,
  119. 'status' => 'completed', 'address' => '历史赠水配送', 'created_by' => 1,
  120. 'created_at' => $now, 'updated_at' => $now, 'received_at' => $now,
  121. ]);
  122. $purchase = ['enterprise_id' => $invited, 'package_id' => $package, 'reference' => $tag . '-receipt'];
  123. $order = $business->purchase($purchase, 1);
  124. check($business->purchase($purchase, 1)['replayed'], 'paid receipt replay');
  125. $row = WaterPromotionReferral::query()->find($id);
  126. check($row->status === 'pending' && (int) $row->quantity === 3 && (int) $row->order_id === $order['id'], 'first receipt qualifies with immutable reward snapshot');
  127. check(WaterPromotionReferral::query()->where('invited_id', $invited)->count() === 1, 'one reward per enterprise');
  128. $review = ['id' => $id, 'revision' => (int) $row->revision, 'decision' => 'issued', 'receipt_refund_checked' => true, 'note' => '回滚测试已核实收款、签收、无退款'];
  129. rejects(fn () => $rewards->review(array_replace($review, ['receipt_refund_checked' => false]), 1), 'explicit receipt and refund verification');
  130. rejects(fn () => $rewards->review(array_replace($review, ['revision' => 99]), 1), 'stale review revision');
  131. rejects(fn () => $rewards->review($review, 1), 'no award without completed receipt');
  132. $business->stock(['sku_id' => $purchaseSku, 'mode' => 'inbound', 'quantity' => 50, 'reference' => $tag . '-inbound', 'note' => '回滚入库'], 1);
  133. $task = $business->createDelivery(['enterprise_id' => $invited, 'sku_id' => $purchaseSku, 'quantity' => 5], 1)['id'];
  134. $dispatch = new WaterDispatch();
  135. $driver = $dispatch->saveDriver(['name' => $tag, 'phone' => '13812345678', 'region' => $tag, 'capacity' => 50], 1)['id'];
  136. $dispatch->shift(['driver_id' => $driver, 'date' => date('Y-m-d'), 'state' => 'on'], 1);
  137. $dispatch->commit(['date' => date('Y-m-d'), 'request_key' => $tag . '-route', 'routes' => [['driver_id' => $driver, 'task_ids' => [$task]]]], 1);
  138. $business->transition(['id' => $task, 'action' => 'depart'], 1);
  139. rejects(fn () => $rewards->review($review, 1), 'in-transit is not a signature');
  140. $business->transition(['id' => $task, 'action' => 'complete', 'empty_returned' => 0, 'receiver' => '回滚签收人'], 1);
  141. $giftBefore = WaterSku::query()->find($giftSku)->toArray();
  142. try {
  143. WaterModel::transaction(function () use ($rewards, $review) {
  144. $rewards->review($review, 1);
  145. throw new RuntimeException('force reward rollback');
  146. });
  147. } catch (RuntimeException $e) {
  148. check($e->getMessage() === 'force reward rollback', 'forced failure rolls back reward');
  149. }
  150. check(WaterPromotionReferral::query()->find($id)->status === 'pending', 'rolled-back reward remains pending');
  151. check(!WaterAccount::query()->where('enterprise_id', $inviter)->where('sku_id', $giftSku)->exists(), 'rolled-back reward creates no entitlement');
  152. $rewards->review($review, 1);
  153. check($rewards->review($review, 1)['replayed'], 'review retry does not issue twice');
  154. check((int) WaterAccount::query()->where('enterprise_id', $inviter)->where('sku_id', $giftSku)->value('entitlement') === 3, 'gift entitlement issued exactly once');
  155. check(WaterSku::query()->find($giftSku)->toArray() === $giftBefore, 'gift changes no physical stock');
  156. check(WaterLedger::query()->where('kind', 'promotion_reward')->where('reference', 'promotion:' . $id)->count() === 1, 'one auditable gift ledger');
  157. rejects(fn () => $rewards->review(array_replace($review, ['decision' => 'rejected']), 1), 'cannot reverse issued reward through rejection');
  158. $dashboard = (new PromotionQuery())->enterprise($inviter);
  159. check($dashboard['total_buckets'] === 3 && $dashboard['total_cents'] === null && !$dashboard['demo'], 'real gift totals, no cash or demo fixture');
  160. check((new MiniApiContract())->data('Rewards', $dashboard), 'rewards contract with real records');
  161. check(
  162. $dashboard['items'][0]['reference'] === null && $dashboard['items'][0]['amount_cents'] === null
  163. && $dashboard['items'][0]['review_note'] === '' && $dashboard['items'][0]['delivery_id'] === null,
  164. 'mini rewards do not expose another enterprise payment, delivery or internal review details'
  165. );
  166. check((new MiniApiContract())->data('Rewards', (new EnterpriseQuery($access))->rewards($invited)), 'bound invitee contract');
  167. $inviterUser = MiniUser::query()->create(['appid' => $tag, 'openid' => 'inviter', 'nickname' => '', 'status' => 1, 'created_at' => $now, 'updated_at' => $now]);
  168. MiniGrant::query()->create(['user_id' => $inviterUser->id, 'role' => 'enterprise_admin', 'resource_id' => $inviter, 'status' => 1, 'created_by' => 1, 'created_at' => $now, 'updated_at' => $now]);
  169. $inviterQuery = new EnterpriseQuery(new Access(new Principal((string) $inviterUser->id)));
  170. check($inviterQuery->profile($inviter)['reward_buckets'] === 3, 'profile shows water buckets rather than cash');
  171. check((new MiniApiContract())->data('Profile', $inviterQuery->profile($inviter)), 'profile contract includes gift bucket total');
  172. check($inviterQuery->ledger($inviter, [])['items'][0]['kind'] === 'promotion_reward', 'gift is visible in enterprise entitlement ledger');
  173. check((new PromotionQuery())->dashboard(['status' => 'issued'])['list']['total'] >= 1, 'admin status filter');
  174. rejects(fn () => (new PromotionQuery())->dashboard(['status' => 'unknown']), 'invalid filter');
  175. $controllerClass = new ReflectionClass(WaterController::class);
  176. $controller = $controllerClass->newInstanceWithoutConstructor();
  177. $controllerClass->getProperty('adminId')->setValue($controller, 0);
  178. $request = new Request("GET /core/water/finance/promotion HTTP/1.1\r\nHost: localhost\r\n\r\n");
  179. rejects(fn () => $controller->promotionDashboard($request), 'unauthorized admin read');
  180. rejects(fn () => $controller->promotionPolicy($request), 'unauthorized admin policy');
  181. rejects(fn () => $controller->promotionReview($request), 'unauthorized admin issuance');
  182. $current = PromotionPolicy::current();
  183. PromotionPolicy::save(array_replace($draft, ['revision' => $current['revision']]), 1);
  184. $oldCustomer = $enterprise('old-customer');
  185. $business->purchase(['enterprise_id' => $oldCustomer, 'package_id' => $package, 'reference' => $tag . '-old'], 1);
  186. rejects(fn () => $referrals->bind($oldCustomer, $code, 0), 'old customer cannot claim new-customer reward');
  187. $low = $enterprise('below-threshold');
  188. $lowId = $referrals->bind($low, $code, 0)['id'];
  189. $cheap = $business->save('package', ['name' => $tag . '-cheap', 'sku_id' => $purchaseSku, 'buckets' => 1, 'price_cents' => 100], 1)['id'];
  190. $business->purchase(['enterprise_id' => $low, 'package_id' => $cheap, 'reference' => $tag . '-low'], 1);
  191. $business->purchase(['enterprise_id' => $low, 'package_id' => $package, 'reference' => $tag . '-later'], 1);
  192. check(WaterPromotionReferral::query()->find($lowId)->status === 'rejected', 'below-threshold first purchase cannot be replaced');
  193. $demo = $enterprise('demo');
  194. $demoId = $referrals->bind($demo, $code, 0)['id'];
  195. $business->purchase(['enterprise_id' => $demo, 'package_id' => $package, 'reference' => 'TEST-' . $tag], 1);
  196. check(WaterPromotionReferral::query()->find($demoId)->status === 'bound', 'demo receipt never qualifies');
  197. $combinedEnterprise = $enterprise('combined');
  198. $combinedId = $referrals->bind($combinedEnterprise, $code, 0)['id'];
  199. $combinedUser = MiniUser::query()->create(['appid' => $tag, 'openid' => 'combined', 'nickname' => '', 'status' => 1, 'created_at' => $now, 'updated_at' => $now]);
  200. MiniGrant::query()->create(['user_id' => $combinedUser->id, 'role' => 'enterprise_admin', 'resource_id' => $combinedEnterprise, 'status' => 1, 'created_by' => 1, 'created_at' => $now, 'updated_at' => $now]);
  201. MiniEnterpriseBalance::query()->create(['enterprise_id' => $combinedEnterprise, 'balance_cents' => 10000, 'created_at' => $now, 'updated_at' => $now]);
  202. $checkoutService = new CheckoutService(new Access(new Principal((string) $combinedUser->id)));
  203. $checkout = $checkoutService->create($combinedEnterprise, [
  204. 'items' => [['package_id' => $cheap, 'quantity' => 15]], 'expected_amount_cents' => 1500,
  205. 'agreement_accepted' => true, 'request_key' => $tag . '-combined-create',
  206. ]);
  207. check(WaterPromotionReferral::query()->find($combinedId)->status === 'bound', 'unpaid checkout earns no reward');
  208. $pay = ['payment_method' => 'balance', 'request_key' => $tag . '-combined-pay'];
  209. $checkoutService->pay($combinedEnterprise, $checkout['id'], $pay);
  210. $combinedRow = WaterPromotionReferral::query()->find($combinedId);
  211. check($combinedRow->status === 'pending' && (int) $combinedRow->purchase_amount_cents === 1500, 'threshold uses complete checkout rather than individual 100-cent receipt');
  212. check((int) $combinedRow->checkout_id === $checkout['id'], 'paid checkout attribution persisted');
  213. check($checkoutService->pay($combinedEnterprise, $checkout['id'], $pay)['replayed'], 'combined payment replay');
  214. check(WaterPromotionReferral::query()->where('invited_id', $combinedEnterprise)->count() === 1, 'fifteen receipts produce one invitation reward');
  215. $rejectInput = ['id' => $combinedId, 'revision' => (int) $combinedRow->revision, 'decision' => 'rejected', 'note' => '审核认定重复客户,驳回'];
  216. $rewards->review($rejectInput, 1);
  217. check($rewards->review($rejectInput, 1)['replayed'], 'rejection retry idempotent');
  218. check((int) WaterAccount::query()->where('enterprise_id', $inviter)->where('sku_id', $giftSku)->value('entitlement') === 3, 'rejection grants no additional entitlement');
  219. } finally {
  220. $db->rollBack();
  221. }
  222. check($snapshot() === $before, 'all business/policy fixtures rolled back');
  223. echo "PASS: $checks promotion checks; all fixture changes rolled back.\n";