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";