| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- <?php
- declare(strict_types=1);
- /** Real business transactions. All fixture rows, policy changes and rewards roll back. */
- require dirname(__DIR__) . '/vendor/autoload.php';
- require __DIR__ . '/support/MiniApiContract.php';
- \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
- \support\App::loadAllConfig(['route']);
- date_default_timezone_set('Asia/Shanghai');
- use app\miniapi\auth\Principal;
- use app\miniapi\model\MiniGrant;
- use app\miniapi\model\MiniUser;
- use app\miniapi\model\MiniEnterpriseBalance;
- use app\miniapi\service\CheckoutService;
- use app\miniapi\service\Access;
- use app\miniapi\service\EnterpriseQuery;
- use app\miniapi\service\PromotionService;
- use plugin\saiadmin\app\controller\water\WaterController;
- use plugin\saiadmin\app\model\water\WaterAccount;
- use plugin\saiadmin\app\model\water\WaterDelivery;
- use plugin\saiadmin\app\model\water\WaterLedger;
- use plugin\saiadmin\app\model\water\WaterModel;
- use plugin\saiadmin\app\model\water\WaterOrder;
- use plugin\saiadmin\app\model\water\WaterPromotionCode;
- use plugin\saiadmin\app\model\water\WaterPromotionReferral;
- use plugin\saiadmin\app\model\water\WaterPromotionSetting;
- use plugin\saiadmin\app\model\water\WaterSku;
- use plugin\saiadmin\app\service\WaterBusiness;
- use plugin\saiadmin\app\service\WaterDispatch;
- use plugin\saiadmin\app\service\promotion\PromotionPolicy;
- use plugin\saiadmin\app\service\promotion\PromotionQuery;
- use plugin\saiadmin\app\service\promotion\PromotionReferrals;
- use plugin\saiadmin\app\service\promotion\PromotionRewards;
- use support\Request;
- $checks = 0;
- function check(bool $ok, string $label): void
- {
- global $checks;
- ++$checks;
- if (!$ok) {
- throw new RuntimeException($label);
- }
- }
- function rejects(callable $operation, string $label): void
- {
- try {
- $operation();
- } catch (\plugin\saiadmin\exception\ApiException | \app\miniapi\exception\ApiException $e) {
- check(true, $label);
- return;
- }
- throw new RuntimeException('Expected rejection: ' . $label);
- }
- $db = (new WaterSku())->getConnection();
- $models = [WaterPromotionSetting::class, WaterPromotionCode::class, WaterPromotionReferral::class, WaterAccount::class, WaterLedger::class, WaterOrder::class, WaterDelivery::class];
- $snapshot = fn () => array_map(fn ($model) => $model::query()->orderBy('id')->get()->toJson(), $models);
- $before = $snapshot();
- $business = new WaterBusiness();
- $referrals = new PromotionReferrals();
- $rewards = new PromotionRewards();
- $tag = 'PROMO-CHECK-' . bin2hex(random_bytes(5));
- $now = date('Y-m-d H:i:s');
- $enterprise = fn ($suffix) => $business->save('enterprise', [
- 'name' => $tag . $suffix, 'contact' => '回滚夹具', 'phone' => '13812345678',
- 'address' => '回滚地址', 'region' => $tag,
- ], 1)['id'];
- $db->beginTransaction();
- try {
- $giftSku = $business->save('sku', ['name' => $tag . '-gift', 'liters' => 18.9], 1)['id'];
- $purchaseSku = $business->save('sku', ['name' => $tag . '-purchase', 'liters' => 18.9], 1)['id'];
- $package = $business->save('package', ['name' => $tag, 'sku_id' => $purchaseSku, 'buckets' => 20, 'price_cents' => 2000], 1)['id'];
- WaterPromotionSetting::query()->where('id', 1)->update(['enabled' => false]);
- $inviter = $enterprise('inviter');
- $invited = $enterprise('invited');
- $other = $enterprise('other');
- rejects(fn () => $referrals->code($inviter), 'disabled activity cannot generate codes');
- $policy = PromotionPolicy::current();
- $draft = ['enabled' => true, 'sku_id' => $giftSku, 'quantity' => 3, 'min_amount_cents' => 1000, 'revision' => $policy['revision']];
- rejects(fn () => PromotionPolicy::save(array_replace($draft, ['enabled' => 'true']), 1), 'strict boolean');
- rejects(fn () => PromotionPolicy::save(array_replace($draft, ['quantity' => 0]), 1), 'no zero rewards when enabled');
- rejects(fn () => PromotionPolicy::save(array_replace($draft, ['sku_id' => 2147483647]), 1), 'valid enabled SKU required');
- $policy = PromotionPolicy::save($draft, 1);
- rejects(fn () => PromotionPolicy::save($draft, 1), 'stale policy revision');
- $code = $referrals->code($inviter)['invite_code'];
- check(strlen($code) === 20 && $referrals->code($inviter)['invite_code'] === $code, 'stable random enterprise code');
- $otherCode = $referrals->code($other)['invite_code'];
- rejects(fn () => $referrals->bind($inviter, $code, 0), 'self invitation');
- rejects(fn () => $referrals->bind($invited, 'not-a-code', 0), 'invalid invitation code');
- $user = MiniUser::query()->create(['appid' => $tag, 'openid' => 'invitee', 'nickname' => '', 'status' => 1, 'created_at' => $now, 'updated_at' => $now]);
- $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]);
- $access = new Access(new Principal((string) $user->id));
- $mini = new PromotionService($access);
- rejects(fn () => $mini->code($inviter, ['request_key' => $tag . '-unauthorized']), 'other enterprise cannot generate invitation');
- $payload = ['invite_code' => $code, 'request_key' => $tag . '-bind'];
- $id = $mini->bind($invited, $payload)['id'];
- check($mini->bind($invited, $payload)['replayed'], 'same command replays binding');
- rejects(fn () => $mini->bind($invited, array_replace($payload, ['invite_code' => $otherCode])), 'changed idempotency payload');
- $grant->status = 2;
- $grant->save();
- rejects(fn () => $mini->bind($invited, $payload), 'revoked grant denies replay');
- $grant->status = 1;
- $grant->save();
- $grant->role = 'enterprise_member';
- $grant->save();
- rejects(fn () => $mini->code($invited, ['request_key' => $tag . '-member-code']), 'ordinary member cannot generate codes');
- rejects(fn () => $mini->bind($invited, $payload), 'ordinary member cannot bind an invitation');
- $grant->role = 'enterprise_admin';
- $grant->save();
- check($referrals->bind($invited, $code, (int) $user->id)['replayed'], 'same enterprise binding across requests');
- rejects(fn () => $referrals->bind($invited, $otherCode, (int) $user->id), 'attribution immutable');
- $inviteeCode = $referrals->code($invited)['invite_code'];
- rejects(fn () => $referrals->bind($inviter, $inviteeCode, 0), 'reciprocal invitation blocked');
- $sameAdmin = $enterprise('same-admin');
- MiniGrant::query()->create(['user_id' => $user->id, 'role' => 'enterprise_admin', 'resource_id' => $sameAdmin, 'status' => 1, 'created_by' => 1, 'created_at' => $now, 'updated_at' => $now]);
- rejects(fn () => $referrals->bind($sameAdmin, $inviteeCode, (int) $user->id), 'shared administrator rejected');
- // Changing/closing the campaign does not rewrite an existing binding's promise.
- PromotionPolicy::save(array_replace($draft, ['enabled' => false, 'quantity' => 9, 'revision' => $policy['revision']]), 1);
- WaterDelivery::query()->create([
- 'enterprise_id' => $invited, 'sku_id' => $purchaseSku, 'quantity' => 1, 'delivered_qty' => 1,
- 'status' => 'completed', 'address' => '历史赠水配送', 'created_by' => 1,
- 'created_at' => $now, 'updated_at' => $now, 'received_at' => $now,
- ]);
- $purchase = ['enterprise_id' => $invited, 'package_id' => $package, 'reference' => $tag . '-receipt'];
- $order = $business->purchase($purchase, 1);
- check($business->purchase($purchase, 1)['replayed'], 'paid receipt replay');
- $row = WaterPromotionReferral::query()->find($id);
- check($row->status === 'pending' && (int) $row->quantity === 3 && (int) $row->order_id === $order['id'], 'first receipt qualifies with immutable reward snapshot');
- check(WaterPromotionReferral::query()->where('invited_id', $invited)->count() === 1, 'one reward per enterprise');
- $review = ['id' => $id, 'revision' => (int) $row->revision, 'decision' => 'issued', 'receipt_refund_checked' => true, 'note' => '回滚测试已核实收款、签收、无退款'];
- rejects(fn () => $rewards->review(array_replace($review, ['receipt_refund_checked' => false]), 1), 'explicit receipt and refund verification');
- rejects(fn () => $rewards->review(array_replace($review, ['revision' => 99]), 1), 'stale review revision');
- rejects(fn () => $rewards->review($review, 1), 'no award without completed receipt');
- $business->stock(['sku_id' => $purchaseSku, 'mode' => 'inbound', 'quantity' => 50, 'reference' => $tag . '-inbound', 'note' => '回滚入库'], 1);
- $task = $business->createDelivery(['enterprise_id' => $invited, 'sku_id' => $purchaseSku, 'quantity' => 5], 1)['id'];
- $dispatch = new WaterDispatch();
- $driver = $dispatch->saveDriver(['name' => $tag, 'phone' => '13812345678', 'region' => $tag, 'capacity' => 50], 1)['id'];
- $dispatch->shift(['driver_id' => $driver, 'date' => date('Y-m-d'), 'state' => 'on'], 1);
- $dispatch->commit(['date' => date('Y-m-d'), 'request_key' => $tag . '-route', 'routes' => [['driver_id' => $driver, 'task_ids' => [$task]]]], 1);
- $business->transition(['id' => $task, 'action' => 'depart'], 1);
- rejects(fn () => $rewards->review($review, 1), 'in-transit is not a signature');
- $business->transition(['id' => $task, 'action' => 'complete', 'empty_returned' => 0, 'receiver' => '回滚签收人'], 1);
- $giftBefore = WaterSku::query()->find($giftSku)->toArray();
- try {
- WaterModel::transaction(function () use ($rewards, $review) {
- $rewards->review($review, 1);
- throw new RuntimeException('force reward rollback');
- });
- } catch (RuntimeException $e) {
- check($e->getMessage() === 'force reward rollback', 'forced failure rolls back reward');
- }
- check(WaterPromotionReferral::query()->find($id)->status === 'pending', 'rolled-back reward remains pending');
- check(!WaterAccount::query()->where('enterprise_id', $inviter)->where('sku_id', $giftSku)->exists(), 'rolled-back reward creates no entitlement');
- $rewards->review($review, 1);
- check($rewards->review($review, 1)['replayed'], 'review retry does not issue twice');
- check((int) WaterAccount::query()->where('enterprise_id', $inviter)->where('sku_id', $giftSku)->value('entitlement') === 3, 'gift entitlement issued exactly once');
- check(WaterSku::query()->find($giftSku)->toArray() === $giftBefore, 'gift changes no physical stock');
- check(WaterLedger::query()->where('kind', 'promotion_reward')->where('reference', 'promotion:' . $id)->count() === 1, 'one auditable gift ledger');
- rejects(fn () => $rewards->review(array_replace($review, ['decision' => 'rejected']), 1), 'cannot reverse issued reward through rejection');
- $dashboard = (new PromotionQuery())->enterprise($inviter);
- check($dashboard['total_buckets'] === 3 && $dashboard['total_cents'] === null && !$dashboard['demo'], 'real gift totals, no cash or demo fixture');
- check((new MiniApiContract())->data('Rewards', $dashboard), 'rewards contract with real records');
- check(
- $dashboard['items'][0]['reference'] === null && $dashboard['items'][0]['amount_cents'] === null
- && $dashboard['items'][0]['review_note'] === '' && $dashboard['items'][0]['delivery_id'] === null,
- 'mini rewards do not expose another enterprise payment, delivery or internal review details'
- );
- check((new MiniApiContract())->data('Rewards', (new EnterpriseQuery($access))->rewards($invited)), 'bound invitee contract');
- $inviterUser = MiniUser::query()->create(['appid' => $tag, 'openid' => 'inviter', 'nickname' => '', 'status' => 1, 'created_at' => $now, 'updated_at' => $now]);
- MiniGrant::query()->create(['user_id' => $inviterUser->id, 'role' => 'enterprise_admin', 'resource_id' => $inviter, 'status' => 1, 'created_by' => 1, 'created_at' => $now, 'updated_at' => $now]);
- $inviterQuery = new EnterpriseQuery(new Access(new Principal((string) $inviterUser->id)));
- check($inviterQuery->profile($inviter)['reward_buckets'] === 3, 'profile shows water buckets rather than cash');
- check((new MiniApiContract())->data('Profile', $inviterQuery->profile($inviter)), 'profile contract includes gift bucket total');
- check($inviterQuery->ledger($inviter, [])['items'][0]['kind'] === 'promotion_reward', 'gift is visible in enterprise entitlement ledger');
- check((new PromotionQuery())->dashboard(['status' => 'issued'])['list']['total'] >= 1, 'admin status filter');
- rejects(fn () => (new PromotionQuery())->dashboard(['status' => 'unknown']), 'invalid filter');
- $controllerClass = new ReflectionClass(WaterController::class);
- $controller = $controllerClass->newInstanceWithoutConstructor();
- $controllerClass->getProperty('adminId')->setValue($controller, 0);
- $request = new Request("GET /core/water/finance/promotion HTTP/1.1\r\nHost: localhost\r\n\r\n");
- rejects(fn () => $controller->promotionDashboard($request), 'unauthorized admin read');
- rejects(fn () => $controller->promotionPolicy($request), 'unauthorized admin policy');
- rejects(fn () => $controller->promotionReview($request), 'unauthorized admin issuance');
- $current = PromotionPolicy::current();
- PromotionPolicy::save(array_replace($draft, ['revision' => $current['revision']]), 1);
- $oldCustomer = $enterprise('old-customer');
- $business->purchase(['enterprise_id' => $oldCustomer, 'package_id' => $package, 'reference' => $tag . '-old'], 1);
- rejects(fn () => $referrals->bind($oldCustomer, $code, 0), 'old customer cannot claim new-customer reward');
- $low = $enterprise('below-threshold');
- $lowId = $referrals->bind($low, $code, 0)['id'];
- $cheap = $business->save('package', ['name' => $tag . '-cheap', 'sku_id' => $purchaseSku, 'buckets' => 1, 'price_cents' => 100], 1)['id'];
- $business->purchase(['enterprise_id' => $low, 'package_id' => $cheap, 'reference' => $tag . '-low'], 1);
- $business->purchase(['enterprise_id' => $low, 'package_id' => $package, 'reference' => $tag . '-later'], 1);
- check(WaterPromotionReferral::query()->find($lowId)->status === 'rejected', 'below-threshold first purchase cannot be replaced');
- $demo = $enterprise('demo');
- $demoId = $referrals->bind($demo, $code, 0)['id'];
- $business->purchase(['enterprise_id' => $demo, 'package_id' => $package, 'reference' => 'TEST-' . $tag], 1);
- check(WaterPromotionReferral::query()->find($demoId)->status === 'bound', 'demo receipt never qualifies');
- $combinedEnterprise = $enterprise('combined');
- $combinedId = $referrals->bind($combinedEnterprise, $code, 0)['id'];
- $combinedUser = MiniUser::query()->create(['appid' => $tag, 'openid' => 'combined', 'nickname' => '', 'status' => 1, 'created_at' => $now, 'updated_at' => $now]);
- MiniGrant::query()->create(['user_id' => $combinedUser->id, 'role' => 'enterprise_admin', 'resource_id' => $combinedEnterprise, 'status' => 1, 'created_by' => 1, 'created_at' => $now, 'updated_at' => $now]);
- MiniEnterpriseBalance::query()->create(['enterprise_id' => $combinedEnterprise, 'balance_cents' => 10000, 'created_at' => $now, 'updated_at' => $now]);
- $checkoutService = new CheckoutService(new Access(new Principal((string) $combinedUser->id)));
- $checkout = $checkoutService->create($combinedEnterprise, [
- 'items' => [['package_id' => $cheap, 'quantity' => 15]], 'expected_amount_cents' => 1500,
- 'agreement_accepted' => true, 'request_key' => $tag . '-combined-create',
- ]);
- check(WaterPromotionReferral::query()->find($combinedId)->status === 'bound', 'unpaid checkout earns no reward');
- $pay = ['payment_method' => 'balance', 'request_key' => $tag . '-combined-pay'];
- $checkoutService->pay($combinedEnterprise, $checkout['id'], $pay);
- $combinedRow = WaterPromotionReferral::query()->find($combinedId);
- check($combinedRow->status === 'pending' && (int) $combinedRow->purchase_amount_cents === 1500, 'threshold uses complete checkout rather than individual 100-cent receipt');
- check((int) $combinedRow->checkout_id === $checkout['id'], 'paid checkout attribution persisted');
- check($checkoutService->pay($combinedEnterprise, $checkout['id'], $pay)['replayed'], 'combined payment replay');
- check(WaterPromotionReferral::query()->where('invited_id', $combinedEnterprise)->count() === 1, 'fifteen receipts produce one invitation reward');
- $rejectInput = ['id' => $combinedId, 'revision' => (int) $combinedRow->revision, 'decision' => 'rejected', 'note' => '审核认定重复客户,驳回'];
- $rewards->review($rejectInput, 1);
- check($rewards->review($rejectInput, 1)['replayed'], 'rejection retry idempotent');
- check((int) WaterAccount::query()->where('enterprise_id', $inviter)->where('sku_id', $giftSku)->value('entitlement') === 3, 'rejection grants no additional entitlement');
- } finally {
- $db->rollBack();
- }
- check($snapshot() === $before, 'all business/policy fixtures rolled back');
- echo "PASS: $checks promotion checks; all fixture changes rolled back.\n";
|