From 50f15cdea8eb3e2aace43fd4b95b65b39d832a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=9D=E5=8D=9C?= Date: Tue, 10 Mar 2026 11:31:02 +0000 Subject: [PATCH] chore: init saasshop repo + sql migrations runner + gitee go --- .editorconfig | 18 + .env.example | 65 + .gitattributes | 11 + .gitee/go.yml | 57 + .gitignore | 33 + README.md | 59 + README_LOCAL.md | 53 + app/Http/Controllers/Admin/AuthController.php | 54 + .../Controllers/Admin/DashboardController.php | 57 + .../Controllers/Admin/MerchantController.php | 73 + .../Controllers/Admin/OrderController.php | 867 ++ app/Http/Controllers/Admin/PlanController.php | 249 + .../Admin/PlatformOrderController.php | 556 ++ .../Admin/PlatformSettingController.php | 184 + .../Admin/ProductCategoryController.php | 124 + .../Controllers/Admin/ProductController.php | 1366 +++ .../Admin/SiteSubscriptionController.php | 196 + .../Controllers/Api/V1/AuthController.php | 79 + .../Controllers/Api/V1/OrderController.php | 57 + .../Controllers/Api/V1/ProductController.php | 31 + .../Controllers/Api/V1/SystemController.php | 34 + .../Concerns/ResolvesMerchantContext.php | 25 + .../Concerns/ResolvesPlatformAdminContext.php | 36 + .../Concerns/ResolvesSiteContext.php | 25 + app/Http/Controllers/Controller.php | 8 + app/Http/Controllers/Front/H5Controller.php | 17 + app/Http/Controllers/Front/PcController.php | 17 + app/Http/Controllers/HomeController.php | 27 + .../MerchantAdmin/AuthController.php | 58 + .../MerchantAdmin/DashboardController.php | 44 + .../MerchantAdmin/OrderController.php | 864 ++ .../ProductCategoryController.php | 123 + .../MerchantAdmin/ProductController.php | 1286 +++ .../MerchantAdmin/UserController.php | 35 + .../Controllers/SiteAdmin/AuthController.php | 57 + .../SiteAdmin/DashboardController.php | 46 + .../SiteAdmin/MerchantController.php | 228 + .../Controllers/SiteAdmin/OrderController.php | 643 ++ .../SiteAdmin/ProductController.php | 699 ++ .../Wechat/MiniProgramController.php | 18 + app/Http/Controllers/Wechat/MpController.php | 18 + app/Http/Middleware/AdminAuth.php | 34 + app/Http/Middleware/MerchantAdminAuth.php | 35 + app/Http/Middleware/SiteAdminAuth.php | 36 + app/Models/Admin.php | 49 + app/Models/ChannelConfig.php | 23 + app/Models/Merchant.php | 50 + app/Models/OauthAccount.php | 30 + app/Models/Order.php | 50 + app/Models/OrderItem.php | 43 + app/Models/PaymentConfig.php | 21 + app/Models/Plan.php | 33 + app/Models/PlatformOrder.php | 53 + app/Models/Product.php | 44 + app/Models/ProductCategory.php | 33 + app/Models/ProductImportHistory.php | 37 + app/Models/SiteSubscription.php | 44 + app/Models/SystemConfig.php | 19 + app/Models/User.php | 53 + app/Providers/AppServiceProvider.php | 24 + app/Support/ApiResponse.php | 28 + app/Support/CacheKeys.php | 183 + app/Support/SubscriptionActivationService.php | 138 + artisan | 18 + bootstrap/app.php | 23 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 5 + composer.json | 89 + composer.lock | 8397 +++++++++++++++++ config/app.php | 126 + config/auth.php | 115 + config/cache.php | 117 + config/database.php | 183 + config/filesystems.php | 80 + config/logging.php | 132 + config/mail.php | 118 + config/queue.php | 129 + config/services.php | 38 + config/session.php | 217 + database/.gitignore | 1 + database/factories/UserFactory.php | 44 + .../0001_01_01_000000_create_users_table.php | 52 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + ...26_03_08_124619_create_merchants_table.php | 38 + .../2026_03_08_124620_create_admins_table.php | 36 + .../2026_03_08_124620_create_orders_table.php | 42 + ...026_03_08_124620_create_products_table.php | 39 + ...tform_fields_to_users_and_orders_table.php | 43 + ..._08_125847_create_oauth_accounts_table.php | 39 + ..._08_140000_create_system_configs_table.php | 28 + ...08_140100_create_channel_configs_table.php | 32 + ...08_140200_create_payment_configs_table.php | 29 + ...142700_create_product_categories_table.php | 38 + ..._03_08_142710_create_order_items_table.php | 32 + ..._create_product_import_histories_table.php | 32 + .../2026_03_10_000100_create_plans_table.php | 31 + ...000200_create_site_subscriptions_table.php | 41 + ...10_000300_create_platform_orders_table.php | 52 + database/migrations/V1__baseline.sql | 3 + database/seeders/DatabaseSeeder.php | 15 + database/seeders/InitialDemoSeeder.php | 392 + docs/ADMIN_BASELINE.md | 84 + docs/API_BASELINE.md | 60 + docs/ARCHITECTURE.md | 35 + docs/DB_SQL_MIGRATIONS.md | 68 + docs/FOUNDATION_PROGRESS_2026-03-08.md | 71 + docs/MERCHANT_REFACTOR_PLAN.md | 63 + docs/MULTI_PLATFORM_PLAN.md | 45 + docs/NEXT_FOUNDATION_STEPS.md | 21 + docs/ORDER_TENANT_PAGINATION_AND_ERRORS.md | 38 + docs/PAGINATION_AND_VALIDATION_PROGRESS.md | 39 + docs/PLATFORM_CONTEXT_AND_CATEGORY_PLAN.md | 50 + docs/REDIS_CACHE_PLAN.md | 40 + docs/SITE_ADMIN_PLAN.md | 230 + package.json | 17 + phpunit.xml | 35 + public/.htaccess | 25 + public/css/admin-base.css | 133 + public/css/admin-components.css | 14 + public/css/admin.css | 1 + public/css/merchant-admin.css | 1 + public/css/public-pages.css | 1 + public/css/site-admin.css | 1 + public/favicon.ico | 0 public/index.php | 20 + public/robots.txt | 2 + resources/css/app.css | 11 + resources/js/app.js | 1 + resources/js/bootstrap.js | 4 + resources/views/admin/auth/login.blade.php | 25 + resources/views/admin/dashboard.blade.php | 32 + resources/views/admin/layouts/app.blade.php | 68 + .../views/admin/merchants/index.blade.php | 50 + resources/views/admin/orders/index.blade.php | 171 + resources/views/admin/orders/show.blade.php | 50 + resources/views/admin/plans/form.blade.php | 74 + resources/views/admin/plans/index.blade.php | 139 + .../admin/platform_orders/index.blade.php | 298 + .../admin/platform_orders/show.blade.php | 158 + .../admin/product_categories/index.blade.php | 70 + .../admin/products/import_histories.blade.php | 109 + .../views/admin/products/index.blade.php | 225 + .../views/admin/settings/channels.blade.php | 98 + .../views/admin/settings/system.blade.php | 79 + .../admin/site_subscriptions/index.blade.php | 144 + resources/views/admin/tenants/index.blade.php | 1 + resources/views/front/h5/index.blade.php | 23 + resources/views/front/pc/index.blade.php | 26 + resources/views/home.blade.php | 45 + .../views/merchant_admin/auth/login.blade.php | 25 + .../views/merchant_admin/dashboard.blade.php | 19 + .../merchant_admin/layouts/app.blade.php | 58 + .../merchant_admin/orders/index.blade.php | 117 + .../merchant_admin/orders/show.blade.php | 46 + .../product_categories/index.blade.php | 48 + .../products/import_histories.blade.php | 101 + .../merchant_admin/products/index.blade.php | 203 + .../merchant_admin/users/index.blade.php | 30 + .../views/site_admin/auth/login.blade.php | 25 + .../views/site_admin/dashboard.blade.php | 18 + .../views/site_admin/layouts/app.blade.php | 61 + .../site_admin/merchants/index.blade.php | 308 + .../views/site_admin/orders/index.blade.php | 206 + .../views/site_admin/products/index.blade.php | 183 + resources/views/welcome.blade.php | 36 + resources/views/welcome_status.blade.php | 30 + routes/api.php | 21 + routes/console.php | 8 + routes/web.php | 177 + scripts/sql_migrate.php | 138 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 9 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tests/Feature/AdminAccessTest.php | 55 + tests/Feature/AdminBusinessPagesTest.php | 349 + tests/Feature/AdminPlanExportTest.php | 57 + tests/Feature/AdminPlanSetStatusTest.php | 72 + tests/Feature/AdminPlanTest.php | 144 + ...OrderActivateSubscriptionErrorMetaTest.php | 114 + ...nPlatformOrderActivateSubscriptionTest.php | 104 + ...ormOrderBatchActivateSubscriptionsTest.php | 374 + ...atformOrderClearSyncErrorsFilteredTest.php | 102 + .../AdminPlatformOrderClearSyncErrorsTest.php | 78 + .../Feature/AdminPlatformOrderExportTest.php | 148 + ...inPlatformOrderMarkPaidAndActivateTest.php | 103 + tests/Feature/AdminPlatformOrderShowTest.php | 123 + tests/Feature/AdminPlatformOrderTest.php | 319 + tests/Feature/AdminProtectedPagesTest.php | 51 + .../AdminSiteSubscriptionExportTest.php | 79 + tests/Feature/AdminSiteSubscriptionTest.php | 143 + tests/Feature/ExampleTest.php | 21 + tests/Feature/ExportEndpointsTest.php | 177 + tests/Feature/MerchantAdminAccessTest.php | 52 + tests/Feature/MerchantBusinessPagesTest.php | 377 + tests/Feature/MerchantProtectedPagesTest.php | 51 + tests/Feature/SiteAdminAccessTest.php | 54 + tests/Feature/SiteAdminBusinessPagesTest.php | 733 ++ tests/Feature/SiteAdminProtectedPagesTest.php | 53 + .../SubscriptionActivationServiceTest.php | 177 + tests/TestCase.php | 10 + tests/Unit/ExampleTest.php | 16 + vite.config.js | 18 + 210 files changed, 29534 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitee/go.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README_LOCAL.md create mode 100644 app/Http/Controllers/Admin/AuthController.php create mode 100644 app/Http/Controllers/Admin/DashboardController.php create mode 100644 app/Http/Controllers/Admin/MerchantController.php create mode 100644 app/Http/Controllers/Admin/OrderController.php create mode 100644 app/Http/Controllers/Admin/PlanController.php create mode 100644 app/Http/Controllers/Admin/PlatformOrderController.php create mode 100644 app/Http/Controllers/Admin/PlatformSettingController.php create mode 100644 app/Http/Controllers/Admin/ProductCategoryController.php create mode 100644 app/Http/Controllers/Admin/ProductController.php create mode 100644 app/Http/Controllers/Admin/SiteSubscriptionController.php create mode 100644 app/Http/Controllers/Api/V1/AuthController.php create mode 100644 app/Http/Controllers/Api/V1/OrderController.php create mode 100644 app/Http/Controllers/Api/V1/ProductController.php create mode 100644 app/Http/Controllers/Api/V1/SystemController.php create mode 100644 app/Http/Controllers/Concerns/ResolvesMerchantContext.php create mode 100644 app/Http/Controllers/Concerns/ResolvesPlatformAdminContext.php create mode 100644 app/Http/Controllers/Concerns/ResolvesSiteContext.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/Front/H5Controller.php create mode 100644 app/Http/Controllers/Front/PcController.php create mode 100644 app/Http/Controllers/HomeController.php create mode 100644 app/Http/Controllers/MerchantAdmin/AuthController.php create mode 100644 app/Http/Controllers/MerchantAdmin/DashboardController.php create mode 100644 app/Http/Controllers/MerchantAdmin/OrderController.php create mode 100644 app/Http/Controllers/MerchantAdmin/ProductCategoryController.php create mode 100644 app/Http/Controllers/MerchantAdmin/ProductController.php create mode 100644 app/Http/Controllers/MerchantAdmin/UserController.php create mode 100644 app/Http/Controllers/SiteAdmin/AuthController.php create mode 100644 app/Http/Controllers/SiteAdmin/DashboardController.php create mode 100644 app/Http/Controllers/SiteAdmin/MerchantController.php create mode 100644 app/Http/Controllers/SiteAdmin/OrderController.php create mode 100644 app/Http/Controllers/SiteAdmin/ProductController.php create mode 100644 app/Http/Controllers/Wechat/MiniProgramController.php create mode 100644 app/Http/Controllers/Wechat/MpController.php create mode 100644 app/Http/Middleware/AdminAuth.php create mode 100644 app/Http/Middleware/MerchantAdminAuth.php create mode 100644 app/Http/Middleware/SiteAdminAuth.php create mode 100644 app/Models/Admin.php create mode 100644 app/Models/ChannelConfig.php create mode 100644 app/Models/Merchant.php create mode 100644 app/Models/OauthAccount.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrderItem.php create mode 100644 app/Models/PaymentConfig.php create mode 100644 app/Models/Plan.php create mode 100644 app/Models/PlatformOrder.php create mode 100644 app/Models/Product.php create mode 100644 app/Models/ProductCategory.php create mode 100644 app/Models/ProductImportHistory.php create mode 100644 app/Models/SiteSubscription.php create mode 100644 app/Models/SystemConfig.php create mode 100644 app/Models/User.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Support/ApiResponse.php create mode 100644 app/Support/CacheKeys.php create mode 100644 app/Support/SubscriptionActivationService.php create mode 100644 artisan create mode 100644 bootstrap/app.php create mode 100755 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/.gitignore create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2026_03_08_124619_create_merchants_table.php create mode 100644 database/migrations/2026_03_08_124620_create_admins_table.php create mode 100644 database/migrations/2026_03_08_124620_create_orders_table.php create mode 100644 database/migrations/2026_03_08_124620_create_products_table.php create mode 100644 database/migrations/2026_03_08_125847_add_multi_platform_fields_to_users_and_orders_table.php create mode 100644 database/migrations/2026_03_08_125847_create_oauth_accounts_table.php create mode 100644 database/migrations/2026_03_08_140000_create_system_configs_table.php create mode 100644 database/migrations/2026_03_08_140100_create_channel_configs_table.php create mode 100644 database/migrations/2026_03_08_140200_create_payment_configs_table.php create mode 100644 database/migrations/2026_03_08_142700_create_product_categories_table.php create mode 100644 database/migrations/2026_03_08_142710_create_order_items_table.php create mode 100644 database/migrations/2026_03_09_000100_create_product_import_histories_table.php create mode 100644 database/migrations/2026_03_10_000100_create_plans_table.php create mode 100644 database/migrations/2026_03_10_000200_create_site_subscriptions_table.php create mode 100644 database/migrations/2026_03_10_000300_create_platform_orders_table.php create mode 100644 database/migrations/V1__baseline.sql create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/InitialDemoSeeder.php create mode 100644 docs/ADMIN_BASELINE.md create mode 100644 docs/API_BASELINE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DB_SQL_MIGRATIONS.md create mode 100644 docs/FOUNDATION_PROGRESS_2026-03-08.md create mode 100644 docs/MERCHANT_REFACTOR_PLAN.md create mode 100644 docs/MULTI_PLATFORM_PLAN.md create mode 100644 docs/NEXT_FOUNDATION_STEPS.md create mode 100644 docs/ORDER_TENANT_PAGINATION_AND_ERRORS.md create mode 100644 docs/PAGINATION_AND_VALIDATION_PROGRESS.md create mode 100644 docs/PLATFORM_CONTEXT_AND_CATEGORY_PLAN.md create mode 100644 docs/REDIS_CACHE_PLAN.md create mode 100644 docs/SITE_ADMIN_PLAN.md create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/css/admin-base.css create mode 100644 public/css/admin-components.css create mode 100644 public/css/admin.css create mode 100644 public/css/merchant-admin.css create mode 100644 public/css/public-pages.css create mode 100644 public/css/site-admin.css create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/js/bootstrap.js create mode 100644 resources/views/admin/auth/login.blade.php create mode 100644 resources/views/admin/dashboard.blade.php create mode 100644 resources/views/admin/layouts/app.blade.php create mode 100644 resources/views/admin/merchants/index.blade.php create mode 100644 resources/views/admin/orders/index.blade.php create mode 100644 resources/views/admin/orders/show.blade.php create mode 100644 resources/views/admin/plans/form.blade.php create mode 100644 resources/views/admin/plans/index.blade.php create mode 100644 resources/views/admin/platform_orders/index.blade.php create mode 100644 resources/views/admin/platform_orders/show.blade.php create mode 100644 resources/views/admin/product_categories/index.blade.php create mode 100644 resources/views/admin/products/import_histories.blade.php create mode 100644 resources/views/admin/products/index.blade.php create mode 100644 resources/views/admin/settings/channels.blade.php create mode 100644 resources/views/admin/settings/system.blade.php create mode 100644 resources/views/admin/site_subscriptions/index.blade.php create mode 100644 resources/views/admin/tenants/index.blade.php create mode 100644 resources/views/front/h5/index.blade.php create mode 100644 resources/views/front/pc/index.blade.php create mode 100644 resources/views/home.blade.php create mode 100644 resources/views/merchant_admin/auth/login.blade.php create mode 100644 resources/views/merchant_admin/dashboard.blade.php create mode 100644 resources/views/merchant_admin/layouts/app.blade.php create mode 100644 resources/views/merchant_admin/orders/index.blade.php create mode 100644 resources/views/merchant_admin/orders/show.blade.php create mode 100644 resources/views/merchant_admin/product_categories/index.blade.php create mode 100644 resources/views/merchant_admin/products/import_histories.blade.php create mode 100644 resources/views/merchant_admin/products/index.blade.php create mode 100644 resources/views/merchant_admin/users/index.blade.php create mode 100644 resources/views/site_admin/auth/login.blade.php create mode 100644 resources/views/site_admin/dashboard.blade.php create mode 100644 resources/views/site_admin/layouts/app.blade.php create mode 100644 resources/views/site_admin/merchants/index.blade.php create mode 100644 resources/views/site_admin/orders/index.blade.php create mode 100644 resources/views/site_admin/products/index.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 resources/views/welcome_status.blade.php create mode 100644 routes/api.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 scripts/sql_migrate.php create mode 100755 storage/app/.gitignore create mode 100755 storage/app/private/.gitignore create mode 100755 storage/app/public/.gitignore create mode 100755 storage/framework/.gitignore create mode 100755 storage/framework/cache/.gitignore create mode 100755 storage/framework/cache/data/.gitignore create mode 100755 storage/framework/sessions/.gitignore create mode 100755 storage/framework/testing/.gitignore create mode 100755 storage/framework/views/.gitignore create mode 100755 storage/logs/.gitignore create mode 100644 tests/Feature/AdminAccessTest.php create mode 100644 tests/Feature/AdminBusinessPagesTest.php create mode 100644 tests/Feature/AdminPlanExportTest.php create mode 100644 tests/Feature/AdminPlanSetStatusTest.php create mode 100644 tests/Feature/AdminPlanTest.php create mode 100644 tests/Feature/AdminPlatformOrderActivateSubscriptionErrorMetaTest.php create mode 100644 tests/Feature/AdminPlatformOrderActivateSubscriptionTest.php create mode 100644 tests/Feature/AdminPlatformOrderBatchActivateSubscriptionsTest.php create mode 100644 tests/Feature/AdminPlatformOrderClearSyncErrorsFilteredTest.php create mode 100644 tests/Feature/AdminPlatformOrderClearSyncErrorsTest.php create mode 100644 tests/Feature/AdminPlatformOrderExportTest.php create mode 100644 tests/Feature/AdminPlatformOrderMarkPaidAndActivateTest.php create mode 100644 tests/Feature/AdminPlatformOrderShowTest.php create mode 100644 tests/Feature/AdminPlatformOrderTest.php create mode 100644 tests/Feature/AdminProtectedPagesTest.php create mode 100644 tests/Feature/AdminSiteSubscriptionExportTest.php create mode 100644 tests/Feature/AdminSiteSubscriptionTest.php create mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/ExportEndpointsTest.php create mode 100644 tests/Feature/MerchantAdminAccessTest.php create mode 100644 tests/Feature/MerchantBusinessPagesTest.php create mode 100644 tests/Feature/MerchantProtectedPagesTest.php create mode 100644 tests/Feature/SiteAdminAccessTest.php create mode 100644 tests/Feature/SiteAdminBusinessPagesTest.php create mode 100644 tests/Feature/SiteAdminProtectedPagesTest.php create mode 100644 tests/Feature/SubscriptionActivationServiceTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 vite.config.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0660ea --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitee/go.yml b/.gitee/go.yml new file mode 100644 index 0000000..256efb6 --- /dev/null +++ b/.gitee/go.yml @@ -0,0 +1,57 @@ +# Gitee Go 流水线配置(模板) +# 目标:代码推送后自动执行“结构变更 SQL 脚本”与基础回归测试。 +# +# 注意:该文件为模板,具体 DB/ENV 需要在 Gitee Go 的「变量/密钥」中配置。 +# 不要把任何密码/令牌写进仓库。 + +version: 1.0 +name: SaaSShop CI + +stages: + - test + +jobs: + test: + stage: test + image: php:8.2 + steps: + - name: 安装系统依赖 + run: | + apt-get update + apt-get install -y git unzip libzip-dev default-mysql-client libsqlite3-dev + docker-php-ext-install pdo pdo_mysql pdo_sqlite + + - name: 安装 Composer + run: | + php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" + php composer-setup.php --install-dir=/usr/local/bin --filename=composer + composer --version + + - name: 安装 PHP 依赖 + run: | + composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: 准备环境文件(CI 默认使用 sqlite,避免依赖外部 DB) + run: | + [ -f .env ] || cp .env.example .env + # 覆盖为 sqlite(可按需在 Gitee Go 变量中替换为 mysql) + sed -i 's/^DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env + sed -i 's%^DB_DATABASE=.*%DB_DATABASE='"$PWD"'/database/database.sqlite%' .env + grep -q '^DB_DATABASE=' .env || echo "DB_DATABASE=$PWD/database/database.sqlite" >> .env + mkdir -p database + touch database/database.sqlite + php artisan key:generate + + - name: 运行 Laravel migrations + run: | + php artisan migrate --force + php artisan migrate:status + + - name: 运行 SQL 结构迁移脚本(V1__xxx.sql) + run: | + composer run db:sql-migrate + + - name: 运行测试(先跑关键链路,再全量) + run: | + php artisan test --filter='AdminPlanTest|AdminSiteSubscriptionTest|AdminPlatformOrderTest' + php artisan test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eef3cda --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db + +# local sqlite database (dev/test) +/database/database.sqlite + +# SQL schema migrations (V1__xxx.sql) +# 需要提交到仓库,供新环境与 CI 执行,因此不要忽略。 + +# helper scripts (optional) +/scripts/sql_migrate_check.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..0165a77 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com)** +- **[Tighten Co.](https://tighten.co)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Redberry](https://redberry.international/laravel-development)** +- **[Active Logic](https://activelogic.com)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/README_LOCAL.md b/README_LOCAL.md new file mode 100644 index 0000000..2b0d0e6 --- /dev/null +++ b/README_LOCAL.md @@ -0,0 +1,53 @@ +# 本地部署说明(当前环境) + +## 访问地址 +- 应用首页:`http://192.168.10.199:9001/` +- 总台管理:`http://192.168.10.199:9001/admin/login` +- 站点后台:`http://192.168.10.199:9001/site-admin/login` +- 商家后台:`http://192.168.10.199:9001/merchant-admin/login` +- phpMyAdmin:`http://192.168.10.199:888/` + +## 应用目录 +- Laravel 项目:`/var/www/sites/app` + +## 技术栈 +- Nginx 1.22 +- PHP 8.2 FPM +- MariaDB 10.11 +- Redis 7 +- Laravel 12 + +## 已配置内容 +- Laravel 已接入 MySQL 数据库 `appdb` +- Laravel 已接入 Redis(缓存 / 队列) +- `9001` 已指向 Laravel `public/` +- `888` 已指向 phpMyAdmin +- 当前项目已完成总台管理、站点后台、商家后台三层后台基础骨架 +- 当前数据库与代码基线已统一使用 `merchant / merchants / merchant_id` 语义 + +## 数据库连接 +- DB_HOST=`127.0.0.1` +- DB_PORT=`3306` +- DB_DATABASE=`appdb` +- DB_USERNAME=`appuser` +- 密码存放于:`/app/working.secret/appdb.env` + +## phpMyAdmin +- 使用 MySQL 账号登录(推荐直接用 `appuser`) +- 地址:`http://192.168.10.199:888/` + +## 商品导入失败明细文件 +- 商品批量导入失败明细 CSV 通过 Laravel `local` 磁盘保存 +- 当前 `local` 磁盘根目录为:`/var/www/sites/app/storage/app/private` +- 总台管理失败文件目录:`/var/www/sites/app/storage/app/private/imports/product-failures/platform/` +- 商家后台失败文件目录:`/var/www/sites/app/storage/app/private/imports/product-failures/merchant_/` +- 页面下载入口: + - 总台:`/admin/products/import-failures/{file}` + - 商家:`/merchant-admin/products/import-failures/{file}` +- 文件内容包含:`row_number + 原始导入字段 + error` +- 已补定时清理脚本:`/usr/local/bin/saasshop_import_failures_cleanup.sh` +- 当前保留策略:默认保留 30 天,每天东八区 `04:30` 自动清理一次过期 failure CSV + +## 注意 +- 敏感凭证不要写进公开文档或长期记忆 +- 当前项目已进入 SaaS 电商基础框架阶段,后续优先继续补经营能力、配置能力与筛选能力 diff --git a/app/Http/Controllers/Admin/AuthController.php b/app/Http/Controllers/Admin/AuthController.php new file mode 100644 index 0000000..f368dc6 --- /dev/null +++ b/app/Http/Controllers/Admin/AuthController.php @@ -0,0 +1,54 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $admin = Admin::query()->where('email', $data['email'])->first(); + if (! $admin || ! Hash::check($data['password'], $admin->password)) { + return back()->withErrors(['email' => '账号或密码错误'])->withInput(); + } + + if (! $admin->isPlatformAdmin()) { + return back()->withErrors(['email' => '当前账号是商家管理员,请从商家后台入口登录'])->withInput(); + } + + $request->session()->put('admin_id', $admin->id); + $request->session()->put('admin_name', $admin->name); + $request->session()->put('admin_email', $admin->email); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_merchant_id', null); + $request->session()->put('admin_scope', $admin->platformLabel()); + + $admin->forceFill(['last_login_at' => now()])->save(); + + return redirect('/admin'); + } + + public function logout(Request $request): RedirectResponse + { + $request->session()->forget(['admin_id', 'admin_name', 'admin_email', 'admin_role', 'admin_merchant_id', 'admin_scope']); + return redirect('/admin/login'); + } +} diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..3a3bded --- /dev/null +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -0,0 +1,57 @@ +ensurePlatformAdmin($request); + + $stats = Cache::remember( + CacheKeys::platformDashboardStats(), + now()->addMinutes(10), + fn () => [ + 'merchants' => Merchant::count(), + 'admins' => Admin::count(), + 'users' => User::count(), + 'products' => Product::count(), + 'orders' => Order::count(), + 'active_merchants' => Merchant::query()->where('status', 'active')->count(), + 'pending_orders' => Order::query()->where('status', 'pending')->count(), + ] + ); + + return view('admin.dashboard', [ + 'adminName' => $admin->name, + 'stats' => $stats, + 'platformAdmin' => $admin, + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + 'platformOverview' => [ + 'system_role' => '总台管理', + 'current_scope' => '总台运营方视角', + 'merchant_mode' => '统一管理多个站点', + 'channel_count' => 5, + 'active_merchants' => $stats['active_merchants'], + 'pending_orders' => $stats['pending_orders'], + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/MerchantController.php b/app/Http/Controllers/Admin/MerchantController.php new file mode 100644 index 0000000..3907c63 --- /dev/null +++ b/app/Http/Controllers/Admin/MerchantController.php @@ -0,0 +1,73 @@ +ensurePlatformAdmin($request); + $page = max((int) $request->integer('page', 1), 1); + + return view('admin.merchants.index', [ + 'merchants' => Cache::remember( + CacheKeys::platformMerchantsList($page), + now()->addMinutes(10), + fn () => Merchant::query()->latest()->paginate(10)->withQueryString() + ), + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string'], + 'slug' => ['required', 'string'], + 'plan' => ['nullable', 'string'], + 'status' => ['nullable', 'string'], + 'contact_name' => ['nullable', 'string'], + 'contact_phone' => ['nullable', 'string'], + 'contact_email' => ['nullable', 'email'], + ]); + + Merchant::query()->create([ + 'name' => $data['name'], + 'slug' => $data['slug'], + 'plan' => $data['plan'] ?? 'basic', + 'status' => $data['status'] ?? 'active', + 'contact_name' => $data['contact_name'] ?? null, + 'contact_phone' => $data['contact_phone'] ?? null, + 'contact_email' => $data['contact_email'] ?? null, + 'activated_at' => now(), + ]); + + $this->flushPlatformCaches(); + + return redirect('/admin/merchants')->with('success', '商家创建成功'); + } + + protected function flushPlatformCaches(): void + { + for ($page = 1; $page <= 5; $page++) { + Cache::forget(CacheKeys::platformMerchantsList($page)); + } + + Cache::forget(CacheKeys::platformDashboardStats()); + Cache::forget(CacheKeys::platformChannelsOverview()); + } +} diff --git a/app/Http/Controllers/Admin/OrderController.php b/app/Http/Controllers/Admin/OrderController.php new file mode 100644 index 0000000..1933bfa --- /dev/null +++ b/app/Http/Controllers/Admin/OrderController.php @@ -0,0 +1,867 @@ +ensurePlatformAdmin($request); + $page = max((int) $request->integer('page', 1), 1); + + $filters = $this->filters($request); + $statusStatsFilters = $filters; + $statusStatsFilters['status'] = ''; + + if ($filters['has_validation_error'] ?? false) { + return view('admin.orders.index', [ + 'orders' => Order::query()->whereRaw('1 = 0')->paginate(10)->withQueryString(), + 'statusStats' => $this->emptyStatusStats(), + 'summaryStats' => $this->emptySummaryStats(), + 'trendStats' => $this->emptyTrendStats(), + 'operationsFocus' => $this->buildOperationsFocus($this->emptySummaryStats(), $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'filters' => $filters, + 'filterOptions' => [ + 'statuses' => $this->statuses, + 'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'], + 'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'], + 'deviceTypes' => ['desktop', 'mobile', 'mini-program', 'mobile-webview', 'app-api'], + 'paymentChannels' => ['wechat_pay', 'alipay'], + 'timeRanges' => [ + 'all' => '全部时间', + 'today' => '今天', + 'last_7_days' => '近7天', + ], + 'sortOptions' => [ + 'latest' => '创建时间倒序', + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + 'product_amount_desc' => '商品金额从高到低', + 'product_amount_asc' => '商品金额从低到高', + ], + ], + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'statusLabels' => $this->statusLabels(), + 'paymentStatusLabels' => $this->paymentStatusLabels(), + 'platformLabels' => $this->platformLabels(), + 'deviceTypeLabels' => $this->deviceTypeLabels(), + 'paymentChannelLabels' => $this->paymentChannelLabels(), + ]); + } + + $summaryStats = Cache::remember( + CacheKeys::platformOrdersSummary($statusStatsFilters), + now()->addMinutes(10), + fn () => $this->buildSummaryStats($this->applyFilters(Order::query(), $statusStatsFilters)) + ); + + return view('admin.orders.index', [ + 'orders' => Cache::remember( + CacheKeys::platformOrdersList($page, $filters), + now()->addMinutes(10), + fn () => $this->applySorting($this->applyFilters(Order::query()->with('merchant'), $filters), $filters) + ->paginate(10) + ->withQueryString() + ), + 'statusStats' => Cache::remember( + CacheKeys::platformOrdersStatusStats($statusStatsFilters), + now()->addMinutes(10), + fn () => $this->buildStatusStats($this->applyFilters(Order::query(), $statusStatsFilters)) + ), + 'summaryStats' => $summaryStats, + 'operationsFocus' => $this->buildOperationsFocus($summaryStats, $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'trendStats' => Cache::remember( + CacheKeys::platformOrdersTrendSummary($statusStatsFilters), + now()->addMinutes(10), + fn () => $this->buildTrendStats($this->applyFilters(Order::query(), $statusStatsFilters)) + ), + 'filters' => $filters, + 'filterOptions' => [ + 'statuses' => $this->statuses, + 'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'], + 'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'], + 'deviceTypes' => ['desktop', 'mobile', 'mini-program', 'mobile-webview', 'app-api'], + 'paymentChannels' => ['wechat_pay', 'alipay'], + 'timeRanges' => [ + 'all' => '全部时间', + 'today' => '今天', + 'last_7_days' => '近7天', + ], + 'sortOptions' => [ + 'latest' => '创建时间倒序', + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + 'product_amount_desc' => '商品金额从高到低', + 'product_amount_asc' => '商品金额从低到高', + ], + ], + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'statusLabels' => $this->statusLabels(), + 'paymentStatusLabels' => $this->paymentStatusLabels(), + 'platformLabels' => $this->platformLabels(), + 'deviceTypeLabels' => $this->deviceTypeLabels(), + 'paymentChannelLabels' => $this->paymentChannelLabels(), + ]); + } + + public function show(Request $request, int $id): View + { + $this->ensurePlatformAdmin($request); + + return view('admin.orders.show', [ + 'order' => Order::query()->with(['merchant', 'items.product', 'user'])->findOrFail($id), + ]); + } + + public function export(Request $request): StreamedResponse|RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $filters = $this->filters($request); + + if ($filters['has_validation_error'] ?? false) { + return redirect('/admin/orders?' . http_build_query($this->exportableFilters($filters))) + ->withErrors($filters['validation_errors'] ?? ['订单筛选条件不合法,请先修正后再导出。']); + } + + $fileName = 'platform_orders_' . now()->format('Ymd_His') . '.csv'; + $exportSummary = $this->buildSummaryStats( + $this->applyFilters(Order::query(), $filters) + ); + + return response()->streamDownload(function () use ($filters, $exportSummary) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + foreach ($this->exportSummaryRows($filters, 'platform') as $summaryRow) { + fputcsv($handle, $summaryRow); + } + + fputcsv($handle, ['导出订单数', $exportSummary['total_orders'] ?? 0]); + fputcsv($handle, ['导出实付总额', number_format((float) ($exportSummary['total_pay_amount'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出平均客单价', number_format((float) ($exportSummary['average_order_amount'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出已支付订单数', $exportSummary['paid_orders'] ?? 0]); + fputcsv($handle, ['导出支付失败订单', $exportSummary['failed_payment_orders'] ?? 0]); + fputcsv($handle, []); + + fputcsv($handle, [ + 'ID', + '商家ID', + '商家名称', + '用户ID', + '订单号', + '订单状态', + '支付状态', + '平台', + '设备类型', + '支付渠道', + '买家姓名', + '买家手机', + '买家邮箱', + '商品金额', + '优惠金额', + '运费', + '实付金额', + '商品行数', + '商品件数', + '商品摘要', + '创建时间', + '支付时间', + '发货时间', + '完成时间', + '备注', + ]); + + foreach ($this->applySorting($this->applyFilters(Order::query()->with(['merchant', 'items']), $filters), $filters)->cursor() as $order) { + $itemCount = $order->items->count(); + $totalQuantity = (int) $order->items->sum('quantity'); + $itemSummary = $order->items + ->map(fn ($item) => trim(($item->product_title ?? '商品') . ' x' . ((int) $item->quantity))) + ->implode(' | '); + + fputcsv($handle, [ + $order->id, + $order->merchant_id, + $order->merchant?->name ?? '', + $order->user_id, + $order->order_no, + $this->statusLabel((string) $order->status), + $this->paymentStatusLabel((string) $order->payment_status), + $this->platformLabel((string) $order->platform), + $this->deviceTypeLabel((string) $order->device_type), + $this->paymentChannelLabel((string) $order->payment_channel), + $order->buyer_name, + $order->buyer_phone, + $order->buyer_email, + number_format((float) $order->product_amount, 2, '.', ''), + number_format((float) $order->discount_amount, 2, '.', ''), + number_format((float) $order->shipping_amount, 2, '.', ''), + number_format((float) $order->pay_amount, 2, '.', ''), + $itemCount, + $totalQuantity, + $itemSummary, + optional($order->created_at)?->format('Y-m-d H:i:s'), + optional($order->paid_at)?->format('Y-m-d H:i:s'), + optional($order->shipped_at)?->format('Y-m-d H:i:s'), + optional($order->completed_at)?->format('Y-m-d H:i:s'), + $order->remark, + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function updateStatus(Request $request, int $id): RedirectResponse + { + $data = $request->validate([ + 'status' => ['required', 'string'], + ]); + + $order = Order::query()->findOrFail($id); + $order->update(['status' => $data['status']]); + + Cache::add(CacheKeys::platformOrdersVersion(), 1, now()->addDays(30)); + Cache::increment(CacheKeys::platformOrdersVersion()); + Cache::forget(CacheKeys::platformDashboardStats()); + + return redirect('/admin/orders')->with('success', '订单状态更新成功'); + } + + protected function filters(Request $request): array + { + $timeRange = trim((string) $request->string('time_range', 'all')); + $rawStartDate = trim((string) $request->string('start_date')); + $rawEndDate = trim((string) $request->string('end_date')); + $minPayAmount = trim((string) $request->string('min_pay_amount')); + $maxPayAmount = trim((string) $request->string('max_pay_amount')); + $validationErrors = []; + + if ($timeRange === 'today') { + $startDate = now()->toDateString(); + $endDate = now()->toDateString(); + } elseif ($timeRange === 'last_7_days') { + $startDate = now()->subDays(6)->toDateString(); + $endDate = now()->toDateString(); + } else { + $timeRange = 'all'; + $startDate = $rawStartDate; + $endDate = $rawEndDate; + } + + if ($rawStartDate !== '' && ! $this->isValidDate($rawStartDate)) { + $validationErrors[] = '开始日期格式不正确,请使用 YYYY-MM-DD。'; + } + + if ($rawEndDate !== '' && ! $this->isValidDate($rawEndDate)) { + $validationErrors[] = '结束日期格式不正确,请使用 YYYY-MM-DD。'; + } + + if ($rawStartDate !== '' && $rawEndDate !== '' && $this->isValidDate($rawStartDate) && $this->isValidDate($rawEndDate) && $rawStartDate > $rawEndDate) { + $validationErrors[] = '开始日期不能晚于结束日期。'; + } + + if ($minPayAmount !== '' && ! is_numeric($minPayAmount)) { + $validationErrors[] = '最低实付金额必须为数字。'; + } + + if ($maxPayAmount !== '' && ! is_numeric($maxPayAmount)) { + $validationErrors[] = '最高实付金额必须为数字。'; + } + + if ($minPayAmount !== '' && $maxPayAmount !== '' && is_numeric($minPayAmount) && is_numeric($maxPayAmount) && (float) $minPayAmount > (float) $maxPayAmount) { + $validationErrors[] = '最低实付金额不能大于最高实付金额。'; + } + + return [ + 'status' => trim((string) $request->string('status')), + 'payment_status' => trim((string) $request->string('payment_status')), + 'platform' => trim((string) $request->string('platform')), + 'device_type' => trim((string) $request->string('device_type')), + 'payment_channel' => trim((string) $request->string('payment_channel')), + 'keyword' => trim((string) $request->string('keyword')), + 'start_date' => $startDate, + 'end_date' => $endDate, + 'min_pay_amount' => $minPayAmount, + 'max_pay_amount' => $maxPayAmount, + 'time_range' => $timeRange, + 'sort' => trim((string) $request->string('sort', 'latest')), + 'validation_errors' => $validationErrors, + 'has_validation_error' => ! empty($validationErrors), + ]; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + return $query + ->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status'])) + ->when(($filters['payment_status'] ?? '') !== '', fn ($builder) => $builder->where('payment_status', $filters['payment_status'])) + ->when(($filters['platform'] ?? '') !== '', fn ($builder) => $builder->where('platform', $filters['platform'])) + ->when(($filters['device_type'] ?? '') !== '', fn ($builder) => $builder->where('device_type', $filters['device_type'])) + ->when(($filters['payment_channel'] ?? '') !== '', fn ($builder) => $builder->where('payment_channel', $filters['payment_channel'])) + ->when(($filters['keyword'] ?? '') !== '', fn ($builder) => $builder->where(function ($subQuery) use ($filters) { + $subQuery->where('order_no', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_name', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_phone', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_email', 'like', '%' . $filters['keyword'] . '%'); + })) + ->when(($filters['start_date'] ?? '') !== '', fn ($builder) => $builder->whereDate('created_at', '>=', $filters['start_date'])) + ->when(($filters['end_date'] ?? '') !== '', fn ($builder) => $builder->whereDate('created_at', '<=', $filters['end_date'])) + ->when(($filters['min_pay_amount'] ?? '') !== '' && is_numeric($filters['min_pay_amount']), fn ($builder) => $builder->where('pay_amount', '>=', $filters['min_pay_amount'])) + ->when(($filters['max_pay_amount'] ?? '') !== '' && is_numeric($filters['max_pay_amount']), fn ($builder) => $builder->where('pay_amount', '<=', $filters['max_pay_amount'])); + } + + protected function buildStatusStats(Builder $query): array + { + $counts = (clone $query) + ->selectRaw('status, COUNT(*) as aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + + $stats = ['all' => (int) $counts->sum()]; + + foreach ($this->statuses as $status) { + $stats[$status] = (int) ($counts[$status] ?? 0); + } + + return $stats; + } + + protected function applySorting(Builder $query, array $filters): Builder + { + return match ($filters['sort'] ?? 'latest') { + 'oldest' => $query->orderBy('created_at')->orderBy('id'), + 'pay_amount_desc' => $query->orderByDesc('pay_amount')->orderByDesc('id'), + 'pay_amount_asc' => $query->orderBy('pay_amount')->orderByDesc('id'), + 'product_amount_desc' => $query->orderByDesc('product_amount')->orderByDesc('id'), + 'product_amount_asc' => $query->orderBy('product_amount')->orderByDesc('id'), + default => $query->latest(), + }; + } + + protected function buildSummaryStats(Builder $query): array + { + $summary = (clone $query) + ->selectRaw('COUNT(*) as total_orders') + ->selectRaw('COALESCE(SUM(pay_amount), 0) as total_pay_amount') + ->selectRaw("SUM(CASE WHEN payment_status = 'unpaid' THEN pay_amount ELSE 0 END) as unpaid_pay_amount") + ->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN pay_amount ELSE 0 END) as paid_pay_amount") + ->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN 1 ELSE 0 END) as paid_orders") + ->selectRaw("SUM(CASE WHEN payment_status = 'refunded' THEN 1 ELSE 0 END) as refunded_orders") + ->selectRaw("SUM(CASE WHEN payment_status = 'failed' THEN 1 ELSE 0 END) as failed_payment_orders") + ->selectRaw("SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as pending_shipment_orders") + ->selectRaw("SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_orders") + ->selectRaw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_orders") + ->first(); + + $totalOrders = (int) ($summary->total_orders ?? 0); + $totalPayAmount = (float) ($summary->total_pay_amount ?? 0); + $paidOrders = (int) ($summary->paid_orders ?? 0); + $refundedOrders = (int) ($summary->refunded_orders ?? 0); + $completedOrders = (int) ($summary->completed_orders ?? 0); + $cancelledOrders = (int) ($summary->cancelled_orders ?? 0); + + return [ + 'total_orders' => $totalOrders, + 'total_pay_amount' => $totalPayAmount, + 'unpaid_pay_amount' => (float) ($summary->unpaid_pay_amount ?? 0), + 'paid_pay_amount' => (float) ($summary->paid_pay_amount ?? 0), + 'paid_orders' => $paidOrders, + 'refunded_orders' => $refundedOrders, + 'failed_payment_orders' => (int) ($summary->failed_payment_orders ?? 0), + 'pending_shipment_orders' => (int) ($summary->pending_shipment_orders ?? 0), + 'completed_orders' => $completedOrders, + 'cancelled_orders' => $cancelledOrders, + 'average_order_amount' => $totalOrders > 0 ? round($totalPayAmount / $totalOrders, 2) : 0, + 'payment_rate' => $totalOrders > 0 ? round(($paidOrders / $totalOrders) * 100, 2) : 0, + 'refund_rate' => $paidOrders > 0 ? round(($refundedOrders / $paidOrders) * 100, 2) : 0, + 'completion_rate' => $totalOrders > 0 ? round(($completedOrders / $totalOrders) * 100, 2) : 0, + 'cancellation_rate' => $totalOrders > 0 ? round(($cancelledOrders / $totalOrders) * 100, 2) : 0, + ]; + } + + protected function buildTrendStats(Builder $query): array + { + $todayStart = Carbon::today(); + $tomorrowStart = (clone $todayStart)->copy()->addDay(); + $last7DaysStart = (clone $todayStart)->copy()->subDays(6)->startOfDay(); + + $today = (clone $query) + ->where('created_at', '>=', $todayStart) + ->where('created_at', '<', $tomorrowStart) + ->selectRaw('COUNT(*) as total_orders') + ->selectRaw('COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN pay_amount ELSE 0 END), 0) as total_pay_amount') + ->first(); + + $last7Days = (clone $query) + ->where('created_at', '>=', $last7DaysStart) + ->where('created_at', '<', $tomorrowStart) + ->selectRaw('COUNT(*) as total_orders') + ->selectRaw('COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN pay_amount ELSE 0 END), 0) as total_pay_amount') + ->first(); + + return [ + 'today_orders' => (int) ($today->total_orders ?? 0), + 'today_pay_amount' => (float) ($today->total_pay_amount ?? 0), + 'last_7_days_orders' => (int) ($last7Days->total_orders ?? 0), + 'last_7_days_pay_amount' => (float) ($last7Days->total_pay_amount ?? 0), + ]; + } + + protected function emptyStatusStats(): array + { + $stats = ['all' => 0]; + + foreach ($this->statuses as $status) { + $stats[$status] = 0; + } + + return $stats; + } + + protected function emptySummaryStats(): array + { + return [ + 'total_orders' => 0, + 'total_pay_amount' => 0, + 'unpaid_pay_amount' => 0, + 'paid_pay_amount' => 0, + 'paid_orders' => 0, + 'refunded_orders' => 0, + 'failed_payment_orders' => 0, + 'pending_shipment_orders' => 0, + 'completed_orders' => 0, + 'cancelled_orders' => 0, + 'average_order_amount' => 0, + 'payment_rate' => 0, + 'refund_rate' => 0, + 'completion_rate' => 0, + 'cancellation_rate' => 0, + ]; + } + + protected function emptyTrendStats(): array + { + return [ + 'today_orders' => 0, + 'today_pay_amount' => 0, + 'last_7_days_orders' => 0, + 'last_7_days_pay_amount' => 0, + ]; + } + + protected function isValidDate(string $value): bool + { + try { + $date = Carbon::createFromFormat('Y-m-d', $value); + } catch (\Throwable $exception) { + return false; + } + + return $date && $date->format('Y-m-d') === $value; + } + + protected function exportableFilters(array $filters): array + { + return array_filter([ + 'status' => $filters['status'] ?? '', + 'payment_status' => $filters['payment_status'] ?? '', + 'platform' => $filters['platform'] ?? '', + 'device_type' => $filters['device_type'] ?? '', + 'payment_channel' => $filters['payment_channel'] ?? '', + 'keyword' => $filters['keyword'] ?? '', + 'start_date' => $filters['start_date'] ?? '', + 'end_date' => $filters['end_date'] ?? '', + 'min_pay_amount' => $filters['min_pay_amount'] ?? '', + 'max_pay_amount' => $filters['max_pay_amount'] ?? '', + 'time_range' => $filters['time_range'] ?? '', + 'sort' => $filters['sort'] ?? '', + ], fn ($value) => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest'); + } + + protected function exportSummaryRows(array $filters, string $scope, ?int $merchantId = null): array + { + return [ + ['导出信息', $scope === 'platform' ? '总台订单导出' : '商家订单导出'], + ['导出时间', now()->format('Y-m-d H:i:s')], + ['商家ID', $merchantId ? (string) $merchantId : '全部商家'], + ['订单状态', $this->statusLabel($filters['status'] ?? '')], + ['支付状态', $this->paymentStatusLabel($filters['payment_status'] ?? '')], + ['平台', $this->platformLabel($filters['platform'] ?? '')], + ['设备类型', $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels())], + ['支付渠道', $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels())], + ['关键词', $this->displayTextValue($filters['keyword'] ?? '')], + ['快捷时间范围', $this->displayFilterValue($filters['time_range'] ?? 'all', [ + 'all' => '全部时间', + 'today' => '今天', + 'last_7_days' => '近7天', + ])], + ['开始日期', $this->displayTextValue($filters['start_date'] ?? '')], + ['结束日期', $this->displayTextValue($filters['end_date'] ?? '')], + ['最低实付金额', $this->displayMoneyValue($filters['min_pay_amount'] ?? '')], + ['最高实付金额', $this->displayMoneyValue($filters['max_pay_amount'] ?? '')], + ['排序', $this->sortLabel($filters['sort'] ?? 'latest')], + ]; + } + + protected function buildActiveFilterSummary(array $filters): array + { + return [ + '关键词' => $this->displayTextValue($filters['keyword'] ?? '', '全部'), + '订单状态' => $this->statusLabel($filters['status'] ?? ''), + '支付状态' => $this->paymentStatusLabel($filters['payment_status'] ?? ''), + '平台' => $this->platformLabel($filters['platform'] ?? ''), + '设备类型' => $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels()), + '支付渠道' => $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels()), + '实付金额区间' => $this->formatMoneyRange($filters['min_pay_amount'] ?? '', $filters['max_pay_amount'] ?? ''), + '排序' => $this->sortLabel($filters['sort'] ?? 'latest'), + ]; + } + + protected function statusLabels(): array + { + return [ + 'pending' => '待处理', + 'paid' => '已支付', + 'shipped' => '已发货', + 'completed' => '已完成', + 'cancelled' => '已取消', + ]; + } + + protected function statusLabel(string $status): string + { + return $this->statusLabels()[$status] ?? '全部'; + } + + protected function paymentStatusLabels(): array + { + return [ + 'unpaid' => '未支付', + 'paid' => '已支付', + 'refunded' => '已退款', + 'failed' => '支付失败', + ]; + } + + protected function paymentStatusLabel(string $status): string + { + return $this->paymentStatusLabels()[$status] ?? '全部'; + } + + protected function platformLabels(): array + { + return [ + 'pc' => 'PC 端', + 'h5' => 'H5', + 'wechat_mp' => '微信公众号', + 'wechat_mini' => '微信小程序', + 'app' => 'APP 接口预留', + ]; + } + + protected function platformLabel(string $platform): string + { + return $this->platformLabels()[$platform] ?? '全部'; + } + + protected function deviceTypeLabels(): array + { + return [ + 'desktop' => '桌面浏览器', + 'mobile' => '移动浏览器', + 'mini-program' => '小程序环境', + 'mobile-webview' => '微信内网页', + 'app-api' => 'APP 接口', + ]; + } + + protected function deviceTypeLabel(string $deviceType): string + { + return $this->deviceTypeLabels()[$deviceType] ?? ($deviceType === '' ? '未设置' : $deviceType); + } + + protected function paymentChannelLabels(): array + { + return [ + 'wechat_pay' => '微信支付', + 'alipay' => '支付宝', + ]; + } + + protected function paymentChannelLabel(string $paymentChannel): string + { + return $this->paymentChannelLabels()[$paymentChannel] ?? ($paymentChannel === '' ? '未设置' : $paymentChannel); + } + + protected function sortLabel(string $sort): string + { + return match ($sort) { + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + 'product_amount_desc' => '商品金额从高到低', + 'product_amount_asc' => '商品金额从低到高', + default => '创建时间倒序', + }; + } + + protected function formatMoneyRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' && is_numeric($min) ? ('¥' . number_format((float) $min, 2, '.', '')) : '不限'; + $maxLabel = $max !== '' && is_numeric($max) ? ('¥' . number_format((float) $max, 2, '.', '')) : '不限'; + + return $minLabel . ' ~ ' . $maxLabel; + } + + protected function displayFilterValue(string $value, array $options): string + { + if ($value === '') { + return '全部'; + } + + return (string) ($options[$value] ?? $value); + } + + protected function displayTextValue(string $value, string $default = '未设置'): string + { + return $value === '' ? $default : $value; + } + + protected function displayMoneyValue(string $value): string + { + if ($value === '') { + return '全部'; + } + + return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value; + } + + protected function workbenchLinks(): array + { + return [ + 'paid_high_amount' => '/admin/orders?sort=pay_amount_desc&payment_status=paid', + 'pending_latest' => '/admin/orders?sort=latest&payment_status=unpaid', + 'failed_latest' => '/admin/orders?sort=latest&payment_status=failed', + 'completed_latest' => '/admin/orders?sort=latest&status=completed', + 'current' => '/admin/orders', + ]; + } + + protected function buildOperationsFocus(array $summaryStats, array $filters): array + { + $pendingCount = (int) Order::query()->where('payment_status', 'unpaid')->count(); + $failedCount = (int) Order::query()->where('payment_status', 'failed')->count(); + $completedCount = (int) Order::query()->where('status', 'completed')->count(); + $links = $this->workbenchLinks(); + $currentQuery = http_build_query(array_filter($this->exportableFilters($filters), fn ($value) => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest')); + $currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : ''); + $workbench = [ + '高金额已支付' => $links['paid_high_amount'], + '待支付跟进' => $links['pending_latest'], + '支付失败排查' => $links['failed_latest'], + '最近完成订单' => $links['completed_latest'], + '返回当前筛选视图' => $currentUrl, + ]; + $signals = [ + '待支付订单' => $pendingCount, + '支付失败订单' => $failedCount, + '已完成订单' => $completedCount, + ]; + + if (($filters['platform'] ?? '') === 'wechat_mini') { + return [ + 'headline' => '当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。', + 'actions' => [ + ['label' => '继续查看微信小程序订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_channel'] ?? '') === 'wechat_pay') { + return [ + 'headline' => '当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。', + 'actions' => [ + ['label' => '继续查看微信支付订单', 'url' => $currentUrl], + ['label' => '去看支付失败订单', 'url' => $links['failed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mini-program') { + return [ + 'headline' => '当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。', + 'actions' => [ + ['label' => '继续查看小程序环境订单', 'url' => $currentUrl], + ['label' => '去看微信支付订单', 'url' => $links['failed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mobile-webview') { + return [ + 'headline' => '当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。', + 'actions' => [ + ['label' => '继续查看微信内网页订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mobile') { + return [ + 'headline' => '当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。', + 'actions' => [ + ['label' => '继续查看移动浏览器订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'desktop') { + return [ + 'headline' => '当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。', + 'actions' => [ + ['label' => '继续查看桌面浏览器订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'failed') { + return [ + 'headline' => '当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。', + 'actions' => [ + ['label' => '继续查看支付失败订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'unpaid') { + return [ + 'headline' => '当前筛选已聚焦待支付订单,建议优先跟进下单未支付用户,并观察支付转化时效。', + 'actions' => [ + ['label' => '继续查看待支付订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'paid') { + return [ + 'headline' => '当前正在查看已支付订单,建议优先关注高金额订单履约进度与异常售后风险。', + 'actions' => [ + ['label' => '继续查看已支付订单', 'url' => $currentUrl], + ['label' => '去看最近完成订单', 'url' => $links['completed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['status'] ?? '') === 'completed') { + return [ + 'headline' => '当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。', + 'actions' => [ + ['label' => '继续查看已完成订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($summaryStats['total_orders'] ?? 0) <= 0) { + return [ + 'headline' => '当前总台视角下暂无订单,建议先确认交易链路、支付链路与站点回写链路是否都已打通。', + 'actions' => [ + ['label' => '先看订单整体情况', 'url' => $links['paid_high_amount']], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($summaryStats['total_orders'] ?? 0) < 5) { + return [ + 'headline' => '当前总台订单仍较少,建议优先关注待支付订单,并同步查看已支付订单质量。', + 'actions' => [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + return [ + 'headline' => $failedCount > 0 + ? '当前总台订单已形成基础规模,建议优先关注待支付、支付失败与高金额已支付订单。' + : '当前总台订单已形成基础规模,建议优先关注待支付与高金额已支付订单,保持交易闭环稳定。', + 'actions' => $failedCount > 0 + ? [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看支付失败订单', 'url' => $links['failed_latest']], + ] + : [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } +} diff --git a/app/Http/Controllers/Admin/PlanController.php b/app/Http/Controllers/Admin/PlanController.php new file mode 100644 index 0000000..6497714 --- /dev/null +++ b/app/Http/Controllers/Admin/PlanController.php @@ -0,0 +1,249 @@ +ensurePlatformAdmin($request); + + $filters = [ + 'status' => trim((string) $request->query('status', '')), + 'billing_cycle' => trim((string) $request->query('billing_cycle', '')), + 'keyword' => trim((string) $request->query('keyword', '')), + 'published' => trim((string) $request->query('published', '')), + ]; + + $query = $this->applyFilters(Plan::query(), $filters) + ->orderBy('sort') + ->orderByDesc('id'); + + $filename = 'plans_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($query) { + $out = fopen('php://output', 'w'); + + // UTF-8 BOM,避免 Excel 打开中文乱码 + fwrite($out, "\xEF\xBB\xBF"); + + fputcsv($out, [ + 'ID', + '套餐名称', + '编码', + '计费周期', + '售价', + '划线价', + '状态', + '排序', + '发布时间', + '描述', + ]); + + $query->chunkById(500, function ($plans) use ($out) { + foreach ($plans as $plan) { + fputcsv($out, [ + $plan->id, + $plan->name, + $plan->code, + $plan->billing_cycle, + (float) $plan->price, + (float) $plan->list_price, + $plan->status, + (int) $plan->sort, + optional($plan->published_at)->format('Y-m-d H:i:s') ?: '', + $plan->description ?: '', + ]); + } + }); + + fclose($out); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function index(Request $request): View + { + $this->ensurePlatformAdmin($request); + + $filters = [ + 'status' => trim((string) $request->query('status', '')), + 'billing_cycle' => trim((string) $request->query('billing_cycle', '')), + 'keyword' => trim((string) $request->query('keyword', '')), + // 发布状态筛选(按 published_at 是否为空) + // - published:已发布(published_at not null) + // - unpublished:未发布(published_at is null) + 'published' => trim((string) $request->query('published', '')), + ]; + + $plansQuery = $this->applyFilters(Plan::query(), $filters); + + $plans = (clone $plansQuery) + ->orderBy('sort') + ->orderByDesc('id') + ->paginate(10) + ->withQueryString(); + + return view('admin.plans.index', [ + 'plans' => $plans, + 'filters' => $filters, + 'filterOptions' => [ + 'statuses' => $this->statusLabels(), + 'billingCycles' => $this->billingCycleLabels(), + ], + 'summaryStats' => [ + 'total_plans' => (clone $plansQuery)->count(), + 'active_plans' => (clone $plansQuery)->where('status', 'active')->count(), + 'monthly_plans' => (clone $plansQuery)->where('billing_cycle', 'monthly')->count(), + 'yearly_plans' => (clone $plansQuery)->where('billing_cycle', 'yearly')->count(), + 'published_plans' => (clone $plansQuery)->whereNotNull('published_at')->count(), + 'unpublished_plans' => (clone $plansQuery)->whereNull('published_at')->count(), + ], + 'statusLabels' => $this->statusLabels(), + 'billingCycleLabels' => $this->billingCycleLabels(), + ]); + } + + public function create(Request $request): View + { + $this->ensurePlatformAdmin($request); + + return view('admin.plans.form', [ + 'plan' => new Plan(), + 'statusLabels' => $this->statusLabels(), + 'billingCycleLabels' => $this->billingCycleLabels(), + 'formAction' => '/admin/plans', + 'method' => 'post', + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $data = $this->validatePlan($request); + $plan = Plan::query()->create($data); + + return redirect('/admin/plans')->with('success', '套餐已创建:' . $plan->name); + } + + public function edit(Request $request, Plan $plan): View + { + $this->ensurePlatformAdmin($request); + + return view('admin.plans.form', [ + 'plan' => $plan, + 'statusLabels' => $this->statusLabels(), + 'billingCycleLabels' => $this->billingCycleLabels(), + 'formAction' => '/admin/plans/' . $plan->id, + 'method' => 'post', + ]); + } + + public function setStatus(Request $request, Plan $plan): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $data = $request->validate([ + 'status' => ['required', Rule::in(array_keys($this->statusLabels()))], + ]); + + $plan->status = (string) $data['status']; + + // 最小治理:当启用且未设置发布时间时,自动补一个发布时间(便于运营口径) + if ($plan->status === 'active' && $plan->published_at === null) { + $plan->published_at = now(); + } + + $plan->save(); + + return redirect()->back()->with('success', '套餐状态已更新:' . ($this->statusLabels()[$plan->status] ?? $plan->status)); + } + + public function update(Request $request, Plan $plan): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $data = $this->validatePlan($request, $plan->id); + $plan->update($data); + + return redirect('/admin/plans')->with('success', '套餐已更新:' . $plan->name); + } + + protected function validatePlan(Request $request, ?int $planId = null): array + { + $data = $request->validate([ + 'code' => ['required', 'string', 'max:50', 'regex:/^[A-Za-z0-9-_]+$/', Rule::unique('plans', 'code')->ignore($planId)], + 'name' => ['required', 'string', 'max:100'], + 'billing_cycle' => ['required', Rule::in(array_keys($this->billingCycleLabels()))], + 'price' => ['required', 'numeric', 'min:0'], + 'list_price' => ['nullable', 'numeric', 'min:0'], + 'status' => ['required', Rule::in(array_keys($this->statusLabels()))], + 'sort' => ['nullable', 'integer', 'min:0'], + 'description' => ['nullable', 'string'], + 'published_at' => ['nullable', 'date'], + ], [ + 'code.regex' => '套餐编码仅支持字母、数字、短横线与下划线。', + ]); + + $data['sort'] = $data['sort'] ?? 0; + + return $data; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + return $query + ->when($filters['status'] !== '', fn (Builder $builder) => $builder->where('status', $filters['status'])) + ->when($filters['billing_cycle'] !== '', fn (Builder $builder) => $builder->where('billing_cycle', $filters['billing_cycle'])) + ->when(($filters['published'] ?? '') !== '', function (Builder $builder) use ($filters) { + $published = (string) ($filters['published'] ?? ''); + if ($published === 'published') { + $builder->whereNotNull('published_at'); + } elseif ($published === 'unpublished') { + $builder->whereNull('published_at'); + } + }) + ->when($filters['keyword'] !== '', function (Builder $builder) use ($filters) { + $keyword = $filters['keyword']; + + $builder->where(function (Builder $subQuery) use ($keyword) { + $subQuery->where('name', 'like', '%' . $keyword . '%') + ->orWhere('code', 'like', '%' . $keyword . '%') + ->orWhere('description', 'like', '%' . $keyword . '%'); + }); + }); + } + + protected function statusLabels(): array + { + return [ + 'active' => '启用中', + 'draft' => '草稿中', + 'inactive' => '未启用', + ]; + } + + protected function billingCycleLabels(): array + { + return [ + 'monthly' => '月付', + 'quarterly' => '季付', + 'yearly' => '年付', + 'one_time' => '一次性', + ]; + } +} diff --git a/app/Http/Controllers/Admin/PlatformOrderController.php b/app/Http/Controllers/Admin/PlatformOrderController.php new file mode 100644 index 0000000..9b6de1a --- /dev/null +++ b/app/Http/Controllers/Admin/PlatformOrderController.php @@ -0,0 +1,556 @@ +ensurePlatformAdmin($request); + + $filters = [ + 'status' => trim((string) $request->query('status', '')), + 'payment_status' => trim((string) $request->query('payment_status', '')), + 'merchant_id' => trim((string) $request->query('merchant_id', '')), + 'plan_id' => trim((string) $request->query('plan_id', '')), + 'fail_only' => (string) $request->query('fail_only', ''), + 'synced_only' => (string) $request->query('synced_only', ''), + 'sync_status' => trim((string) $request->query('sync_status', '')), + // 只看“可同步订阅”的订单:已支付 + 已生效 + 未同步(用于运营快速处理) + 'syncable_only' => (string) $request->query('syncable_only', ''), + // 只看最近 24 小时批量同步过的订单(可治理追踪) + 'batch_synced_24h' => (string) $request->query('batch_synced_24h', ''), + ]; + + $orders = $this->applyFilters(PlatformOrder::query()->with(['merchant', 'plan', 'siteSubscription']), $filters) + ->latest('id') + ->paginate(10) + ->withQueryString(); + + $baseQuery = $this->applyFilters(PlatformOrder::query(), $filters); + + // 同步失败原因聚合(Top 5):用于运营快速判断“常见失败原因” + // 注意:这里用 JSON_EXTRACT 做 group by,MySQL 会返回带引号的 JSON 字符串,展示时做一次 trim 处理。 + $failedReasonRows = (clone $baseQuery) + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL") + ->selectRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') as reason, count(*) as cnt") + ->groupBy('reason') + ->orderByDesc('cnt') + ->limit(5) + ->get(); + + $failedReasonStats = $failedReasonRows->map(function ($row) { + $reason = (string) ($row->reason ?? ''); + $reason = trim($reason, "\" "); + + return [ + 'reason' => $reason !== '' ? $reason : '(空)', + 'count' => (int) ($row->cnt ?? 0), + ]; + })->values()->all(); + + return view('admin.platform_orders.index', [ + 'orders' => $orders, + 'filters' => $filters, + 'filterOptions' => [ + 'statuses' => $this->statusLabels(), + 'paymentStatuses' => $this->paymentStatusLabels(), + ], + 'merchants' => PlatformOrder::query()->with('merchant') + ->select('merchant_id') + ->whereNotNull('merchant_id') + ->distinct() + ->get() + ->pluck('merchant') + ->filter() + ->unique('id') + ->values(), + 'plans' => PlatformOrder::query()->with('plan') + ->select('plan_id') + ->whereNotNull('plan_id') + ->distinct() + ->get() + ->pluck('plan') + ->filter() + ->unique('id') + ->values(), + 'statusLabels' => $this->statusLabels(), + 'paymentStatusLabels' => $this->paymentStatusLabels(), + 'summaryStats' => [ + 'total_orders' => (clone $baseQuery)->count(), + 'paid_orders' => (clone $baseQuery)->where('payment_status', 'paid')->count(), + 'activated_orders' => (clone $baseQuery)->where('status', 'activated')->count(), + 'synced_orders' => (clone $baseQuery) + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL") + ->count(), + 'failed_sync_orders' => (clone $baseQuery) + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL") + ->count(), + 'unsynced_orders' => (clone $baseQuery) + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL") + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL") + ->count(), + 'total_payable_amount' => (float) ((clone $baseQuery)->sum('payable_amount') ?: 0), + 'total_paid_amount' => (float) ((clone $baseQuery)->sum('paid_amount') ?: 0), + ], + 'failedReasonStats' => $failedReasonStats, + ]); + } + + public function show(Request $request, PlatformOrder $order): View + { + $this->ensurePlatformAdmin($request); + + $order->loadMissing(['merchant', 'plan', 'siteSubscription']); + + return view('admin.platform_orders.show', [ + 'order' => $order, + 'statusLabels' => $this->statusLabels(), + 'paymentStatusLabels' => $this->paymentStatusLabels(), + ]); + } + + public function activateSubscription(Request $request, PlatformOrder $order, SubscriptionActivationService $service): RedirectResponse + { + $admin = $this->ensurePlatformAdmin($request); + + try { + $subscription = $service->activateOrder($order->id, $admin->id); + + // 同步成功:清理失败记录(若存在) + $meta = (array) ($order->meta ?? []); + data_forget($meta, 'subscription_activation_error'); + $order->meta = $meta; + $order->save(); + } catch (\Throwable $e) { + // 同步失败:写入错误信息,便于运营排查(可治理) + $meta = (array) ($order->meta ?? []); + data_set($meta, 'subscription_activation_error', [ + 'message' => $e->getMessage(), + 'at' => now()->toDateTimeString(), + 'admin_id' => $admin->id, + ]); + $order->meta = $meta; + $order->save(); + + return redirect()->back()->with('error', '订阅同步失败:' . $e->getMessage()); + } + + return redirect()->back()->with('success', '订阅已同步:' . $subscription->subscription_no); + } + + public function markPaidAndActivate(Request $request, PlatformOrder $order, SubscriptionActivationService $service): RedirectResponse + { + $admin = $this->ensurePlatformAdmin($request); + + // 最小状态推进:将订单标记为已支付 + 已生效,并补齐时间与金额字段 + $now = now(); + $order->payment_status = 'paid'; + $order->status = 'activated'; + $order->paid_at = $order->paid_at ?: $now; + $order->activated_at = $order->activated_at ?: $now; + $order->paid_amount = $order->paid_amount > 0 ? $order->paid_amount : $order->payable_amount; + $order->save(); + + // 立刻同步订阅 + try { + $subscription = $service->activateOrder($order->id, $admin->id); + + $meta = (array) ($order->meta ?? []); + data_forget($meta, 'subscription_activation_error'); + $order->meta = $meta; + $order->save(); + } catch (\Throwable $e) { + $meta = (array) ($order->meta ?? []); + data_set($meta, 'subscription_activation_error', [ + 'message' => $e->getMessage(), + 'at' => now()->toDateTimeString(), + 'admin_id' => $admin->id, + ]); + $order->meta = $meta; + $order->save(); + + return redirect()->back()->with('error', '订单已标记为已支付/已生效,但订阅同步失败:' . $e->getMessage()); + } + + return redirect()->back()->with('success', '订单已标记支付并生效,订阅已同步:' . $subscription->subscription_no); + } + + public function export(Request $request): StreamedResponse + { + $this->ensurePlatformAdmin($request); + + $filters = [ + 'status' => trim((string) $request->query('status', '')), + 'payment_status' => trim((string) $request->query('payment_status', '')), + 'merchant_id' => trim((string) $request->query('merchant_id', '')), + 'plan_id' => trim((string) $request->query('plan_id', '')), + 'fail_only' => (string) $request->query('fail_only', ''), + 'synced_only' => (string) $request->query('synced_only', ''), + 'sync_status' => trim((string) $request->query('sync_status', '')), + // 只看“可同步订阅”的订单:已支付 + 已生效 + 未同步(用于运营快速处理) + 'syncable_only' => (string) $request->query('syncable_only', ''), + // 只看最近 24 小时批量同步过的订单(可治理追踪) + 'batch_synced_24h' => (string) $request->query('batch_synced_24h', ''), + ]; + + $includeMeta = (string) $request->query('include_meta', '') === '1'; + + $query = $this->applyFilters( + PlatformOrder::query()->with(['merchant', 'plan', 'siteSubscription']), + $filters + )->orderBy('id'); + + $filename = 'platform_orders_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($query, $includeMeta) { + $out = fopen('php://output', 'w'); + + // UTF-8 BOM,避免 Excel 打开中文乱码 + fwrite($out, "\xEF\xBB\xBF"); + + $headers = [ + 'ID', + '订单号', + '站点', + '套餐', + '订单类型', + '订单状态', + '支付状态', + '应付金额', + '已付金额', + '下单时间', + '支付时间', + '生效时间', + '同步状态', + '订阅号', + '订阅到期', + '同步时间', + '同步失败原因', + '同步失败时间', + ]; + + if ($includeMeta) { + $headers[] = '原始meta(JSON)'; + } + + fputcsv($out, $headers); + + $query->chunkById(500, function ($orders) use ($out, $includeMeta) { + foreach ($orders as $order) { + $syncedId = (int) data_get($order->meta, 'subscription_activation.subscription_id', 0); + $syncErr = (string) (data_get($order->meta, 'subscription_activation_error.message') ?? ''); + + if ($syncedId > 0) { + $syncStatus = '已同步'; + } elseif ($syncErr !== '') { + $syncStatus = '同步失败'; + } else { + $syncStatus = '未同步'; + } + + $row = [ + $order->id, + $order->order_no, + $order->merchant?->name ?? '', + $order->plan_name ?: ($order->plan?->name ?? ''), + $order->order_type, + $order->status, + $order->payment_status, + (float) $order->payable_amount, + (float) $order->paid_amount, + optional($order->placed_at)->format('Y-m-d H:i:s') ?: '', + optional($order->paid_at)->format('Y-m-d H:i:s') ?: '', + optional($order->activated_at)->format('Y-m-d H:i:s') ?: '', + $syncStatus, + $order->siteSubscription?->subscription_no ?: '', + optional($order->siteSubscription?->ends_at)->format('Y-m-d H:i:s') ?: '', + (string) (data_get($order->meta, 'subscription_activation.synced_at') ?? ''), + $syncErr, + (string) (data_get($order->meta, 'subscription_activation_error.at') ?? ''), + ]; + + if ($includeMeta) { + $row[] = json_encode($order->meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + fputcsv($out, $row); + } + }); + + fclose($out); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function batchActivateSubscriptions(Request $request, SubscriptionActivationService $service): RedirectResponse + { + $admin = $this->ensurePlatformAdmin($request); + + // 支持两种 scope: + // - scope=filtered:只处理当前筛选范围内的订单(更安全,默认) + // - scope=all:处理全部订单(谨慎) + $scope = (string) $request->input('scope', 'filtered'); + + $filters = [ + 'status' => trim((string) $request->input('status', '')), + 'payment_status' => trim((string) $request->input('payment_status', '')), + 'merchant_id' => trim((string) $request->input('merchant_id', '')), + 'plan_id' => trim((string) $request->input('plan_id', '')), + 'fail_only' => (string) $request->input('fail_only', ''), + 'synced_only' => (string) $request->input('synced_only', ''), + 'sync_status' => trim((string) $request->input('sync_status', '')), + 'syncable_only' => (string) $request->input('syncable_only', ''), + 'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''), + ]; + + // 防误操作:批量同步默认要求先勾选“只看可同步”,避免无意识扩大处理范围 + if ($scope === 'filtered' && ($filters['syncable_only'] ?? '') !== '1') { + return redirect()->back()->with('warning', '为避免误操作,请先在筛选条件中勾选「只看可同步」,再执行批量同步订阅。'); + } + + // 防误操作:scope=all 需要二次确认 + if ($scope === 'all' && (string) $request->input('confirm', '') !== 'YES') { + return redirect()->back()->with('warning', '为避免误操作,执行全量批量同步前请在确认框输入 YES。'); + } + + $query = PlatformOrder::query(); + + if ($scope === 'filtered') { + $query = $this->applyFilters($query, $filters); + } + + // 只处理“可同步”的订单(双保险,避免误操作) + $query = $query + ->where('payment_status', 'paid') + ->where('status', 'activated') + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL"); + + $limit = (int) $request->input('limit', 50); + $limit = max(1, min(500, $limit)); + + $matchedTotal = (clone $query)->count(); + + // 默认按最新订单优先处理:避免 seed/demo 数据干扰测试,同时也更符合“先处理新问题”的运营直觉 + $orders = $query->orderByDesc('id')->limit($limit)->get(['id']); + $processed = $orders->count(); + + $success = 0; + $failed = 0; + $failedReasonCounts = []; + + foreach ($orders as $orderRow) { + try { + $service->activateOrder($orderRow->id, $admin->id); + + // 轻量审计:记录批量同步动作(方便追溯) + $order = PlatformOrder::query()->find($orderRow->id); + if ($order) { + $meta = (array) ($order->meta ?? []); + $audit = (array) (data_get($meta, 'audit', []) ?? []); + $nowStr = now()->toDateTimeString(); + $audit[] = [ + 'action' => 'batch_activate_subscription', + 'scope' => $scope, + 'at' => $nowStr, + 'admin_id' => $admin->id, + ]; + data_set($meta, 'audit', $audit); + + // 便于筛选/统计:记录最近一次批量同步信息(扁平字段) + data_set($meta, 'batch_activation', [ + 'at' => $nowStr, + 'admin_id' => $admin->id, + 'scope' => $scope, + ]); + + $order->meta = $meta; + $order->save(); + } + + $success++; + } catch (\Throwable $e) { + $failed++; + + $reason = trim((string) $e->getMessage()); + $reason = $reason !== '' ? $reason : '未知错误'; + $failedReasonCounts[$reason] = ($failedReasonCounts[$reason] ?? 0) + 1; + + // 批量同步失败也需要可治理:写入失败原因到订单 meta,便于后续筛选/导出/清理 + $order = PlatformOrder::query()->find($orderRow->id); + if ($order) { + $meta = (array) ($order->meta ?? []); + data_set($meta, 'subscription_activation_error', [ + 'message' => $reason, + 'at' => now()->toDateTimeString(), + 'admin_id' => $admin->id, + ]); + $order->meta = $meta; + $order->save(); + } + } + } + + $msg = '批量同步订阅完成:成功 ' . $success . ' 条,失败 ' . $failed . ' 条(命中 ' . $matchedTotal . ' 条,本次处理 ' . $processed . ' 条,limit=' . $limit . ')'; + + if ($failed > 0 && count($failedReasonCounts) > 0) { + arsort($failedReasonCounts); + $top = array_slice($failedReasonCounts, 0, 3, true); + $topText = collect($top)->map(function ($cnt, $reason) { + $reason = mb_substr((string) $reason, 0, 60); + return $reason . '(' . $cnt . ')'; + })->implode(';'); + + $msg .= ';失败原因Top:' . $topText; + } + + return redirect()->back()->with('success', $msg); + } + + public function clearSyncErrors(Request $request): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + // 支持两种模式: + // - scope=all(默认):清理所有订单的失败标记 + // - scope=filtered:仅清理当前筛选结果命中的订单(更安全) + $scope = (string) $request->input('scope', 'all'); + + $filters = [ + 'status' => trim((string) $request->input('status', '')), + 'payment_status' => trim((string) $request->input('payment_status', '')), + 'merchant_id' => trim((string) $request->input('merchant_id', '')), + 'plan_id' => trim((string) $request->input('plan_id', '')), + 'fail_only' => (string) $request->input('fail_only', ''), + 'synced_only' => (string) $request->input('synced_only', ''), + 'sync_status' => trim((string) $request->input('sync_status', '')), + 'syncable_only' => (string) $request->input('syncable_only', ''), + 'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''), + ]; + + $query = PlatformOrder::query() + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL"); + + if ($scope === 'filtered') { + $query = $this->applyFilters($query, $filters); + } + + $orders = $query->get(['id', 'meta']); + $matched = $orders->count(); + + $cleared = 0; + foreach ($orders as $order) { + $meta = (array) ($order->meta ?? []); + if (! data_get($meta, 'subscription_activation_error')) { + continue; + } + + data_forget($meta, 'subscription_activation_error'); + + // 轻量审计:记录清理动作(不做独立表,先落 meta,便于排查) + $audit = (array) (data_get($meta, 'audit', []) ?? []); + $audit[] = [ + 'action' => 'clear_sync_error', + 'scope' => $scope, + 'at' => now()->toDateTimeString(), + 'admin_id' => $this->platformAdminId($request), + ]; + data_set($meta, 'audit', $audit); + + $order->meta = $meta; + $order->save(); + $cleared++; + } + + $msg = $scope === 'filtered' + ? '已清除当前筛选范围内的同步失败标记:' + : '已清除全部订单的同步失败标记:'; + + return redirect()->back()->with('success', $msg . $cleared . ' 条(命中 ' . $matched . ' 条)'); + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + return $query + ->when($filters['status'] !== '', fn (Builder $builder) => $builder->where('status', $filters['status'])) + ->when($filters['payment_status'] !== '', fn (Builder $builder) => $builder->where('payment_status', $filters['payment_status'])) + ->when(($filters['merchant_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('merchant_id', (int) $filters['merchant_id'])) + ->when(($filters['plan_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('plan_id', (int) $filters['plan_id'])) + ->when(($filters['fail_only'] ?? '') !== '', function (Builder $builder) { + // 只看同步失败:meta.subscription_activation_error.message 存在即视为失败 + $builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL"); + }) + ->when(($filters['synced_only'] ?? '') !== '', function (Builder $builder) { + // 只看已同步:meta.subscription_activation.subscription_id 存在即视为已同步 + $builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL"); + }) + ->when(($filters['sync_status'] ?? '') !== '', function (Builder $builder) use ($filters) { + // 同步状态筛选:unsynced / synced / failed + if (($filters['sync_status'] ?? '') === 'synced') { + $builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL") + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL"); + } elseif (($filters['sync_status'] ?? '') === 'failed') { + $builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL"); + } elseif (($filters['sync_status'] ?? '') === 'unsynced') { + $builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL") + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL"); + } + }) + ->when(($filters['syncable_only'] ?? '') !== '', function (Builder $builder) { + // 只看可同步:已支付 + 已生效 + 尚未写入 subscription_activation.subscription_id + $builder->where('payment_status', 'paid') + ->where('status', 'activated') + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL"); + }) + ->when(($filters['batch_synced_24h'] ?? '') !== '', function (Builder $builder) { + // 只看最近 24 小时批量同步过的订单(基于 meta.batch_activation.at) + $since = now()->subHours(24)->format('Y-m-d H:i:s'); + + $builder->whereRaw("JSON_EXTRACT(meta, '$.batch_activation.at') IS NOT NULL"); + + // sqlite 测试库没有 JSON_UNQUOTE(),需要做兼容 + $driver = $builder->getQuery()->getConnection()->getDriverName(); + if ($driver === 'sqlite') { + $builder->whereRaw("JSON_EXTRACT(meta, '$.batch_activation.at') >= ?", [$since]); + } else { + $builder->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_activation.at')) >= ?", [$since]); + } + }); + } + + protected function statusLabels(): array + { + return [ + 'pending' => '待处理', + 'paid' => '已支付', + 'activated' => '已生效', + 'cancelled' => '已取消', + 'refunded' => '已退款', + ]; + } + + protected function paymentStatusLabels(): array + { + return [ + 'unpaid' => '未支付', + 'paid' => '已支付', + 'partially_refunded' => '部分退款', + 'refunded' => '已退款', + 'failed' => '支付失败', + ]; + } +} diff --git a/app/Http/Controllers/Admin/PlatformSettingController.php b/app/Http/Controllers/Admin/PlatformSettingController.php new file mode 100644 index 0000000..cee1a92 --- /dev/null +++ b/app/Http/Controllers/Admin/PlatformSettingController.php @@ -0,0 +1,184 @@ +ensurePlatformAdmin($request); + + $configs = Cache::remember( + CacheKeys::platformSystemConfigs(), + now()->addMinutes(10), + fn () => SystemConfig::query() + ->orderBy('group') + ->orderBy('id') + ->get() + ); + + return view('admin.settings.system', [ + 'systemSettings' => $configs, + 'groupedCount' => $configs->groupBy('group')->count(), + 'valueTypeOptions' => ['string', 'boolean', 'number', 'json'], + 'editingConfigId' => (int) session('editing_config_id', 0), + ]); + } + + public function updateSystem(Request $request, int $id): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $config = SystemConfig::query()->findOrFail($id); + + $data = $request->validate([ + 'config_name' => ['required', 'string'], + 'config_value' => ['nullable', 'string'], + 'group' => ['required', 'string'], + 'value_type' => ['required', 'string'], + 'autoload' => ['nullable', 'boolean'], + 'remark' => ['nullable', 'string'], + ]); + + try { + $normalizedValue = match ($data['value_type']) { + 'boolean' => in_array(strtolower((string) ($data['config_value'] ?? '')), ['1', 'true', 'yes', 'on'], true) ? '1' : '0', + 'number' => (string) (is_numeric($data['config_value'] ?? null) ? $data['config_value'] : 0), + 'json' => $this->normalizeJsonConfigValue($data['config_value'] ?? null), + default => $data['config_value'] ?? null, + }; + } catch (ValidationException $exception) { + return redirect('/admin/settings/system') + ->withErrors($exception->errors()) + ->withInput() + ->with('editing_config_id', $config->id); + } + + $config->update([ + 'config_name' => $data['config_name'], + 'config_value' => $normalizedValue, + 'group' => $data['group'], + 'value_type' => $data['value_type'], + 'autoload' => (bool) ($data['autoload'] ?? false), + 'remark' => $data['remark'] ?? null, + ]); + + Cache::forget(CacheKeys::platformSystemConfigs()); + + return redirect('/admin/settings/system')->with('success', '系统配置更新成功'); + } + + public function channels(Request $request): View + { + $this->ensurePlatformAdmin($request); + + $payload = Cache::remember( + CacheKeys::platformChannelsOverview(), + now()->addMinutes(10), + fn () => [ + 'channels' => ChannelConfig::query() + ->orderBy('sort') + ->orderBy('id') + ->get(), + 'paymentConfigs' => PaymentConfig::query()->orderBy('id')->get(), + 'merchantCount' => Merchant::count(), + ] + ); + + return view('admin.settings.channels', $payload); + } + + public function updateChannel(Request $request, int $id): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $channel = ChannelConfig::query()->findOrFail($id); + + $data = $request->validate([ + 'channel_name' => ['required', 'string'], + 'channel_type' => ['required', 'string'], + 'status' => ['required', 'string'], + 'entry_path' => ['nullable', 'string'], + 'supports_login' => ['nullable', 'boolean'], + 'supports_payment' => ['nullable', 'boolean'], + 'supports_share' => ['nullable', 'boolean'], + 'remark' => ['nullable', 'string'], + ]); + + $channel->update([ + 'channel_name' => $data['channel_name'], + 'channel_type' => $data['channel_type'], + 'status' => $data['status'], + 'entry_path' => $data['entry_path'] ?? null, + 'supports_login' => (bool) ($data['supports_login'] ?? false), + 'supports_payment' => (bool) ($data['supports_payment'] ?? false), + 'supports_share' => (bool) ($data['supports_share'] ?? false), + 'remark' => $data['remark'] ?? null, + ]); + + Cache::forget(CacheKeys::platformChannelsOverview()); + + return redirect('/admin/settings/channels')->with('success', '渠道配置更新成功'); + } + + public function updatePayment(Request $request, int $id): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $payment = PaymentConfig::query()->findOrFail($id); + + $data = $request->validate([ + 'payment_name' => ['required', 'string'], + 'provider' => ['required', 'string'], + 'status' => ['required', 'string'], + 'is_sandbox' => ['nullable', 'boolean'], + 'supports_refund' => ['nullable', 'boolean'], + 'remark' => ['nullable', 'string'], + ]); + + $payment->update([ + 'payment_name' => $data['payment_name'], + 'provider' => $data['provider'], + 'status' => $data['status'], + 'is_sandbox' => (bool) ($data['is_sandbox'] ?? false), + 'supports_refund' => (bool) ($data['supports_refund'] ?? false), + 'remark' => $data['remark'] ?? null, + ]); + + Cache::forget(CacheKeys::platformChannelsOverview()); + + return redirect('/admin/settings/channels')->with('success', '支付配置更新成功'); + } + + protected function normalizeJsonConfigValue(?string $value): string + { + if ($value === null || trim($value) === '') { + return '{}'; + } + + $decoded = json_decode($value, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw ValidationException::withMessages([ + 'config_value' => 'JSON 配置值格式不正确,请检查后重试。', + ]); + } + + return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + } +} diff --git a/app/Http/Controllers/Admin/ProductCategoryController.php b/app/Http/Controllers/Admin/ProductCategoryController.php new file mode 100644 index 0000000..c0c2893 --- /dev/null +++ b/app/Http/Controllers/Admin/ProductCategoryController.php @@ -0,0 +1,124 @@ +ensurePlatformAdmin($request); + + $page = max((int) $request->integer('page', 1), 1); + + return view('admin.product_categories.index', [ + 'categories' => Cache::remember( + CacheKeys::platformCategoriesList($page), + now()->addMinutes(10), + fn () => ProductCategory::query()->with('merchant')->orderBy('merchant_id')->orderBy('sort')->orderBy('id')->paginate(10)->withQueryString() + ), + 'merchants' => Merchant::query()->orderBy('id')->get(), + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $data = $request->validate([ + 'merchant_id' => ['required', 'integer'], + 'name' => ['required', 'string'], + 'slug' => [ + 'required', + 'string', + Rule::unique('product_categories', 'slug')->where(fn ($query) => $query->where('merchant_id', $data['merchant_id'] ?? $request->input('merchant_id'))), + ], + 'status' => ['nullable', 'string'], + 'sort' => ['nullable', 'integer'], + 'description' => ['nullable', 'string'], + ], [ + 'slug.unique' => '当前商家下该分类 slug 已存在,请换一个。', + ]); + + ProductCategory::query()->create([ + 'merchant_id' => $data['merchant_id'], + 'name' => $data['name'], + 'slug' => $data['slug'], + 'status' => $data['status'] ?? 'active', + 'sort' => $data['sort'] ?? 0, + 'description' => $data['description'] ?? null, + ]); + + $this->flushPlatformCaches(); + + return redirect('/admin/product-categories')->with('success', '商品分类创建成功'); + } + + public function update(Request $request, int $id): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $category = ProductCategory::query()->findOrFail($id); + + $data = $request->validate([ + 'name' => ['required', 'string'], + 'slug' => [ + 'nullable', + 'string', + Rule::unique('product_categories', 'slug')->where(fn ($query) => $query->where('merchant_id', $category->merchant_id))->ignore($category->id), + ], + 'status' => ['required', 'string'], + 'sort' => ['nullable', 'integer'], + 'description' => ['nullable', 'string'], + ], [ + 'slug.unique' => '当前商家下该分类 slug 已存在,请换一个。', + ]); + $category->update([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? $category->slug, + 'status' => $data['status'], + 'sort' => $data['sort'] ?? 0, + 'description' => $data['description'] ?? null, + ]); + + $this->flushPlatformCaches(); + + return redirect('/admin/product-categories')->with('success', '商品分类更新成功'); + } + + public function destroy(Request $request, int $id): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $category = ProductCategory::query()->findOrFail($id); + $category->delete(); + + $this->flushPlatformCaches(); + + return redirect('/admin/product-categories')->with('success', '商品分类删除成功'); + } + + protected function flushPlatformCaches(): void + { + for ($page = 1; $page <= 5; $page++) { + Cache::forget(CacheKeys::platformProductsList($page)); + Cache::forget(CacheKeys::platformCategoriesList($page)); + } + } +} diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php new file mode 100644 index 0000000..522f9c3 --- /dev/null +++ b/app/Http/Controllers/Admin/ProductController.php @@ -0,0 +1,1366 @@ +ensurePlatformAdmin($request); + + $page = max((int) $request->integer('page', 1), 1); + $filters = $this->filters($request); + + $statusStatsFilters = $filters; + $statusStatsFilters['status'] = ''; + + $summaryStats = Cache::remember( + CacheKeys::platformProductsSummary($statusStatsFilters), + now()->addMinutes(10), + fn () => $this->buildSummaryStats($this->applyFilters(Product::query(), $statusStatsFilters)) + ); + + $importHistoryData = $this->buildImportHistorySummaryData(); + + return view('admin.products.index', array_merge([ + 'products' => Cache::remember( + CacheKeys::platformProductsList($page, $filters), + now()->addMinutes(10), + fn () => $this->applySorting( + $this->applyFilters(Product::query()->with(['merchant', 'category']), $filters), + $filters + )->paginate(10)->withQueryString() + ), + 'statusStats' => Cache::remember( + CacheKeys::platformProductsStatusStats($statusStatsFilters), + now()->addMinutes(10), + fn () => $this->buildStatusStats($this->applyFilters(Product::query(), $statusStatsFilters)) + ), + 'summaryStats' => $summaryStats, + 'operationsFocus' => $this->buildOperationsFocus($summaryStats, $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'filters' => $filters, + 'filterOptions' => [ + 'statuses' => $this->statusOptions, + 'sortOptions' => [ + 'latest' => '最新创建', + 'price_asc' => '价格从低到高', + 'price_desc' => '价格从高到低', + 'stock_asc' => '库存从低到高', + 'stock_desc' => '库存从高到低', + ], + ], + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'statusLabels' => $this->statusLabels(), + 'merchants' => Merchant::query()->orderBy('id')->get(), + 'categories' => ProductCategory::query()->with('merchant')->orderBy('merchant_id')->orderBy('sort')->orderBy('id')->get(), + 'pageMeta' => [ + 'current' => $page, + 'perPage' => 10, + ], + 'importHistoryStats' => $importHistoryData['stats'], + 'importHistories' => $importHistoryData['histories'], + ], $this->filtersViewData($filters))); + } + + public function store(Request $request): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $data = $request->validate([ + 'merchant_id' => ['required', 'integer', 'min:1'], + 'category_id' => ['nullable', 'integer'], + 'title' => ['required', 'string', 'max:200'], + 'slug' => ['required', 'string', 'max:200'], + 'sku' => ['nullable', 'string', 'max:120'], + 'summary' => ['nullable', 'string', 'max:500'], + 'price' => ['required', 'numeric', 'min:0'], + 'stock' => ['required', 'integer', 'min:0'], + ], [ + 'merchant_id.required' => '请选择站点。', + 'title.required' => '请填写商品标题。', + 'slug.required' => '请填写商品 slug。', + ]); + + Product::query()->create([ + 'merchant_id' => (int) $data['merchant_id'], + 'category_id' => $data['category_id'] ? (int) $data['category_id'] : null, + 'title' => $data['title'], + 'slug' => $data['slug'], + 'sku' => $data['sku'] ?? '', + 'summary' => $data['summary'] ?? '', + 'content' => '', + 'price' => (float) $data['price'], + 'original_price' => (float) $data['price'], + 'stock' => (int) $data['stock'], + 'status' => 'draft', + 'images' => [], + ]); + + $this->flushPlatformCaches(); + + return redirect('/admin/products')->with('success', '商品创建成功'); + } + + public function update(Request $request, int $id): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:200'], + 'slug' => ['required', 'string', 'max:200'], + 'sku' => ['nullable', 'string', 'max:120'], + 'summary' => ['nullable', 'string', 'max:500'], + 'price' => ['required', 'numeric', 'min:0'], + 'stock' => ['required', 'integer', 'min:0'], + 'status' => ['required', 'string', Rule::in($this->statusOptions)], + 'category_id' => ['nullable', 'integer'], + ]); + + $product = Product::query()->with('merchant')->findOrFail($id); + + $categoryIdRaw = $data['category_id'] ?? null; + $categoryId = ($categoryIdRaw === null || $categoryIdRaw === '') ? null : (int) $categoryIdRaw; + + if ($categoryId !== null) { + $category = ProductCategory::query()->where('merchant_id', $product->merchant_id)->whereKey($categoryId)->first(); + if (! $category) { + return redirect('/admin/products')->withErrors(['category_id' => '所选分类不存在或不属于当前商品站点。']); + } + } + + $product->update([ + 'title' => $data['title'], + 'slug' => $data['slug'], + 'sku' => $data['sku'] ?? '', + 'summary' => $data['summary'] ?? '', + 'price' => (float) $data['price'], + 'stock' => (int) $data['stock'], + 'status' => $data['status'], + 'category_id' => $categoryId, + ]); + + $this->flushPlatformCaches(); + + return redirect('/admin/products')->with('success', '商品更新成功'); + } + + public function destroy(Request $request, int $id): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + Product::query()->whereKey($id)->delete(); + + $this->flushPlatformCaches(); + + return redirect('/admin/products')->with('success', '商品已删除'); + } + + public function export(Request $request): StreamedResponse|RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $filters = $this->filters($request); + + $fileName = 'platform_products_' . now()->format('Ymd_His') . '.csv'; + + $exportSummary = $this->buildSummaryStats( + $this->applyFilters(Product::query(), $filters) + ); + + return response()->streamDownload(function () use ($filters, $exportSummary) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, ['导出信息', '总台商品导出']); + fputcsv($handle, ['导出时间', now()->format('Y-m-d H:i:s')]); + fputcsv($handle, ['站点', $this->merchantLabel($filters['merchant_id'] ?? '')]); + fputcsv($handle, ['状态', $this->statusLabel($filters['status'] ?? '')]); + fputcsv($handle, ['分类', $this->platformCategoryLabel((string) ($filters['category_id'] ?? ''))]); + fputcsv($handle, ['关键词', $this->displayTextValue((string) ($filters['keyword'] ?? ''))]); + fputcsv($handle, ['最低价格', $this->displayMoneyValue((string) ($filters['min_price'] ?? ''))]); + fputcsv($handle, ['最高价格', $this->displayMoneyValue((string) ($filters['max_price'] ?? ''))]); + fputcsv($handle, ['最低库存', $this->displayStockValue((string) ($filters['min_stock'] ?? ''))]); + fputcsv($handle, ['最高库存', $this->displayStockValue((string) ($filters['max_stock'] ?? ''))]); + fputcsv($handle, ['排序', $this->sortLabel((string) ($filters['sort'] ?? 'latest'))]); + fputcsv($handle, ['导出商品数', $exportSummary['total_products'] ?? 0]); + fputcsv($handle, ['导出总库存', $exportSummary['total_stock'] ?? 0]); + fputcsv($handle, ['导出总货值', number_format((float) ($exportSummary['total_stock_value'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出平均售价', number_format((float) ($exportSummary['average_price'] ?? 0), 2, '.', '')]); + fputcsv($handle, []); + + fputcsv($handle, [ + 'ID', + '站点ID', + '站点名称', + '分类ID', + '分类名称', + '分类标识', + '商品标题', + '商品Slug', + 'SKU', + '售价', + '划线价', + '库存', + '状态', + '商品简介', + '创建时间', + '更新时间', + ]); + + foreach ($this->applySorting($this->applyFilters(Product::query()->with(['merchant', 'category']), $filters), $filters)->cursor() as $product) { + fputcsv($handle, [ + $product->id, + $product->merchant_id, + $product->merchant?->name ?? '', + $product->category_id, + $product->category?->name ?? '', + $product->category?->slug ?? '', + $product->title, + $product->slug, + $product->sku, + number_format((float) $product->price, 2, '.', ''), + number_format((float) $product->original_price, 2, '.', ''), + $product->stock, + $this->statusLabel((string) $product->status), + $product->summary, + optional($product->created_at)?->format('Y-m-d H:i:s'), + optional($product->updated_at)?->format('Y-m-d H:i:s'), + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function downloadImportTemplate(Request $request): StreamedResponse + { + $this->ensurePlatformAdmin($request); + + $fileName = 'platform_product_import_template.csv'; + $header = $this->importTemplateHeader(); + + return response()->streamDownload(function () use ($header) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + fputcsv($handle, $header); + fputcsv($handle, [ + 1, + '演示商品', + 'demo-product', + 'SKU-DEMO-001', + '199.00', + '299.00', + 100, + 'default', + '演示商品简介', + 'published', + ]); + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function import(Request $request): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + // 兼容历史字段名 csv_file / import_file + $file = $request->file('import_file') ?: $request->file('csv_file'); + + if (! $file) { + return redirect('/admin/products')->withErrors(['import_file' => '请上传 CSV 文件。']); + } + + if (! $file instanceof \Illuminate\Http\UploadedFile || $file->getError() !== UPLOAD_ERR_OK) { + return redirect('/admin/products')->withErrors(['import_file' => '上传失败,请重试。']); + } + + $handle = fopen($file->getRealPath(), 'r'); + + $headerRead = false; + $expectedHeader = $this->importTemplateHeader(); + $importedCount = 0; + $failedCount = 0; + $failures = []; + + while (($row = fgetcsv($handle)) !== false) { + if (! $headerRead) { + $headerRead = true; + + if ($row !== $expectedHeader) { + fclose($handle); + + return redirect('/admin/products')->withErrors(['import_file' => '上传的 CSV 头部与模板不匹配,请重新下载模板。']); + } + + continue; + } + + $data = $this->parseCsvRow($row, $expectedHeader); + $merchantId = (int) ($data['merchant_id'] ?? 0); + + if ($merchantId <= 0) { + $failedCount++; + $failures[] = $this->buildImportFailureRow(count($failures) + 2, $data, 'merchant_id 不能为空'); + continue; + } + + try { + $this->importProductRow([ + 'title' => $data['title'] ?? '', + 'slug' => $data['slug'] ?? '', + 'sku' => $data['sku'] ?? '', + 'price' => $data['price'] ?? 0, + 'original_price' => $data['original_price'] ?? 0, + 'stock' => $data['stock'] ?? 0, + 'category_slug' => $data['category_slug'] ?? '', + 'summary' => $data['summary'] ?? '', + 'status' => $data['status'] ?? 'published', + ], $merchantId, count($failures) + 2); + + $importedCount++; + } catch (\InvalidArgumentException $exception) { + $failedCount++; + $failures[] = $this->buildImportFailureRow(count($failures) + 2, $data, $exception->getMessage()); + } + } + + fclose($handle); + + if ($importedCount > 0) { + $this->flushPlatformCaches(); + } + + $failureFile = $failedCount > 0 + ? $this->storeImportFailuresCsv('platform', $expectedHeader, $failures) + : null; + + ProductImportHistory::query()->create([ + 'scope' => 'platform', + 'merchant_id' => null, + 'admin_id' => $this->platformAdminId($request), + 'file_name' => $file->getClientOriginalName() ?: $file->getFilename(), + 'success_count' => $importedCount, + 'failed_count' => $failedCount, + 'failure_file' => $failureFile, + 'imported_at' => now(), + ]); + + $result = [ + 'success' => $importedCount, + 'failed' => $failedCount, + 'failure_file' => $failureFile, + 'messages' => array_slice(array_map(fn (array $failure) => $failure['error'], $failures), 0, 10), + ]; + + return redirect('/admin/products') + ->with('import_result', $result) + ->with( + $failedCount > 0 ? 'warning' : 'success', + $failedCount > 0 + ? "商品批量导入完成,成功 {$importedCount} 条,失败 {$failedCount} 条。" + : "商品批量导入完成,本次成功导入 {$importedCount} 条。" + ); + } + + public function downloadImportFailures(Request $request, string $file): StreamedResponse + { + $this->ensurePlatformAdmin($request); + + $safeName = basename($file); + $path = 'private/imports/product-failures/' . $safeName; + + abort_unless(Storage::disk('local')->exists($path), 404); + + return Storage::disk('local')->download($path, $safeName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function batchUpdate(Request $request): RedirectResponse + { + $this->ensurePlatformAdmin($request); + + $returnUrl = $this->sanitizeBatchReturnUrl($request->input('return_url'), '/admin/products'); + + $data = $request->validate([ + 'product_ids' => ['required', 'array', 'min:1'], + 'product_ids.*' => ['integer', 'distinct'], + 'action' => ['required', 'string', Rule::in(['change_status', 'change_category'])], + 'status' => ['nullable', 'string', Rule::in($this->statusOptions)], + 'category_id' => ['nullable', 'integer'], + ], [ + 'product_ids.required' => '请先选择至少一个商品。', + 'product_ids.array' => '批量商品参数格式不正确。', + 'product_ids.min' => '请先选择至少一个商品。', + 'action.required' => '请选择批量操作类型。', + 'action.in' => '暂不支持该批量操作。', + 'status.in' => '批量状态仅支持 draft / published / offline。', + ]); + + $productIds = collect($data['product_ids'] ?? [])->map(fn ($id) => (int) $id)->filter(fn ($id) => $id > 0)->values(); + + if ($productIds->isEmpty()) { + return redirect('/admin/products')->withErrors(['product_ids' => '请先选择至少一个商品。']); + } + + $products = Product::query()->whereIn('id', $productIds)->get(); + + if ($products->count() !== $productIds->count()) { + return redirect('/admin/products')->withErrors(['product_ids' => '勾选商品中存在不存在或已删除的数据,请刷新后重试。']); + } + + $updatedCount = 0; + + if (($data['action'] ?? '') === 'change_status') { + $status = trim((string) ($data['status'] ?? '')); + + if ($status === '') { + return redirect($returnUrl)->withErrors(['status' => '请选择要批量更新的商品状态。']); + } + + $updatedCount = Product::query() + ->whereIn('id', $productIds) + ->where('status', '!=', $status) + ->update([ + 'status' => $status, + 'updated_at' => now(), + ]); + } + + if (($data['action'] ?? '') === 'change_category') { + $groupedProducts = $products->groupBy('merchant_id'); + $categoryIdRaw = $data['category_id'] ?? null; + $categoryId = ($categoryIdRaw === null || $categoryIdRaw === '') ? null : (int) $categoryIdRaw; + + if ($categoryId === null) { + foreach ($groupedProducts as $merchantId => $merchantProducts) { + $ids = $merchantProducts->pluck('id')->all(); + $updatedCount += Product::query() + ->whereIn('id', $ids) + ->whereNotNull('category_id') + ->update([ + 'category_id' => null, + 'updated_at' => now(), + ]); + } + } else { + foreach ($groupedProducts as $merchantId => $merchantProducts) { + $category = ProductCategory::query() + ->where('merchant_id', (int) $merchantId) + ->whereKey($categoryId) + ->first(); + + if (! $category) { + return redirect($returnUrl)->withErrors([ + 'category_id' => "所选分类不属于商家 #{$merchantId},暂不支持跨商家批量改分类。", + ]); + } + + $ids = $merchantProducts->pluck('id')->all(); + $updatedCount += Product::query() + ->whereIn('id', $ids) + ->update([ + 'category_id' => $categoryId, + 'updated_at' => now(), + ]); + } + } + } + + if ($updatedCount > 0) { + $this->flushPlatformCaches(); + } + + return redirect($returnUrl)->with('success', "批量操作已完成,本次更新 {$updatedCount} 条商品。"); + } + + public function importHistories(Request $request): View + { + $this->ensurePlatformAdmin($request); + + $filters = $this->importHistoryFilters($request); + + $query = ProductImportHistory::query() + ->where('scope', 'platform') + ->with(['merchant', 'admin']); + + $merchantId = (int) ($filters['merchant_id'] ?? 0); + if ($merchantId > 0) { + $query->where('merchant_id', $merchantId); + } + + if (($filters['result_status'] ?? 'all') === 'success_only') { + $query->where('failed_count', 0); + } elseif (($filters['result_status'] ?? 'all') === 'has_failures') { + $query->where('failed_count', '>', 0); + } + + $start = $filters['start_date'] ?? null; + $end = $filters['end_date'] ?? null; + if ($start) { + $query->whereDate('imported_at', '>=', $start); + } + if ($end) { + $query->whereDate('imported_at', '<=', $end); + } + + $sort = $filters['sort'] ?? 'latest'; + if ($sort === 'oldest') { + $query->orderBy('imported_at')->orderBy('id'); + } else { + $query->orderByDesc('imported_at')->orderByDesc('id'); + } + + $importHistories = $query->paginate(15)->withQueryString(); + $this->hydrateFailureAvailability($importHistories->getCollection()); + + $statsQuery = (clone $query)->getQuery(); + $stats = ProductImportHistory::query()->fromSub($statsQuery, 'h') + ->selectRaw('COUNT(*) as total_imports') + ->selectRaw('COALESCE(SUM(success_count), 0) as total_success') + ->selectRaw('COALESCE(SUM(failed_count), 0) as total_failed') + ->selectRaw('SUM(CASE WHEN failed_count > 0 THEN 1 ELSE 0 END) as warning_imports') + ->first(); + + return view('admin.products.import_histories', [ + 'importHistories' => $importHistories, + 'importHistoryFilters' => $filters, + 'importHistoryFilterOptions' => [ + 'sorts' => [ + 'latest' => '最新导入', + 'oldest' => '最早导入', + ], + ], + 'importHistoryStats' => [ + 'total_imports' => (int) ($stats->total_imports ?? 0), + 'total_success' => (int) ($stats->total_success ?? 0), + 'total_failed' => (int) ($stats->total_failed ?? 0), + 'warning_imports' => (int) ($stats->warning_imports ?? 0), + ], + 'historyMerchants' => Merchant::query()->orderBy('id')->get(), + ]); + } + + public function exportImportHistories(Request $request): StreamedResponse + { + $this->ensurePlatformAdmin($request); + + $filters = $this->importHistoryFilters($request); + + $query = ProductImportHistory::query() + ->where('scope', 'platform') + ->with(['merchant', 'admin']); + + $merchantId = (int) ($filters['merchant_id'] ?? 0); + if ($merchantId > 0) { + $query->where('merchant_id', $merchantId); + } + + if (($filters['result_status'] ?? 'all') === 'success_only') { + $query->where('failed_count', 0); + } elseif (($filters['result_status'] ?? 'all') === 'has_failures') { + $query->where('failed_count', '>', 0); + } + + $start = $filters['start_date'] ?? null; + $end = $filters['end_date'] ?? null; + if ($start) { + $query->whereDate('imported_at', '>=', $start); + } + if ($end) { + $query->whereDate('imported_at', '<=', $end); + } + + $sort = $filters['sort'] ?? 'latest'; + if ($sort === 'oldest') { + $query->orderBy('imported_at')->orderBy('id'); + } else { + $query->orderByDesc('imported_at')->orderByDesc('id'); + } + + $fileName = 'platform_product_import_histories_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($query, $filters) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, ['导出信息', '平台商品导入历史']); + fputcsv($handle, ['导出时间', now()->format('Y-m-d H:i:s')]); + fputcsv($handle, ['站点', $this->merchantLabel($filters['merchant_id'] ?? '')]); + fputcsv($handle, ['导入结果', $filters['result_status'] ?? 'all']); + fputcsv($handle, []); + + fputcsv($handle, ['ID', '导入时间', '站点', '上传文件', '成功', '失败', '操作者', 'failure_file']); + + foreach ($query->cursor() as $history) { + fputcsv($handle, [ + $history->id, + optional($history->imported_at)?->format('Y-m-d H:i:s'), + $history->merchant?->name ?? '平台批量导入', + $history->file_name, + $history->success_count, + $history->failed_count, + $history->admin?->name ?? '-', + $history->failure_file ?? '', + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + protected function sanitizeBatchReturnUrl(?string $returnUrl, string $default): string + { + $candidate = trim((string) $returnUrl); + + if ($candidate === '') { + return $default; + } + + return str_starts_with($candidate, '/admin/products') ? $candidate : $default; + } + + protected function filters(Request $request): array + { + return [ + 'status' => trim((string) $request->string('status')), + 'merchant_id' => trim((string) $request->string('merchant_id')), + 'category_id' => trim((string) $request->string('category_id')), + 'keyword' => trim((string) $request->string('keyword')), + 'min_price' => trim((string) $request->string('min_price')), + 'max_price' => trim((string) $request->string('max_price')), + 'min_stock' => trim((string) $request->string('min_stock')), + 'max_stock' => trim((string) $request->string('max_stock')), + 'sort' => trim((string) $request->string('sort', 'latest')), + ]; + } + + protected function filtersViewData(array $filters): array + { + return [ + 'hasActiveFilters' => ($filters['status'] ?? '') !== '' + || ($filters['merchant_id'] ?? '') !== '' + || ($filters['category_id'] ?? '') !== '' + || ($filters['keyword'] ?? '') !== '' + || ($filters['min_price'] ?? '') !== '' + || ($filters['max_price'] ?? '') !== '' + || ($filters['min_stock'] ?? '') !== '' + || ($filters['max_stock'] ?? '') !== '' + || ($filters['sort'] ?? 'latest') !== 'latest', + ]; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + $merchantId = (int) ($filters['merchant_id'] ?? 0); + $categoryId = (int) ($filters['category_id'] ?? 0); + + return $query + ->when(($filters['status'] ?? '') !== '', fn (Builder $builder) => $builder->where('status', $filters['status'])) + ->when($merchantId > 0, fn (Builder $builder) => $builder->where('merchant_id', $merchantId)) + ->when($categoryId > 0, fn (Builder $builder) => $builder->where('category_id', $categoryId)) + ->when(($filters['keyword'] ?? '') !== '', function (Builder $builder) use ($filters) { + $keyword = (string) ($filters['keyword'] ?? ''); + + $builder->where(function (Builder $subQuery) use ($keyword) { + $subQuery->where('title', 'like', '%' . $keyword . '%') + ->orWhere('sku', 'like', '%' . $keyword . '%') + ->orWhere('slug', 'like', '%' . $keyword . '%'); + }); + }) + ->when(($filters['min_price'] ?? '') !== '' && is_numeric($filters['min_price']), function (Builder $builder) use ($filters) { + $builder->where('price', '>=', (float) $filters['min_price']); + }) + ->when(($filters['max_price'] ?? '') !== '' && is_numeric($filters['max_price']), function (Builder $builder) use ($filters) { + $builder->where('price', '<=', (float) $filters['max_price']); + }) + ->when(($filters['min_stock'] ?? '') !== '' && filter_var($filters['min_stock'], FILTER_VALIDATE_INT) !== false, function (Builder $builder) use ($filters) { + $builder->where('stock', '>=', (int) $filters['min_stock']); + }) + ->when(($filters['max_stock'] ?? '') !== '' && filter_var($filters['max_stock'], FILTER_VALIDATE_INT) !== false, function (Builder $builder) use ($filters) { + $builder->where('stock', '<=', (int) $filters['max_stock']); + }); + } + + protected function applySorting(Builder $query, array $filters): Builder + { + return match ($filters['sort'] ?? 'latest') { + 'price_asc' => $query->orderBy('price')->orderByDesc('id'), + 'price_desc' => $query->orderByDesc('price')->orderByDesc('id'), + 'stock_asc' => $query->orderBy('stock')->orderByDesc('id'), + 'stock_desc' => $query->orderByDesc('stock')->orderByDesc('id'), + default => $query->latest(), + }; + } + + protected function buildSummaryStats(Builder $query): array + { + $summary = (clone $query) + ->selectRaw('COUNT(*) as total_products') + ->selectRaw('COALESCE(SUM(stock), 0) as total_stock') + ->selectRaw('COALESCE(SUM(price * stock), 0) as total_stock_value') + ->selectRaw('COALESCE(AVG(price), 0) as average_price') + ->first(); + + return [ + 'total_products' => (int) ($summary->total_products ?? 0), + 'total_stock' => (int) ($summary->total_stock ?? 0), + 'total_stock_value' => (float) ($summary->total_stock_value ?? 0), + 'average_price' => (float) ($summary->average_price ?? 0), + ]; + } + + protected function buildStatusStats(Builder $query): array + { + $counts = (clone $query) + ->selectRaw('status, COUNT(*) as aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + + $stats = ['all' => (int) $counts->sum()]; + + foreach ($this->statusOptions as $status) { + $stats[$status] = (int) ($counts[$status] ?? 0); + } + + return $stats; + } + + protected function buildActiveFilterSummary(array $filters): array + { + return [ + '站点' => $this->merchantLabel($filters['merchant_id'] ?? ''), + '分类' => $this->platformCategoryLabel((string) ($filters['category_id'] ?? '')), + '状态' => $this->statusLabel((string) ($filters['status'] ?? '')), + '关键词' => $this->displayTextValue((string) ($filters['keyword'] ?? '')), + '价格区间' => $this->formatMoneyRange((string) ($filters['min_price'] ?? ''), (string) ($filters['max_price'] ?? '')), + '库存区间' => $this->formatStockRange((string) ($filters['min_stock'] ?? ''), (string) ($filters['max_stock'] ?? '')), + '排序' => $this->sortLabel((string) ($filters['sort'] ?? 'latest')), + ]; + } + + protected function statusLabels(): array + { + return [ + 'draft' => '草稿', + 'published' => '已上架', + 'offline' => '已下架', + ]; + } + + protected function statusLabel(string $status): string + { + if ($status === '') { + return '全部'; + } + + return $this->statusLabels()[$status] ?? $status; + } + + protected function merchantLabel(string $merchantId): string + { + $merchantId = trim($merchantId); + if ($merchantId === '' || ! ctype_digit($merchantId) || (int) $merchantId <= 0) { + return '全部'; + } + + $merchant = Merchant::query()->find((int) $merchantId); + if (! $merchant) { + return (string) $merchantId; + } + + return $merchant->id . ' / ' . $merchant->name; + } + + protected function platformCategoryLabel(string $categoryId): string + { + $categoryId = trim($categoryId); + + if ($categoryId === '' || ! ctype_digit($categoryId) || (int) $categoryId <= 0) { + return '全部'; + } + + $category = ProductCategory::query()->with('merchant')->find((int) $categoryId); + if (! $category) { + return '分类 #' . $categoryId; + } + + $merchantName = $category->merchant?->name ?? ('商家#' . $category->merchant_id); + + return $merchantName . ' / ' . $category->name; + } + + protected function formatMoneyRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' && is_numeric($min) + ? ('¥' . number_format((float) $min, 2, '.', '')) + : '不限'; + $maxLabel = $max !== '' && is_numeric($max) + ? ('¥' . number_format((float) $max, 2, '.', '')) + : '不限'; + + return $minLabel . ' ~ ' . $maxLabel; + } + + protected function formatStockRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' ? $min : '不限'; + $maxLabel = $max !== '' ? $max : '不限'; + + return $minLabel . ' ~ ' . $maxLabel . ' 件'; + } + + protected function sortLabel(string $sort): string + { + return match ($sort) { + 'price_asc' => '价格从低到高', + 'price_desc' => '价格从高到低', + 'stock_asc' => '库存从低到高', + 'stock_desc' => '库存从高到低', + default => '最新创建', + }; + } + + protected function displayTextValue(string $value, string $default = '全部'): string + { + return trim($value) === '' ? $default : $value; + } + + protected function displayMoneyValue(string $value): string + { + $value = trim($value); + if ($value === '') { + return '全部'; + } + + return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value; + } + + protected function displayStockValue(string $value): string + { + $value = trim($value); + return $value === '' ? '全部' : ($value . ' 件'); + } + + protected function workbenchLinks(): array + { + return [ + 'published_stock_desc' => '/admin/products?sort=stock_desc&status=published', + 'published_stock_asc' => '/admin/products?sort=stock_asc&status=published', + 'latest' => '/admin/products?sort=latest', + 'draft' => '/admin/products?status=draft&sort=latest', + 'current' => '/admin/products', + ]; + } + + protected function buildOperationsFocus(array $summaryStats, array $filters): array + { + $publishedCount = (int) Product::query()->where('status', 'published')->count(); + $lowStockCount = (int) Product::query()->where('status', 'published')->where('stock', '<=', 20)->count(); + $categoryCount = (int) ProductCategory::query()->count(); + $merchantCount = (int) Merchant::query()->count(); + + $links = $this->workbenchLinks(); + $currentQuery = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '' && $value !== 'latest')); + $currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : ''); + + $workbench = [ + '高库存已上架' => $links['published_stock_desc'], + '低库存补货' => $links['published_stock_asc'], + '最近新增' => $links['latest'], + '草稿待整理' => $links['draft'], + '返回当前筛选视图' => $currentUrl, + ]; + + $signals = [ + '已上架商品' => $publishedCount, + '低库存商品' => $lowStockCount, + '分类覆盖数' => $categoryCount, + '站点数量' => $merchantCount, + ]; + + $isPublished = ($filters['status'] ?? '') === 'published'; + $hasMerchantFilter = ($filters['merchant_id'] ?? '') !== ''; + $hasCategoryFilter = ($filters['category_id'] ?? '') !== ''; + $hasKeywordFilter = ($filters['keyword'] ?? '') !== ''; + $hasPriceRangeFilter = (($filters['min_price'] ?? '') !== '') || (($filters['max_price'] ?? '') !== ''); + + $categoryLabel = $hasCategoryFilter ? $this->platformCategoryLabel((string) ($filters['category_id'] ?? '')) : ''; + $keyword = (string) ($filters['keyword'] ?? ''); + $priceRange = $hasPriceRangeFilter ? $this->formatMoneyRange((string) ($filters['min_price'] ?? ''), (string) ($filters['max_price'] ?? '')) : ''; + + $response = function (string $headline, array $actions) use ($workbench, $signals) { + return [ + 'headline' => $headline, + 'actions' => $actions, + 'workbench' => $workbench, + 'signals' => $signals, + ]; + }; + + if (($filters['status'] ?? '') === 'draft') { + return $response( + '当前正在查看草稿商品,建议优先补齐标题、分类、价格与库存后再安排上架。', + [ + ['label' => '继续查看当前草稿', 'url' => $currentUrl], + ['label' => '去看已上架商品', 'url' => $links['published_stock_desc']], + ] + ); + } + + $hasPublishedStockFocus = $isPublished + && ((($filters['max_stock'] ?? '') !== '') + || ((($filters['min_stock'] ?? '') !== '') + && is_numeric($filters['min_stock']) + && (int) $filters['min_stock'] <= 20)); + + if ($hasPublishedStockFocus) { + return $response( + '当前筛选已聚焦已上架库存视角,建议优先确认低库存补货节奏,并同步观察高库存结构是否健康。', + [ + ['label' => '继续查看当前库存视角', 'url' => $currentUrl], + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ] + ); + } + + if ($isPublished && $hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对在售商品命名、分类承接、价格梯度与站点承接是否一致,并同步观察库存结构与站点覆盖是否健康。', + [ + ['label' => '继续查看当前已上架分类关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前已上架分类关键词商品', 'url' => $this->buildUrl('/admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($isPublished && $hasCategoryFilter && $hasKeywordFilter) { + return $response( + '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对在售商品命名、分类承接与站点承接是否一致,并同步观察价格带、库存结构与站点覆盖是否健康。', + [ + ['label' => '继续查看当前已上架分类关键词商品', 'url' => $currentUrl], + ['label' => '去看当前已上架分类商品', 'url' => $this->buildUrl('/admin/products', array_merge($filters, ['keyword' => '']))], + ] + ); + } + + if ($isPublished && $hasCategoryFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类在售商品的价格结构、库存分布与站点覆盖是否协调。', + [ + ['label' => '继续查看当前已上架分类价格带商品', 'url' => $currentUrl], + ['label' => '去看当前已上架分类商品', 'url' => $this->buildUrl('/admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($isPublished && $hasKeywordFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 结果,建议优先核对在售商品命名、卖点表达与价格梯度是否一致,并同步观察库存结构、站点承接与站点覆盖是否健康。', + [ + ['label' => '继续查看当前已上架关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前已上架关键词商品', 'url' => $this->buildUrl('/admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($isPublished && $hasKeywordFilter) { + return $response( + '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的结果,建议优先核对在售商品命名、卖点表达与站点承接是否一致,并同步观察价格带、库存结构与站点覆盖是否健康。', + [ + ['label' => '继续查看当前已上架关键词商品', 'url' => $currentUrl], + ['label' => '去看全部已上架商品', 'url' => $links['published_stock_desc']], + ] + ); + } + + if ($hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对分类承接、命名卖点与价格梯度是否一致,并同步观察库存结构、站点承接与站点覆盖是否健康。', + [ + ['label' => '继续查看当前分类关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前分类关键词商品', 'url' => $this->buildUrl('/admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($hasCategoryFilter && $hasKeywordFilter) { + return $response( + '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对分类承接、命名卖点与站点承接是否一致,并同步观察相关商品的价格带、库存结构与站点覆盖。', + [ + ['label' => '继续查看当前分类关键词商品', 'url' => $currentUrl], + ['label' => '去看当前分类商品', 'url' => $this->buildUrl('/admin/products', array_merge($filters, ['keyword' => '']))], + ] + ); + } + + if ($isPublished && $hasCategoryFilter) { + return $response( + '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类商品,建议优先核对该分类在售商品的价格带、库存结构与站点覆盖是否均衡。', + [ + ['label' => '继续查看当前已上架分类商品', 'url' => $currentUrl], + ['label' => '去看当前分类商品', 'url' => $this->buildUrl('/admin/products', array_merge($filters, ['status' => '', 'keyword' => '']))], + ] + ); + } + + if ($hasCategoryFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类价格结构是否连贯,并同步观察库存分布、转化表现与站点覆盖是否健康。', + [ + ['label' => '继续查看当前分类价格带商品', 'url' => $currentUrl], + ['label' => '去看当前分类商品', 'url' => $this->buildUrl('/admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($hasCategoryFilter) { + return $response( + '当前筛选已聚焦“' . $categoryLabel . '”分类商品,建议优先核对分类承接是否准确,并同步观察价格带、库存结构与站点覆盖是否均衡。', + [ + ['label' => '继续查看当前分类商品', 'url' => $currentUrl], + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ] + ); + } + + if ($hasKeywordFilter) { + return $response( + '当前筛选已聚焦关键词“' . $keyword . '”命中的商品,建议优先核对命名、卖点与站点承接是否一致,并同步观察相关商品的价格带与库存结构。', + [ + ['label' => '继续查看当前关键词商品', 'url' => $currentUrl], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ] + ); + } + + if ($hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦价格带 ' . $priceRange . ' 商品,建议优先核对定价梯度是否连贯,并同步观察库存结构、转化表现与站点覆盖是否匹配。', + [ + ['label' => '继续查看当前价格带商品', 'url' => $currentUrl], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ] + ); + } + + if ($isPublished) { + return $response( + '当前正在查看已上架商品,建议优先关注库存结构、价格带与分类覆盖是否均衡。', + [ + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ['label' => '继续查看已上架商品', 'url' => $currentUrl], + ] + ); + } + + if (($summaryStats['total_products'] ?? 0) <= 0) { + return $response( + '当前平台暂无商品,建议先补齐基础商品数据,再开始做上架与库存运营。', + [ + ['label' => '先看商品空白情况', 'url' => $links['latest']], + ['label' => '查看草稿商品', 'url' => $links['draft']], + ] + ); + } + + if (($summaryStats['total_products'] ?? 0) < 3) { + return $response( + '当前平台商品仍较少,建议优先查看最近新增与已上架商品,确认基础信息是否完整。', + [ + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ['label' => '去看已上架商品', 'url' => $links['published_stock_desc']], + ] + ); + } + + return $response( + $lowStockCount > 0 + ? '当前平台商品已形成基础规模,建议优先巡检低库存商品,并同步关注高库存结构是否均衡。' + : '当前平台商品已形成基础规模,建议优先关注高库存结构与最近新增商品质量。', + $lowStockCount > 0 + ? [ + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ] + : [ + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ] + ); + } + + protected function buildUrl(string $path, array $filters): string + { + $query = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '' && $value !== 'latest')); + return $path . ($query !== '' ? ('?' . $query) : ''); + } + + protected function buildImportHistorySummaryData(): array + { + $histories = ProductImportHistory::query() + ->where('scope', 'platform') + ->with(['merchant', 'admin']) + ->orderByDesc('imported_at') + ->orderByDesc('id') + ->take(20) + ->get(); + + $this->hydrateFailureAvailability($histories); + + $stats = ProductImportHistory::query() + ->where('scope', 'platform') + ->selectRaw('COUNT(*) as total_imports') + ->selectRaw('COALESCE(SUM(success_count), 0) as total_success') + ->selectRaw('COALESCE(SUM(failed_count), 0) as total_failed') + ->selectRaw('SUM(CASE WHEN failed_count > 0 THEN 1 ELSE 0 END) as warning_imports') + ->first(); + + return [ + 'histories' => $histories, + 'stats' => [ + 'total_imports' => (int) ($stats->total_imports ?? 0), + 'total_success' => (int) ($stats->total_success ?? 0), + 'total_failed' => (int) ($stats->total_failed ?? 0), + 'warning_imports' => (int) ($stats->warning_imports ?? 0), + ], + ]; + } + + protected function hydrateFailureAvailability($histories): void + { + $disk = Storage::disk('local'); + $base = 'private/imports/product-failures/'; + + foreach ($histories as $history) { + $history->failure_file_available = $history->failure_file + ? $disk->exists($base . basename((string) $history->failure_file)) + : false; + } + } + + protected function flushPlatformCaches(): void + { + Cache::add(CacheKeys::platformProductsVersion(), 1, now()->addDays(30)); + Cache::increment(CacheKeys::platformProductsVersion()); + Cache::forget(CacheKeys::platformDashboardStats()); + } + + protected function importTemplateHeader(): array + { + return [ + 'merchant_id', + 'title', + 'slug', + 'sku', + 'price', + 'original_price', + 'stock', + 'category_slug', + 'summary', + 'status', + ]; + } + + protected function parseCsvRow(array $row, array $header): array + { + $data = []; + foreach ($header as $index => $column) { + $data[$column] = $row[$index] ?? null; + } + return $data; + } + + protected function buildImportFailureRow(int $line, array $data, string $error): array + { + return [ + 'line' => $line, + 'data' => $data, + 'error' => $error, + ]; + } + + protected function storeImportFailuresCsv(string $scope, array $header, array $failures): string + { + $fileName = $scope . '_product_import_failures_' . now()->format('Ymd_His') . '_' . substr(md5((string) microtime(true)), 0, 6) . '.csv'; + $path = 'private/imports/product-failures/' . $fileName; + + $lines = []; + $lines[] = implode(',', array_merge($header, ['error'])); + foreach ($failures as $failure) { + $data = $failure['data'] ?? []; + $row = []; + foreach ($header as $column) { + $row[] = (string) ($data[$column] ?? ''); + } + $row[] = (string) ($failure['error'] ?? ''); + $lines[] = $this->csvLine($row); + } + + Storage::disk('local')->put($path, "\xEF\xBB\xBF" . implode("\n", $lines)); + + return $fileName; + } + + protected function csvLine(array $fields): string + { + $handle = fopen('php://temp', 'r+'); + fputcsv($handle, $fields); + rewind($handle); + $line = stream_get_contents($handle); + fclose($handle); + + return rtrim((string) $line, "\n\r"); + } + + protected function importProductRow(array $data, int $merchantId, int $line): void + { + $title = trim((string) ($data['title'] ?? '')); + $slug = trim((string) ($data['slug'] ?? '')); + + if ($title === '') { + throw new \InvalidArgumentException("第 {$line} 行 title 不能为空"); + } + + if ($slug === '') { + throw new \InvalidArgumentException("第 {$line} 行 slug 不能为空"); + } + + $merchant = Merchant::query()->find($merchantId); + if (! $merchant) { + throw new \InvalidArgumentException("第 {$line} 行 merchant_id 不存在"); + } + + $categoryId = null; + $categorySlug = trim((string) ($data['category_slug'] ?? '')); + if ($categorySlug !== '') { + $category = ProductCategory::query()->where('merchant_id', $merchantId)->where('slug', $categorySlug)->first(); + if (! $category) { + throw new \InvalidArgumentException("第 {$line} 行 category_slug 不存在"); + } + $categoryId = $category->id; + } + + $status = trim((string) ($data['status'] ?? 'published')); + if (! in_array($status, $this->statusOptions, true)) { + $status = 'published'; + } + + $price = is_numeric($data['price'] ?? null) ? (float) $data['price'] : 0.0; + $originalPrice = is_numeric($data['original_price'] ?? null) ? (float) $data['original_price'] : $price; + $stock = filter_var($data['stock'] ?? 0, FILTER_VALIDATE_INT) !== false ? (int) $data['stock'] : 0; + + Product::query()->updateOrCreate([ + 'merchant_id' => $merchantId, + 'slug' => $slug, + ], [ + 'category_id' => $categoryId, + 'title' => $title, + 'sku' => trim((string) ($data['sku'] ?? '')), + 'summary' => trim((string) ($data['summary'] ?? '')), + 'content' => '', + 'price' => $price, + 'original_price' => $originalPrice, + 'stock' => $stock, + 'status' => $status, + 'images' => [], + ]); + } + + protected function importHistoryFilters(Request $request): array + { + $merchantId = trim((string) $request->string('merchant_id')); + $resultStatus = trim((string) $request->string('import_result_status', 'all')); + $timeRange = trim((string) $request->string('import_time_range', 'all')); + $rawStart = trim((string) $request->string('start_date')); + $rawEnd = trim((string) $request->string('end_date')); + $sort = trim((string) $request->string('import_sort', 'latest')); + + $dateErrors = []; + + $startDate = null; + $endDate = null; + + if ($timeRange === 'today') { + $startDate = now()->toDateString(); + $endDate = now()->toDateString(); + } elseif ($timeRange === 'last_7_days') { + $startDate = now()->subDays(6)->toDateString(); + $endDate = now()->toDateString(); + } elseif ($timeRange === 'custom') { + if ($rawStart !== '' && ! $this->isValidDate($rawStart)) { + $dateErrors[] = '开始日期格式不正确,请使用 YYYY-MM-DD。'; + } + if ($rawEnd !== '' && ! $this->isValidDate($rawEnd)) { + $dateErrors[] = '结束日期格式不正确,请使用 YYYY-MM-DD。'; + } + if ($rawStart !== '' && $rawEnd !== '' && $this->isValidDate($rawStart) && $this->isValidDate($rawEnd) && $rawStart > $rawEnd) { + $dateErrors[] = '开始日期不能晚于结束日期。'; + } + + $startDate = $rawStart !== '' ? $rawStart : null; + $endDate = $rawEnd !== '' ? $rawEnd : null; + } + + return [ + 'merchant_id' => $merchantId, + 'result_status' => in_array($resultStatus, ['all', 'success_only', 'has_failures'], true) ? $resultStatus : 'all', + 'time_range' => in_array($timeRange, ['all', 'today', 'last_7_days', 'custom'], true) ? $timeRange : 'all', + 'start_date' => $startDate, + 'end_date' => $endDate, + 'sort' => in_array($sort, ['latest', 'oldest'], true) ? $sort : 'latest', + 'date_errors' => $dateErrors, + ]; + } + + protected function isValidDate(string $value): bool + { + try { + $date = Carbon::createFromFormat('Y-m-d', $value); + } catch (\Throwable $exception) { + return false; + } + + return $date && $date->format('Y-m-d') === $value; + } +} diff --git a/app/Http/Controllers/Admin/SiteSubscriptionController.php b/app/Http/Controllers/Admin/SiteSubscriptionController.php new file mode 100644 index 0000000..e1795f0 --- /dev/null +++ b/app/Http/Controllers/Admin/SiteSubscriptionController.php @@ -0,0 +1,196 @@ +ensurePlatformAdmin($request); + + $filters = [ + 'status' => trim((string) $request->query('status', '')), + 'keyword' => trim((string) $request->query('keyword', '')), + 'merchant_id' => trim((string) $request->query('merchant_id', '')), + 'plan_id' => trim((string) $request->query('plan_id', '')), + 'expiry' => trim((string) $request->query('expiry', '')), + ]; + + $query = $this->applyFilters( + SiteSubscription::query()->with(['merchant', 'plan']), + $filters + )->orderBy('id'); + + $filename = 'site_subscriptions_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($query) { + $out = fopen('php://output', 'w'); + + // UTF-8 BOM,避免 Excel 打开中文乱码 + fwrite($out, "\xEF\xBB\xBF"); + + fputcsv($out, [ + 'ID', + '订阅号', + '站点', + '套餐', + '状态', + '计费周期', + '周期(月)', + '金额', + '开始时间', + '到期时间', + '到期状态', + '试用到期', + '生效时间', + '取消时间', + ]); + + $statusLabels = $this->statusLabels(); + + $query->chunkById(500, function ($subs) use ($out, $statusLabels) { + foreach ($subs as $sub) { + $endsAt = $sub->ends_at; + $expiryLabel = '无到期'; + if ($endsAt) { + if ($endsAt->lt(now())) { + $expiryLabel = '已过期'; + } elseif ($endsAt->lt(now()->addDays(7))) { + $expiryLabel = '7天内到期'; + } else { + $expiryLabel = '未到期'; + } + } + + $status = (string) ($sub->status ?? ''); + $statusText = ($statusLabels[$status] ?? $status); + $statusText = $statusText . ' (' . $status . ')'; + + fputcsv($out, [ + $sub->id, + $sub->subscription_no, + $sub->merchant?->name ?? '', + $sub->plan_name ?: ($sub->plan?->name ?? ''), + $statusText, + $sub->billing_cycle ?: '', + (int) $sub->period_months, + (float) $sub->amount, + optional($sub->starts_at)->format('Y-m-d H:i:s') ?: '', + optional($sub->ends_at)->format('Y-m-d H:i:s') ?: '', + $expiryLabel, + optional($sub->trial_ends_at)->format('Y-m-d H:i:s') ?: '', + optional($sub->activated_at)->format('Y-m-d H:i:s') ?: '', + optional($sub->cancelled_at)->format('Y-m-d H:i:s') ?: '', + ]); + } + }); + + fclose($out); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function index(Request $request): View + { + $this->ensurePlatformAdmin($request); + + $filters = [ + 'status' => trim((string) $request->query('status', '')), + 'keyword' => trim((string) $request->query('keyword', '')), + 'merchant_id' => trim((string) $request->query('merchant_id', '')), + 'plan_id' => trim((string) $request->query('plan_id', '')), + // 到期辅助筛选(不改变 status 字段,仅按 ends_at 计算) + // - expired:已过期(ends_at < now) + // - expiring_7d:7 天内到期(now <= ends_at < now+7d) + 'expiry' => trim((string) $request->query('expiry', '')), + ]; + + $query = $this->applyFilters( + SiteSubscription::query()->with(['merchant', 'plan']), + $filters + ); + + $subscriptions = (clone $query) + ->latest('id') + ->paginate(10) + ->withQueryString(); + + $baseQuery = $this->applyFilters(SiteSubscription::query(), $filters); + + return view('admin.site_subscriptions.index', [ + 'subscriptions' => $subscriptions, + 'filters' => $filters, + 'statusLabels' => $this->statusLabels(), + 'filterOptions' => [ + 'statuses' => $this->statusLabels(), + ], + 'merchants' => SiteSubscription::query()->with('merchant')->select('merchant_id')->distinct()->get()->pluck('merchant')->filter()->unique('id')->values(), + 'plans' => SiteSubscription::query()->with('plan')->select('plan_id')->whereNotNull('plan_id')->distinct()->get()->pluck('plan')->filter()->unique('id')->values(), + 'summaryStats' => [ + 'total_subscriptions' => (clone $baseQuery)->count(), + 'activated_subscriptions' => (clone $baseQuery)->where('status', 'activated')->count(), + 'pending_subscriptions' => (clone $baseQuery)->where('status', 'pending')->count(), + 'cancelled_subscriptions' => (clone $baseQuery)->where('status', 'cancelled')->count(), + // 可治理辅助指标:按 ends_at 计算 + 'expired_subscriptions' => (clone $baseQuery) + ->whereNotNull('ends_at') + ->where('ends_at', '<', now()) + ->count(), + 'expiring_7d_subscriptions' => (clone $baseQuery) + ->whereNotNull('ends_at') + ->where('ends_at', '>=', now()) + ->where('ends_at', '<', now()->addDays(7)) + ->count(), + ], + ]); + } + + protected function statusLabels(): array + { + return [ + 'pending' => '待生效', + 'activated' => '已生效', + 'cancelled' => '已取消', + 'expired' => '已过期', + ]; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + return $query + ->when($filters['status'] !== '', fn (Builder $builder) => $builder->where('status', $filters['status'])) + ->when(($filters['merchant_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('merchant_id', (int) $filters['merchant_id'])) + ->when(($filters['plan_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('plan_id', (int) $filters['plan_id'])) + ->when(($filters['expiry'] ?? '') !== '', function (Builder $builder) use ($filters) { + $expiry = (string) ($filters['expiry'] ?? ''); + if ($expiry === 'expired') { + $builder->whereNotNull('ends_at')->where('ends_at', '<', now()); + } elseif ($expiry === 'expiring_7d') { + $builder->whereNotNull('ends_at') + ->where('ends_at', '>=', now()) + ->where('ends_at', '<', now()->addDays(7)); + } + }) + ->when($filters['keyword'] !== '', function (Builder $builder) use ($filters) { + $keyword = $filters['keyword']; + + $builder->where(function (Builder $subQuery) use ($keyword) { + $subQuery->where('subscription_no', 'like', '%' . $keyword . '%') + ->orWhere('plan_name', 'like', '%' . $keyword . '%') + ->orWhere('billing_cycle', 'like', '%' . $keyword . '%'); + }); + }); + } +} + diff --git a/app/Http/Controllers/Api/V1/AuthController.php b/app/Http/Controllers/Api/V1/AuthController.php new file mode 100644 index 0000000..b0472bb --- /dev/null +++ b/app/Http/Controllers/Api/V1/AuthController.php @@ -0,0 +1,79 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + 'source' => ['nullable', 'string'], + ]); + + $user = User::query()->where('email', $data['email'])->first(); + if (! $user || ! Hash::check($data['password'], $user->password)) { + return ApiResponse::error('账号或密码错误', 1001, null, 422); + } + + $source = $data['source'] ?? 'pc'; + $user->forceFill(['last_login_source' => $source])->save(); + + return ApiResponse::success([ + 'token' => base64_encode($user->id . '|' . now()->timestamp . '|demo'), + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'source' => $source, + ], + ], '登录成功'); + } + + public function wechatPlaceholder(Request $request): JsonResponse + { + $data = $request->validate([ + 'platform' => ['required', 'in:wechat_mp,wechat_mini,app'], + 'openid' => ['nullable', 'string'], + 'unionid' => ['nullable', 'string'], + 'nickname' => ['nullable', 'string'], + ]); + + $merchant = Merchant::query()->first(); + $user = User::query()->first(); + + if ($user && ($data['openid'] ?? null)) { + OauthAccount::query()->updateOrCreate( + [ + 'provider' => $data['platform'], + 'openid' => $data['openid'], + ], + [ + 'user_id' => $user->id, + 'merchant_id' => $merchant?->id, + 'platform' => $data['platform'], + 'provider' => $data['platform'], + 'unionid' => $data['unionid'] ?? null, + 'nickname' => $data['nickname'] ?? null, + 'raw_payload' => $data, + ] + ); + } + + return ApiResponse::success([ + 'platform' => $data['platform'], + 'next_step' => '待接入真实微信 / App 登录流程', + 'received' => $data, + ], '渠道登录占位已预留'); + } +} diff --git a/app/Http/Controllers/Api/V1/OrderController.php b/app/Http/Controllers/Api/V1/OrderController.php new file mode 100644 index 0000000..9082a0e --- /dev/null +++ b/app/Http/Controllers/Api/V1/OrderController.php @@ -0,0 +1,57 @@ +latest()->get(); + return ApiResponse::success($orders, '订单列表获取成功'); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'merchant_id' => ['required', 'integer'], + 'user_id' => ['nullable', 'integer'], + 'buyer_name' => ['nullable', 'string'], + 'buyer_phone' => ['nullable', 'string'], + 'buyer_email' => ['nullable', 'email'], + 'platform' => ['nullable', 'string'], + 'payment_channel' => ['nullable', 'string'], + 'product_amount' => ['required', 'numeric'], + 'discount_amount' => ['nullable', 'numeric'], + 'shipping_amount' => ['nullable', 'numeric'], + 'pay_amount' => ['required', 'numeric'], + 'remark' => ['nullable', 'string'], + ]); + + $order = Order::query()->create([ + 'merchant_id' => $data['merchant_id'], + 'user_id' => $data['user_id'] ?? null, + 'order_no' => 'ORD' . now()->format('YmdHis') . random_int(1000, 9999), + 'status' => 'pending', + 'platform' => $data['platform'] ?? 'h5', + 'payment_channel' => $data['payment_channel'] ?? 'wechat_pay', + 'payment_status' => 'unpaid', + 'device_type' => $data['platform'] ?? 'h5', + 'product_amount' => $data['product_amount'], + 'discount_amount' => $data['discount_amount'] ?? 0, + 'shipping_amount' => $data['shipping_amount'] ?? 0, + 'pay_amount' => $data['pay_amount'], + 'buyer_name' => $data['buyer_name'] ?? null, + 'buyer_phone' => $data['buyer_phone'] ?? null, + 'buyer_email' => $data['buyer_email'] ?? null, + 'remark' => $data['remark'] ?? null, + ]); + + return ApiResponse::success($order, '订单创建成功'); + } +} diff --git a/app/Http/Controllers/Api/V1/ProductController.php b/app/Http/Controllers/Api/V1/ProductController.php new file mode 100644 index 0000000..5dfff59 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ProductController.php @@ -0,0 +1,31 @@ +select(['id', 'merchant_id', 'title', 'slug', 'sku', 'summary', 'price', 'original_price', 'stock', 'status']) + ->latest() + ->get(); + + return ApiResponse::success($products, '商品列表获取成功'); + } + + public function show(int $id): JsonResponse + { + $product = Product::query()->find($id); + if (! $product) { + return ApiResponse::error('商品不存在', 404, null, 404); + } + + return ApiResponse::success($product, '商品详情获取成功'); + } +} diff --git a/app/Http/Controllers/Api/V1/SystemController.php b/app/Http/Controllers/Api/V1/SystemController.php new file mode 100644 index 0000000..2bb7188 --- /dev/null +++ b/app/Http/Controllers/Api/V1/SystemController.php @@ -0,0 +1,34 @@ +json([ + 'ok' => true, + 'message' => 'SaaSShop API is alive', + 'time' => now()->toDateTimeString(), + 'version' => 'v1', + ]); + } + + public function platforms(): JsonResponse + { + return response()->json([ + 'ok' => true, + 'platforms' => [ + ['key' => 'pc', 'name' => 'PC 端', 'status' => 'ready'], + ['key' => 'h5', 'name' => 'H5', 'status' => 'ready'], + ['key' => 'wechat_mp', 'name' => '微信公众号', 'status' => 'reserved'], + ['key' => 'wechat_mini', 'name' => '微信小程序', 'status' => 'reserved'], + ['key' => 'app', 'name' => 'APP', 'status' => 'reserved'], + ], + 'api_prefix' => '/api/v1', + ]); + } +} diff --git a/app/Http/Controllers/Concerns/ResolvesMerchantContext.php b/app/Http/Controllers/Concerns/ResolvesMerchantContext.php new file mode 100644 index 0000000..0de2922 --- /dev/null +++ b/app/Http/Controllers/Concerns/ResolvesMerchantContext.php @@ -0,0 +1,25 @@ +session()->get('admin_merchant_id'); + } + + protected function merchant(Request $request): Merchant + { + return Merchant::query()->findOrFail($this->merchantId($request)); + } + + protected function merchantAdmin(Request $request): Admin + { + return Admin::query()->findOrFail((int) $request->session()->get('admin_id')); + } +} diff --git a/app/Http/Controllers/Concerns/ResolvesPlatformAdminContext.php b/app/Http/Controllers/Concerns/ResolvesPlatformAdminContext.php new file mode 100644 index 0000000..87cfeb8 --- /dev/null +++ b/app/Http/Controllers/Concerns/ResolvesPlatformAdminContext.php @@ -0,0 +1,36 @@ +session()->get('admin_id'); + + if (! $adminId) { + return null; + } + + $admin = Admin::query()->find($adminId); + + return $admin && $admin->isPlatformAdmin() ? $admin : null; + } + + protected function platformAdminId(Request $request): ?int + { + return $this->platformAdmin($request)?->id; + } + + protected function ensurePlatformAdmin(Request $request): Admin + { + $admin = $this->platformAdmin($request); + + abort_unless($admin, 403, '当前账号没有总台管理访问权限'); + + return $admin; + } +} diff --git a/app/Http/Controllers/Concerns/ResolvesSiteContext.php b/app/Http/Controllers/Concerns/ResolvesSiteContext.php new file mode 100644 index 0000000..1f3fa30 --- /dev/null +++ b/app/Http/Controllers/Concerns/ResolvesSiteContext.php @@ -0,0 +1,25 @@ +session()->get('admin_site_id') ?: $request->session()->get('admin_merchant_id')); + } + + protected function site(Request $request): Merchant + { + return Merchant::query()->findOrFail($this->siteId($request)); + } + + protected function siteAdmin(Request $request): Admin + { + return Admin::query()->findOrFail((int) $request->session()->get('admin_id')); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ + Product::query()->latest()->limit(8)->get(), + ]); + } +} diff --git a/app/Http/Controllers/Front/PcController.php b/app/Http/Controllers/Front/PcController.php new file mode 100644 index 0000000..6d40ab6 --- /dev/null +++ b/app/Http/Controllers/Front/PcController.php @@ -0,0 +1,17 @@ + Product::query()->latest()->limit(8)->get(), + ]); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..76c474a --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,27 @@ + Merchant::count(), + 'productCount' => Product::count(), + 'orderCount' => Order::count(), + 'platforms' => [ + ['name' => 'PC 端', 'path' => '/pc', 'status' => 'ready'], + ['name' => 'H5', 'path' => '/h5', 'status' => 'ready'], + ['name' => '微信公众号', 'path' => '/wechat/mp', 'status' => 'reserved'], + ['name' => '微信小程序', 'path' => '/wechat/mini', 'status' => 'reserved'], + ['name' => 'APP 接口层', 'path' => '/api/v1/platforms', 'status' => 'reserved'], + ], + ]); + } +} diff --git a/app/Http/Controllers/MerchantAdmin/AuthController.php b/app/Http/Controllers/MerchantAdmin/AuthController.php new file mode 100644 index 0000000..e6985d2 --- /dev/null +++ b/app/Http/Controllers/MerchantAdmin/AuthController.php @@ -0,0 +1,58 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $admin = Admin::query()->where('email', $data['email'])->first(); + if (! $admin || ! Hash::check($data['password'], $admin->password)) { + return back()->withErrors(['email' => '账号或密码错误'])->withInput(); + } + + if (! $admin->isMerchantAdmin()) { + return back()->withErrors(['email' => '当前账号不是商家管理员,不能登录商家后台'])->withInput(); + } + + $merchantId = $admin->merchantId(); + + $request->session()->put('admin_id', $admin->id); + $request->session()->put('admin_name', $admin->name); + $request->session()->put('admin_email', $admin->email); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_merchant_id', $merchantId); + $request->session()->put('admin_scope', 'merchant'); + $request->session()->put('merchant_name', $admin->merchant?->name); + + $admin->forceFill(['last_login_at' => now()])->save(); + + return redirect('/merchant-admin'); + } + + public function logout(Request $request): RedirectResponse + { + $request->session()->forget(['admin_id', 'admin_name', 'admin_email', 'admin_role', 'admin_merchant_id', 'admin_scope', 'merchant_name']); + return redirect('/merchant-admin/login'); + } +} diff --git a/app/Http/Controllers/MerchantAdmin/DashboardController.php b/app/Http/Controllers/MerchantAdmin/DashboardController.php new file mode 100644 index 0000000..9b07e05 --- /dev/null +++ b/app/Http/Controllers/MerchantAdmin/DashboardController.php @@ -0,0 +1,44 @@ +merchantId($request); + $merchant = $this->merchant($request); + + $stats = Cache::remember( + CacheKeys::merchantDashboardStats($merchantId), + now()->addMinutes(10), + fn () => [ + 'users' => User::query()->forMerchant($merchantId)->count(), + 'products' => Product::query()->forMerchant($merchantId)->count(), + 'orders' => Order::query()->forMerchant($merchantId)->count(), + 'pending_orders' => Order::query()->forMerchant($merchantId)->where('status', 'pending')->count(), + ] + ); + + return view('merchant_admin.dashboard', [ + 'merchant' => $merchant, + 'stats' => $stats, + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + ]); + } +} diff --git a/app/Http/Controllers/MerchantAdmin/OrderController.php b/app/Http/Controllers/MerchantAdmin/OrderController.php new file mode 100644 index 0000000..78f6dd7 --- /dev/null +++ b/app/Http/Controllers/MerchantAdmin/OrderController.php @@ -0,0 +1,864 @@ +merchantId($request); + $page = max((int) $request->integer('page', 1), 1); + + $filters = $this->filters($request); + $statusStatsFilters = $filters; + $statusStatsFilters['status'] = ''; + + if ($filters['has_validation_error'] ?? false) { + return view('merchant_admin.orders.index', [ + 'orders' => Order::query()->whereRaw('1 = 0')->paginate(10)->withQueryString(), + 'statusStats' => $this->emptyStatusStats(), + 'summaryStats' => $this->emptySummaryStats(), + 'trendStats' => $this->emptyTrendStats(), + 'operationsFocus' => $this->buildOperationsFocus($merchantId, $this->emptySummaryStats(), $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'filters' => $filters, + 'filterOptions' => [ + 'statuses' => $this->statuses, + 'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'], + 'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'], + 'deviceTypes' => ['desktop', 'mobile', 'mini-program', 'mobile-webview', 'app-api'], + 'paymentChannels' => ['wechat_pay', 'alipay'], + 'timeRanges' => [ + 'all' => '全部时间', + 'today' => '今天', + 'last_7_days' => '近7天', + ], + 'sortOptions' => [ + 'latest' => '创建时间倒序', + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + 'product_amount_desc' => '商品金额从高到低', + 'product_amount_asc' => '商品金额从低到高', + ], + ], + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'statusLabels' => $this->statusLabels(), + 'paymentStatusLabels' => $this->paymentStatusLabels(), + 'platformLabels' => $this->platformLabels(), + 'deviceTypeLabels' => $this->deviceTypeLabels(), + 'paymentChannelLabels' => $this->paymentChannelLabels(), + ]); + } + + $summaryStats = Cache::remember( + CacheKeys::merchantOrdersSummary($merchantId, $statusStatsFilters), + now()->addMinutes(10), + fn () => $this->buildSummaryStats($this->applyFilters(Order::query()->forMerchant($merchantId), $statusStatsFilters)) + ); + + return view('merchant_admin.orders.index', [ + 'orders' => Cache::remember( + CacheKeys::merchantOrdersList($merchantId, $page, $filters), + now()->addMinutes(10), + fn () => $this->applySorting($this->applyFilters(Order::query()->forMerchant($merchantId), $filters), $filters) + ->paginate(10) + ->withQueryString() + ), + 'statusStats' => Cache::remember( + CacheKeys::merchantOrdersStatusStats($merchantId, $statusStatsFilters), + now()->addMinutes(10), + fn () => $this->buildStatusStats($this->applyFilters(Order::query()->forMerchant($merchantId), $statusStatsFilters)) + ), + 'summaryStats' => $summaryStats, + 'operationsFocus' => $this->buildOperationsFocus($merchantId, $summaryStats, $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'trendStats' => Cache::remember( + CacheKeys::merchantOrdersTrendSummary($merchantId, $statusStatsFilters), + now()->addMinutes(10), + fn () => $this->buildTrendStats($this->applyFilters(Order::query()->forMerchant($merchantId), $statusStatsFilters)) + ), + 'filters' => $filters, + 'filterOptions' => [ + 'statuses' => $this->statuses, + 'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'], + 'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'], + 'deviceTypes' => ['desktop', 'mobile', 'mini-program', 'mobile-webview', 'app-api'], + 'paymentChannels' => ['wechat_pay', 'alipay'], + 'timeRanges' => [ + 'all' => '全部时间', + 'today' => '今天', + 'last_7_days' => '近7天', + ], + 'sortOptions' => [ + 'latest' => '创建时间倒序', + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + 'product_amount_desc' => '商品金额从高到低', + 'product_amount_asc' => '商品金额从低到高', + ], + ], + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'statusLabels' => $this->statusLabels(), + 'paymentStatusLabels' => $this->paymentStatusLabels(), + 'platformLabels' => $this->platformLabels(), + 'deviceTypeLabels' => $this->deviceTypeLabels(), + 'paymentChannelLabels' => $this->paymentChannelLabels(), + ]); + } + + public function show(Request $request, int $id): View + { + $merchantId = $this->merchantId($request); + + return view('merchant_admin.orders.show', [ + 'order' => Order::query()->with(['items.product', 'user'])->forMerchant($merchantId)->findOrFail($id), + ]); + } + + public function export(Request $request): StreamedResponse|RedirectResponse + { + $merchantId = $this->merchantId($request); + $filters = $this->filters($request); + + if ($filters['has_validation_error'] ?? false) { + return redirect('/merchant-admin/orders?' . http_build_query($this->exportableFilters($filters))) + ->withErrors($filters['validation_errors'] ?? ['订单筛选条件不合法,请先修正后再导出。']); + } + + $fileName = 'merchant_' . $merchantId . '_orders_' . now()->format('Ymd_His') . '.csv'; + $exportSummary = $this->buildSummaryStats( + $this->applyFilters(Order::query()->forMerchant($merchantId), $filters) + ); + + return response()->streamDownload(function () use ($merchantId, $filters, $exportSummary) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + foreach ($this->exportSummaryRows($filters, 'merchant', $merchantId) as $summaryRow) { + fputcsv($handle, $summaryRow); + } + + fputcsv($handle, ['导出订单数', $exportSummary['total_orders'] ?? 0]); + fputcsv($handle, ['导出实付总额', number_format((float) ($exportSummary['total_pay_amount'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出平均客单价', number_format((float) ($exportSummary['average_order_amount'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出已支付订单数', $exportSummary['paid_orders'] ?? 0]); + fputcsv($handle, ['导出支付失败订单', $exportSummary['failed_payment_orders'] ?? 0]); + fputcsv($handle, []); + + fputcsv($handle, [ + 'ID', + '用户ID', + '订单号', + '订单状态', + '支付状态', + '平台', + '设备类型', + '支付渠道', + '买家姓名', + '买家手机', + '买家邮箱', + '商品金额', + '优惠金额', + '运费', + '实付金额', + '商品行数', + '商品件数', + '商品摘要', + '创建时间', + '支付时间', + '发货时间', + '完成时间', + '备注', + ]); + + foreach ($this->applySorting($this->applyFilters(Order::query()->with('items')->forMerchant($merchantId), $filters), $filters)->cursor() as $order) { + $itemCount = $order->items->count(); + $totalQuantity = (int) $order->items->sum('quantity'); + $itemSummary = $order->items + ->map(fn ($item) => trim(($item->product_title ?? '商品') . ' x' . ((int) $item->quantity))) + ->implode(' | '); + + fputcsv($handle, [ + $order->id, + $order->user_id, + $order->order_no, + $this->statusLabel((string) $order->status), + $this->paymentStatusLabel((string) $order->payment_status), + $this->platformLabel((string) $order->platform), + $this->deviceTypeLabel((string) $order->device_type), + $this->paymentChannelLabel((string) $order->payment_channel), + $order->buyer_name, + $order->buyer_phone, + $order->buyer_email, + number_format((float) $order->product_amount, 2, '.', ''), + number_format((float) $order->discount_amount, 2, '.', ''), + number_format((float) $order->shipping_amount, 2, '.', ''), + number_format((float) $order->pay_amount, 2, '.', ''), + $itemCount, + $totalQuantity, + $itemSummary, + optional($order->created_at)?->format('Y-m-d H:i:s'), + optional($order->paid_at)?->format('Y-m-d H:i:s'), + optional($order->shipped_at)?->format('Y-m-d H:i:s'), + optional($order->completed_at)?->format('Y-m-d H:i:s'), + $order->remark, + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function updateStatus(Request $request, int $id): RedirectResponse + { + $merchantId = $this->merchantId($request); + + $data = $request->validate([ + 'status' => ['required', 'string'], + ]); + + $order = Order::query()->forMerchant($merchantId)->findOrFail($id); + $order->update(['status' => $data['status']]); + + Cache::add(CacheKeys::merchantOrdersVersion($merchantId), 1, now()->addDays(30)); + Cache::increment(CacheKeys::merchantOrdersVersion($merchantId)); + Cache::forget(CacheKeys::merchantDashboardStats($merchantId)); + + return redirect('/merchant-admin/orders')->with('success', '订单状态更新成功'); + } + + protected function filters(Request $request): array + { + $timeRange = trim((string) $request->string('time_range', 'all')); + $rawStartDate = trim((string) $request->string('start_date')); + $rawEndDate = trim((string) $request->string('end_date')); + $minPayAmount = trim((string) $request->string('min_pay_amount')); + $maxPayAmount = trim((string) $request->string('max_pay_amount')); + $validationErrors = []; + + if ($timeRange === 'today') { + $startDate = now()->toDateString(); + $endDate = now()->toDateString(); + } elseif ($timeRange === 'last_7_days') { + $startDate = now()->subDays(6)->toDateString(); + $endDate = now()->toDateString(); + } else { + $timeRange = 'all'; + $startDate = $rawStartDate; + $endDate = $rawEndDate; + } + + if ($rawStartDate !== '' && ! $this->isValidDate($rawStartDate)) { + $validationErrors[] = '开始日期格式不正确,请使用 YYYY-MM-DD。'; + } + + if ($rawEndDate !== '' && ! $this->isValidDate($rawEndDate)) { + $validationErrors[] = '结束日期格式不正确,请使用 YYYY-MM-DD。'; + } + + if ($rawStartDate !== '' && $rawEndDate !== '' && $this->isValidDate($rawStartDate) && $this->isValidDate($rawEndDate) && $rawStartDate > $rawEndDate) { + $validationErrors[] = '开始日期不能晚于结束日期。'; + } + + if ($minPayAmount !== '' && ! is_numeric($minPayAmount)) { + $validationErrors[] = '最低实付金额必须为数字。'; + } + + if ($maxPayAmount !== '' && ! is_numeric($maxPayAmount)) { + $validationErrors[] = '最高实付金额必须为数字。'; + } + + if ($minPayAmount !== '' && $maxPayAmount !== '' && is_numeric($minPayAmount) && is_numeric($maxPayAmount) && (float) $minPayAmount > (float) $maxPayAmount) { + $validationErrors[] = '最低实付金额不能大于最高实付金额。'; + } + + return [ + 'status' => trim((string) $request->string('status')), + 'payment_status' => trim((string) $request->string('payment_status')), + 'platform' => trim((string) $request->string('platform')), + 'device_type' => trim((string) $request->string('device_type')), + 'payment_channel' => trim((string) $request->string('payment_channel')), + 'keyword' => trim((string) $request->string('keyword')), + 'start_date' => $startDate, + 'end_date' => $endDate, + 'min_pay_amount' => $minPayAmount, + 'max_pay_amount' => $maxPayAmount, + 'time_range' => $timeRange, + 'sort' => trim((string) $request->string('sort', 'latest')), + 'validation_errors' => $validationErrors, + 'has_validation_error' => ! empty($validationErrors), + ]; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + return $query + ->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status'])) + ->when(($filters['payment_status'] ?? '') !== '', fn ($builder) => $builder->where('payment_status', $filters['payment_status'])) + ->when(($filters['platform'] ?? '') !== '', fn ($builder) => $builder->where('platform', $filters['platform'])) + ->when(($filters['device_type'] ?? '') !== '', fn ($builder) => $builder->where('device_type', $filters['device_type'])) + ->when(($filters['payment_channel'] ?? '') !== '', fn ($builder) => $builder->where('payment_channel', $filters['payment_channel'])) + ->when(($filters['keyword'] ?? '') !== '', fn ($builder) => $builder->where(function ($subQuery) use ($filters) { + $subQuery->where('order_no', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_name', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_phone', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_email', 'like', '%' . $filters['keyword'] . '%'); + })) + ->when(($filters['start_date'] ?? '') !== '', fn ($builder) => $builder->whereDate('created_at', '>=', $filters['start_date'])) + ->when(($filters['end_date'] ?? '') !== '', fn ($builder) => $builder->whereDate('created_at', '<=', $filters['end_date'])) + ->when(($filters['min_pay_amount'] ?? '') !== '' && is_numeric($filters['min_pay_amount']), fn ($builder) => $builder->where('pay_amount', '>=', $filters['min_pay_amount'])) + ->when(($filters['max_pay_amount'] ?? '') !== '' && is_numeric($filters['max_pay_amount']), fn ($builder) => $builder->where('pay_amount', '<=', $filters['max_pay_amount'])); + } + + protected function buildStatusStats(Builder $query): array + { + $counts = (clone $query) + ->selectRaw('status, COUNT(*) as aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + + $stats = ['all' => (int) $counts->sum()]; + + foreach ($this->statuses as $status) { + $stats[$status] = (int) ($counts[$status] ?? 0); + } + + return $stats; + } + + protected function applySorting(Builder $query, array $filters): Builder + { + return match ($filters['sort'] ?? 'latest') { + 'oldest' => $query->orderBy('created_at')->orderBy('id'), + 'pay_amount_desc' => $query->orderByDesc('pay_amount')->orderByDesc('id'), + 'pay_amount_asc' => $query->orderBy('pay_amount')->orderByDesc('id'), + 'product_amount_desc' => $query->orderByDesc('product_amount')->orderByDesc('id'), + 'product_amount_asc' => $query->orderBy('product_amount')->orderByDesc('id'), + default => $query->latest(), + }; + } + + protected function buildSummaryStats(Builder $query): array + { + $summary = (clone $query) + ->selectRaw('COUNT(*) as total_orders') + ->selectRaw('COALESCE(SUM(pay_amount), 0) as total_pay_amount') + ->selectRaw("SUM(CASE WHEN payment_status = 'unpaid' THEN pay_amount ELSE 0 END) as unpaid_pay_amount") + ->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN pay_amount ELSE 0 END) as paid_pay_amount") + ->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN 1 ELSE 0 END) as paid_orders") + ->selectRaw("SUM(CASE WHEN payment_status = 'refunded' THEN 1 ELSE 0 END) as refunded_orders") + ->selectRaw("SUM(CASE WHEN payment_status = 'failed' THEN 1 ELSE 0 END) as failed_payment_orders") + ->selectRaw("SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as pending_shipment_orders") + ->selectRaw("SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_orders") + ->selectRaw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_orders") + ->first(); + + $totalOrders = (int) ($summary->total_orders ?? 0); + $totalPayAmount = (float) ($summary->total_pay_amount ?? 0); + $paidOrders = (int) ($summary->paid_orders ?? 0); + $refundedOrders = (int) ($summary->refunded_orders ?? 0); + $completedOrders = (int) ($summary->completed_orders ?? 0); + $cancelledOrders = (int) ($summary->cancelled_orders ?? 0); + + return [ + 'total_orders' => $totalOrders, + 'total_pay_amount' => $totalPayAmount, + 'unpaid_pay_amount' => (float) ($summary->unpaid_pay_amount ?? 0), + 'paid_pay_amount' => (float) ($summary->paid_pay_amount ?? 0), + 'paid_orders' => $paidOrders, + 'refunded_orders' => $refundedOrders, + 'failed_payment_orders' => (int) ($summary->failed_payment_orders ?? 0), + 'pending_shipment_orders' => (int) ($summary->pending_shipment_orders ?? 0), + 'completed_orders' => $completedOrders, + 'cancelled_orders' => $cancelledOrders, + 'average_order_amount' => $totalOrders > 0 ? round($totalPayAmount / $totalOrders, 2) : 0, + 'payment_rate' => $totalOrders > 0 ? round(($paidOrders / $totalOrders) * 100, 2) : 0, + 'refund_rate' => $paidOrders > 0 ? round(($refundedOrders / $paidOrders) * 100, 2) : 0, + 'completion_rate' => $totalOrders > 0 ? round(($completedOrders / $totalOrders) * 100, 2) : 0, + 'cancellation_rate' => $totalOrders > 0 ? round(($cancelledOrders / $totalOrders) * 100, 2) : 0, + ]; + } + + protected function buildTrendStats(Builder $query): array + { + $todayStart = Carbon::today(); + $tomorrowStart = (clone $todayStart)->copy()->addDay(); + $last7DaysStart = (clone $todayStart)->copy()->subDays(6)->startOfDay(); + + $today = (clone $query) + ->where('created_at', '>=', $todayStart) + ->where('created_at', '<', $tomorrowStart) + ->selectRaw('COUNT(*) as total_orders') + ->selectRaw('COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN pay_amount ELSE 0 END), 0) as total_pay_amount') + ->first(); + + $last7Days = (clone $query) + ->where('created_at', '>=', $last7DaysStart) + ->where('created_at', '<', $tomorrowStart) + ->selectRaw('COUNT(*) as total_orders') + ->selectRaw('COALESCE(SUM(CASE WHEN payment_status = \'paid\' THEN pay_amount ELSE 0 END), 0) as total_pay_amount') + ->first(); + + return [ + 'today_orders' => (int) ($today->total_orders ?? 0), + 'today_pay_amount' => (float) ($today->total_pay_amount ?? 0), + 'last_7_days_orders' => (int) ($last7Days->total_orders ?? 0), + 'last_7_days_pay_amount' => (float) ($last7Days->total_pay_amount ?? 0), + ]; + } + + protected function emptyStatusStats(): array + { + $stats = ['all' => 0]; + + foreach ($this->statuses as $status) { + $stats[$status] = 0; + } + + return $stats; + } + + protected function emptySummaryStats(): array + { + return [ + 'total_orders' => 0, + 'total_pay_amount' => 0, + 'unpaid_pay_amount' => 0, + 'paid_pay_amount' => 0, + 'paid_orders' => 0, + 'refunded_orders' => 0, + 'failed_payment_orders' => 0, + 'pending_shipment_orders' => 0, + 'completed_orders' => 0, + 'cancelled_orders' => 0, + 'average_order_amount' => 0, + 'payment_rate' => 0, + 'refund_rate' => 0, + 'completion_rate' => 0, + 'cancellation_rate' => 0, + ]; + } + + protected function emptyTrendStats(): array + { + return [ + 'today_orders' => 0, + 'today_pay_amount' => 0, + 'last_7_days_orders' => 0, + 'last_7_days_pay_amount' => 0, + ]; + } + + protected function isValidDate(string $value): bool + { + try { + $date = Carbon::createFromFormat('Y-m-d', $value); + } catch (\Throwable $exception) { + return false; + } + + return $date && $date->format('Y-m-d') === $value; + } + + protected function exportableFilters(array $filters): array + { + return array_filter([ + 'status' => $filters['status'] ?? '', + 'payment_status' => $filters['payment_status'] ?? '', + 'platform' => $filters['platform'] ?? '', + 'device_type' => $filters['device_type'] ?? '', + 'payment_channel' => $filters['payment_channel'] ?? '', + 'keyword' => $filters['keyword'] ?? '', + 'start_date' => $filters['start_date'] ?? '', + 'end_date' => $filters['end_date'] ?? '', + 'min_pay_amount' => $filters['min_pay_amount'] ?? '', + 'max_pay_amount' => $filters['max_pay_amount'] ?? '', + 'time_range' => $filters['time_range'] ?? '', + 'sort' => $filters['sort'] ?? '', + ], fn ($value) => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest'); + } + + protected function exportSummaryRows(array $filters, string $scope, ?int $merchantId = null): array + { + return [ + ['导出信息', $scope === 'platform' ? '平台订单导出' : '商家订单导出'], + ['导出时间', now()->format('Y-m-d H:i:s')], + ['商家ID', $merchantId ? (string) $merchantId : '全部商家'], + ['订单状态', $this->statusLabel($filters['status'] ?? '')], + ['支付状态', $this->paymentStatusLabel($filters['payment_status'] ?? '')], + ['平台', $this->platformLabel($filters['platform'] ?? '')], + ['设备类型', $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels())], + ['支付渠道', $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels())], + ['关键词', $this->displayTextValue($filters['keyword'] ?? '')], + ['快捷时间范围', $this->displayFilterValue($filters['time_range'] ?? 'all', [ + 'all' => '全部时间', + 'today' => '今天', + 'last_7_days' => '近7天', + ])], + ['开始日期', $this->displayTextValue($filters['start_date'] ?? '')], + ['结束日期', $this->displayTextValue($filters['end_date'] ?? '')], + ['最低实付金额', $this->displayMoneyValue($filters['min_pay_amount'] ?? '')], + ['最高实付金额', $this->displayMoneyValue($filters['max_pay_amount'] ?? '')], + ['排序', $this->sortLabel($filters['sort'] ?? 'latest')], + ]; + } + + protected function buildActiveFilterSummary(array $filters): array + { + return [ + '关键词' => $this->displayTextValue($filters['keyword'] ?? '', '全部'), + '订单状态' => $this->statusLabel($filters['status'] ?? ''), + '支付状态' => $this->paymentStatusLabel($filters['payment_status'] ?? ''), + '平台' => $this->platformLabel($filters['platform'] ?? ''), + '设备类型' => $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels()), + '支付渠道' => $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels()), + '实付金额区间' => $this->formatMoneyRange($filters['min_pay_amount'] ?? '', $filters['max_pay_amount'] ?? ''), + '排序' => $this->sortLabel($filters['sort'] ?? 'latest'), + ]; + } + + protected function statusLabels(): array + { + return [ + 'pending' => '待处理', + 'paid' => '已支付', + 'shipped' => '已发货', + 'completed' => '已完成', + 'cancelled' => '已取消', + ]; + } + + protected function statusLabel(string $status): string + { + return $this->statusLabels()[$status] ?? '全部'; + } + + protected function paymentStatusLabels(): array + { + return [ + 'unpaid' => '未支付', + 'paid' => '已支付', + 'refunded' => '已退款', + 'failed' => '支付失败', + ]; + } + + protected function paymentStatusLabel(string $status): string + { + return $this->paymentStatusLabels()[$status] ?? '全部'; + } + + protected function platformLabels(): array + { + return [ + 'pc' => 'PC 端', + 'h5' => 'H5', + 'wechat_mp' => '微信公众号', + 'wechat_mini' => '微信小程序', + 'app' => 'APP 接口预留', + ]; + } + + protected function platformLabel(string $platform): string + { + return $this->platformLabels()[$platform] ?? '全部'; + } + + protected function deviceTypeLabels(): array + { + return [ + 'desktop' => '桌面浏览器', + 'mobile' => '移动浏览器', + 'mini-program' => '小程序环境', + 'mobile-webview' => '微信内网页', + 'app-api' => 'APP 接口', + ]; + } + + protected function deviceTypeLabel(string $deviceType): string + { + return $this->deviceTypeLabels()[$deviceType] ?? ($deviceType === '' ? '未设置' : $deviceType); + } + + protected function paymentChannelLabels(): array + { + return [ + 'wechat_pay' => '微信支付', + 'alipay' => '支付宝', + ]; + } + + protected function paymentChannelLabel(string $paymentChannel): string + { + return $this->paymentChannelLabels()[$paymentChannel] ?? ($paymentChannel === '' ? '未设置' : $paymentChannel); + } + + protected function sortLabel(string $sort): string + { + return match ($sort) { + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + 'product_amount_desc' => '商品金额从高到低', + 'product_amount_asc' => '商品金额从低到高', + default => '创建时间倒序', + }; + } + + protected function formatMoneyRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' && is_numeric($min) ? ('¥' . number_format((float) $min, 2, '.', '')) : '不限'; + $maxLabel = $max !== '' && is_numeric($max) ? ('¥' . number_format((float) $max, 2, '.', '')) : '不限'; + + return $minLabel . ' ~ ' . $maxLabel; + } + + protected function displayFilterValue(string $value, array $options): string + { + if ($value === '') { + return '全部'; + } + + return (string) ($options[$value] ?? $value); + } + + protected function displayTextValue(string $value, string $default = '未设置'): string + { + return $value === '' ? $default : $value; + } + + protected function displayMoneyValue(string $value): string + { + if ($value === '') { + return '全部'; + } + + return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value; + } + + protected function workbenchLinks(): array + { + return [ + 'paid_high_amount' => '/merchant-admin/orders?sort=pay_amount_desc&payment_status=paid', + 'pending_latest' => '/merchant-admin/orders?sort=latest&payment_status=unpaid', + 'failed_latest' => '/merchant-admin/orders?sort=latest&payment_status=failed', + 'completed_latest' => '/merchant-admin/orders?sort=latest&status=completed', + 'current' => '/merchant-admin/orders', + ]; + } + + protected function buildOperationsFocus(int $merchantId, array $summaryStats, array $filters): array + { + $pendingCount = (int) Order::query()->forMerchant($merchantId)->where('payment_status', 'unpaid')->count(); + $failedCount = (int) Order::query()->forMerchant($merchantId)->where('payment_status', 'failed')->count(); + $completedCount = (int) Order::query()->forMerchant($merchantId)->where('status', 'completed')->count(); + $links = $this->workbenchLinks(); + $currentQuery = http_build_query(array_filter($this->exportableFilters($filters), fn ($value) => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest')); + $currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : ''); + $workbench = [ + '高金额已支付' => $links['paid_high_amount'], + '待支付跟进' => $links['pending_latest'], + '支付失败排查' => $links['failed_latest'], + '最近完成订单' => $links['completed_latest'], + '返回当前筛选视图' => $currentUrl, + ]; + $signals = [ + '待支付订单' => $pendingCount, + '支付失败订单' => $failedCount, + '已完成订单' => $completedCount, + ]; + + if (($filters['platform'] ?? '') === 'wechat_mini') { + return [ + 'headline' => '当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。', + 'actions' => [ + ['label' => '继续查看微信小程序订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_channel'] ?? '') === 'wechat_pay') { + return [ + 'headline' => '当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。', + 'actions' => [ + ['label' => '继续查看微信支付订单', 'url' => $currentUrl], + ['label' => '去看支付失败订单', 'url' => $links['failed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mini-program') { + return [ + 'headline' => '当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。', + 'actions' => [ + ['label' => '继续查看小程序环境订单', 'url' => $currentUrl], + ['label' => '去看微信支付订单', 'url' => $links['failed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mobile-webview') { + return [ + 'headline' => '当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。', + 'actions' => [ + ['label' => '继续查看微信内网页订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mobile') { + return [ + 'headline' => '当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。', + 'actions' => [ + ['label' => '继续查看移动浏览器订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'desktop') { + return [ + 'headline' => '当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。', + 'actions' => [ + ['label' => '继续查看桌面浏览器订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'failed') { + return [ + 'headline' => '当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。', + 'actions' => [ + ['label' => '继续查看支付失败订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'unpaid') { + return [ + 'headline' => '当前筛选已聚焦待支付订单,建议优先跟进下单未支付用户,并观察支付转化时效。', + 'actions' => [ + ['label' => '继续查看待支付订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'paid') { + return [ + 'headline' => '当前正在查看已支付订单,建议优先关注高金额订单履约进度与异常售后风险。', + 'actions' => [ + ['label' => '继续查看已支付订单', 'url' => $currentUrl], + ['label' => '去看最近完成订单', 'url' => $links['completed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['status'] ?? '') === 'completed') { + return [ + 'headline' => '当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。', + 'actions' => [ + ['label' => '继续查看已完成订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($summaryStats['total_orders'] ?? 0) <= 0) { + return [ + 'headline' => '当前商家暂无订单,建议先确认交易链路、支付链路与回写链路是否都已打通。', + 'actions' => [ + ['label' => '先看订单整体情况', 'url' => $links['paid_high_amount']], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($summaryStats['total_orders'] ?? 0) < 5) { + return [ + 'headline' => '当前商家已有少量订单沉淀,建议优先关注待支付订单,并同步查看已支付订单质量。', + 'actions' => [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + return [ + 'headline' => $failedCount > 0 + ? '当前商家订单已形成基础规模,建议优先关注待支付、支付失败与高金额已支付订单。' + : '当前商家订单已形成基础规模,建议优先关注待支付与高金额已支付订单,保持交易闭环稳定。', + 'actions' => $failedCount > 0 + ? [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看支付失败订单', 'url' => $links['failed_latest']], + ] + : [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } +} diff --git a/app/Http/Controllers/MerchantAdmin/ProductCategoryController.php b/app/Http/Controllers/MerchantAdmin/ProductCategoryController.php new file mode 100644 index 0000000..dcf698b --- /dev/null +++ b/app/Http/Controllers/MerchantAdmin/ProductCategoryController.php @@ -0,0 +1,123 @@ +merchantId($request); + + $page = max((int) $request->integer('page', 1), 1); + + return view('merchant_admin.product_categories.index', [ + 'categories' => Cache::remember( + CacheKeys::merchantCategoriesList($merchantId, $page), + now()->addMinutes(10), + fn () => ProductCategory::query()->forMerchant($merchantId)->orderBy('sort')->orderBy('id')->paginate(10)->withQueryString() + ), + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $merchantId = $this->merchantId($request); + + $data = $request->validate([ + 'name' => ['required', 'string'], + 'slug' => [ + 'required', + 'string', + Rule::unique('product_categories', 'slug')->where(fn ($query) => $query->where('merchant_id', $merchantId)), + ], + 'status' => ['nullable', 'string'], + 'sort' => ['nullable', 'integer'], + 'description' => ['nullable', 'string'], + ], [ + 'slug.unique' => '当前商家下该分类 slug 已存在,请换一个。', + ]); + + ProductCategory::query()->create([ + 'merchant_id' => $merchantId, + 'name' => $data['name'], + 'slug' => $data['slug'], + 'status' => $data['status'] ?? 'active', + 'sort' => $data['sort'] ?? 0, + 'description' => $data['description'] ?? null, + ]); + + $this->flushMerchantCaches($merchantId); + + return redirect('/merchant-admin/product-categories')->with('success', '商品分类创建成功'); + } + + public function update(Request $request, int $id): RedirectResponse + { + $merchantId = $this->merchantId($request); + + $category = ProductCategory::query()->forMerchant($merchantId)->findOrFail($id); + + $data = $request->validate([ + 'name' => ['required', 'string'], + 'slug' => [ + 'nullable', + 'string', + Rule::unique('product_categories', 'slug')->where(fn ($query) => $query->where('merchant_id', $merchantId))->ignore($category->id), + ], + 'status' => ['required', 'string'], + 'sort' => ['nullable', 'integer'], + 'description' => ['nullable', 'string'], + ], [ + 'slug.unique' => '当前商家下该分类 slug 已存在,请换一个。', + ]); + $category->update([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? $category->slug, + 'status' => $data['status'], + 'sort' => $data['sort'] ?? 0, + 'description' => $data['description'] ?? null, + ]); + + $this->flushMerchantCaches($merchantId); + + return redirect('/merchant-admin/product-categories')->with('success', '商品分类更新成功'); + } + + public function destroy(Request $request, int $id): RedirectResponse + { + $merchantId = $this->merchantId($request); + + $category = ProductCategory::query()->forMerchant($merchantId)->findOrFail($id); + $category->delete(); + + $this->flushMerchantCaches($merchantId); + + return redirect('/merchant-admin/product-categories')->with('success', '商品分类删除成功'); + } + + protected function flushMerchantCaches(int $merchantId): void + { + for ($page = 1; $page <= 5; $page++) { + Cache::forget(CacheKeys::merchantProductsList($merchantId, $page)); + Cache::forget(CacheKeys::merchantCategoriesList($merchantId, $page)); + } + + Cache::forget(CacheKeys::merchantDashboardStats($merchantId)); + } +} diff --git a/app/Http/Controllers/MerchantAdmin/ProductController.php b/app/Http/Controllers/MerchantAdmin/ProductController.php new file mode 100644 index 0000000..c1cf150 --- /dev/null +++ b/app/Http/Controllers/MerchantAdmin/ProductController.php @@ -0,0 +1,1286 @@ +merchant($request); + + $filters = $this->filters($request); + $page = max((int) $request->integer('page', 1), 1); + + $summaryStats = Cache::remember( + CacheKeys::merchantProductsSummary($merchant->id, $filters), + now()->addMinutes(10), + fn () => $this->buildSummaryStats($this->applyFilters(Product::query()->forMerchant($merchant->id), $filters)) + ); + + $importHistoryData = $this->buildImportHistorySummaryData($merchant->id); + + return view('merchant_admin.products.index', [ + 'merchant' => $merchant, + 'products' => Cache::remember( + CacheKeys::merchantProductsList($merchant->id, $page, $filters), + now()->addMinutes(10), + fn () => $this->applySorting($this->applyFilters(Product::query()->forMerchant($merchant->id)->with('category'), $filters), $filters) + ->paginate(10) + ->withQueryString() + ), + 'statusStats' => Cache::remember( + CacheKeys::merchantProductsStatusStats($merchant->id, array_merge($filters, ['status' => ''])), + now()->addMinutes(10), + fn () => $this->buildStatusStats($this->applyFilters(Product::query()->forMerchant($merchant->id), array_merge($filters, ['status' => '']))) + ), + 'summaryStats' => $summaryStats, + 'operationsFocus' => $this->buildOperationsFocus($summaryStats, $filters, $merchant->id), + 'workbenchLinks' => $this->workbenchLinks($merchant->id), + 'filters' => $filters, + 'filterOptions' => [ + 'statuses' => $this->statusOptions, + 'sortOptions' => [ + 'latest' => '最新创建', + 'price_asc' => '价格从低到高', + 'price_desc' => '价格从高到低', + 'stock_asc' => '库存从低到高', + 'stock_desc' => '库存从高到低', + ], + ], + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'statusLabels' => $this->statusLabels(), + 'categories' => ProductCategory::query()->where('merchant_id', $merchant->id)->orderBy('sort')->orderBy('id')->get(), + 'pageMeta' => [ + 'current' => $page, + 'perPage' => 10, + ], + 'importHistoryStats' => $importHistoryData['stats'], + 'importHistories' => $importHistoryData['histories'], + ]); + } + + public function store(Request $request): RedirectResponse + { + $merchantId = $this->merchantId($request); + + $data = $request->validate([ + 'category_id' => ['nullable', 'integer'], + 'title' => ['required', 'string', 'max:200'], + 'slug' => ['required', 'string', 'max:200'], + 'sku' => ['nullable', 'string', 'max:120'], + 'summary' => ['nullable', 'string', 'max:500'], + 'price' => ['required', 'numeric', 'min:0'], + 'stock' => ['required', 'integer', 'min:0'], + ]); + + $categoryIdRaw = $data['category_id'] ?? null; + $categoryId = ($categoryIdRaw === null || $categoryIdRaw === '') ? null : (int) $categoryIdRaw; + + if ($categoryId !== null) { + $category = ProductCategory::query()->where('merchant_id', $merchantId)->whereKey($categoryId)->first(); + if (! $category) { + return redirect('/merchant-admin/products')->withErrors(['category_id' => '所选分类不存在或不属于当前商家。']); + } + } + + Product::query()->create([ + 'merchant_id' => $merchantId, + 'category_id' => $categoryId, + 'title' => $data['title'], + 'slug' => $data['slug'], + 'sku' => $data['sku'] ?? '', + 'summary' => $data['summary'] ?? '', + 'content' => '', + 'price' => (float) $data['price'], + 'original_price' => (float) $data['price'], + 'stock' => (int) $data['stock'], + 'status' => 'draft', + 'images' => [], + ]); + + $this->flushMerchantCaches($merchantId); + + return redirect('/merchant-admin/products')->with('success', '商品创建成功'); + } + + public function update(Request $request, int $id): RedirectResponse + { + $merchantId = $this->merchantId($request); + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:200'], + 'slug' => ['required', 'string', 'max:200'], + 'sku' => ['nullable', 'string', 'max:120'], + 'summary' => ['nullable', 'string', 'max:500'], + 'price' => ['required', 'numeric', 'min:0'], + 'stock' => ['required', 'integer', 'min:0'], + 'status' => ['required', 'string', Rule::in($this->statusOptions)], + 'category_id' => ['nullable', 'integer'], + ]); + + $product = Product::query()->forMerchant($merchantId)->findOrFail($id); + + $categoryIdRaw = $data['category_id'] ?? null; + $categoryId = ($categoryIdRaw === null || $categoryIdRaw === '') ? null : (int) $categoryIdRaw; + + if ($categoryId !== null) { + $category = ProductCategory::query()->where('merchant_id', $merchantId)->whereKey($categoryId)->first(); + if (! $category) { + return redirect('/merchant-admin/products')->withErrors(['category_id' => '所选分类不存在或不属于当前商家。']); + } + } + + $product->update([ + 'title' => $data['title'], + 'slug' => $data['slug'], + 'sku' => $data['sku'] ?? '', + 'summary' => $data['summary'] ?? '', + 'price' => (float) $data['price'], + 'stock' => (int) $data['stock'], + 'status' => $data['status'], + 'category_id' => $categoryId, + ]); + + $this->flushMerchantCaches($merchantId); + + return redirect('/merchant-admin/products')->with('success', '商品更新成功'); + } + + public function destroy(Request $request, int $id): RedirectResponse + { + $merchantId = $this->merchantId($request); + + Product::query()->forMerchant($merchantId)->whereKey($id)->delete(); + + $this->flushMerchantCaches($merchantId); + + return redirect('/merchant-admin/products')->with('success', '商品已删除'); + } + + public function export(Request $request): StreamedResponse + { + $merchantId = $this->merchantId($request); + + $filters = $this->filters($request); + $fileName = 'merchant_' . $merchantId . '_products_' . now()->format('Ymd_His') . '.csv'; + + $exportSummary = $this->buildSummaryStats( + $this->applyFilters(Product::query()->forMerchant($merchantId), $filters) + ); + + return response()->streamDownload(function () use ($merchantId, $filters, $exportSummary) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, ['导出信息', '商家商品导出']); + fputcsv($handle, ['导出时间', now()->format('Y-m-d H:i:s')]); + fputcsv($handle, ['状态', $this->statusLabel($filters['status'] ?? '')]); + fputcsv($handle, ['分类', $this->categoryLabel((string) ($filters['category_id'] ?? ''), $merchantId)]); + fputcsv($handle, ['关键词', $this->displayTextValue((string) ($filters['keyword'] ?? ''))]); + fputcsv($handle, ['最低价格', $this->displayMoneyValue((string) ($filters['min_price'] ?? ''))]); + fputcsv($handle, ['最高价格', $this->displayMoneyValue((string) ($filters['max_price'] ?? ''))]); + fputcsv($handle, ['最低库存', $this->displayStockValue((string) ($filters['min_stock'] ?? ''))]); + fputcsv($handle, ['最高库存', $this->displayStockValue((string) ($filters['max_stock'] ?? ''))]); + fputcsv($handle, ['排序', $this->sortLabel((string) ($filters['sort'] ?? 'latest'))]); + fputcsv($handle, ['导出商品数', $exportSummary['total_products'] ?? 0]); + fputcsv($handle, ['导出总库存', $exportSummary['total_stock'] ?? 0]); + fputcsv($handle, ['导出总货值', number_format((float) ($exportSummary['total_stock_value'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出平均售价', number_format((float) ($exportSummary['average_price'] ?? 0), 2, '.', '')]); + fputcsv($handle, []); + + fputcsv($handle, [ + 'ID', + '分类ID', + '分类名称', + '分类标识', + '商品标题', + '商品Slug', + 'SKU', + '售价', + '划线价', + '库存', + '状态', + '商品简介', + '创建时间', + '更新时间', + ]); + + foreach ($this->applySorting($this->applyFilters(Product::query()->forMerchant($merchantId)->with('category'), $filters), $filters)->cursor() as $product) { + fputcsv($handle, [ + $product->id, + $product->category_id, + $product->category?->name ?? '', + $product->category?->slug ?? '', + $product->title, + $product->slug, + $product->sku, + number_format((float) $product->price, 2, '.', ''), + number_format((float) $product->original_price, 2, '.', ''), + $product->stock, + $this->statusLabel((string) $product->status), + $product->summary, + optional($product->created_at)?->format('Y-m-d H:i:s'), + optional($product->updated_at)?->format('Y-m-d H:i:s'), + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function downloadImportTemplate(Request $request): StreamedResponse + { + $merchantId = $this->merchantId($request); + + $fileName = 'merchant_' . $merchantId . '_product_import_template.csv'; + $header = $this->importTemplateHeader(); + + return response()->streamDownload(function () use ($header) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + fputcsv($handle, $header); + fputcsv($handle, [ + '演示商品', + 'demo-product', + 'SKU-DEMO-001', + '199.00', + '299.00', + 100, + 'default', + '演示商品简介', + 'published', + ]); + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function import(Request $request): RedirectResponse + { + $merchantId = $this->merchantId($request); + + $file = $request->file('import_file') ?: $request->file('csv_file'); + + if (! $file) { + return redirect('/merchant-admin/products')->withErrors(['import_file' => '请上传 CSV 文件。']); + } + + if (! $file instanceof \Illuminate\Http\UploadedFile || $file->getError() !== UPLOAD_ERR_OK) { + return redirect('/merchant-admin/products')->withErrors(['import_file' => '上传失败,请重试。']); + } + + $handle = fopen($file->getRealPath(), 'r'); + + $headerRead = false; + $expectedHeader = $this->importTemplateHeader(); + $importedCount = 0; + $failedCount = 0; + $failures = []; + + while (($row = fgetcsv($handle)) !== false) { + if (! $headerRead) { + $headerRead = true; + + if ($row !== $expectedHeader) { + fclose($handle); + + return redirect('/merchant-admin/products')->withErrors(['import_file' => '上传的 CSV 头部与模板不匹配,请重新下载模板。']); + } + + continue; + } + + $data = $this->parseCsvRow($row, $expectedHeader); + + try { + $this->importProductRow([ + 'title' => $data['title'] ?? '', + 'slug' => $data['slug'] ?? '', + 'sku' => $data['sku'] ?? '', + 'price' => $data['price'] ?? 0, + 'original_price' => $data['original_price'] ?? 0, + 'stock' => $data['stock'] ?? 0, + 'category_slug' => $data['category_slug'] ?? '', + 'summary' => $data['summary'] ?? '', + 'status' => $data['status'] ?? 'published', + ], $merchantId, count($failures) + 2); + + $importedCount++; + } catch (\InvalidArgumentException $exception) { + $failedCount++; + $failures[] = $this->buildImportFailureRow(count($failures) + 2, $data, $exception->getMessage()); + } + } + + fclose($handle); + + if ($importedCount > 0) { + $this->flushMerchantCaches($merchantId); + } + + $failureFile = $failedCount > 0 + ? $this->storeImportFailuresCsv('merchant_' . $merchantId, $expectedHeader, $failures) + : null; + + ProductImportHistory::query()->create([ + 'scope' => 'merchant', + 'merchant_id' => $merchantId, + 'admin_id' => $this->merchantAdmin($request)->id, + 'file_name' => $file->getClientOriginalName() ?: $file->getFilename(), + 'success_count' => $importedCount, + 'failed_count' => $failedCount, + 'failure_file' => $failureFile, + 'imported_at' => now(), + ]); + + $result = [ + 'success' => $importedCount, + 'failed' => $failedCount, + 'failure_file' => $failureFile, + 'messages' => array_slice(array_map(fn (array $failure) => $failure['error'], $failures), 0, 10), + ]; + + return redirect('/merchant-admin/products') + ->with('import_result', $result) + ->with( + $failedCount > 0 ? 'warning' : 'success', + $failedCount > 0 + ? "商品批量导入完成,成功 {$importedCount} 条,失败 {$failedCount} 条。" + : "商品批量导入完成,本次成功导入 {$importedCount} 条。" + ); + } + + public function downloadImportFailures(Request $request, string $file): StreamedResponse + { + $merchantId = $this->merchantId($request); + + // 商家端限制:只能下载当前商家的 failure 文件(通过 history 表校验) + $safeName = basename($file); + $history = ProductImportHistory::query() + ->where('scope', 'merchant') + ->where('merchant_id', $merchantId) + ->where('failure_file', $safeName) + ->first(); + + abort_unless($history, 404); + + $path = 'private/imports/product-failures/' . $safeName; + abort_unless(Storage::disk('local')->exists($path), 404); + + return Storage::disk('local')->download($path, $safeName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function batchUpdate(Request $request): RedirectResponse + { + $merchantId = $this->merchantId($request); + $returnUrl = $this->sanitizeBatchReturnUrl($request->input('return_url'), '/merchant-admin/products'); + + $data = $request->validate([ + 'product_ids' => ['required', 'array', 'min:1'], + 'product_ids.*' => ['integer', 'distinct'], + 'action' => ['required', 'string', Rule::in(['change_status', 'change_category'])], + 'status' => ['nullable', 'string', Rule::in($this->statusOptions)], + 'category_id' => ['nullable', 'integer'], + ], [ + 'product_ids.required' => '请先选择至少一个商品。', + 'product_ids.array' => '批量商品参数格式不正确。', + 'product_ids.min' => '请先选择至少一个商品。', + 'action.required' => '请选择批量操作类型。', + 'action.in' => '暂不支持该批量操作。', + 'status.in' => '批量状态仅支持 draft / published / offline。', + ]); + + $productIds = collect($data['product_ids'] ?? [])->map(fn ($id) => (int) $id)->filter(fn ($id) => $id > 0)->values(); + + if ($productIds->isEmpty()) { + return redirect('/merchant-admin/products')->withErrors(['product_ids' => '请先选择至少一个商品。']); + } + + $products = Product::query()->forMerchant($merchantId)->whereIn('id', $productIds)->get(); + + if ($products->count() !== $productIds->count()) { + return redirect('/merchant-admin/products')->withErrors(['product_ids' => '勾选商品中存在越权或已删除的数据,请刷新后重试。']); + } + + $updatedCount = 0; + + if (($data['action'] ?? '') === 'change_status') { + $status = trim((string) ($data['status'] ?? '')); + + if ($status === '') { + return redirect($returnUrl)->withErrors(['status' => '请选择要批量更新的商品状态。']); + } + + $updatedCount = Product::query() + ->forMerchant($merchantId) + ->whereIn('id', $productIds) + ->where('status', '!=', $status) + ->update([ + 'status' => $status, + 'updated_at' => now(), + ]); + } + + if (($data['action'] ?? '') === 'change_category') { + $categoryIdRaw = $data['category_id'] ?? null; + $categoryId = ($categoryIdRaw === null || $categoryIdRaw === '') ? null : (int) $categoryIdRaw; + + if ($categoryId === null) { + $updatedCount = Product::query() + ->forMerchant($merchantId) + ->whereIn('id', $productIds) + ->whereNotNull('category_id') + ->update([ + 'category_id' => null, + 'updated_at' => now(), + ]); + } else { + $category = ProductCategory::query()->where('merchant_id', $merchantId)->where('id', $categoryId)->first(); + + if (! $category) { + return redirect($returnUrl)->withErrors(['category_id' => '所选分类不存在或不属于当前商家。']); + } + + $updatedCount = Product::query() + ->forMerchant($merchantId) + ->whereIn('id', $productIds) + ->update([ + 'category_id' => $categoryId, + 'updated_at' => now(), + ]); + } + } + + if ($updatedCount > 0) { + $this->flushMerchantCaches($merchantId); + } + + return redirect($returnUrl)->with('success', "批量操作已完成,本次更新 {$updatedCount} 条商品。"); + } + + public function importHistories(Request $request): View + { + $merchantId = $this->merchantId($request); + + $filters = $this->importHistoryFilters($request); + + $query = ProductImportHistory::query() + ->where('scope', 'merchant') + ->where('merchant_id', $merchantId) + ->with(['admin']); + + if (($filters['result_status'] ?? 'all') === 'success_only') { + $query->where('failed_count', 0); + } elseif (($filters['result_status'] ?? 'all') === 'has_failures') { + $query->where('failed_count', '>', 0); + } + + $start = $filters['start_date'] ?? null; + $end = $filters['end_date'] ?? null; + if ($start) { + $query->whereDate('imported_at', '>=', $start); + } + if ($end) { + $query->whereDate('imported_at', '<=', $end); + } + + $sort = $filters['sort'] ?? 'latest'; + if ($sort === 'oldest') { + $query->orderBy('imported_at')->orderBy('id'); + } else { + $query->orderByDesc('imported_at')->orderByDesc('id'); + } + + $importHistories = $query->paginate(15)->withQueryString(); + $this->hydrateFailureAvailability($importHistories->getCollection()); + + $statsQuery = (clone $query)->getQuery(); + $stats = ProductImportHistory::query()->fromSub($statsQuery, 'h') + ->selectRaw('COUNT(*) as total_imports') + ->selectRaw('COALESCE(SUM(success_count), 0) as total_success') + ->selectRaw('COALESCE(SUM(failed_count), 0) as total_failed') + ->selectRaw('SUM(CASE WHEN failed_count > 0 THEN 1 ELSE 0 END) as warning_imports') + ->first(); + + return view('merchant_admin.products.import_histories', [ + 'importHistories' => $importHistories, + 'importHistoryFilters' => $filters, + 'importHistoryFilterOptions' => [ + 'sorts' => [ + 'latest' => '最新导入', + 'oldest' => '最早导入', + ], + ], + 'importHistoryStats' => [ + 'total_imports' => (int) ($stats->total_imports ?? 0), + 'total_success' => (int) ($stats->total_success ?? 0), + 'total_failed' => (int) ($stats->total_failed ?? 0), + 'warning_imports' => (int) ($stats->warning_imports ?? 0), + ], + ]); + } + + public function exportImportHistories(Request $request): StreamedResponse + { + $merchantId = $this->merchantId($request); + + $filters = $this->importHistoryFilters($request); + + $query = ProductImportHistory::query() + ->where('scope', 'merchant') + ->where('merchant_id', $merchantId) + ->with(['admin']); + + if (($filters['result_status'] ?? 'all') === 'success_only') { + $query->where('failed_count', 0); + } elseif (($filters['result_status'] ?? 'all') === 'has_failures') { + $query->where('failed_count', '>', 0); + } + + $start = $filters['start_date'] ?? null; + $end = $filters['end_date'] ?? null; + if ($start) { + $query->whereDate('imported_at', '>=', $start); + } + if ($end) { + $query->whereDate('imported_at', '<=', $end); + } + + $sort = $filters['sort'] ?? 'latest'; + if ($sort === 'oldest') { + $query->orderBy('imported_at')->orderBy('id'); + } else { + $query->orderByDesc('imported_at')->orderByDesc('id'); + } + + $fileName = 'merchant_' . $merchantId . '_product_import_histories_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($query, $filters) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, ['导出信息', '商家商品导入历史']); + fputcsv($handle, ['导出时间', now()->format('Y-m-d H:i:s')]); + fputcsv($handle, ['导入结果', $filters['result_status'] ?? 'all']); + fputcsv($handle, []); + + fputcsv($handle, ['ID', '导入时间', '上传文件', '成功', '失败', '操作者', 'failure_file']); + + foreach ($query->cursor() as $history) { + fputcsv($handle, [ + $history->id, + optional($history->imported_at)?->format('Y-m-d H:i:s'), + $history->file_name, + $history->success_count, + $history->failed_count, + $history->admin?->name ?? '-', + $history->failure_file ?? '', + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + protected function sanitizeBatchReturnUrl(?string $returnUrl, string $default): string + { + $candidate = trim((string) $returnUrl); + + if ($candidate === '') { + return $default; + } + + return str_starts_with($candidate, '/merchant-admin/products') ? $candidate : $default; + } + + protected function filters(Request $request): array + { + return [ + 'status' => trim((string) $request->string('status')), + 'category_id' => trim((string) $request->string('category_id')), + 'keyword' => trim((string) $request->string('keyword')), + 'min_price' => trim((string) $request->string('min_price')), + 'max_price' => trim((string) $request->string('max_price')), + 'min_stock' => trim((string) $request->string('min_stock')), + 'max_stock' => trim((string) $request->string('max_stock')), + 'sort' => trim((string) $request->string('sort', 'latest')), + ]; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + $categoryId = (int) ($filters['category_id'] ?? 0); + + return $query + ->when(($filters['status'] ?? '') !== '', fn (Builder $builder) => $builder->where('status', $filters['status'])) + ->when($categoryId > 0, fn (Builder $builder) => $builder->where('category_id', $categoryId)) + ->when(($filters['keyword'] ?? '') !== '', function (Builder $builder) use ($filters) { + $keyword = (string) ($filters['keyword'] ?? ''); + + $builder->where(function (Builder $subQuery) use ($keyword) { + $subQuery->where('title', 'like', '%' . $keyword . '%') + ->orWhere('sku', 'like', '%' . $keyword . '%') + ->orWhere('slug', 'like', '%' . $keyword . '%'); + }); + }) + ->when(($filters['min_price'] ?? '') !== '' && is_numeric($filters['min_price']), fn (Builder $builder) => $builder->where('price', '>=', (float) $filters['min_price'])) + ->when(($filters['max_price'] ?? '') !== '' && is_numeric($filters['max_price']), fn (Builder $builder) => $builder->where('price', '<=', (float) $filters['max_price'])) + ->when(($filters['min_stock'] ?? '') !== '' && filter_var($filters['min_stock'], FILTER_VALIDATE_INT) !== false, fn (Builder $builder) => $builder->where('stock', '>=', (int) $filters['min_stock'])) + ->when(($filters['max_stock'] ?? '') !== '' && filter_var($filters['max_stock'], FILTER_VALIDATE_INT) !== false, fn (Builder $builder) => $builder->where('stock', '<=', (int) $filters['max_stock'])); + } + + protected function applySorting(Builder $query, array $filters): Builder + { + return match ($filters['sort'] ?? 'latest') { + 'price_asc' => $query->orderBy('price')->orderByDesc('id'), + 'price_desc' => $query->orderByDesc('price')->orderByDesc('id'), + 'stock_asc' => $query->orderBy('stock')->orderByDesc('id'), + 'stock_desc' => $query->orderByDesc('stock')->orderByDesc('id'), + default => $query->latest(), + }; + } + + protected function buildSummaryStats(Builder $query): array + { + $summary = (clone $query) + ->selectRaw('COUNT(*) as total_products') + ->selectRaw('COALESCE(SUM(stock), 0) as total_stock') + ->selectRaw('COALESCE(SUM(price * stock), 0) as total_stock_value') + ->selectRaw('COALESCE(AVG(price), 0) as average_price') + ->first(); + + return [ + 'total_products' => (int) ($summary->total_products ?? 0), + 'total_stock' => (int) ($summary->total_stock ?? 0), + 'total_stock_value' => (float) ($summary->total_stock_value ?? 0), + 'average_price' => (float) ($summary->average_price ?? 0), + ]; + } + + protected function buildStatusStats(Builder $query): array + { + $counts = (clone $query) + ->selectRaw('status, COUNT(*) as aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + + $stats = ['all' => (int) $counts->sum()]; + + foreach ($this->statusOptions as $status) { + $stats[$status] = (int) ($counts[$status] ?? 0); + } + + return $stats; + } + + protected function buildActiveFilterSummary(array $filters): array + { + return [ + '分类' => $this->categoryLabel((string) ($filters['category_id'] ?? ''), null), + '状态' => $this->statusLabel((string) ($filters['status'] ?? '')), + '关键词' => $this->displayTextValue((string) ($filters['keyword'] ?? '')), + '价格区间' => $this->formatMoneyRange((string) ($filters['min_price'] ?? ''), (string) ($filters['max_price'] ?? '')), + '库存区间' => $this->formatStockRange((string) ($filters['min_stock'] ?? ''), (string) ($filters['max_stock'] ?? '')), + '排序' => $this->sortLabel((string) ($filters['sort'] ?? 'latest')), + ]; + } + + protected function statusLabels(): array + { + return [ + 'draft' => '草稿', + 'published' => '已上架', + 'offline' => '已下架', + ]; + } + + protected function statusLabel(string $status): string + { + if ($status === '') { + return '全部'; + } + + return $this->statusLabels()[$status] ?? $status; + } + + protected function categoryLabel(string $categoryId, ?int $merchantId): string + { + $categoryId = trim($categoryId); + if ($categoryId === '' || ! ctype_digit($categoryId) || (int) $categoryId <= 0) { + return '全部'; + } + + $query = ProductCategory::query()->whereKey((int) $categoryId); + if ($merchantId) { + $query->where('merchant_id', $merchantId); + } + + $category = $query->first(); + + return $category?->name ?? ('分类 #' . $categoryId); + } + + protected function formatMoneyRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' && is_numeric($min) + ? ('¥' . number_format((float) $min, 2, '.', '')) + : '不限'; + $maxLabel = $max !== '' && is_numeric($max) + ? ('¥' . number_format((float) $max, 2, '.', '')) + : '不限'; + + return $minLabel . ' ~ ' . $maxLabel; + } + + protected function formatStockRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' ? $min : '不限'; + $maxLabel = $max !== '' ? $max : '不限'; + + return $minLabel . ' ~ ' . $maxLabel . ' 件'; + } + + protected function sortLabel(string $sort): string + { + return match ($sort) { + 'price_asc' => '价格从低到高', + 'price_desc' => '价格从高到低', + 'stock_asc' => '库存从低到高', + 'stock_desc' => '库存从高到低', + default => '最新创建', + }; + } + + protected function displayTextValue(string $value, string $default = '全部'): string + { + return trim($value) === '' ? $default : $value; + } + + protected function displayMoneyValue(string $value): string + { + $value = trim($value); + if ($value === '') { + return '全部'; + } + + return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value; + } + + protected function displayStockValue(string $value): string + { + $value = trim($value); + return $value === '' ? '全部' : ($value . ' 件'); + } + + protected function workbenchLinks(int $merchantId): array + { + return [ + 'published_stock_desc' => '/merchant-admin/products?sort=stock_desc&status=published', + 'published_stock_asc' => '/merchant-admin/products?sort=stock_asc&status=published', + 'latest' => '/merchant-admin/products?sort=latest', + 'draft' => '/merchant-admin/products?status=draft&sort=latest', + 'current' => '/merchant-admin/products', + ]; + } + + protected function buildOperationsFocus(array $summaryStats, array $filters, int $merchantId): array + { + $publishedCount = (int) Product::query()->forMerchant($merchantId)->where('status', 'published')->count(); + $lowStockCount = (int) Product::query()->forMerchant($merchantId)->where('status', 'published')->where('stock', '<=', 20)->count(); + $categoryCount = (int) ProductCategory::query()->where('merchant_id', $merchantId)->count(); + + $links = $this->workbenchLinks($merchantId); + $currentQuery = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '' && $value !== 'latest')); + $currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : ''); + + $workbench = [ + '高库存已上架' => $links['published_stock_desc'], + '低库存补货' => $links['published_stock_asc'], + '最近新增' => $links['latest'], + '草稿待整理' => $links['draft'], + '返回当前筛选视图' => $currentUrl, + ]; + + $signals = [ + '已上架商品' => $publishedCount, + '低库存商品' => $lowStockCount, + '分类覆盖数' => $categoryCount, + ]; + + $response = function (string $headline, array $actions) use ($workbench, $signals) { + return [ + 'headline' => $headline, + 'actions' => $actions, + 'workbench' => $workbench, + 'signals' => $signals, + ]; + }; + + $isPublished = ($filters['status'] ?? '') === 'published'; + $hasCategoryFilter = ($filters['category_id'] ?? '') !== ''; + $hasKeywordFilter = ($filters['keyword'] ?? '') !== ''; + $hasPriceRangeFilter = (($filters['min_price'] ?? '') !== '') || (($filters['max_price'] ?? '') !== ''); + + $categoryLabel = $hasCategoryFilter ? $this->categoryLabel((string) ($filters['category_id'] ?? ''), $merchantId) : ''; + $keyword = (string) ($filters['keyword'] ?? ''); + $priceRange = $hasPriceRangeFilter ? $this->formatMoneyRange((string) ($filters['min_price'] ?? ''), (string) ($filters['max_price'] ?? '')) : ''; + + $hasPublishedStockFocus = $isPublished + && ((($filters['max_stock'] ?? '') !== '') + || ((($filters['min_stock'] ?? '') !== '') + && is_numeric($filters['min_stock']) + && (int) $filters['min_stock'] <= 20)); + + if (($filters['status'] ?? '') === 'draft') { + return $response( + '当前正在查看草稿商品,建议优先补齐标题、分类、价格与库存后再安排上架。', + [ + ['label' => '继续查看当前草稿', 'url' => $currentUrl], + ['label' => '去看已上架商品', 'url' => $links['published_stock_desc']], + ] + ); + } + + if ($hasPublishedStockFocus) { + return $response( + '当前筛选已聚焦已上架库存视角,建议优先确认低库存补货节奏,并同步观察高库存结构是否健康。', + [ + ['label' => '继续查看当前库存视角', 'url' => $currentUrl], + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ] + ); + } + + if ($isPublished && $hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对在售商品命名、分类承接、价格梯度与搜索结果是否一致,并同步观察库存结构与转化表现是否健康。', + [ + ['label' => '继续查看当前已上架分类关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前已上架分类关键词商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($isPublished && $hasCategoryFilter && $hasKeywordFilter) { + return $response( + '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对在售商品命名、分类承接与搜索结果是否一致,并同步观察价格带与库存结构是否健康。', + [ + ['label' => '继续查看当前已上架分类关键词商品', 'url' => $currentUrl], + ['label' => '去看当前已上架分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['keyword' => '']))], + ] + ); + } + + if ($isPublished && $hasCategoryFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类在售商品的价格结构、库存分布与转化表现是否协调。', + [ + ['label' => '继续查看当前已上架分类价格带商品', 'url' => $currentUrl], + ['label' => '继续查看当前已上架分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($isPublished && $hasKeywordFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 结果,建议优先核对在售商品命名、卖点表达与价格梯度是否一致,并同步观察库存结构、转化表现与搜索承接是否健康。', + [ + ['label' => '继续查看当前已上架关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前已上架关键词商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($isPublished && $hasKeywordFilter) { + return $response( + '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的结果,建议优先核对在售商品命名、卖点表达与搜索承接是否一致,并同步观察价格带与库存结构是否健康。', + [ + ['label' => '继续查看当前已上架关键词商品', 'url' => $currentUrl], + ['label' => '去看全部已上架商品', 'url' => $links['published_stock_desc']], + ] + ); + } + + if ($hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对分类承接、命名卖点与价格梯度是否一致,并同步观察库存结构、转化表现与搜索承接是否健康。', + [ + ['label' => '继续查看当前分类关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前分类关键词商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($hasCategoryFilter && $hasKeywordFilter) { + return $response( + '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对分类承接、命名卖点与搜索结果是否一致,并同步观察相关商品的价格带与库存结构。', + [ + ['label' => '继续查看当前分类关键词商品', 'url' => $currentUrl], + ['label' => '继续查看当前分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['keyword' => '']))], + ] + ); + } + + if ($isPublished && $hasCategoryFilter) { + return $response( + '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类商品,建议优先核对该分类在售商品的价格带、库存结构与转化表现是否均衡。', + [ + ['label' => '继续查看当前已上架分类商品', 'url' => $currentUrl], + ['label' => '继续查看当前分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['status' => '', 'keyword' => '']))], + ] + ); + } + + if ($hasCategoryFilter && $hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类价格结构是否连贯,并同步观察库存分布与转化表现是否健康。', + [ + ['label' => '继续查看当前分类价格带商品', 'url' => $currentUrl], + ['label' => '继续查看当前分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], + ] + ); + } + + if ($hasCategoryFilter) { + return $response( + '当前筛选已聚焦“' . $categoryLabel . '”分类商品,建议优先核对分类承接是否准确,并同步观察价格带与库存结构是否均衡。', + [ + ['label' => '继续查看当前分类商品', 'url' => $currentUrl], + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ] + ); + } + + if ($hasKeywordFilter) { + return $response( + '当前筛选已聚焦关键词“' . $keyword . '”命中的商品,建议优先核对命名、卖点与搜索承接是否一致,并同步观察相关商品的价格带与库存结构。', + [ + ['label' => '继续查看当前关键词商品', 'url' => $currentUrl], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ] + ); + } + + if ($hasPriceRangeFilter) { + return $response( + '当前筛选已聚焦价格带 ' . $priceRange . ' 商品,建议优先核对定价梯度是否连贯,并同步观察库存结构与转化表现是否匹配。', + [ + ['label' => '继续查看当前价格带商品', 'url' => $currentUrl], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ] + ); + } + + if ($isPublished) { + return $response( + '当前正在查看已上架商品,建议优先关注库存结构、价格带与分类覆盖是否均衡。', + [ + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ['label' => '继续查看已上架商品', 'url' => $currentUrl], + ] + ); + } + + if (($summaryStats['total_products'] ?? 0) <= 0) { + return $response( + '当前商家暂无商品,建议先补齐基础商品数据,再开始做上架与库存运营。', + [ + ['label' => '先看商品空白情况', 'url' => $links['latest']], + ['label' => '查看草稿商品', 'url' => $links['draft']], + ] + ); + } + + if (($summaryStats['total_products'] ?? 0) < 3) { + return $response( + '当前商家商品仍较少,建议优先查看最近新增与已上架商品,确认基础信息是否完整。', + [ + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ['label' => '去看已上架商品', 'url' => $links['published_stock_desc']], + ] + ); + } + + return $response( + $lowStockCount > 0 + ? '当前商家商品已形成基础规模,建议优先巡检低库存商品,并同步关注高库存结构是否均衡。' + : '当前商家商品已形成基础规模,建议优先关注高库存结构与最近新增商品质量。', + $lowStockCount > 0 + ? [ + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ] + : [ + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ] + ); + } + + protected function buildUrl(string $path, array $filters): string + { + $query = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '' && $value !== 'latest')); + return $path . ($query !== '' ? ('?' . $query) : ''); + } + + protected function buildImportHistorySummaryData(int $merchantId): array + { + $histories = ProductImportHistory::query() + ->where('scope', 'merchant') + ->where('merchant_id', $merchantId) + ->with(['admin']) + ->orderByDesc('imported_at') + ->orderByDesc('id') + ->take(20) + ->get(); + + $this->hydrateFailureAvailability($histories); + + $stats = ProductImportHistory::query() + ->where('scope', 'merchant') + ->where('merchant_id', $merchantId) + ->selectRaw('COUNT(*) as total_imports') + ->selectRaw('COALESCE(SUM(success_count), 0) as total_success') + ->selectRaw('COALESCE(SUM(failed_count), 0) as total_failed') + ->selectRaw('SUM(CASE WHEN failed_count > 0 THEN 1 ELSE 0 END) as warning_imports') + ->first(); + + return [ + 'histories' => $histories, + 'stats' => [ + 'total_imports' => (int) ($stats->total_imports ?? 0), + 'total_success' => (int) ($stats->total_success ?? 0), + 'total_failed' => (int) ($stats->total_failed ?? 0), + 'warning_imports' => (int) ($stats->warning_imports ?? 0), + ], + ]; + } + + protected function hydrateFailureAvailability($histories): void + { + $disk = Storage::disk('local'); + $base = 'private/imports/product-failures/'; + + foreach ($histories as $history) { + $history->failure_file_available = $history->failure_file + ? $disk->exists($base . basename((string) $history->failure_file)) + : false; + } + } + + protected function flushMerchantCaches(int $merchantId): void + { + Cache::add(CacheKeys::merchantProductsVersion($merchantId), 1, now()->addDays(30)); + Cache::increment(CacheKeys::merchantProductsVersion($merchantId)); + Cache::forget(CacheKeys::merchantDashboardStats($merchantId)); + } + + protected function importTemplateHeader(): array + { + return [ + 'title', + 'slug', + 'sku', + 'price', + 'original_price', + 'stock', + 'category_slug', + 'summary', + 'status', + ]; + } + + protected function parseCsvRow(array $row, array $header): array + { + $data = []; + foreach ($header as $index => $column) { + $data[$column] = $row[$index] ?? null; + } + return $data; + } + + protected function buildImportFailureRow(int $line, array $data, string $error): array + { + return [ + 'line' => $line, + 'data' => $data, + 'error' => $error, + ]; + } + + protected function storeImportFailuresCsv(string $scope, array $header, array $failures): string + { + $fileName = $scope . '_product_import_failures_' . now()->format('Ymd_His') . '_' . substr(md5((string) microtime(true)), 0, 6) . '.csv'; + $path = 'private/imports/product-failures/' . $fileName; + + $lines = []; + $lines[] = implode(',', array_merge($header, ['error'])); + foreach ($failures as $failure) { + $data = $failure['data'] ?? []; + $row = []; + foreach ($header as $column) { + $row[] = (string) ($data[$column] ?? ''); + } + $row[] = (string) ($failure['error'] ?? ''); + $lines[] = $this->csvLine($row); + } + + Storage::disk('local')->put($path, "\xEF\xBB\xBF" . implode("\n", $lines)); + + return $fileName; + } + + protected function csvLine(array $fields): string + { + $handle = fopen('php://temp', 'r+'); + fputcsv($handle, $fields); + rewind($handle); + $line = stream_get_contents($handle); + fclose($handle); + + return rtrim((string) $line, "\n\r"); + } + + protected function importProductRow(array $data, int $merchantId, int $line): void + { + $title = trim((string) ($data['title'] ?? '')); + $slug = trim((string) ($data['slug'] ?? '')); + + if ($title === '') { + throw new \InvalidArgumentException("第 {$line} 行 title 不能为空"); + } + + if ($slug === '') { + throw new \InvalidArgumentException("第 {$line} 行 slug 不能为空"); + } + + $categoryId = null; + $categorySlug = trim((string) ($data['category_slug'] ?? '')); + if ($categorySlug !== '') { + $category = ProductCategory::query()->where('merchant_id', $merchantId)->where('slug', $categorySlug)->first(); + if (! $category) { + throw new \InvalidArgumentException("第 {$line} 行 category_slug 不存在"); + } + $categoryId = $category->id; + } + + $status = trim((string) ($data['status'] ?? 'published')); + if (! in_array($status, $this->statusOptions, true)) { + $status = 'published'; + } + + $price = is_numeric($data['price'] ?? null) ? (float) $data['price'] : 0.0; + $originalPrice = is_numeric($data['original_price'] ?? null) ? (float) $data['original_price'] : $price; + $stock = filter_var($data['stock'] ?? 0, FILTER_VALIDATE_INT) !== false ? (int) $data['stock'] : 0; + + Product::query()->forMerchant($merchantId)->updateOrCreate([ + 'merchant_id' => $merchantId, + 'slug' => $slug, + ], [ + 'category_id' => $categoryId, + 'title' => $title, + 'sku' => trim((string) ($data['sku'] ?? '')), + 'summary' => trim((string) ($data['summary'] ?? '')), + 'content' => '', + 'price' => $price, + 'original_price' => $originalPrice, + 'stock' => $stock, + 'status' => $status, + 'images' => [], + ]); + } + + protected function importHistoryFilters(Request $request): array + { + $resultStatus = trim((string) $request->string('import_result_status', 'all')); + $timeRange = trim((string) $request->string('import_time_range', 'all')); + $rawStart = trim((string) $request->string('start_date')); + $rawEnd = trim((string) $request->string('end_date')); + $sort = trim((string) $request->string('import_sort', 'latest')); + + $dateErrors = []; + + $startDate = null; + $endDate = null; + + if ($timeRange === 'today') { + $startDate = now()->toDateString(); + $endDate = now()->toDateString(); + } elseif ($timeRange === 'last_7_days') { + $startDate = now()->subDays(6)->toDateString(); + $endDate = now()->toDateString(); + } elseif ($timeRange === 'custom') { + if ($rawStart !== '' && ! $this->isValidDate($rawStart)) { + $dateErrors[] = '开始日期格式不正确,请使用 YYYY-MM-DD。'; + } + if ($rawEnd !== '' && ! $this->isValidDate($rawEnd)) { + $dateErrors[] = '结束日期格式不正确,请使用 YYYY-MM-DD。'; + } + if ($rawStart !== '' && $rawEnd !== '' && $this->isValidDate($rawStart) && $this->isValidDate($rawEnd) && $rawStart > $rawEnd) { + $dateErrors[] = '开始日期不能晚于结束日期。'; + } + + $startDate = $rawStart !== '' ? $rawStart : null; + $endDate = $rawEnd !== '' ? $rawEnd : null; + } + + return [ + 'result_status' => in_array($resultStatus, ['all', 'success_only', 'has_failures'], true) ? $resultStatus : 'all', + 'time_range' => in_array($timeRange, ['all', 'today', 'last_7_days', 'custom'], true) ? $timeRange : 'all', + 'start_date' => $startDate, + 'end_date' => $endDate, + 'sort' => in_array($sort, ['latest', 'oldest'], true) ? $sort : 'latest', + 'date_errors' => $dateErrors, + ]; + } + + protected function isValidDate(string $value): bool + { + try { + $date = Carbon::createFromFormat('Y-m-d', $value); + } catch (\Throwable $exception) { + return false; + } + + return $date && $date->format('Y-m-d') === $value; + } +} diff --git a/app/Http/Controllers/MerchantAdmin/UserController.php b/app/Http/Controllers/MerchantAdmin/UserController.php new file mode 100644 index 0000000..49898d6 --- /dev/null +++ b/app/Http/Controllers/MerchantAdmin/UserController.php @@ -0,0 +1,35 @@ +merchantId($request); + + $page = max((int) $request->integer('page', 1), 1); + + return view('merchant_admin.users.index', [ + 'users' => Cache::remember( + CacheKeys::merchantUsersList($merchantId, $page), + now()->addMinutes(10), + fn () => User::query()->forMerchant($merchantId)->latest()->paginate(10)->withQueryString() + ), + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + ]); + } +} diff --git a/app/Http/Controllers/SiteAdmin/AuthController.php b/app/Http/Controllers/SiteAdmin/AuthController.php new file mode 100644 index 0000000..3e2437f --- /dev/null +++ b/app/Http/Controllers/SiteAdmin/AuthController.php @@ -0,0 +1,57 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $admin = Admin::query()->with('merchant')->where('email', $data['email'])->first(); + if (! $admin || ! Hash::check($data['password'], $admin->password)) { + return back()->withErrors(['email' => '账号或密码错误'])->withInput(); + } + + if (! $admin->isMerchantAdmin()) { + return back()->withErrors(['email' => '当前账号不是站点管理员,不能登录站点后台'])->withInput(); + } + + $siteId = $admin->merchantId(); + + $request->session()->put('admin_id', $admin->id); + $request->session()->put('admin_name', $admin->name); + $request->session()->put('admin_email', $admin->email); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_merchant_id', $siteId); + $request->session()->put('admin_site_id', $siteId); + $request->session()->put('admin_scope', 'site'); + $request->session()->put('site_name', $admin->merchant?->name); + + $admin->forceFill(['last_login_at' => now()])->save(); + + return redirect('/site-admin'); + } + + public function logout(Request $request): RedirectResponse + { + $request->session()->forget(['admin_id', 'admin_name', 'admin_email', 'admin_role', 'admin_merchant_id', 'admin_site_id', 'admin_scope', 'site_name']); + + return redirect('/site-admin/login'); + } +} diff --git a/app/Http/Controllers/SiteAdmin/DashboardController.php b/app/Http/Controllers/SiteAdmin/DashboardController.php new file mode 100644 index 0000000..3645e4e --- /dev/null +++ b/app/Http/Controllers/SiteAdmin/DashboardController.php @@ -0,0 +1,46 @@ +siteId($request); + $site = $this->site($request); + + $stats = Cache::remember( + CacheKeys::merchantDashboardStats($siteId), + now()->addMinutes(10), + fn () => [ + 'admins' => Admin::query()->where('merchant_id', $siteId)->count(), + 'users' => User::query()->forMerchant($siteId)->count(), + 'products' => Product::query()->forMerchant($siteId)->count(), + 'orders' => Order::query()->forMerchant($siteId)->count(), + 'pending_orders' => Order::query()->forMerchant($siteId)->where('status', 'pending')->count(), + ] + ); + + return view('site_admin.dashboard', [ + 'site' => $site, + 'stats' => $stats, + 'cacheMeta' => [ + 'store' => config('cache.default'), + 'ttl' => '10m', + ], + ]); + } +} diff --git a/app/Http/Controllers/SiteAdmin/MerchantController.php b/app/Http/Controllers/SiteAdmin/MerchantController.php new file mode 100644 index 0000000..e4ae694 --- /dev/null +++ b/app/Http/Controllers/SiteAdmin/MerchantController.php @@ -0,0 +1,228 @@ +site($request); + $filters = $this->filters($request); + + $query = $this->applySorting( + $this->applyFilters( + Merchant::query() + ->withCount(['admins', 'users', 'products', 'orders', 'categories']) + ->whereKey($site->id), + $filters + ), + $filters + ); + + $merchants = $query->get(); + $summaryStats = $this->buildSummaryStats($site); + + return view('site_admin.merchants.index', [ + 'site' => $site, + 'merchants' => $merchants, + 'filters' => $filters, + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'summaryStats' => $summaryStats, + 'statusLabels' => $this->statusLabels(), + 'planLabels' => $this->planLabels(), + 'filterOptions' => [ + 'statuses' => array_keys($this->statusLabels()), + 'plans' => array_keys($this->planLabels()), + 'sortOptions' => [ + 'latest' => '最近激活优先', + 'name_asc' => '名称 A-Z', + 'name_desc' => '名称 Z-A', + ], + ], + ]); + } + + public function export(Request $request): StreamedResponse + { + $site = $this->site($request); + $filters = $this->filters($request); + + $merchants = $this->applySorting( + $this->applyFilters( + Merchant::query() + ->withCount(['admins', 'users', 'products', 'orders', 'categories']) + ->whereKey($site->id), + $filters + ), + $filters + )->get(); + + $summaryStats = $this->buildSummaryStats($site); + $fileName = 'site_' . $site->id . '_merchants_' . now()->format('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($site, $filters, $merchants, $summaryStats) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, ['导出信息', '站点商家导出']); + fputcsv($handle, ['站点ID', $site->id]); + fputcsv($handle, ['站点名称', $site->name]); + fputcsv($handle, ['关键词', ($filters['keyword'] ?? '') !== '' ? $filters['keyword'] : '全部']); + fputcsv($handle, ['状态', $this->statusLabel($filters['status'] ?? '')]); + fputcsv($handle, ['套餐', $this->planLabel($filters['plan'] ?? '')]); + fputcsv($handle, ['排序', $this->sortLabel($filters['sort'] ?? 'latest')]); + fputcsv($handle, ['承接站点数', $summaryStats['site_count'] ?? 0]); + fputcsv($handle, ['启用中站点', $summaryStats['active_site_count'] ?? 0]); + fputcsv($handle, ['站点管理员数', $summaryStats['admin_count'] ?? 0]); + fputcsv($handle, ['站点用户数', $summaryStats['user_count'] ?? 0]); + fputcsv($handle, ['站点商品数', $summaryStats['product_count'] ?? 0]); + fputcsv($handle, ['站点订单数', $summaryStats['order_count'] ?? 0]); + fputcsv($handle, ['商品分类数', $summaryStats['category_count'] ?? 0]); + fputcsv($handle, []); + + fputcsv($handle, ['当前站点资料', '']); + fputcsv($handle, ['站点标识', $site->slug]); + fputcsv($handle, ['当前状态', $this->statusLabel((string) $site->status)]); + fputcsv($handle, ['当前套餐', $this->planLabel((string) $site->plan)]); + fputcsv($handle, ['联系人', $site->contact_name ?: '未设置']); + fputcsv($handle, ['联系电话', $site->contact_phone ?: '未设置']); + fputcsv($handle, ['联系邮箱', $site->contact_email ?: '未设置']); + fputcsv($handle, ['激活时间', $site->activated_at?->format('Y-m-d H:i:s') ?? '未激活']); + fputcsv($handle, []); + + fputcsv($handle, ['ID', '名称', 'Slug', '状态', '套餐', '联系人', '联系电话', '联系邮箱', '管理员数', '用户数', '商品数', '订单数', '商品分类数', '激活时间']); + + foreach ($merchants as $merchant) { + fputcsv($handle, [ + $merchant->id, + $merchant->name, + $merchant->slug, + $this->statusLabel((string) $merchant->status), + $this->planLabel((string) $merchant->plan), + $merchant->contact_name ?: '未设置', + $merchant->contact_phone ?: '未设置', + $merchant->contact_email ?: '未设置', + $merchant->admins_count ?? 0, + $merchant->users_count ?? 0, + $merchant->products_count ?? 0, + $merchant->orders_count ?? 0, + $merchant->categories_count ?? 0, + $merchant->activated_at?->format('Y-m-d H:i:s') ?? '未激活', + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + protected function filters(Request $request): array + { + return [ + 'keyword' => trim((string) $request->string('keyword')), + 'status' => trim((string) $request->string('status')), + 'plan' => trim((string) $request->string('plan')), + 'sort' => trim((string) $request->string('sort', 'latest')), + ]; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + return $query + ->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status'])) + ->when(($filters['plan'] ?? '') !== '', fn ($builder) => $builder->where('plan', $filters['plan'])) + ->when(($filters['keyword'] ?? '') !== '', function ($builder) use ($filters) { + $keyword = $filters['keyword']; + + $builder->where(function ($subQuery) use ($keyword) { + $subQuery->where('name', 'like', '%' . $keyword . '%') + ->orWhere('slug', 'like', '%' . $keyword . '%') + ->orWhere('contact_name', 'like', '%' . $keyword . '%') + ->orWhere('contact_phone', 'like', '%' . $keyword . '%') + ->orWhere('contact_email', 'like', '%' . $keyword . '%'); + }); + }); + } + + protected function applySorting(Builder $query, array $filters): Builder + { + return match ($filters['sort'] ?? 'latest') { + 'name_asc' => $query->orderBy('name')->orderBy('id'), + 'name_desc' => $query->orderByDesc('name')->orderByDesc('id'), + default => $query->orderByDesc('activated_at')->orderByDesc('id'), + }; + } + + protected function buildSummaryStats(Merchant $site): array + { + $site->loadCount(['admins', 'users', 'products', 'orders', 'categories']); + + return [ + 'site_count' => 1, + 'active_site_count' => $site->status === 'active' ? 1 : 0, + 'admin_count' => (int) ($site->admins_count ?? 0), + 'user_count' => (int) ($site->users_count ?? 0), + 'product_count' => (int) ($site->products_count ?? 0), + 'order_count' => (int) ($site->orders_count ?? 0), + 'category_count' => (int) ($site->categories_count ?? 0), + ]; + } + + protected function buildActiveFilterSummary(array $filters): array + { + return [ + '关键词' => ($filters['keyword'] ?? '') !== '' ? $filters['keyword'] : '全部', + '状态' => $this->statusLabel($filters['status'] ?? ''), + '套餐' => $this->planLabel($filters['plan'] ?? ''), + '排序' => $this->sortLabel($filters['sort'] ?? 'latest'), + ]; + } + + protected function statusLabels(): array + { + return [ + 'active' => '启用中', + 'inactive' => '未启用', + 'suspended' => '已停用', + ]; + } + + protected function statusLabel(string $status): string + { + return $this->statusLabels()[$status] ?? '全部'; + } + + protected function planLabels(): array + { + return [ + 'basic' => '基础版', + 'pro' => '专业版', + 'enterprise' => '企业版', + ]; + } + + protected function planLabel(string $plan): string + { + return $this->planLabels()[$plan] ?? (($plan === '') ? '全部' : $plan); + } + + protected function sortLabel(string $sort): string + { + return match ($sort) { + 'name_asc' => '名称 A-Z', + 'name_desc' => '名称 Z-A', + default => '最近激活优先', + }; + } +} diff --git a/app/Http/Controllers/SiteAdmin/OrderController.php b/app/Http/Controllers/SiteAdmin/OrderController.php new file mode 100644 index 0000000..455b4f4 --- /dev/null +++ b/app/Http/Controllers/SiteAdmin/OrderController.php @@ -0,0 +1,643 @@ +siteId($request); + $site = $this->site($request); + $filters = $this->filters($request); + $statusStatsFilters = $filters; + $statusStatsFilters['status'] = ''; + + if ($filters['has_validation_error'] ?? false) { + return view('site_admin.orders.index', [ + 'site' => $site, + 'orders' => Order::query()->whereRaw('1 = 0')->paginate(10)->withQueryString(), + 'summaryStats' => $this->emptySummaryStats(), + 'statusStats' => $this->emptyStatusStats(), + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'operationsFocus' => $this->buildOperationsFocus($siteId, $this->emptySummaryStats(), $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'filters' => $filters, + 'statusLabels' => $this->statusLabels(), + 'paymentStatusLabels' => $this->paymentStatusLabels(), + 'platformLabels' => $this->platformLabels(), + 'deviceTypeLabels' => $this->deviceTypeLabels(), + 'paymentChannelLabels' => $this->paymentChannelLabels(), + 'filterOptions' => [ + 'statuses' => $this->statuses, + 'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'], + 'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'], + 'paymentChannels' => ['wechat_pay', 'alipay'], + 'sortOptions' => [ + 'latest' => '创建时间倒序', + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + ], + ], + ]); + } + + $summaryStats = $this->buildSummaryStats( + $this->applyFilters(Order::query()->forMerchant($siteId), $statusStatsFilters) + ); + + return view('site_admin.orders.index', [ + 'site' => $site, + 'orders' => $this->applySorting( + $this->applyFilters(Order::query()->forMerchant($siteId), $filters), + $filters + )->paginate(10)->withQueryString(), + 'summaryStats' => $summaryStats, + 'statusStats' => $this->buildStatusStats( + $this->applyFilters(Order::query()->forMerchant($siteId), $statusStatsFilters) + ), + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'operationsFocus' => $this->buildOperationsFocus($siteId, $summaryStats, $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'filters' => $filters, + 'statusLabels' => $this->statusLabels(), + 'paymentStatusLabels' => $this->paymentStatusLabels(), + 'platformLabels' => $this->platformLabels(), + 'deviceTypeLabels' => $this->deviceTypeLabels(), + 'paymentChannelLabels' => $this->paymentChannelLabels(), + 'filterOptions' => [ + 'statuses' => $this->statuses, + 'paymentStatuses' => ['unpaid', 'paid', 'refunded', 'failed'], + 'platforms' => ['pc', 'h5', 'wechat_mp', 'wechat_mini', 'app'], + 'paymentChannels' => ['wechat_pay', 'alipay'], + 'sortOptions' => [ + 'latest' => '创建时间倒序', + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + ], + ], + ]); + } + + public function export(Request $request): StreamedResponse|RedirectResponse + { + $siteId = $this->siteId($request); + $filters = $this->filters($request); + + if ($filters['has_validation_error'] ?? false) { + return redirect('/site-admin/orders?' . http_build_query($this->exportableFilters($filters))) + ->withErrors($filters['validation_errors'] ?? ['订单筛选条件不合法,请先修正后再导出。']); + } + + $fileName = 'site_' . $siteId . '_orders_' . now()->format('Ymd_His') . '.csv'; + $exportSummary = $this->buildSummaryStats( + $this->applyFilters(Order::query()->forMerchant($siteId), $filters) + ); + + return response()->streamDownload(function () use ($siteId, $filters, $exportSummary) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, ['导出信息', '站点订单导出']); + fputcsv($handle, ['站点ID', $siteId]); + fputcsv($handle, ['关键词', ($filters['keyword'] ?? '') !== '' ? $filters['keyword'] : '全部']); + fputcsv($handle, ['订单状态', $this->statusLabel($filters['status'] ?? '')]); + fputcsv($handle, ['支付状态', $this->paymentStatusLabel($filters['payment_status'] ?? '')]); + fputcsv($handle, ['平台', $this->platformLabel($filters['platform'] ?? '')]); + fputcsv($handle, ['设备类型', $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels())]); + fputcsv($handle, ['支付渠道', $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels())]); + fputcsv($handle, ['最低实付金额', $this->displayMoneyValue($filters['min_pay_amount'] ?? '')]); + fputcsv($handle, ['最高实付金额', $this->displayMoneyValue($filters['max_pay_amount'] ?? '')]); + fputcsv($handle, ['排序', $this->sortLabel($filters['sort'] ?? 'latest')]); + fputcsv($handle, ['导出订单数', $exportSummary['total_orders'] ?? 0]); + fputcsv($handle, ['导出实付总额', number_format((float) ($exportSummary['total_pay_amount'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出平均客单价', number_format((float) ($exportSummary['average_order_amount'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出已支付订单数', $exportSummary['paid_orders'] ?? 0]); + fputcsv($handle, ['导出支付失败订单', $exportSummary['failed_payment_orders'] ?? 0]); + fputcsv($handle, []); + + fputcsv($handle, [ + 'ID', + '订单号', + '订单状态', + '支付状态', + '平台', + '设备类型', + '支付渠道', + '买家姓名', + '买家手机', + '买家邮箱', + '商品金额', + '优惠金额', + '运费', + '实付金额', + '创建时间', + '支付时间', + '完成时间', + '备注', + ]); + + foreach ($this->applySorting($this->applyFilters(Order::query()->forMerchant($siteId), $filters), $filters)->cursor() as $order) { + fputcsv($handle, [ + $order->id, + $order->order_no, + $this->statusLabel((string) $order->status), + $this->paymentStatusLabel((string) $order->payment_status), + $this->platformLabel((string) $order->platform), + $this->deviceTypeLabel((string) $order->device_type), + $this->paymentChannelLabel((string) $order->payment_channel), + $order->buyer_name, + $order->buyer_phone, + $order->buyer_email, + number_format((float) $order->product_amount, 2, '.', ''), + number_format((float) $order->discount_amount, 2, '.', ''), + number_format((float) $order->shipping_amount, 2, '.', ''), + number_format((float) $order->pay_amount, 2, '.', ''), + optional($order->created_at)?->format('Y-m-d H:i:s'), + optional($order->paid_at)?->format('Y-m-d H:i:s'), + optional($order->completed_at)?->format('Y-m-d H:i:s'), + $order->remark, + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + protected function filters(Request $request): array + { + $minPayAmount = trim((string) $request->string('min_pay_amount')); + $maxPayAmount = trim((string) $request->string('max_pay_amount')); + $validationErrors = []; + + if ($minPayAmount !== '' && ! is_numeric($minPayAmount)) { + $validationErrors[] = '最低实付金额必须为数字。'; + } + + if ($maxPayAmount !== '' && ! is_numeric($maxPayAmount)) { + $validationErrors[] = '最高实付金额必须为数字。'; + } + + if ($minPayAmount !== '' && $maxPayAmount !== '' && is_numeric($minPayAmount) && is_numeric($maxPayAmount) && (float) $minPayAmount > (float) $maxPayAmount) { + $validationErrors[] = '最低实付金额不能大于最高实付金额。'; + } + + return [ + 'keyword' => trim((string) $request->string('keyword')), + 'status' => trim((string) $request->string('status')), + 'payment_status' => trim((string) $request->string('payment_status')), + 'platform' => trim((string) $request->string('platform')), + 'device_type' => trim((string) $request->string('device_type')), + 'payment_channel' => trim((string) $request->string('payment_channel')), + 'min_pay_amount' => $minPayAmount, + 'max_pay_amount' => $maxPayAmount, + 'sort' => trim((string) $request->string('sort', 'latest')), + 'validation_errors' => $validationErrors, + 'has_validation_error' => ! empty($validationErrors), + ]; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + return $query + ->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status'])) + ->when(($filters['payment_status'] ?? '') !== '', fn ($builder) => $builder->where('payment_status', $filters['payment_status'])) + ->when(($filters['platform'] ?? '') !== '', fn ($builder) => $builder->where('platform', $filters['platform'])) + ->when(($filters['device_type'] ?? '') !== '', fn ($builder) => $builder->where('device_type', $filters['device_type'])) + ->when(($filters['payment_channel'] ?? '') !== '', fn ($builder) => $builder->where('payment_channel', $filters['payment_channel'])) + ->when(($filters['keyword'] ?? '') !== '', fn ($builder) => $builder->where(function ($subQuery) use ($filters) { + $subQuery->where('order_no', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_name', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_phone', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('buyer_email', 'like', '%' . $filters['keyword'] . '%'); + })) + ->when(($filters['min_pay_amount'] ?? '') !== '' && is_numeric($filters['min_pay_amount']), fn ($builder) => $builder->where('pay_amount', '>=', $filters['min_pay_amount'])) + ->when(($filters['max_pay_amount'] ?? '') !== '' && is_numeric($filters['max_pay_amount']), fn ($builder) => $builder->where('pay_amount', '<=', $filters['max_pay_amount'])); + } + + protected function applySorting(Builder $query, array $filters): Builder + { + return match ($filters['sort'] ?? 'latest') { + 'oldest' => $query->orderBy('created_at')->orderBy('id'), + 'pay_amount_desc' => $query->orderByDesc('pay_amount')->orderByDesc('id'), + 'pay_amount_asc' => $query->orderBy('pay_amount')->orderByDesc('id'), + default => $query->latest(), + }; + } + + protected function buildSummaryStats(Builder $query): array + { + $summary = (clone $query) + ->selectRaw('COUNT(*) as total_orders') + ->selectRaw('COALESCE(SUM(pay_amount), 0) as total_pay_amount') + ->selectRaw('COALESCE(AVG(pay_amount), 0) as average_order_amount') + ->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN 1 ELSE 0 END) as paid_orders") + ->selectRaw("SUM(CASE WHEN payment_status = 'failed' THEN 1 ELSE 0 END) as failed_payment_orders") + ->first(); + + return [ + 'total_orders' => (int) ($summary->total_orders ?? 0), + 'total_pay_amount' => (float) ($summary->total_pay_amount ?? 0), + 'average_order_amount' => (float) ($summary->average_order_amount ?? 0), + 'paid_orders' => (int) ($summary->paid_orders ?? 0), + 'failed_payment_orders' => (int) ($summary->failed_payment_orders ?? 0), + ]; + } + + protected function buildStatusStats(Builder $query): array + { + $counts = (clone $query) + ->selectRaw('status, COUNT(*) as aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + + $stats = ['all' => (int) $counts->sum()]; + + foreach ($this->statuses as $status) { + $stats[$status] = (int) ($counts[$status] ?? 0); + } + + return $stats; + } + + protected function emptySummaryStats(): array + { + return [ + 'total_orders' => 0, + 'total_pay_amount' => 0, + 'average_order_amount' => 0, + 'paid_orders' => 0, + 'failed_payment_orders' => 0, + ]; + } + + protected function emptyStatusStats(): array + { + $stats = ['all' => 0]; + + foreach ($this->statuses as $status) { + $stats[$status] = 0; + } + + return $stats; + } + + protected function exportableFilters(array $filters): array + { + return [ + 'keyword' => $filters['keyword'] ?? '', + 'status' => $filters['status'] ?? '', + 'payment_status' => $filters['payment_status'] ?? '', + 'platform' => $filters['platform'] ?? '', + 'device_type' => $filters['device_type'] ?? '', + 'payment_channel' => $filters['payment_channel'] ?? '', + 'min_pay_amount' => $filters['min_pay_amount'] ?? '', + 'max_pay_amount' => $filters['max_pay_amount'] ?? '', + 'sort' => $filters['sort'] ?? 'latest', + ]; + } + + protected function buildActiveFilterSummary(array $filters): array + { + return [ + '关键词' => ($filters['keyword'] ?? '') !== '' ? $filters['keyword'] : '全部', + '订单状态' => $this->statusLabel($filters['status'] ?? ''), + '支付状态' => $this->paymentStatusLabel($filters['payment_status'] ?? ''), + '平台' => $this->platformLabel($filters['platform'] ?? ''), + '设备类型' => $this->displayFilterValue((string) ($filters['device_type'] ?? ''), $this->deviceTypeLabels()), + '支付渠道' => $this->displayFilterValue((string) ($filters['payment_channel'] ?? ''), $this->paymentChannelLabels()), + '实付金额区间' => $this->formatMoneyRange($filters['min_pay_amount'] ?? '', $filters['max_pay_amount'] ?? ''), + '排序' => $this->sortLabel($filters['sort'] ?? 'latest'), + ]; + } + + protected function statusLabels(): array + { + return [ + 'pending' => '待处理', + 'paid' => '已支付', + 'shipped' => '已发货', + 'completed' => '已完成', + 'cancelled' => '已取消', + ]; + } + + protected function statusLabel(string $status): string + { + return $this->statusLabels()[$status] ?? '全部'; + } + + protected function paymentStatusLabels(): array + { + return [ + 'unpaid' => '未支付', + 'paid' => '已支付', + 'refunded' => '已退款', + 'failed' => '支付失败', + ]; + } + + protected function paymentStatusLabel(string $status): string + { + return $this->paymentStatusLabels()[$status] ?? '全部'; + } + + protected function platformLabels(): array + { + return [ + 'pc' => 'PC 端', + 'h5' => 'H5', + 'wechat_mp' => '微信公众号', + 'wechat_mini' => '微信小程序', + 'app' => 'APP 接口预留', + ]; + } + + protected function platformLabel(string $platform): string + { + return $this->platformLabels()[$platform] ?? '全部'; + } + + protected function deviceTypeLabels(): array + { + return [ + 'desktop' => '桌面浏览器', + 'mobile' => '移动浏览器', + 'mini-program' => '小程序环境', + 'mobile-webview' => '微信内网页', + 'app-api' => 'APP 接口', + ]; + } + + protected function deviceTypeLabel(string $deviceType): string + { + return $this->deviceTypeLabels()[$deviceType] ?? ($deviceType === '' ? '未设置' : $deviceType); + } + + protected function paymentChannelLabels(): array + { + return [ + 'wechat_pay' => '微信支付', + 'alipay' => '支付宝', + ]; + } + + protected function paymentChannelLabel(string $paymentChannel): string + { + return $this->paymentChannelLabels()[$paymentChannel] ?? ($paymentChannel === '' ? '未设置' : $paymentChannel); + } + + protected function formatMoneyRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' && is_numeric($min) + ? ('¥' . number_format((float) $min, 2, '.', '')) + : '不限'; + $maxLabel = $max !== '' && is_numeric($max) + ? ('¥' . number_format((float) $max, 2, '.', '')) + : '不限'; + + return $minLabel . ' ~ ' . $maxLabel; + } + + protected function sortLabel(string $sort): string + { + return match ($sort) { + 'oldest' => '创建时间正序', + 'pay_amount_desc' => '实付金额从高到低', + 'pay_amount_asc' => '实付金额从低到高', + default => '创建时间倒序', + }; + } + + protected function displayFilterValue(string $value, array $options): string + { + if ($value === '') { + return '全部'; + } + + return (string) ($options[$value] ?? $value); + } + + protected function displayMoneyValue(string $value): string + { + if ($value === '') { + return '全部'; + } + + return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value; + } + + protected function workbenchLinks(): array + { + return [ + 'paid_high_amount' => '/site-admin/orders?sort=pay_amount_desc&payment_status=paid', + 'pending_latest' => '/site-admin/orders?sort=latest&payment_status=unpaid', + 'failed_latest' => '/site-admin/orders?sort=latest&payment_status=failed', + 'completed_latest' => '/site-admin/orders?sort=latest&status=completed', + 'current' => '/site-admin/orders', + ]; + } + + protected function buildOperationsFocus(int $siteId, array $summaryStats, array $filters): array + { + $pendingCount = (int) Order::query()->forMerchant($siteId)->where('payment_status', 'unpaid')->count(); + $failedCount = (int) Order::query()->forMerchant($siteId)->where('payment_status', 'failed')->count(); + $completedCount = (int) Order::query()->forMerchant($siteId)->where('status', 'completed')->count(); + $links = $this->workbenchLinks(); + $currentQuery = http_build_query(array_filter($this->exportableFilters($filters), fn ($value) => $value !== null && $value !== '')); + $currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : ''); + $workbench = [ + '高金额已支付' => $links['paid_high_amount'], + '待支付跟进' => $links['pending_latest'], + '支付失败排查' => $links['failed_latest'], + '最近完成订单' => $links['completed_latest'], + '返回当前筛选视图' => $currentUrl, + ]; + $signals = [ + '待支付订单' => $pendingCount, + '支付失败订单' => $failedCount, + '已完成订单' => $completedCount, + ]; + + if (($filters['platform'] ?? '') === 'wechat_mini') { + return [ + 'headline' => '当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。', + 'actions' => [ + ['label' => '继续查看微信小程序订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_channel'] ?? '') === 'wechat_pay') { + return [ + 'headline' => '当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。', + 'actions' => [ + ['label' => '继续查看微信支付订单', 'url' => $currentUrl], + ['label' => '去看支付失败订单', 'url' => $links['failed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mini-program') { + return [ + 'headline' => '当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。', + 'actions' => [ + ['label' => '继续查看小程序环境订单', 'url' => $currentUrl], + ['label' => '去看微信支付订单', 'url' => $links['failed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mobile-webview') { + return [ + 'headline' => '当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。', + 'actions' => [ + ['label' => '继续查看微信内网页订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'mobile') { + return [ + 'headline' => '当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。', + 'actions' => [ + ['label' => '继续查看移动浏览器订单', 'url' => $currentUrl], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['device_type'] ?? '') === 'desktop') { + return [ + 'headline' => '当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。', + 'actions' => [ + ['label' => '继续查看桌面浏览器订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'failed') { + return [ + 'headline' => '当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。', + 'actions' => [ + ['label' => '继续查看支付失败订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'unpaid') { + return [ + 'headline' => '当前筛选已聚焦待支付订单,建议优先跟进下单未支付用户,并观察支付转化时效。', + 'actions' => [ + ['label' => '继续查看待支付订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['payment_status'] ?? '') === 'paid') { + return [ + 'headline' => '当前正在查看已支付订单,建议优先关注高金额订单履约进度与异常售后风险。', + 'actions' => [ + ['label' => '继续查看已支付订单', 'url' => $currentUrl], + ['label' => '去看最近完成订单', 'url' => $links['completed_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($filters['status'] ?? '') === 'completed') { + return [ + 'headline' => '当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。', + 'actions' => [ + ['label' => '继续查看已完成订单', 'url' => $currentUrl], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($summaryStats['total_orders'] ?? 0) <= 0) { + return [ + 'headline' => '当前站点暂无订单,建议先确认交易链路、支付链路与回写链路是否都已打通。', + 'actions' => [ + ['label' => '先看订单整体情况', 'url' => $links['paid_high_amount']], + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($summaryStats['total_orders'] ?? 0) < 5) { + return [ + 'headline' => '当前站点已有少量订单沉淀,建议优先关注待支付订单,并同步查看已支付订单质量。', + 'actions' => [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + return [ + 'headline' => $failedCount > 0 + ? '当前站点订单已形成基础规模,建议优先关注待支付、支付失败与高金额已支付订单。' + : '当前站点订单已形成基础规模,建议优先关注待支付与高金额已支付订单,保持交易闭环稳定。', + 'actions' => $failedCount > 0 + ? [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看支付失败订单', 'url' => $links['failed_latest']], + ] + : [ + ['label' => '去看待支付订单', 'url' => $links['pending_latest']], + ['label' => '去看高金额已支付订单', 'url' => $links['paid_high_amount']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } +} diff --git a/app/Http/Controllers/SiteAdmin/ProductController.php b/app/Http/Controllers/SiteAdmin/ProductController.php new file mode 100644 index 0000000..00ea424 --- /dev/null +++ b/app/Http/Controllers/SiteAdmin/ProductController.php @@ -0,0 +1,699 @@ +siteId($request); + $site = $this->site($request); + $filters = $this->filters($request); + $statusStatsFilters = $filters; + $statusStatsFilters['status'] = ''; + + if ($filters['has_validation_error'] ?? false) { + return view('site_admin.products.index', [ + 'site' => $site, + 'products' => Product::query()->whereRaw('1 = 0')->paginate(10)->withQueryString(), + 'summaryStats' => $this->emptySummaryStats(), + 'statusStats' => $this->emptyStatusStats(), + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'operationsFocus' => $this->buildOperationsFocus($siteId, $this->emptySummaryStats(), $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'filters' => $filters, + 'statusLabels' => $this->statusLabels(), + 'filterOptions' => [ + 'statuses' => $this->statusOptions, + 'sortOptions' => [ + 'latest' => '最新创建', + 'price_asc' => '价格从低到高', + 'price_desc' => '价格从高到低', + 'stock_asc' => '库存从低到高', + 'stock_desc' => '库存从高到低', + ], + ], + 'categories' => ProductCategory::query()->forMerchant($siteId)->orderBy('sort')->orderBy('id')->get(), + ]); + } + + $summaryStats = $this->buildSummaryStats( + $this->applyFilters(Product::query()->forMerchant($siteId), $statusStatsFilters) + ); + + return view('site_admin.products.index', [ + 'site' => $site, + 'products' => $this->applySorting( + $this->applyFilters(Product::query()->with('category')->forMerchant($siteId), $filters), + $filters + )->paginate(10)->withQueryString(), + 'summaryStats' => $summaryStats, + 'statusStats' => $this->buildStatusStats( + $this->applyFilters(Product::query()->forMerchant($siteId), $statusStatsFilters) + ), + 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), + 'operationsFocus' => $this->buildOperationsFocus($siteId, $summaryStats, $filters), + 'workbenchLinks' => $this->workbenchLinks(), + 'filters' => $filters, + 'statusLabels' => $this->statusLabels(), + 'filterOptions' => [ + 'statuses' => $this->statusOptions, + 'sortOptions' => [ + 'latest' => '最新创建', + 'price_asc' => '价格从低到高', + 'price_desc' => '价格从高到低', + 'stock_asc' => '库存从低到高', + 'stock_desc' => '库存从高到低', + ], + ], + 'categories' => ProductCategory::query()->forMerchant($siteId)->orderBy('sort')->orderBy('id')->get(), + ]); + } + + public function export(Request $request): StreamedResponse|RedirectResponse + { + $siteId = $this->siteId($request); + $filters = $this->filters($request); + + if ($filters['has_validation_error'] ?? false) { + return redirect('/site-admin/products?' . http_build_query($this->exportableFilters($filters))) + ->withErrors($filters['validation_errors'] ?? ['商品筛选条件不合法,请先修正后再导出。']); + } + + $fileName = 'site_' . $siteId . '_products_' . now()->format('Ymd_His') . '.csv'; + + $exportSummary = $this->buildSummaryStats( + $this->applyFilters(Product::query()->forMerchant($siteId), $filters) + ); + + return response()->streamDownload(function () use ($siteId, $filters, $exportSummary) { + $handle = fopen('php://output', 'w'); + fwrite($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, ['导出信息', '站点商品导出']); + fputcsv($handle, ['站点ID', $siteId]); + fputcsv($handle, ['关键词', $filters['keyword'] !== '' ? $filters['keyword'] : '全部']); + fputcsv($handle, ['状态', $this->statusLabel($filters['status'] ?? '')]); + fputcsv($handle, ['分类', $this->categoryLabel($filters['category_id'] ?? '')]); + fputcsv($handle, ['最低价格', $filters['min_price'] !== '' && is_numeric($filters['min_price']) ? ('¥' . number_format((float) $filters['min_price'], 2, '.', '')) : '全部']); + fputcsv($handle, ['最高价格', $filters['max_price'] !== '' && is_numeric($filters['max_price']) ? ('¥' . number_format((float) $filters['max_price'], 2, '.', '')) : '全部']); + fputcsv($handle, ['最低库存', $filters['min_stock'] !== '' ? ($filters['min_stock'] . ' 件') : '全部']); + fputcsv($handle, ['最高库存', $filters['max_stock'] !== '' ? ($filters['max_stock'] . ' 件') : '全部']); + fputcsv($handle, ['排序', $this->sortLabel($filters['sort'] ?? 'latest')]); + fputcsv($handle, ['导出商品数', $exportSummary['total_products'] ?? 0]); + fputcsv($handle, ['导出总库存', $exportSummary['total_stock'] ?? 0]); + fputcsv($handle, ['导出总货值', number_format((float) ($exportSummary['total_stock_value'] ?? 0), 2, '.', '')]); + fputcsv($handle, ['导出平均售价', number_format((float) ($exportSummary['average_price'] ?? 0), 2, '.', '')]); + fputcsv($handle, []); + + fputcsv($handle, [ + 'ID', + '分类ID', + '分类名称', + '商品标题', + '商品Slug', + 'SKU', + '售价', + '划线价', + '库存', + '状态', + '商品简介', + '创建时间', + '更新时间', + ]); + + foreach ($this->applySorting($this->applyFilters(Product::query()->with('category')->forMerchant($siteId), $filters), $filters)->cursor() as $product) { + fputcsv($handle, [ + $product->id, + $product->category_id, + $product->category?->name ?? '', + $product->title, + $product->slug, + $product->sku, + number_format((float) $product->price, 2, '.', ''), + number_format((float) $product->original_price, 2, '.', ''), + $product->stock, + $this->statusLabel($product->status), + $product->summary, + optional($product->created_at)?->format('Y-m-d H:i:s'), + optional($product->updated_at)?->format('Y-m-d H:i:s'), + ]); + } + + fclose($handle); + }, $fileName, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + protected function filters(Request $request): array + { + $minPrice = trim((string) $request->string('min_price')); + $maxPrice = trim((string) $request->string('max_price')); + $minStock = trim((string) $request->string('min_stock')); + $maxStock = trim((string) $request->string('max_stock')); + $validationErrors = []; + + if ($minPrice !== '' && ! is_numeric($minPrice)) { + $validationErrors[] = '最低价格必须为数字。'; + } + + if ($maxPrice !== '' && ! is_numeric($maxPrice)) { + $validationErrors[] = '最高价格必须为数字。'; + } + + if ($minPrice !== '' && $maxPrice !== '' && is_numeric($minPrice) && is_numeric($maxPrice) && (float) $minPrice > (float) $maxPrice) { + $validationErrors[] = '最低价格不能大于最高价格。'; + } + + if ($minStock !== '' && filter_var($minStock, FILTER_VALIDATE_INT) === false) { + $validationErrors[] = '最低库存必须为整数。'; + } + + if ($maxStock !== '' && filter_var($maxStock, FILTER_VALIDATE_INT) === false) { + $validationErrors[] = '最高库存必须为整数。'; + } + + if ($minStock !== '' && $maxStock !== '' && filter_var($minStock, FILTER_VALIDATE_INT) !== false && filter_var($maxStock, FILTER_VALIDATE_INT) !== false && (int) $minStock > (int) $maxStock) { + $validationErrors[] = '最低库存不能大于最高库存。'; + } + + return [ + 'keyword' => trim((string) $request->string('keyword')), + 'status' => trim((string) $request->string('status')), + 'category_id' => trim((string) $request->string('category_id')), + 'min_price' => $minPrice, + 'max_price' => $maxPrice, + 'min_stock' => $minStock, + 'max_stock' => $maxStock, + 'sort' => trim((string) $request->string('sort', 'latest')), + 'validation_errors' => $validationErrors, + 'has_validation_error' => ! empty($validationErrors), + ]; + } + + protected function applyFilters(Builder $query, array $filters): Builder + { + return $query + ->when(($filters['keyword'] ?? '') !== '', fn ($builder) => $builder->where(function ($subQuery) use ($filters) { + $subQuery->where('title', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('sku', 'like', '%' . $filters['keyword'] . '%') + ->orWhere('slug', 'like', '%' . $filters['keyword'] . '%'); + })) + ->when(($filters['status'] ?? '') !== '', fn ($builder) => $builder->where('status', $filters['status'])) + ->when(($filters['category_id'] ?? '') !== '', fn ($builder) => $builder->where('category_id', $filters['category_id'])) + ->when(($filters['min_price'] ?? '') !== '' && is_numeric($filters['min_price']), fn ($builder) => $builder->where('price', '>=', $filters['min_price'])) + ->when(($filters['max_price'] ?? '') !== '' && is_numeric($filters['max_price']), fn ($builder) => $builder->where('price', '<=', $filters['max_price'])) + ->when(($filters['min_stock'] ?? '') !== '' && filter_var($filters['min_stock'], FILTER_VALIDATE_INT) !== false, fn ($builder) => $builder->where('stock', '>=', (int) $filters['min_stock'])) + ->when(($filters['max_stock'] ?? '') !== '' && filter_var($filters['max_stock'], FILTER_VALIDATE_INT) !== false, fn ($builder) => $builder->where('stock', '<=', (int) $filters['max_stock'])); + } + + protected function applySorting(Builder $query, array $filters): Builder + { + return match ($filters['sort'] ?? 'latest') { + 'price_asc' => $query->orderBy('price')->orderByDesc('id'), + 'price_desc' => $query->orderByDesc('price')->orderByDesc('id'), + 'stock_asc' => $query->orderBy('stock')->orderByDesc('id'), + 'stock_desc' => $query->orderByDesc('stock')->orderByDesc('id'), + default => $query->latest(), + }; + } + + protected function buildSummaryStats(Builder $query): array + { + $summary = (clone $query) + ->selectRaw('COUNT(*) as total_products') + ->selectRaw('COALESCE(SUM(stock), 0) as total_stock') + ->selectRaw('COALESCE(SUM(price * stock), 0) as total_stock_value') + ->selectRaw('COALESCE(AVG(price), 0) as average_price') + ->first(); + + return [ + 'total_products' => (int) ($summary->total_products ?? 0), + 'total_stock' => (int) ($summary->total_stock ?? 0), + 'total_stock_value' => (float) ($summary->total_stock_value ?? 0), + 'average_price' => (float) ($summary->average_price ?? 0), + ]; + } + + protected function buildStatusStats(Builder $query): array + { + $counts = (clone $query) + ->selectRaw('status, COUNT(*) as aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + + $stats = ['all' => (int) $counts->sum()]; + + foreach ($this->statusOptions as $status) { + $stats[$status] = (int) ($counts[$status] ?? 0); + } + + return $stats; + } + + protected function emptySummaryStats(): array + { + return [ + 'total_products' => 0, + 'total_stock' => 0, + 'total_stock_value' => 0, + 'average_price' => 0, + ]; + } + + protected function emptyStatusStats(): array + { + $stats = ['all' => 0]; + + foreach ($this->statusOptions as $status) { + $stats[$status] = 0; + } + + return $stats; + } + + protected function exportableFilters(array $filters): array + { + return [ + 'keyword' => $filters['keyword'] ?? '', + 'status' => $filters['status'] ?? '', + 'category_id' => $filters['category_id'] ?? '', + 'min_price' => $filters['min_price'] ?? '', + 'max_price' => $filters['max_price'] ?? '', + 'min_stock' => $filters['min_stock'] ?? '', + 'max_stock' => $filters['max_stock'] ?? '', + 'sort' => $filters['sort'] ?? 'latest', + ]; + } + + protected function buildActiveFilterSummary(array $filters): array + { + return [ + '关键词' => $this->displayTextValue($filters['keyword'] ?? '', '全部'), + '状态' => $this->statusLabel($filters['status'] ?? ''), + '分类' => $this->categoryLabel($filters['category_id'] ?? ''), + '价格区间' => $this->formatMoneyRange($filters['min_price'] ?? '', $filters['max_price'] ?? ''), + '库存区间' => $this->formatStockRange($filters['min_stock'] ?? '', $filters['max_stock'] ?? ''), + '排序' => $this->sortLabel($filters['sort'] ?? 'latest'), + ]; + } + + protected function statusLabels(): array + { + return [ + 'draft' => '草稿', + 'published' => '已上架', + 'offline' => '已下架', + ]; + } + + protected function statusLabel(string $status): string + { + return $this->statusLabels()[$status] ?? '全部'; + } + + protected function categoryLabel(string $categoryId): string + { + if ($categoryId === '') { + return '全部'; + } + + $category = ProductCategory::query()->find($categoryId); + + return $category?->name ?? ('分类 #' . $categoryId); + } + + protected function formatMoneyRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' && is_numeric($min) + ? ('¥' . number_format((float) $min, 2, '.', '')) + : '不限'; + $maxLabel = $max !== '' && is_numeric($max) + ? ('¥' . number_format((float) $max, 2, '.', '')) + : '不限'; + + return $minLabel . ' ~ ' . $maxLabel; + } + + protected function formatStockRange(string $min, string $max): string + { + if ($min === '' && $max === '') { + return '全部'; + } + + $minLabel = $min !== '' ? $min : '不限'; + $maxLabel = $max !== '' ? $max : '不限'; + + return $minLabel . ' ~ ' . $maxLabel . ' 件'; + } + + protected function sortLabel(string $sort): string + { + return match ($sort) { + 'price_asc' => '价格从低到高', + 'price_desc' => '价格从高到低', + 'stock_asc' => '库存从低到高', + 'stock_desc' => '库存从高到低', + default => '最新创建', + }; + } + + protected function displayTextValue(string $value, string $default = '未设置'): string + { + return $value === '' ? $default : $value; + } + + protected function displayMoneyValue(string $value): string + { + if ($value === '') { + return '全部'; + } + + return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value; + } + + protected function displayStockValue(string $value): string + { + return $value === '' ? '全部' : ($value . ' 件'); + } + + protected function workbenchLinks(): array + { + return [ + 'published_stock_desc' => '/site-admin/products?sort=stock_desc&status=published', + 'published_stock_asc' => '/site-admin/products?sort=stock_asc&status=published', + 'latest' => '/site-admin/products?sort=latest', + 'draft' => '/site-admin/products?status=draft&sort=latest', + 'current' => '/site-admin/products', + ]; + } + + protected function hasCategoryFilter(array $filters): bool + { + return ($filters['category_id'] ?? '') !== ''; + } + + protected function hasKeywordFilter(array $filters): bool + { + return ($filters['keyword'] ?? '') !== ''; + } + + protected function hasPriceRangeFilter(array $filters): bool + { + return (($filters['min_price'] ?? '') !== '') || (($filters['max_price'] ?? '') !== ''); + } + + protected function hasPublishedStockFocus(array $filters): bool + { + return ($filters['status'] ?? '') === 'published' + && ((($filters['max_stock'] ?? '') !== '') + || ((($filters['min_stock'] ?? '') !== '') + && is_numeric($filters['min_stock']) + && (int) $filters['min_stock'] <= 20)); + } + + protected function buildOperationsFocusResponse(string $headline, array $actions, array $workbench, array $signals): array + { + return [ + 'headline' => $headline, + 'actions' => $actions, + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + protected function buildOperationsFocus(int $siteId, array $summaryStats, array $filters): array + { + $publishedCount = (int) Product::query()->forMerchant($siteId)->where('status', 'published')->count(); + $lowStockCount = (int) Product::query()->forMerchant($siteId)->where('status', 'published')->where('stock', '<=', 20)->count(); + $categoryCount = (int) ProductCategory::query()->forMerchant($siteId)->count(); + $links = $this->workbenchLinks(); + $currentQuery = http_build_query(array_filter($this->exportableFilters($filters), fn ($value) => $value !== null && $value !== '')); + $currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : ''); + $workbench = [ + '高库存已上架' => $links['published_stock_desc'], + '低库存补货' => $links['published_stock_asc'], + '最近新增' => $links['latest'], + '草稿待整理' => $links['draft'], + '返回当前筛选视图' => $currentUrl, + ]; + $signals = [ + '已上架商品' => $publishedCount, + '低库存商品' => $lowStockCount, + '分类覆盖数' => $categoryCount, + ]; + + $isPublished = ($filters['status'] ?? '') === 'published'; + $hasCategoryFilter = $this->hasCategoryFilter($filters); + $hasKeywordFilter = $this->hasKeywordFilter($filters); + $hasPriceRangeFilter = $this->hasPriceRangeFilter($filters); + $categoryLabel = $hasCategoryFilter ? $this->categoryLabel((string) ($filters['category_id'] ?? '')) : ''; + $keyword = (string) ($filters['keyword'] ?? ''); + $priceRange = $hasPriceRangeFilter ? $this->formatMoneyRange((string) ($filters['min_price'] ?? ''), (string) ($filters['max_price'] ?? '')) : ''; + $categoryUrl = $links['current'] . '?category_id=' . ($filters['category_id'] ?? ''); + $categoryKeywordUrl = $categoryUrl . '&keyword=' . urlencode($keyword); + $publishedCategoryUrl = $links['current'] . '?status=published&category_id=' . ($filters['category_id'] ?? ''); + $publishedKeywordUrl = $links['current'] . '?status=published&keyword=' . urlencode($keyword); + $publishedCategoryKeywordUrl = $publishedCategoryUrl . '&keyword=' . urlencode($keyword); + + if (($filters['status'] ?? '') === 'draft') { + return $this->buildOperationsFocusResponse( + '当前正在查看草稿商品,建议优先补齐标题、分类、价格与库存后再安排上架。', + [ + ['label' => '继续查看当前草稿', 'url' => $currentUrl], + ['label' => '去看已上架商品', 'url' => $links['published_stock_desc']], + ], + $workbench, + $signals, + ); + } + + if ($this->hasPublishedStockFocus($filters)) { + return $this->buildOperationsFocusResponse( + '当前筛选已聚焦已上架库存视角,建议优先确认低库存补货节奏,并同步观察高库存结构是否健康。', + [ + ['label' => '继续查看当前库存视角', 'url' => $currentUrl], + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ], + $workbench, + $signals, + ); + } + + if ($isPublished && $hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) { + return [ + 'headline' => '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对在售商品命名、分类承接、价格梯度与搜索结果是否一致,并同步观察库存结构是否健康。', + 'actions' => [ + ['label' => '继续查看当前已上架分类关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前已上架分类关键词商品', 'url' => $publishedCategoryKeywordUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($isPublished && $hasCategoryFilter && $hasKeywordFilter) { + return [ + 'headline' => '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对在售商品命名、分类承接与搜索结果是否一致,并同步观察价格带与库存结构是否健康。', + 'actions' => [ + ['label' => '继续查看当前已上架分类关键词商品', 'url' => $currentUrl], + ['label' => '去看当前已上架分类商品', 'url' => $publishedCategoryUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($isPublished && $hasCategoryFilter && $hasPriceRangeFilter) { + return [ + 'headline' => '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类在售商品的价格结构、库存分布与承接效率是否协调。', + 'actions' => [ + ['label' => '继续查看当前已上架分类价格带商品', 'url' => $currentUrl], + ['label' => '去看当前已上架分类商品', 'url' => $publishedCategoryUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($isPublished && $hasKeywordFilter && $hasPriceRangeFilter) { + return [ + 'headline' => '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 结果,建议优先核对在售商品命名、卖点表达与价格梯度是否一致,并同步观察库存结构与搜索承接是否健康。', + 'actions' => [ + ['label' => '继续查看当前已上架关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前已上架关键词商品', 'url' => $publishedKeywordUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($isPublished && $hasKeywordFilter) { + return [ + 'headline' => '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的结果,建议优先核对在售商品命名、卖点表达与搜索承接是否一致,并同步观察价格带与库存结构是否健康。', + 'actions' => [ + ['label' => '继续查看当前已上架关键词商品', 'url' => $currentUrl], + ['label' => '去看全部已上架商品', 'url' => $links['published_stock_desc']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) { + return [ + 'headline' => '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对分类承接、命名卖点与价格梯度是否一致,并同步观察库存结构与搜索承接是否健康。', + 'actions' => [ + ['label' => '继续查看当前分类关键词价格带商品', 'url' => $currentUrl], + ['label' => '去看当前分类关键词商品', 'url' => $categoryKeywordUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($hasCategoryFilter && $hasKeywordFilter) { + return [ + 'headline' => '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对分类承接、命名卖点与搜索结果是否一致,并同步观察相关商品的价格带与库存结构。', + 'actions' => [ + ['label' => '继续查看当前分类关键词商品', 'url' => $currentUrl], + ['label' => '去看当前分类商品', 'url' => $categoryUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($hasCategoryFilter && $hasPriceRangeFilter) { + return [ + 'headline' => '当前筛选已聚焦“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类价格结构是否连贯,并同步观察库存分布与承接效率是否健康。', + 'actions' => [ + ['label' => '继续查看当前分类价格带商品', 'url' => $currentUrl], + ['label' => '去看当前分类商品', 'url' => $categoryUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($isPublished && $hasCategoryFilter) { + return [ + 'headline' => '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类商品,建议优先核对该分类在售商品的价格带、库存结构与承接质量是否均衡。', + 'actions' => [ + ['label' => '继续查看当前已上架分类商品', 'url' => $currentUrl], + ['label' => '去看当前分类商品', 'url' => $categoryUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($hasCategoryFilter) { + return [ + 'headline' => '当前筛选已聚焦“' . $categoryLabel . '”分类商品,建议优先核对分类承接是否准确,并同步观察价格带与库存结构是否均衡。', + 'actions' => [ + ['label' => '继续查看当前分类商品', 'url' => $currentUrl], + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($hasKeywordFilter) { + return [ + 'headline' => '当前筛选已聚焦关键词“' . $keyword . '”命中的商品,建议优先核对命名、卖点与搜索承接是否一致,并同步观察相关商品的价格带与库存结构。', + 'actions' => [ + ['label' => '继续查看当前关键词商品', 'url' => $currentUrl], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($hasPriceRangeFilter) { + return [ + 'headline' => '当前筛选已聚焦价格带 ' . $priceRange . ' 商品,建议优先核对定价梯度是否连贯,并同步观察库存结构与转化表现是否匹配。', + 'actions' => [ + ['label' => '继续查看当前价格带商品', 'url' => $currentUrl], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if ($isPublished) { + return [ + 'headline' => '当前正在查看已上架商品,建议优先关注库存结构、价格带与分类覆盖是否均衡。', + 'actions' => [ + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ['label' => '继续查看已上架商品', 'url' => $currentUrl], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($summaryStats['total_products'] ?? 0) <= 0) { + return [ + 'headline' => '当前站点暂无商品,建议先补齐基础商品数据,再开始做上架与库存运营。', + 'actions' => [ + ['label' => '先看商品空白情况', 'url' => $links['latest']], + ['label' => '查看草稿商品', 'url' => $links['draft']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + if (($summaryStats['total_products'] ?? 0) < 3) { + return [ + 'headline' => '当前站点商品仍较少,建议优先查看最近新增与已上架商品,确认基础信息是否完整。', + 'actions' => [ + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ['label' => '去看已上架商品', 'url' => $links['published_stock_desc']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } + + return [ + 'headline' => $lowStockCount > 0 + ? '当前站点商品已形成基础规模,建议优先巡检低库存商品,并同步关注高库存结构是否均衡。' + : '当前站点商品已形成基础规模,建议优先关注高库存结构与最近新增商品质量。', + 'actions' => $lowStockCount > 0 + ? [ + ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ] + : [ + ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], + ['label' => '去看最近新增商品', 'url' => $links['latest']], + ], + 'workbench' => $workbench, + 'signals' => $signals, + ]; + } +} diff --git a/app/Http/Controllers/Wechat/MiniProgramController.php b/app/Http/Controllers/Wechat/MiniProgramController.php new file mode 100644 index 0000000..9a2081e --- /dev/null +++ b/app/Http/Controllers/Wechat/MiniProgramController.php @@ -0,0 +1,18 @@ +json([ + 'ok' => true, + 'channel' => 'wechat_mini', + 'message' => '微信小程序接口占位已预留', + ]); + } +} diff --git a/app/Http/Controllers/Wechat/MpController.php b/app/Http/Controllers/Wechat/MpController.php new file mode 100644 index 0000000..0032592 --- /dev/null +++ b/app/Http/Controllers/Wechat/MpController.php @@ -0,0 +1,18 @@ +json([ + 'ok' => true, + 'channel' => 'wechat_mp', + 'message' => '微信公众号接口占位已预留', + ]); + } +} diff --git a/app/Http/Middleware/AdminAuth.php b/app/Http/Middleware/AdminAuth.php new file mode 100644 index 0000000..45a0479 --- /dev/null +++ b/app/Http/Middleware/AdminAuth.php @@ -0,0 +1,34 @@ +session()->get('admin_id'); + + if (! $adminId) { + return redirect('/admin/login'); + } + + $admin = Admin::query()->find($adminId); + + if (! $admin || ! $admin->isPlatformAdmin()) { + abort(403, '当前账号没有总台管理访问权限'); + } + + $request->session()->put('admin_scope', $admin->platformLabel()); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_name', $admin->name); + $request->session()->put('admin_email', $admin->email); + $request->session()->put('admin_merchant_id', null); + + return $next($request); + } +} diff --git a/app/Http/Middleware/MerchantAdminAuth.php b/app/Http/Middleware/MerchantAdminAuth.php new file mode 100644 index 0000000..ed5bceb --- /dev/null +++ b/app/Http/Middleware/MerchantAdminAuth.php @@ -0,0 +1,35 @@ +session()->get('admin_id'); + + if (! $adminId) { + return redirect('/merchant-admin/login'); + } + + $admin = Admin::query()->with('merchant')->find($adminId); + + if (! $admin || ! $admin->isMerchantAdmin()) { + abort(403, '当前账号未绑定商家后台访问权限'); + } + + $request->session()->put('admin_scope', 'merchant'); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_name', $admin->name); + $request->session()->put('admin_email', $admin->email); + $request->session()->put('admin_merchant_id', $admin->merchantId()); + $request->session()->put('merchant_name', $admin->merchant?->name); + + return $next($request); + } +} diff --git a/app/Http/Middleware/SiteAdminAuth.php b/app/Http/Middleware/SiteAdminAuth.php new file mode 100644 index 0000000..814759f --- /dev/null +++ b/app/Http/Middleware/SiteAdminAuth.php @@ -0,0 +1,36 @@ +session()->get('admin_id'); + + if (! $adminId) { + return redirect('/site-admin/login'); + } + + $admin = Admin::query()->with('merchant')->find($adminId); + + if (! $admin || ! $admin->isMerchantAdmin()) { + abort(403, '当前账号未绑定站点后台访问权限'); + } + + $request->session()->put('admin_scope', 'site'); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_name', $admin->name); + $request->session()->put('admin_email', $admin->email); + $request->session()->put('admin_merchant_id', $admin->merchantId()); + $request->session()->put('admin_site_id', $admin->merchantId()); + $request->session()->put('site_name', $admin->merchant?->name); + + return $next($request); + } +} diff --git a/app/Models/Admin.php b/app/Models/Admin.php new file mode 100644 index 0000000..60762a2 --- /dev/null +++ b/app/Models/Admin.php @@ -0,0 +1,49 @@ + 'datetime', + ]; + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class, 'merchant_id'); + } + + public function merchantId(): ?int + { + return $this->merchant_id ? (int) $this->merchant_id : null; + } + + public function isPlatformAdmin(): bool + { + return $this->merchantId() === null; + } + + public function isMerchantAdmin(): bool + { + return $this->merchantId() !== null; + } + + public function platformLabel(): string + { + return $this->isPlatformAdmin() ? 'platform' : 'merchant'; + } +} diff --git a/app/Models/ChannelConfig.php b/app/Models/ChannelConfig.php new file mode 100644 index 0000000..79aa0b9 --- /dev/null +++ b/app/Models/ChannelConfig.php @@ -0,0 +1,23 @@ + 'boolean', + 'supports_payment' => 'boolean', + 'supports_share' => 'boolean', + 'settings' => 'array', + ]; +} diff --git a/app/Models/Merchant.php b/app/Models/Merchant.php new file mode 100644 index 0000000..684f2cb --- /dev/null +++ b/app/Models/Merchant.php @@ -0,0 +1,50 @@ + 'datetime', + 'activated_at' => 'datetime', + 'settings' => 'array', + ]; + + public function admins(): HasMany + { + return $this->hasMany(Admin::class, 'merchant_id'); + } + + public function users(): HasMany + { + return $this->hasMany(User::class, 'merchant_id'); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class, 'merchant_id'); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class, 'merchant_id'); + } + + public function categories(): HasMany + { + return $this->hasMany(ProductCategory::class, 'merchant_id'); + } +} diff --git a/app/Models/OauthAccount.php b/app/Models/OauthAccount.php new file mode 100644 index 0000000..bf12161 --- /dev/null +++ b/app/Models/OauthAccount.php @@ -0,0 +1,30 @@ + 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class, 'merchant_id'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..d233521 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,50 @@ + 'decimal:2', + 'discount_amount' => 'decimal:2', + 'shipping_amount' => 'decimal:2', + 'pay_amount' => 'decimal:2', + 'paid_at' => 'datetime', + 'shipped_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class, 'merchant_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function items(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function scopeForMerchant(Builder $query, int $merchantId): Builder + { + return $query->where('merchant_id', $merchantId); + } +} diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php new file mode 100644 index 0000000..2c60a7c --- /dev/null +++ b/app/Models/OrderItem.php @@ -0,0 +1,43 @@ + 'decimal:2', + 'line_total_amount' => 'decimal:2', + 'snapshot' => 'array', + ]; + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class, 'merchant_id'); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function scopeForMerchant(Builder $query, int $merchantId): Builder + { + return $query->where('merchant_id', $merchantId); + } +} diff --git a/app/Models/PaymentConfig.php b/app/Models/PaymentConfig.php new file mode 100644 index 0000000..3271323 --- /dev/null +++ b/app/Models/PaymentConfig.php @@ -0,0 +1,21 @@ + 'boolean', + 'supports_refund' => 'boolean', + 'settings' => 'array', + ]; +} diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 0000000..63011df --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,33 @@ + 'decimal:2', + 'list_price' => 'decimal:2', + 'settings' => 'array', + 'published_at' => 'datetime', + ]; + + public function subscriptions(): HasMany + { + return $this->hasMany(SiteSubscription::class); + } + + public function platformOrders(): HasMany + { + return $this->hasMany(PlatformOrder::class); + } +} diff --git a/app/Models/PlatformOrder.php b/app/Models/PlatformOrder.php new file mode 100644 index 0000000..22306c7 --- /dev/null +++ b/app/Models/PlatformOrder.php @@ -0,0 +1,53 @@ + 'decimal:2', + 'discount_amount' => 'decimal:2', + 'payable_amount' => 'decimal:2', + 'paid_amount' => 'decimal:2', + 'placed_at' => 'datetime', + 'paid_at' => 'datetime', + 'activated_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'refunded_at' => 'datetime', + 'plan_snapshot' => 'array', + 'meta' => 'array', + ]; + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class, 'merchant_id'); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + public function siteSubscription(): BelongsTo + { + return $this->belongsTo(SiteSubscription::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(Admin::class, 'created_by_admin_id'); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..263110f --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,44 @@ + 'decimal:2', + 'original_price' => 'decimal:2', + 'images' => 'array', + ]; + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class, 'merchant_id'); + } + + public function category(): BelongsTo + { + return $this->belongsTo(ProductCategory::class, 'category_id'); + } + + public function orderItems(): HasMany + { + return $this->hasMany(OrderItem::class); + } + + public function scopeForMerchant(Builder $query, int $merchantId): Builder + { + return $query->where('merchant_id', $merchantId); + } +} diff --git a/app/Models/ProductCategory.php b/app/Models/ProductCategory.php new file mode 100644 index 0000000..a5ad539 --- /dev/null +++ b/app/Models/ProductCategory.php @@ -0,0 +1,33 @@ +belongsTo(Merchant::class, 'merchant_id'); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class, 'category_id'); + } + + public function scopeForMerchant(Builder $query, int $merchantId): Builder + { + return $query->where('merchant_id', $merchantId); + } +} diff --git a/app/Models/ProductImportHistory.php b/app/Models/ProductImportHistory.php new file mode 100644 index 0000000..d61ae58 --- /dev/null +++ b/app/Models/ProductImportHistory.php @@ -0,0 +1,37 @@ + 'datetime', + ]; + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class); + } + + public function admin(): BelongsTo + { + return $this->belongsTo(Admin::class); + } +} diff --git a/app/Models/SiteSubscription.php b/app/Models/SiteSubscription.php new file mode 100644 index 0000000..e4bd22c --- /dev/null +++ b/app/Models/SiteSubscription.php @@ -0,0 +1,44 @@ + 'decimal:2', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'trial_ends_at' => 'datetime', + 'activated_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'snapshot' => 'array', + 'meta' => 'array', + ]; + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class, 'merchant_id'); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + public function platformOrders(): HasMany + { + return $this->hasMany(PlatformOrder::class); + } +} diff --git a/app/Models/SystemConfig.php b/app/Models/SystemConfig.php new file mode 100644 index 0000000..30b691a --- /dev/null +++ b/app/Models/SystemConfig.php @@ -0,0 +1,19 @@ + 'boolean', + ]; +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..0007364 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,53 @@ + 'datetime', + 'password' => 'hashed', + ]; + } + + public function merchant(): BelongsTo + { + return $this->belongsTo(Merchant::class, 'merchant_id'); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function oauthAccounts(): HasMany + { + return $this->hasMany(OauthAccount::class); + } + + public function scopeForMerchant(Builder $query, int $merchantId): Builder + { + return $query->where('merchant_id', $merchantId); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ +json([ + 'code' => $code, + 'message' => $message, + 'data' => $data, + 'server_time' => now()->toDateTimeString(), + ]); + } + + public static function error(string $message = 'error', int $code = 1, mixed $data = null, int $httpStatus = 400): JsonResponse + { + return response()->json([ + 'code' => $code, + 'message' => $message, + 'data' => $data, + 'server_time' => now()->toDateTimeString(), + ], $httpStatus); + } +} diff --git a/app/Support/CacheKeys.php b/app/Support/CacheKeys.php new file mode 100644 index 0000000..2079512 --- /dev/null +++ b/app/Support/CacheKeys.php @@ -0,0 +1,183 @@ +get(self::merchantProductsVersion($merchantId), 1); + + return "merchant:{$merchantId}:products:v{$version}:list:page:{$page}:filters:{$suffix}"; + } + + public static function merchantProductsStatusStats(int $merchantId, array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::merchantProductsVersion($merchantId), 1); + + return "merchant:{$merchantId}:products:v{$version}:status-stats:filters:{$suffix}"; + } + + public static function merchantProductsSummary(int $merchantId, array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::merchantProductsVersion($merchantId), 1); + + return "merchant:{$merchantId}:products:v{$version}:summary:filters:{$suffix}"; + } + + public static function platformDashboardStats(): string + { + return 'platform:dashboard:stats'; + } + + public static function platformProductsVersion(): string + { + return 'platform:products:version'; + } + + public static function platformMerchantsList(int $page = 1): string + { + return "platform:merchants:list:page:{$page}"; + } + + public static function merchantOrdersVersion(int $merchantId): string + { + return "merchant:{$merchantId}:orders:version"; + } + + public static function merchantOrdersList(int $merchantId, int $page = 1, array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::merchantOrdersVersion($merchantId), 1); + + return "merchant:{$merchantId}:orders:v{$version}:list:page:{$page}:filters:{$suffix}"; + } + + public static function merchantOrdersStatusStats(int $merchantId, array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::merchantOrdersVersion($merchantId), 1); + + return "merchant:{$merchantId}:orders:v{$version}:status-stats:filters:{$suffix}"; + } + + public static function merchantOrdersSummary(int $merchantId, array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::merchantOrdersVersion($merchantId), 1); + + return "merchant:{$merchantId}:orders:v{$version}:summary:filters:{$suffix}"; + } + + public static function merchantOrdersTrendSummary(int $merchantId, array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::merchantOrdersVersion($merchantId), 1); + + return "merchant:{$merchantId}:orders:v{$version}:trend-summary:filters:{$suffix}"; + } + + public static function platformOrdersVersion(): string + { + return 'platform:orders:version'; + } + + public static function platformOrdersList(int $page = 1, array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::platformOrdersVersion(), 1); + + return "platform:orders:v{$version}:list:page:{$page}:filters:{$suffix}"; + } + + public static function platformOrdersStatusStats(array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::platformOrdersVersion(), 1); + + return "platform:orders:v{$version}:status-stats:filters:{$suffix}"; + } + + public static function platformOrdersSummary(array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::platformOrdersVersion(), 1); + + return "platform:orders:v{$version}:summary:filters:{$suffix}"; + } + + public static function platformOrdersTrendSummary(array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::platformOrdersVersion(), 1); + + return "platform:orders:v{$version}:trend-summary:filters:{$suffix}"; + } + + public static function platformProductsList(int $page = 1, array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::platformProductsVersion(), 1); + + return "platform:products:v{$version}:list:page:{$page}:filters:{$suffix}"; + } + + public static function platformProductsStatusStats(array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::platformProductsVersion(), 1); + + return "platform:products:v{$version}:status-stats:filters:{$suffix}"; + } + + public static function platformProductsSummary(array $filters = []): string + { + $suffix = self::filtersSuffix($filters); + $version = (int) cache()->get(self::platformProductsVersion(), 1); + + return "platform:products:v{$version}:summary:filters:{$suffix}"; + } + + public static function merchantCategoriesList(int $merchantId, int $page = 1): string + { + return "merchant:{$merchantId}:categories:list:page:{$page}"; + } + + public static function merchantUsersList(int $merchantId, int $page = 1): string + { + return "merchant:{$merchantId}:users:list:page:{$page}"; + } + + public static function platformCategoriesList(int $page = 1): string + { + return "platform:categories:list:page:{$page}"; + } + + public static function platformSystemConfigs(): string + { + return 'platform:settings:system'; + } + + public static function platformChannelsOverview(): string + { + return 'platform:settings:channels'; + } +} diff --git a/app/Support/SubscriptionActivationService.php b/app/Support/SubscriptionActivationService.php new file mode 100644 index 0000000..ccce05f --- /dev/null +++ b/app/Support/SubscriptionActivationService.php @@ -0,0 +1,138 @@ + 续期/延长该订阅 + * 2) 未绑定 => 创建新订阅并回写订单 site_subscription_id + */ +class SubscriptionActivationService +{ + /** + * @throws ModelNotFoundException + */ + public function activateOrder(int $orderId, ?int $adminId = null): SiteSubscription + { + /** @var PlatformOrder $order */ + $order = PlatformOrder::query()->findOrFail($orderId); + + // 最小校验:必须已支付 + 已生效 + if ($order->payment_status !== 'paid' || $order->status !== 'activated') { + throw new \InvalidArgumentException('订单未满足生效条件:需 payment_status=paid 且 status=activated'); + } + + $now = now(); + $months = max(1, (int) $order->period_months) * max(1, (int) ($order->quantity ?? 1)); + + // 幂等保护:若该订单已同步过订阅,则直接返回对应订阅(避免重复点击导致无限续期) + $activationMeta = (array) data_get($order->meta ?? [], 'subscription_activation', []); + $activatedSubscriptionId = (int) ($activationMeta['subscription_id'] ?? 0); + if ($activatedSubscriptionId > 0) { + $subscription = SiteSubscription::query()->find($activatedSubscriptionId); + if ($subscription) { + return $subscription; + } + } + + // 订阅快照:优先使用订单上记录的 plan_name / billing_cycle / plan_snapshot + $snapshot = [ + 'from_order_id' => $order->id, + 'order_no' => $order->order_no, + 'order_type' => $order->order_type, + 'plan_snapshot' => $order->plan_snapshot, + ]; + + if ($order->site_subscription_id) { + /** @var SiteSubscription $subscription */ + $subscription = SiteSubscription::query()->findOrFail($order->site_subscription_id); + + // 以 ends_at 为基准续期: + // - 若 ends_at 为空或已过期 => 从 now 起算 + // - 若仍有效 => 从 ends_at 起算 + $base = $subscription->ends_at && $subscription->ends_at->greaterThan($now) + ? $subscription->ends_at->copy() + : $now->copy(); + + $newEndsAt = $base->copy()->addMonthsNoOverflow($months); + + // starts_at:若为空则补齐 + $startsAt = $subscription->starts_at ?: $now->copy(); + + $subscription->fill([ + 'status' => 'activated', + 'plan_id' => $order->plan_id, + 'plan_name' => $order->plan_name, + 'billing_cycle' => $order->billing_cycle, + 'period_months' => (int) $order->period_months, + 'amount' => $order->paid_amount > 0 ? $order->paid_amount : $order->payable_amount, + 'starts_at' => $startsAt, + 'ends_at' => $newEndsAt, + 'activated_at' => $subscription->activated_at ?: $now, + 'snapshot' => array_merge((array) ($subscription->snapshot ?? []), $snapshot), + ]); + $subscription->save(); + + // 写入订单 meta,标记该订单已完成订阅同步(幂等) + $meta = (array) ($order->meta ?? []); + data_set($meta, 'subscription_activation', [ + 'subscription_id' => $subscription->id, + 'synced_at' => $now->toDateTimeString(), + 'admin_id' => $adminId, + ]); + $order->meta = $meta; + $order->save(); + + return $subscription; + } + + // 创建新订阅 + $subscriptionNo = 'SUB' . $now->format('YmdHis') . Str::padLeft((string) random_int(1, 9999), 4, '0'); + + $subscription = SiteSubscription::query()->create([ + 'merchant_id' => $order->merchant_id, + 'plan_id' => $order->plan_id, + 'status' => 'activated', + 'source' => 'platform_order', + 'subscription_no' => $subscriptionNo, + 'plan_name' => $order->plan_name, + 'billing_cycle' => $order->billing_cycle, + 'period_months' => (int) $order->period_months, + 'amount' => $order->paid_amount > 0 ? $order->paid_amount : $order->payable_amount, + 'starts_at' => $now, + 'ends_at' => $now->copy()->addMonthsNoOverflow($months), + 'activated_at' => $now, + 'snapshot' => $snapshot, + 'meta' => [ + 'activated_by_admin_id' => $adminId, + ], + ]); + + // 回写订单 + 写入 meta 标记同步完成(幂等) + $order->site_subscription_id = $subscription->id; + $order->activated_at = $order->activated_at ?: $now; + + $meta = (array) ($order->meta ?? []); + data_set($meta, 'subscription_activation', [ + 'subscription_id' => $subscription->id, + 'synced_at' => $now->toDateTimeString(), + 'admin_id' => $adminId, + ]); + $order->meta = $meta; + + $order->save(); + + return $subscription; + } +} diff --git a/artisan b/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..04d6ba8 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,23 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'admin.auth' => \App\Http\Middleware\AdminAuth::class, + 'merchant.admin.auth' => \App\Http\Middleware\MerchantAdminAuth::class, + 'site.admin.auth' => \App\Http\Middleware\SiteAdminAuth::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..38b258d --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,5 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:27:06+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.53.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/f57f035c0d34503d9ff30be76159bb35a003cd1f", + "reference": "f57f035c0d34503d9ff30be76159bb35a003cd1f", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.7", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-24T14:35:15+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", + "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.13" + }, + "time": "2026-02-06T12:17:10+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.10", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-02-20T19:59:49+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "84b1ca48347efdbe775426f108622a42735a6579" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", + "reference": "84b1ca48347efdbe775426f108622a42735a6579", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-05T21:37:03+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.32.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + }, + "time": "2026-02-25T17:01:41+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-14T17:24:56+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-15T06:54:53+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:26:29+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.3", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.3" + }, + "time": "2026-02-13T03:05:33+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.21", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", + "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.21" + }, + "time": "2026-03-06T21:21:28+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:39:26+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T14:06:20+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", + "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-17T07:53:42+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-20T16:42:42+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T11:45:34+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:40:50+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:15:18+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T16:33:18+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:50:00+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-05T15:24:09+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "608476f4604102976d687c483ac63a79ba18cc97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:07:59+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:50:00+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-09T09:33:46+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "1888cf064399868af3784b9e043240f1d89d25ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce", + "reference": "1888cf064399868af3784b9e043240f1d89d25ce", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-17T07:53:42+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:30:35+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-15T10:53:20+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-02-09T13:44:54+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.27.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", + "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.93.1", + "illuminate/view": "^12.51.0", + "larastan/larastan": "^3.9.2", + "laravel-zero/framework": "^12.0.5", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.5" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-02-10T20:00:20+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.53.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", + "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-02-06T12:16:02+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.1", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-02-17T17:33:08+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-09T09:33:46+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..423eed5 --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..7d1eb0d --- /dev/null +++ b/config/auth.php @@ -0,0 +1,115 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', App\Models\User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..df933e7 --- /dev/null +++ b/config/database.php @@ -0,0 +1,183 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..9e998a4 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..522b284 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..6a90eb8 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..584104c --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,44 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..207003a --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,52 @@ +id(); + $table->unsignedBigInteger('merchant_id')->nullable()->index(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('phone', 30)->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->string('status', 30)->default('active'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_03_08_124619_create_merchants_table.php b/database/migrations/2026_03_08_124619_create_merchants_table.php new file mode 100644 index 0000000..4da310d --- /dev/null +++ b/database/migrations/2026_03_08_124619_create_merchants_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('domain')->nullable()->unique(); + $table->string('contact_name')->nullable(); + $table->string('contact_phone', 30)->nullable(); + $table->string('contact_email')->nullable(); + $table->string('plan', 50)->default('basic'); + $table->string('status', 30)->default('active'); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->json('settings')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('merchants'); + } +}; diff --git a/database/migrations/2026_03_08_124620_create_admins_table.php b/database/migrations/2026_03_08_124620_create_admins_table.php new file mode 100644 index 0000000..f8728ab --- /dev/null +++ b/database/migrations/2026_03_08_124620_create_admins_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('merchant_id')->nullable()->constrained('merchants')->nullOnDelete(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('phone', 30)->nullable(); + $table->string('password'); + $table->string('role', 30)->default('owner'); + $table->string('status', 30)->default('active'); + $table->timestamp('last_login_at')->nullable(); + $table->rememberToken(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('admins'); + } +}; diff --git a/database/migrations/2026_03_08_124620_create_orders_table.php b/database/migrations/2026_03_08_124620_create_orders_table.php new file mode 100644 index 0000000..fc51816 --- /dev/null +++ b/database/migrations/2026_03_08_124620_create_orders_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('order_no', 50)->unique(); + $table->string('status', 30)->default('pending'); + $table->decimal('product_amount', 10, 2)->default(0); + $table->decimal('discount_amount', 10, 2)->default(0); + $table->decimal('shipping_amount', 10, 2)->default(0); + $table->decimal('pay_amount', 10, 2)->default(0); + $table->string('buyer_name')->nullable(); + $table->string('buyer_phone', 30)->nullable(); + $table->string('buyer_email')->nullable(); + $table->text('remark')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_03_08_124620_create_products_table.php b/database/migrations/2026_03_08_124620_create_products_table.php new file mode 100644 index 0000000..7ef8f8c --- /dev/null +++ b/database/migrations/2026_03_08_124620_create_products_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete(); + $table->string('title'); + $table->string('slug'); + $table->string('sku', 80)->unique(); + $table->text('summary')->nullable(); + $table->longText('content')->nullable(); + $table->decimal('price', 10, 2)->default(0); + $table->decimal('original_price', 10, 2)->nullable(); + $table->integer('stock')->default(0); + $table->string('status', 30)->default('draft'); + $table->json('images')->nullable(); + $table->timestamps(); + $table->unique(['merchant_id', 'slug']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_03_08_125847_add_multi_platform_fields_to_users_and_orders_table.php b/database/migrations/2026_03_08_125847_add_multi_platform_fields_to_users_and_orders_table.php new file mode 100644 index 0000000..330adb1 --- /dev/null +++ b/database/migrations/2026_03_08_125847_add_multi_platform_fields_to_users_and_orders_table.php @@ -0,0 +1,43 @@ +string('register_source', 30)->default('pc')->after('status'); + $table->string('last_login_source', 30)->nullable()->after('register_source'); + $table->string('wechat_openid', 64)->nullable()->after('last_login_source'); + $table->string('wechat_unionid', 64)->nullable()->after('wechat_openid'); + $table->string('mini_openid', 64)->nullable()->after('wechat_unionid'); + }); + + Schema::table('orders', function (Blueprint $table) { + $table->string('platform', 30)->default('pc')->after('status'); + $table->string('payment_channel', 30)->nullable()->after('platform'); + $table->string('payment_status', 30)->default('unpaid')->after('payment_channel'); + $table->string('device_type', 30)->nullable()->after('payment_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['register_source', 'last_login_source', 'wechat_openid', 'wechat_unionid', 'mini_openid']); + }); + + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn(['platform', 'payment_channel', 'payment_status', 'device_type']); + }); + } +}; diff --git a/database/migrations/2026_03_08_125847_create_oauth_accounts_table.php b/database/migrations/2026_03_08_125847_create_oauth_accounts_table.php new file mode 100644 index 0000000..75d69da --- /dev/null +++ b/database/migrations/2026_03_08_125847_create_oauth_accounts_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('merchant_id')->nullable()->constrained('merchants')->nullOnDelete(); + $table->string('platform', 30); + $table->string('provider', 30); + $table->string('openid', 100)->nullable(); + $table->string('unionid', 100)->nullable(); + $table->string('app_id', 100)->nullable(); + $table->string('nickname')->nullable(); + $table->string('avatar')->nullable(); + $table->json('raw_payload')->nullable(); + $table->timestamps(); + $table->index(['platform', 'provider']); + $table->unique(['provider', 'openid']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_accounts'); + } +}; diff --git a/database/migrations/2026_03_08_140000_create_system_configs_table.php b/database/migrations/2026_03_08_140000_create_system_configs_table.php new file mode 100644 index 0000000..856a581 --- /dev/null +++ b/database/migrations/2026_03_08_140000_create_system_configs_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('config_key')->unique(); + $table->string('config_name'); + $table->text('config_value')->nullable(); + $table->string('value_type', 30)->default('string'); + $table->boolean('autoload')->default(true); + $table->string('group', 50)->default('system')->index(); + $table->string('remark')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('system_configs'); + } +}; diff --git a/database/migrations/2026_03_08_140100_create_channel_configs_table.php b/database/migrations/2026_03_08_140100_create_channel_configs_table.php new file mode 100644 index 0000000..a3ef8e0 --- /dev/null +++ b/database/migrations/2026_03_08_140100_create_channel_configs_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('channel_code', 50)->unique(); + $table->string('channel_name'); + $table->string('channel_type', 50)->default('sales'); + $table->string('status', 30)->default('enabled')->index(); + $table->string('entry_path')->nullable(); + $table->boolean('supports_login')->default(false); + $table->boolean('supports_payment')->default(false); + $table->boolean('supports_share')->default(false); + $table->unsignedInteger('sort')->default(0); + $table->json('settings')->nullable(); + $table->string('remark')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('channel_configs'); + } +}; diff --git a/database/migrations/2026_03_08_140200_create_payment_configs_table.php b/database/migrations/2026_03_08_140200_create_payment_configs_table.php new file mode 100644 index 0000000..ff474f5 --- /dev/null +++ b/database/migrations/2026_03_08_140200_create_payment_configs_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('payment_code', 50)->unique(); + $table->string('payment_name'); + $table->string('provider', 50); + $table->string('status', 30)->default('disabled')->index(); + $table->boolean('is_sandbox')->default(true); + $table->boolean('supports_refund')->default(false); + $table->json('settings')->nullable(); + $table->string('remark')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_configs'); + } +}; diff --git a/database/migrations/2026_03_08_142700_create_product_categories_table.php b/database/migrations/2026_03_08_142700_create_product_categories_table.php new file mode 100644 index 0000000..15f456a --- /dev/null +++ b/database/migrations/2026_03_08_142700_create_product_categories_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->string('status', 30)->default('active'); + $table->unsignedInteger('sort')->default(0); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->unique(['merchant_id', 'slug']); + }); + + Schema::table('products', function (Blueprint $table) { + $table->foreignId('category_id')->nullable()->after('merchant_id')->constrained('product_categories')->nullOnDelete(); + $table->index(['merchant_id', 'category_id']); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropConstrainedForeignId('category_id'); + }); + + Schema::dropIfExists('product_categories'); + } +}; diff --git a/database/migrations/2026_03_08_142710_create_order_items_table.php b/database/migrations/2026_03_08_142710_create_order_items_table.php new file mode 100644 index 0000000..e74479e --- /dev/null +++ b/database/migrations/2026_03_08_142710_create_order_items_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->string('product_title'); + $table->string('product_sku', 80)->nullable(); + $table->decimal('product_price', 10, 2)->default(0); + $table->unsignedInteger('quantity')->default(1); + $table->decimal('line_total_amount', 10, 2)->default(0); + $table->json('snapshot')->nullable(); + $table->timestamps(); + + $table->index(['merchant_id', 'order_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_items'); + } +}; diff --git a/database/migrations/2026_03_09_000100_create_product_import_histories_table.php b/database/migrations/2026_03_09_000100_create_product_import_histories_table.php new file mode 100644 index 0000000..a7f8de9 --- /dev/null +++ b/database/migrations/2026_03_09_000100_create_product_import_histories_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('scope', 30); + $table->foreignId('merchant_id')->nullable()->constrained('merchants')->nullOnDelete(); + $table->foreignId('admin_id')->nullable()->constrained('admins')->nullOnDelete(); + $table->string('file_name'); + $table->unsignedInteger('success_count')->default(0); + $table->unsignedInteger('failed_count')->default(0); + $table->string('failure_file')->nullable(); + $table->timestamp('imported_at'); + $table->timestamps(); + + $table->index(['scope', 'imported_at']); + $table->index(['merchant_id', 'imported_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_import_histories'); + } +}; diff --git a/database/migrations/2026_03_10_000100_create_plans_table.php b/database/migrations/2026_03_10_000100_create_plans_table.php new file mode 100644 index 0000000..8dfb68f --- /dev/null +++ b/database/migrations/2026_03_10_000100_create_plans_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('code', 50)->unique(); + $table->string('name', 100); + $table->string('billing_cycle', 30)->default('monthly'); + $table->decimal('price', 10, 2)->default(0); + $table->decimal('list_price', 10, 2)->default(0); + $table->string('status', 30)->default('active'); + $table->unsignedInteger('sort')->default(0); + $table->text('description')->nullable(); + $table->json('settings')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('plans'); + } +}; diff --git a/database/migrations/2026_03_10_000200_create_site_subscriptions_table.php b/database/migrations/2026_03_10_000200_create_site_subscriptions_table.php new file mode 100644 index 0000000..798fecd --- /dev/null +++ b/database/migrations/2026_03_10_000200_create_site_subscriptions_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete(); + $table->foreignId('plan_id')->nullable()->constrained('plans')->nullOnDelete(); + $table->string('status', 30)->default('pending'); + $table->string('source', 30)->default('manual'); + $table->string('subscription_no', 50)->unique(); + $table->string('plan_name', 100)->nullable(); + $table->string('billing_cycle', 30)->nullable(); + $table->unsignedInteger('period_months')->default(1); + $table->decimal('amount', 10, 2)->default(0); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->json('snapshot')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['merchant_id', 'status']); + $table->index(['plan_id', 'status']); + $table->index('ends_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('site_subscriptions'); + } +}; diff --git a/database/migrations/2026_03_10_000300_create_platform_orders_table.php b/database/migrations/2026_03_10_000300_create_platform_orders_table.php new file mode 100644 index 0000000..6ee7997 --- /dev/null +++ b/database/migrations/2026_03_10_000300_create_platform_orders_table.php @@ -0,0 +1,52 @@ +id(); + $table->foreignId('merchant_id')->constrained('merchants')->cascadeOnDelete(); + $table->foreignId('plan_id')->nullable()->constrained('plans')->nullOnDelete(); + $table->foreignId('site_subscription_id')->nullable()->constrained('site_subscriptions')->nullOnDelete(); + $table->foreignId('created_by_admin_id')->nullable()->constrained('admins')->nullOnDelete(); + $table->string('order_no', 50)->unique(); + $table->string('order_type', 30)->default('new_purchase'); + $table->string('status', 30)->default('pending'); + $table->string('payment_status', 30)->default('unpaid'); + $table->string('payment_channel', 30)->nullable(); + $table->string('plan_name', 100)->nullable(); + $table->string('billing_cycle', 30)->nullable(); + $table->unsignedInteger('period_months')->default(1); + $table->unsignedInteger('quantity')->default(1); + $table->decimal('list_amount', 10, 2)->default(0); + $table->decimal('discount_amount', 10, 2)->default(0); + $table->decimal('payable_amount', 10, 2)->default(0); + $table->decimal('paid_amount', 10, 2)->default(0); + $table->timestamp('placed_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamp('refunded_at')->nullable(); + $table->json('plan_snapshot')->nullable(); + $table->json('meta')->nullable(); + $table->text('remark')->nullable(); + $table->timestamps(); + + $table->index(['merchant_id', 'status']); + $table->index(['payment_status', 'status']); + $table->index(['plan_id', 'status']); + $table->index('placed_at'); + $table->index('paid_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_orders'); + } +}; diff --git a/database/migrations/V1__baseline.sql b/database/migrations/V1__baseline.sql new file mode 100644 index 0000000..ede165c --- /dev/null +++ b/database/migrations/V1__baseline.sql @@ -0,0 +1,3 @@ +-- V1 baseline +-- 说明:用于初始化 SQL 迁移体系的基线版本。 +-- 注意:本文件不做任何结构变更,仅作为版本占位。 diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..f0df367 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,15 @@ +call([ + InitialDemoSeeder::class, + ]); + } +} diff --git a/database/seeders/InitialDemoSeeder.php b/database/seeders/InitialDemoSeeder.php new file mode 100644 index 0000000..86569bf --- /dev/null +++ b/database/seeders/InitialDemoSeeder.php @@ -0,0 +1,392 @@ +seedPlatformConfigs(); + + $merchant = Merchant::query()->firstOrCreate( + ['slug' => 'demo-shop'], + [ + 'name' => '演示店铺', + 'domain' => null, + 'contact_name' => '林哥', + 'contact_phone' => '13800000000', + 'contact_email' => 'demo@example.com', + 'plan' => 'pro', + 'status' => 'active', + 'activated_at' => now(), + 'settings' => ['currency' => 'CNY'], + ] + ); + + $platformAdmin = Admin::query()->updateOrCreate( + ['email' => 'platform.admin@demo.local'], + [ + 'merchant_id' => null, + 'name' => '平台管理员', + 'phone' => '13900000000', + 'password' => Hash::make('Platform@123456'), + 'role' => 'platform_owner', + 'status' => 'active', + ] + ); + + $merchantAdmin = Admin::query()->updateOrCreate( + ['email' => 'merchant.admin@demo.local'], + [ + 'merchant_id' => $merchant->id, + 'name' => '商家管理员', + 'phone' => '13900000001', + 'password' => Hash::make('Merchant@123456'), + 'role' => 'merchant_owner', + 'status' => 'active', + ] + ); + + Admin::query()->where('email', 'admin@demo.local')->delete(); + + $user = User::query()->firstOrCreate( + ['email' => 'user@demo.local'], + [ + 'merchant_id' => $merchant->id, + 'name' => '演示用户', + 'phone' => '13700000000', + 'password' => Hash::make('User@123456'), + 'status' => 'active', + 'register_source' => 'pc', + 'last_login_source' => 'pc', + ] + ); + + $defaultCategory = ProductCategory::query()->firstOrCreate( + ['merchant_id' => $merchant->id, 'slug' => 'default'], + [ + 'name' => '默认分类', + 'status' => 'active', + 'sort' => 10, + 'description' => '系统初始化生成的默认商品分类', + ] + ); + + $featuredCategory = ProductCategory::query()->firstOrCreate( + ['merchant_id' => $merchant->id, 'slug' => 'featured'], + [ + 'name' => '精选商品', + 'status' => 'active', + 'sort' => 20, + 'description' => '用于首页和推荐位的精选分类', + ] + ); + + $product = Product::query()->firstOrCreate( + ['sku' => 'SKU-DEMO-001'], + [ + 'merchant_id' => $merchant->id, + 'category_id' => $defaultCategory->id, + 'title' => '演示商品', + 'slug' => 'demo-product', + 'summary' => '这是 SaaS 电商项目的演示商品。', + 'content' => '后续可扩展商品详情、规格、库存与多图。', + 'price' => 199.00, + 'original_price' => 299.00, + 'stock' => 100, + 'status' => 'published', + 'images' => ['https://via.placeholder.com/600x600.png?text=Demo+Product'], + ] + ); + + $plans = [ + [ + 'code' => 'starter_monthly', + 'name' => '基础版(月付)', + 'billing_cycle' => 'monthly', + 'price' => 99, + 'list_price' => 129, + 'status' => 'active', + 'sort' => 10, + 'description' => '适合刚开站的试运营阶段,可用于 Demo 场景。', + 'published_at' => now()->subDays(5), + ], + [ + 'code' => 'pro_yearly', + 'name' => '专业版(年付)', + 'billing_cycle' => 'yearly', + 'price' => 1999, + 'list_price' => 2599, + 'status' => 'active', + 'sort' => 20, + 'description' => '面向成长型站点,后续搭配授权项配置。', + 'published_at' => now()->subDays(3), + ], + ]; + + foreach ($plans as $planData) { + Plan::query()->updateOrCreate( + ['code' => $planData['code']], + $planData + ); + } + + $subscription = SiteSubscription::query()->firstOrCreate( + ['subscription_no' => 'SUB202603100001'], + [ + 'merchant_id' => $merchant->id, + 'plan_id' => Plan::query()->where('code', 'starter_monthly')->value('id'), + 'status' => 'activated', + 'source' => 'manual', + 'plan_name' => '基础版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'amount' => 99, + 'starts_at' => now()->subDays(20), + 'ends_at' => now()->addDays(10), + 'activated_at' => now()->subDays(20), + 'snapshot' => ['features' => ['基础功能']], + ] + ); + + PlatformOrder::query()->updateOrCreate( + ['order_no' => 'PO202603100001'], + [ + 'merchant_id' => $merchant->id, + 'plan_id' => $subscription->plan_id, + 'site_subscription_id' => $subscription->id, + 'created_by_admin_id' => $platformAdmin->id, + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'payment_channel' => 'manual', + 'plan_name' => '基础版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'list_amount' => 129, + 'discount_amount' => 30, + 'payable_amount' => 99, + 'paid_amount' => 99, + 'placed_at' => now()->subDays(21), + 'paid_at' => now()->subDays(20), + 'activated_at' => now()->subDays(20), + 'plan_snapshot' => ['name' => '基础版(月付)', 'cycle' => 'monthly'], + 'meta' => ['note' => '初始化演示订单'], + ] + ); + + $secondProduct = Product::query()->firstOrCreate( + ['sku' => 'SKU-DEMO-002'], + [ + 'merchant_id' => $merchant->id, + 'category_id' => $featuredCategory->id, + 'title' => '精选礼包', + 'slug' => 'featured-bundle', + 'summary' => '用于验证分类和订单明细的第二个演示商品。', + 'content' => '后续可以扩展为组合商品、营销礼包、加价购等场景。', + 'price' => 99.00, + 'original_price' => 129.00, + 'stock' => 50, + 'status' => 'published', + 'images' => ['https://via.placeholder.com/600x600.png?text=Featured+Bundle'], + ] + ); + + $orders = [ + [ + 'order_no' => 'ORD202603080001', + 'status' => 'pending', + 'platform' => 'pc', + 'payment_channel' => 'wechat_pay', + 'payment_status' => 'unpaid', + 'device_type' => 'desktop', + 'product_amount' => 397.00, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'pay_amount' => 397.00, + 'remark' => '初始化演示订单', + 'items' => [ + ['product' => $product, 'price' => 199.00, 'quantity' => 1, 'category' => $defaultCategory->name], + ['product' => $secondProduct, 'price' => 99.00, 'quantity' => 2, 'category' => $featuredCategory->name], + ], + ], + [ + 'order_no' => 'ORD202603080002', + 'status' => 'paid', + 'platform' => 'h5', + 'payment_channel' => 'alipay', + 'payment_status' => 'paid', + 'device_type' => 'mobile', + 'product_amount' => 99.00, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'pay_amount' => 99.00, + 'remark' => '已支付待发货演示订单', + 'items' => [ + ['product' => $secondProduct, 'price' => 99.00, 'quantity' => 1, 'category' => $featuredCategory->name], + ], + ], + [ + 'order_no' => 'ORD202603080003', + 'status' => 'shipped', + 'platform' => 'wechat_mini', + 'payment_channel' => 'wechat_pay', + 'payment_status' => 'paid', + 'device_type' => 'mini-program', + 'product_amount' => 199.00, + 'discount_amount' => 10.00, + 'shipping_amount' => 0, + 'pay_amount' => 189.00, + 'remark' => '已发货演示订单', + 'items' => [ + ['product' => $product, 'price' => 199.00, 'quantity' => 1, 'category' => $defaultCategory->name], + ], + ], + [ + 'order_no' => 'ORD202603080004', + 'status' => 'completed', + 'platform' => 'wechat_mp', + 'payment_channel' => 'wechat_pay', + 'payment_status' => 'paid', + 'device_type' => 'mobile-webview', + 'product_amount' => 99.00, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'pay_amount' => 99.00, + 'remark' => '已完成演示订单', + 'items' => [ + ['product' => $secondProduct, 'price' => 99.00, 'quantity' => 1, 'category' => $featuredCategory->name], + ], + ], + [ + 'order_no' => 'ORD202603080005', + 'status' => 'cancelled', + 'platform' => 'app', + 'payment_channel' => 'wechat_pay', + 'payment_status' => 'failed', + 'device_type' => 'app-api', + 'product_amount' => 199.00, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'pay_amount' => 199.00, + 'remark' => '已取消演示订单', + 'items' => [ + ['product' => $product, 'price' => 199.00, 'quantity' => 1, 'category' => $defaultCategory->name], + ], + ], + ]; + + foreach ($orders as $index => $orderData) { + $order = Order::query()->updateOrCreate( + ['order_no' => $orderData['order_no']], + [ + 'merchant_id' => $merchant->id, + 'user_id' => $user->id, + 'status' => $orderData['status'], + 'platform' => $orderData['platform'], + 'payment_channel' => $orderData['payment_channel'], + 'payment_status' => $orderData['payment_status'], + 'device_type' => $orderData['device_type'], + 'product_amount' => $orderData['product_amount'], + 'discount_amount' => $orderData['discount_amount'], + 'shipping_amount' => $orderData['shipping_amount'], + 'pay_amount' => $orderData['pay_amount'], + 'buyer_name' => $user->name, + 'buyer_phone' => $user->phone, + 'buyer_email' => $user->email, + 'remark' => $orderData['remark'], + 'paid_at' => in_array($orderData['payment_status'], ['paid', 'refunded'], true) ? now()->subDays(5 - $index) : null, + 'shipped_at' => in_array($orderData['status'], ['shipped', 'completed'], true) ? now()->subDays(3 - min($index, 2)) : null, + 'completed_at' => $orderData['status'] === 'completed' ? now()->subDay() : null, + 'created_at' => now()->subDays(6 - $index), + 'updated_at' => now()->subDays(max(0, 5 - $index)), + ] + ); + + foreach ($orderData['items'] as $item) { + OrderItem::query()->updateOrCreate( + ['order_id' => $order->id, 'product_id' => $item['product']->id], + [ + 'merchant_id' => $merchant->id, + 'product_title' => $item['product']->title, + 'product_sku' => $item['product']->sku, + 'product_price' => $item['price'], + 'quantity' => $item['quantity'], + 'line_total_amount' => $item['price'] * $item['quantity'], + 'snapshot' => [ + 'category' => $item['category'], + 'price' => number_format($item['price'], 2, '.', ''), + ], + ] + ); + } + } + } + + protected function seedPlatformConfigs(): void + { + $systemConfigs = [ + ['config_key' => 'platform_name', 'config_name' => '平台名称', 'config_value' => 'SaaSShop', 'value_type' => 'string', 'autoload' => true, 'group' => 'system', 'remark' => '平台展示名称'], + ['config_key' => 'default_currency', 'config_name' => '默认货币', 'config_value' => 'CNY', 'value_type' => 'string', 'autoload' => true, 'group' => 'system', 'remark' => '全局默认结算货币'], + ['config_key' => 'default_locale', 'config_name' => '默认语言', 'config_value' => 'zh-CN', 'value_type' => 'string', 'autoload' => true, 'group' => 'system', 'remark' => '后台默认语言'], + ['config_key' => 'merchant_mode', 'config_name' => '商家模式', 'config_value' => 'multi-merchant', 'value_type' => 'string', 'autoload' => true, 'group' => 'merchant', 'remark' => '当前系统采用多商家平台模式'], + ['config_key' => 'feature_order_auto_close', 'config_name' => '订单自动关闭开关', 'config_value' => '1', 'value_type' => 'boolean', 'autoload' => true, 'group' => 'feature', 'remark' => '用于演示布尔型配置编辑'], + ['config_key' => 'default_order_auto_close_minutes', 'config_name' => '默认订单关闭分钟数', 'config_value' => '30', 'value_type' => 'number', 'autoload' => true, 'group' => 'feature', 'remark' => '用于演示数值型配置编辑'], + ['config_key' => 'platform_theme_tokens', 'config_name' => '平台主题 Tokens', 'config_value' => '{"primary":"#2563eb","success":"#059669"}', 'value_type' => 'json', 'autoload' => true, 'group' => 'theme', 'remark' => '用于演示 JSON 型配置编辑'], + ['config_key' => 'app_api_status', 'config_name' => 'APP API 状态', 'config_value' => 'reserved', 'value_type' => 'string', 'autoload' => true, 'group' => 'channel', 'remark' => 'APP 前端暂缓,统一 API 已预留'], + ['config_key' => 'wechat_mp_status', 'config_name' => '公众号状态', 'config_value' => 'placeholder', 'value_type' => 'string', 'autoload' => true, 'group' => 'channel', 'remark' => '公众号能力占位中'], + ['config_key' => 'wechat_mini_status', 'config_name' => '小程序状态', 'config_value' => 'placeholder', 'value_type' => 'string', 'autoload' => true, 'group' => 'channel', 'remark' => '小程序能力占位中'], + ]; + + foreach ($systemConfigs as $config) { + SystemConfig::query()->updateOrCreate( + ['config_key' => $config['config_key']], + $config + ); + } + + $channelConfigs = [ + ['channel_code' => 'pc', 'channel_name' => 'PC 商城', 'channel_type' => 'sales', 'status' => 'enabled', 'entry_path' => '/pc', 'supports_login' => true, 'supports_payment' => true, 'supports_share' => true, 'sort' => 10, 'settings' => ['device' => 'desktop'], 'remark' => '面向桌面浏览器的标准商城入口'], + ['channel_code' => 'h5', 'channel_name' => 'H5 商城', 'channel_type' => 'sales', 'status' => 'enabled', 'entry_path' => '/h5', 'supports_login' => true, 'supports_payment' => true, 'supports_share' => true, 'sort' => 20, 'settings' => ['device' => 'mobile-web'], 'remark' => '面向移动浏览器和分享链路'], + ['channel_code' => 'wechat_mp', 'channel_name' => '微信公众号', 'channel_type' => 'wechat', 'status' => 'reserved', 'entry_path' => '/wechat/mp', 'supports_login' => true, 'supports_payment' => true, 'supports_share' => true, 'sort' => 30, 'settings' => ['scene' => 'official-account'], 'remark' => '已预留占位接口,待接入微信能力'], + ['channel_code' => 'wechat_mini', 'channel_name' => '微信小程序', 'channel_type' => 'wechat', 'status' => 'reserved', 'entry_path' => '/wechat/mini', 'supports_login' => true, 'supports_payment' => true, 'supports_share' => true, 'sort' => 40, 'settings' => ['scene' => 'mini-program'], 'remark' => '已预留占位接口,待接入微信能力'], + ['channel_code' => 'app_api', 'channel_name' => 'APP API', 'channel_type' => 'api', 'status' => 'reserved', 'entry_path' => '/api/v1', 'supports_login' => true, 'supports_payment' => true, 'supports_share' => false, 'sort' => 50, 'settings' => ['scene' => 'mobile-app-api'], 'remark' => '当前不做 APP 前端,但统一 API 已预留'], + ]; + + foreach ($channelConfigs as $channel) { + ChannelConfig::query()->updateOrCreate( + ['channel_code' => $channel['channel_code']], + $channel + ); + } + + $paymentConfigs = [ + ['payment_code' => 'wechat_pay', 'payment_name' => '微信支付', 'provider' => 'wechat', 'status' => 'reserved', 'is_sandbox' => true, 'supports_refund' => true, 'settings' => ['mode' => 'service-provider'], 'remark' => '平台级微信支付配置预留'], + ['payment_code' => 'alipay', 'payment_name' => '支付宝', 'provider' => 'alipay', 'status' => 'reserved', 'is_sandbox' => true, 'supports_refund' => true, 'settings' => ['mode' => 'standard'], 'remark' => '平台级支付宝配置预留'], + ]; + + foreach ($paymentConfigs as $payment) { + PaymentConfig::query()->updateOrCreate( + ['payment_code' => $payment['payment_code']], + $payment + ); + } + } +} diff --git a/docs/ADMIN_BASELINE.md b/docs/ADMIN_BASELINE.md new file mode 100644 index 0000000..ce2a7b4 --- /dev/null +++ b/docs/ADMIN_BASELINE.md @@ -0,0 +1,84 @@ +# 总台管理基础能力(当前已完成) + +## 访问入口 +- 登录页:`/admin/login` +- 总台仪表盘:`/admin` +- 站点管理:`/admin/merchants` +- 商品巡检:`/admin/products` +- 订单监控:`/admin/orders` +- 系统配置:`/admin/settings/system` +- 渠道配置:`/admin/settings/channels` + +## 当前定位 +当前 `/admin` 已明确为 **总台管理(Platform Ops)**,面向平台运营方: +- 管理站点 +- 查看总台视角的订单 / 商品 / 用户规模 +- 承接全局系统配置与渠道配置骨架 +- 为后续拆分站点后台、商家后台做边界准备 + +## 当前能力 +- 总台管理登录(Session) +- 登录态校验中间件 `admin.auth` +- 总台统一布局模板与导航分组 +- 总台仪表盘与总台定位说明 +- 站点管理列表 + 新增站点 +- 商品巡检列表 + 新增 / 更新 / 删除 +- 订单监控列表 + 状态更新 + 详情页 +- 系统配置页面已接入数据库读取(`system_configs`) +- 渠道配置页面已接入数据库读取(`channel_configs`) +- 支付配置基线已落库并在渠道配置页展示(`payment_configs`) +- Session 中已记录 `admin_role` / `admin_merchant_id` / `admin_merchant_id` / `admin_scope`,其中商家后台优先使用 `admin_merchant_id`,为后续总台 / 站点 / 商家三层后台边界做准备 + +## 演示账号 +### 总台管理 +- 邮箱:`platform.admin@demo.local` +- 密码:`Platform@123456` +- 作用域:总台管理员(`merchant_id = null`) + +### 站点后台 +- 邮箱:`merchant.admin@demo.local` +- 密码:`Merchant@123456` +- 作用域:站点管理员(当前绑定 `merchant_id = 1`,第一阶段复用 Merchant 承接) + +### 商家后台 +- 邮箱:`merchant.admin@demo.local` +- 密码:`Merchant@123456` +- 作用域:商家管理员(当前绑定 `merchant_id = 1`) + +> 生产环境需要补独立权限体系、菜单权限控制、配置落库、操作审计,以及更细粒度的管理员角色权限。 + +## 当前三层后台边界 +### 总台管理(`/admin`) +- 管站点 +- 看总台视角订单 / 商品 / 用户规模 +- 管系统配置、渠道配置、支付配置 +- 仅允许 `merchant_id = null` 的总台管理员登录 +- 面向平台运营方 + +### 站点后台(`/site-admin`) +- 当前先展示绑定站点范围内的数据 +- 第一阶段继续复用 `Merchant / merchants / merchant_id` 作为站点承接层 +- 已具备站点商品、站点订单的最小筛选与导出入口 +- 仅允许绑定商家的管理员登录,并映射为站点作用域 +- 面向站点运营人员 + +### 商家后台(`/merchant-admin`) +- 只展示当前登录商家的数据 +- 当前商家后台会使用 `admin_merchant_id` 作为登录态中的商家作用域标识,并在数据查询时继续映射到底层 `merchant_id` 过滤:仪表盘、商品列表、订单列表、订单详情、订单状态更新、商品新增 +- 仅允许绑定商家的管理员登录 +- 面向商家运营人员 + +## 当前代码层封装进展 +- `Admin` 模型已补充商家语义方法:`merchant()`、`merchantId()`、`isPlatformAdmin()`、`isMerchantAdmin()` +- 商家后台已新增 `ResolvesMerchantContext` trait,用于统一解析 `merchantId()`、`merchant()`、`merchantAdmin()` +- 站点后台已新增 `ResolvesSiteContext` trait,用于统一解析 `siteId()`、`site()`、`siteAdmin()` +- 商家后台控制器已开始改用封装后的商家上下文,而不直接在每个控制器里重复读取 session +- `Product` / `Order` / `User` 模型已新增 `forMerchant($merchantId)` scope,商家侧与站点侧查询开始用统一 scope 收口 +- 商家后台已补充商品编辑 / 删除,以及商家用户列表页 `/merchant-admin/users` + +## 下一步建议 +1. 继续把站点后台从最小筛选 / 导出推进到更贴近运营流程的汇总与异常提示 +2. 为总台站点管理到站点后台补更顺滑的链路设计(例如后续单独设计站点管理员体系或安全代入方案) +3. 给商家后台继续补订单筛选、商品分类、用户详情等经营能力 +4. 给总台配置、渠道配置、支付配置补编辑能力与审计 +5. 继续补齐 merchant 语义收口后的配置编辑、审计与权限细化 diff --git a/docs/API_BASELINE.md b/docs/API_BASELINE.md new file mode 100644 index 0000000..39a0eec --- /dev/null +++ b/docs/API_BASELINE.md @@ -0,0 +1,60 @@ +# API 基础规范(当前版本) + +## 基础前缀 +- `/api/v1` + +## 当前接口 +### 系统 +- `GET /api/v1/ping` +- `GET /api/v1/platforms` + +### 认证 +- `POST /api/v1/auth/login` +- `POST /api/v1/auth/channel-login` + +### 商品 +- `GET /api/v1/products` +- `GET /api/v1/products/{id}` + +### 订单 +- `GET /api/v1/orders` +- `POST /api/v1/orders` + +## 统一返回结构 +```json +{ + "code": 0, + "message": "ok", + "data": {}, + "server_time": "2026-03-08 13:00:00" +} +``` + +## 多端预留字段 +### users +- `register_source` +- `last_login_source` +- `wechat_openid` +- `wechat_unionid` +- `mini_openid` + +### orders +- `platform` +- `payment_channel` +- `payment_status` +- `device_type` + +### oauth_accounts +- `platform` +- `provider` +- `openid` +- `unionid` +- `app_id` +- `raw_payload` + +## 支持方向 +- PC +- H5 +- 微信公众号 +- 微信小程序 +- APP(预留 API 层) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..862621d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,35 @@ +# SaaSShop 架构草图(当前阶段) + +## 已落地模块骨架 +- 首页 `/` +- 健康检查 `/health` +- 后台首页 `/admin` + +## 当前数据表 +- `merchants`:商家 +- `admins`:后台管理员 +- `users`:商城用户 +- `products`:商品 +- `orders`:订单 + +## 当前设计方向 +### 1. 多商家平台 +- 每个商家对应一个店铺主体 +- 关键业务表挂 `merchant_id` +- 后续可扩展为域名绑定、子域名、独立套餐与配置 + +### 2. 管理后台 +- 入口:`/admin` +- 后续增加:登录、权限、菜单、仪表盘、资源管理 + +### 3. 商城业务 +- 商品:基础标题、SKU、价格、库存、状态 +- 订单:订单号、金额、买家信息、支付与履约时间 +- 用户:基础账户 + merchant 归属 + +## 下一步建议 +1. 上管理员登录鉴权 +2. 增加后台布局模板 +3. 做商家 / 商品 / 订单 CRUD +4. 补订单明细表 `order_items` +5. 补商品分类、规格、多图与购物车 diff --git a/docs/DB_SQL_MIGRATIONS.md b/docs/DB_SQL_MIGRATIONS.md new file mode 100644 index 0000000..242e736 --- /dev/null +++ b/docs/DB_SQL_MIGRATIONS.md @@ -0,0 +1,68 @@ +# SaaSShop 数据库结构迁移(SQL 版) + +> 目标:数据库**结构变更**以 SQL 脚本形式版本化,便于在新环境 / CI(Gitee Go)自动执行。 +> +> 约束:只推结构变更,不推业务数据。 + +## 目录与命名 + +- 脚本目录:`database/migrations/` +- 命名规则:`V{数字}__{描述}.sql` + - 示例:`V2__add_platform_order_indexes.sql` + - 数字必须递增(V1、V2、V3...) + +## 执行方式 + +### 1)Laravel 原生迁移 + +首次初始化仍执行 Laravel migration(PHP 迁移),用于建立基础表: + +```bash +php artisan migrate --force +``` + +### 2)SQL 脚本迁移 + +执行 SQL 结构迁移: + +```bash +composer run db:sql-migrate +# 或 +php scripts/sql_migrate.php +``` + +该命令会: +- 扫描 `database/migrations/V*__*.sql` +- 按版本号升序执行 +- 执行成功后写入记录表 `schema_sql_migrations` + +## 记录表 + +- 表名:`schema_sql_migrations` +- 字段: + - `version`:如 `V1` / `V2` + - `description`:从文件名解析 + - `applied_at`:执行时间 + +## 编写脚本建议 + +- **仅结构变更**:CREATE/ALTER/DROP/INDEX 等 +- 不写 INSERT/UPDATE 业务数据(除非是结构初始化必要数据,且要评估影响) +- 每个脚本尽量“可重复执行”或在脚本内加存在性判断(视具体 DB 方言支持) + +## 新环境落地步骤(推荐) + +```bash +git clone +cd saasshop +composer install +cp .env.example .env +php artisan key:generate +php artisan migrate --force +composer run db:sql-migrate +php artisan test +``` + +## CI(Gitee Go) + +仓库内提供 `.gitee/go.yml` 模板,需在 Gitee Go 中配置数据库连接与环境变量后启用。 diff --git a/docs/FOUNDATION_PROGRESS_2026-03-08.md b/docs/FOUNDATION_PROGRESS_2026-03-08.md new file mode 100644 index 0000000..7d0020d --- /dev/null +++ b/docs/FOUNDATION_PROGRESS_2026-03-08.md @@ -0,0 +1,71 @@ +# 2026-03-08 基础能力推进记录 + +## 本次完成 + +### 1. 补齐基础数据结构 +- 新增 `product_categories` 表 + - 字段:`merchant_id`、`name`、`slug`、`status`、`sort`、`description` +- 给 `products` 表新增 `category_id` +- 新增 `order_items` 表 + - 字段:`merchant_id`、`order_id`、`product_id`、`product_title`、`product_sku`、`product_price`、`quantity`、`line_total_amount`、`snapshot` + +### 2. 新增模型与关系 +- `App\Models\ProductCategory` +- `App\Models\OrderItem` +- `Product` 增加: + - `category()` + - `orderItems()` +- `Order` 增加: + - `items()` + +### 3. Seeder 扩展 +- 初始化两个演示分类: + - 默认分类 + - 精选商品 +- 初始化第二个演示商品 +- 初始化订单明细 2 条,覆盖多商品场景 +- 初始化演示订单金额同步调整为 `397.00` + +### 4. 商家后台展示增强 +- 商品页已展示商品分类表 +- 商品创建 / 编辑支持选择分类 +- 订单详情页已展示订单明细 + +### 5. Redis 缓存接入(第一阶段) +- `.env` 已切换 `CACHE_STORE=redis` +- 新增缓存 key 辅助类:`App\Support\CacheKeys` +- 已缓存: + - 商家仪表盘统计 + - 商家商品列表 +- 已实现基础缓存失效: + - 商品新增 / 更新 / 删除 + - 订单状态更新 + +## 影响范围 +- migration +- model +- seeder +- merchant admin controllers +- merchant admin views +- docs + +## 风险提示 +- 由于新增 migration,需要执行迁移 +- 如果 Redis 服务异常,缓存层会受影响;但当前 `/health` 已可辅助确认 Redis 连通性 +- 当前商品分类仅做基础展示,尚未独立做商家后台分类 CRUD + +## 建议验证命令 +```bash +cd /var/www/sites/app +php artisan migrate +php artisan db:seed --force +php artisan optimize:clear +php artisan route:list --path=merchant-admin +``` + +## 建议验证页面 +- `/health` +- `/merchant-admin` +- `/merchant-admin/products` +- `/merchant-admin/orders` +- `/merchant-admin/orders/{id}` diff --git a/docs/MERCHANT_REFACTOR_PLAN.md b/docs/MERCHANT_REFACTOR_PLAN.md new file mode 100644 index 0000000..daeed83 --- /dev/null +++ b/docs/MERCHANT_REFACTOR_PLAN.md @@ -0,0 +1,63 @@ +# merchant 语义重构收口记录 + +## 背景 +项目在基础阶段曾短暂使用 `tenant` 语义,但当前产品定位已经明确为“总台管理 + 站点后台 + 商家后台”的 SaaS 电商结构,因此数据库层、代码层、页面层统一收口到 `merchant / 商家` 更符合后续长期演进方向。 + +## 本轮结论 +`tenant -> merchant` 主链路重构已基本完成,当前项目基线统一采用: +- 表:`merchants` +- 外键:`merchant_id` +- 领域模型:`Merchant` +- 页面文案:`商家` +- 总台站点管理入口:`/admin/merchants` +- 商家后台入口:`/merchant-admin` + +## 已完成项 + +### 数据库基线 +- 基线表已统一为 `merchants` +- 相关业务表统一使用 `merchant_id` +- 相关外键已指向 `merchants` +- 已通过 `php artisan migrate:fresh --seed` 验证 + +### 代码层 +- 模型关系已统一使用 `merchant()` +- 总台管理控制器已切到 `MerchantController` +- 商家后台上下文统一使用 `admin_merchant_id` +- 总台管理与商家后台鉴权边界均已验证通过 +- API 入参 / 返回中的商家归属字段已统一为 `merchant_id` + +### 页面与文案 +- 总台管理 dashboard / 商品 / 分类 / 订单 / 渠道配置页已改为商家语义 +- 总台站点管理页统一为 `admin/merchants` +- 首页与后台入口文案均已完成阶段性收口 + +### 缓存与配置 +- 总台站点列表缓存 key 已统一为 `platform:merchants:list:page:{page}` +- 总台基础配置项已从 `tenant_mode` 调整为 `merchant_mode` + +### 验证结果 +已完成以下验证: +- `php artisan migrate:fresh --seed` +- `php artisan optimize:clear` +- `php artisan route:list --path=admin` +- `php artisan route:list --path=merchant-admin` +- `php artisan route:list --path=api/v1` +- 总台账号登录总台管理成功 +- 商家账号登录商家后台成功 +- 商家账号访问总台管理返回 403 +- 总台账号访问商家后台返回 403 +- 总台 / 商家关键页面与基础 API 返回正常 + +## 收尾原则 +为了保持开发环境基线干净: +1. 不再继续引入新的 `tenant` 语义 +2. 不再新增兼容性双命名 +3. 文档、配置、缓存 key、页面文案统一使用 `merchant` +4. 后续新功能在此基线上继续推进 + +## 当前剩余事项 +1. 继续补表单 `old()` 回填 +2. 给总台 / 商家订单列表增加筛选条件 +3. 补总台配置编辑能力 +4. 后续视情况升级分页缓存失效策略 diff --git a/docs/MULTI_PLATFORM_PLAN.md b/docs/MULTI_PLATFORM_PLAN.md new file mode 100644 index 0000000..10a6486 --- /dev/null +++ b/docs/MULTI_PLATFORM_PLAN.md @@ -0,0 +1,45 @@ +# 多端支持基础规划 + +## 当前目标 +优先把 SaaS 电商系统的基础框架搭好,并确保可访问。 + +## 现阶段支持策略 +### 已可直接访问 +- PC 端页面:`/pc` +- H5 页面:`/h5` +- 后台:`/admin` +- API:`/api/v1/*` + +### 已预留接口 +- 微信公众号:`/wechat/mp` +- 微信小程序:`/wechat/mini` +- APP 端:统一走 `/api/v1/*`,后续由原生 App / Flutter / uni-app 对接 + +## 建议的多端架构分层 +1. **管理后台层** + - PC Web 后台 + - 负责商家、商品、订单、用户、配置管理 + +2. **商城展示层** + - PC 模板 + - H5 模板 + - 小程序前端 + - 微信公众号页面/菜单跳转 H5 + +3. **统一业务接口层** + - `/api/v1` + - 提供登录、商品、购物车、订单、支付、用户信息等接口 + - 后续 APP 端复用这一层 + +4. **渠道适配层** + - 微信公众号 OAuth + - 微信小程序登录 + - 微信支付/消息能力 + - 后续 APP 登录/推送/支付适配 + +## 下一步落地方向 +1. 后台登录鉴权 +2. API 统一返回结构 +3. 商品 / 订单 API +4. 微信登录与支付预留字段 +5. 按端拆分前端模板与菜单配置 diff --git a/docs/NEXT_FOUNDATION_STEPS.md b/docs/NEXT_FOUNDATION_STEPS.md new file mode 100644 index 0000000..3008ae8 --- /dev/null +++ b/docs/NEXT_FOUNDATION_STEPS.md @@ -0,0 +1,21 @@ +# 下一阶段基础建设建议 + +## 已完成的基础能力 +- LNMP + Redis 环境 +- Laravel 多端骨架 +- 多端 API 基础层 +- 后台登录与基础管理页 +- 商家 / 商品 / 订单基础管理 + +## 建议的下一步顺序 +1. 管理员权限与角色模型 +2. 商品分类 / 订单明细表 +3. 后台商家隔离基础(按 merchant_id 过滤) +4. API token / Sanctum 或 JWT 方案 +5. 微信登录与支付配置表 +6. 系统配置中心(站点、支付、渠道参数) + +## 当前原则 +- 先补结构与底座 +- 再做功能细节 +- 最后做美化与体验 diff --git a/docs/ORDER_TENANT_PAGINATION_AND_ERRORS.md b/docs/ORDER_TENANT_PAGINATION_AND_ERRORS.md new file mode 100644 index 0000000..10fa294 --- /dev/null +++ b/docs/ORDER_TENANT_PAGINATION_AND_ERRORS.md @@ -0,0 +1,38 @@ +# 订单 / 站点分页与错误提示增强 + +## 本轮完成 + +### 分页接入 +已完成分页(每页 10 条): +- 商家订单列表 +- 总台订单列表 +- 总台站点列表 + +### 缓存 key 扩展 +新增分页缓存 key: +- `merchant:{merchantId}:orders:list:page:{page}` +- `platform:orders:list:page:{page}` +- `platform:merchants:list:page:{page}` + +### 失效策略 +- 商家订单状态更新:清理商家订单列表前 5 页缓存 + 商家仪表盘缓存 +- 总台订单状态更新:清理总台订单列表前 5 页缓存 + 总台仪表盘缓存 +- 新增站点:清理总台站点列表前 5 页缓存 + 总台仪表盘缓存 + +### 页面错误提示 +已在两个后台 layout 中统一加入表单错误展示: +- 总台管理 layout +- 商家后台 layout + +现在后端 `validate()` 失败后,页面顶部会统一显示错误列表,不再只是跳回页面却看不清错在哪里。 + +## 当前意义 +- 后台主要列表已逐步摆脱“一次性全量加载”的演示模式 +- 缓存与分页继续保持同步设计 +- 页面交互体验开始补齐基本可用性 + +## 下一步建议 +1. 给商品 / 分类 / 站点创建表单补 old() 回填 +2. 给配置页面增加编辑保存能力 +3. 继续把总台订单和商家订单加入筛选条件(状态 / 平台 / 时间) +4. 逐步把分页缓存失效从“前 5 页”升级为版本号式 key 方案 diff --git a/docs/PAGINATION_AND_VALIDATION_PROGRESS.md b/docs/PAGINATION_AND_VALIDATION_PROGRESS.md new file mode 100644 index 0000000..814df88 --- /dev/null +++ b/docs/PAGINATION_AND_VALIDATION_PROGRESS.md @@ -0,0 +1,39 @@ +# 分页与分类唯一性校验推进 + +## 本轮完成 + +### 分类唯一性校验 +- 商家后台商品分类:按 `merchant_id + slug` 做唯一性校验 +- 总台管理商品分类:同样按商家维度校验 `slug` 唯一 +- 已补充更友好的中文报错文案 +- 分类编辑时也支持修改 slug,并保持唯一性约束 + +### 分页接入 +已将以下列表切为分页(每页 10 条): +- 商家商品列表 +- 总台商品列表 +- 商家分类列表 +- 总台分类列表 +- 商家用户列表 + +### 分页缓存 key 调整 +缓存 key 已按页码区分,例如: +- `merchant:{merchantId}:products:list:page:{page}` +- `platform:products:list:page:{page}` +- `merchant:{merchantId}:categories:list:page:{page}` +- `platform:categories:list:page:{page}` +- `merchant:{merchantId}:users:list:page:{page}` + +### 缓存失效策略同步升级 +当商品 / 分类发生变更时,当前会尝试清理前 5 页的分页缓存,并同步清理相关统计缓存。 + +## 当前意义 +- 列表页开始具备面向真实数据规模扩展的能力 +- 分类 slug 不再容易出现同商家冲突 +- Redis 缓存与分页结构已开始联动,而不是停留在单页列表阶段 + +## 下一步建议 +1. 继续给订单列表和站点列表补分页 +2. 给分类 / 商品创建与更新操作增加表单错误提示展示 +3. 配置页开始做编辑能力与缓存刷新 +4. 后续将“清理前 5 页缓存”升级为更稳的版本号式 key 或集中失效策略 diff --git a/docs/PLATFORM_CONTEXT_AND_CATEGORY_PLAN.md b/docs/PLATFORM_CONTEXT_AND_CATEGORY_PLAN.md new file mode 100644 index 0000000..aa8b5d8 --- /dev/null +++ b/docs/PLATFORM_CONTEXT_AND_CATEGORY_PLAN.md @@ -0,0 +1,50 @@ +# 总台上下文封装与分类管理推进 + +## 本轮完成 + +### 总台上下文封装 +- 新增 `App\Http\Controllers\Concerns\ResolvesPlatformAdminContext` +- 总台管理控制器开始统一通过该 trait 获取总台管理员上下文 +- `AdminAuth` 已改为从数据库校验 `admin_id` 对应管理员是否仍具备总台权限 +- `Admin` 模型新增 `platformLabel()`,统一总台 / 商家标识输出 + +### 总台缓存第一阶段 +已接入缓存: +- 总台仪表盘统计 +- 总台站点列表 +- 总台商品列表 +- 系统配置列表 +- 渠道 / 支付 / 站点概览聚合数据 + +新增总台缓存 key: +- `platform:dashboard:stats` +- `platform:merchants:list` +- `platform:products:list` +- `platform:settings:system` +- `platform:settings:channels` + +### 分类管理 +#### 商家后台 +- 新增 `/merchant-admin/product-categories` +- 支持分类列表 / 新增 / 更新 / 删除 +- 分类变更后自动清理商家商品列表缓存和商家仪表盘缓存 + +#### 总台管理 +- 新增 `/admin/product-categories` +- 支持跨商家查看分类、创建分类、更新分类、删除分类 +- 总台商品页面已支持分类选择与商家校验 + +### 总台订单展示增强 +- 总台订单列表已显示站点信息 +- 总台订单详情已显示订单明细 + +## 当前意义 +- 总台管理已不再只是“能用”,开始形成更稳定的上下文解析层 +- 商家后台的分类结构已从“只读展示”升级为“可运营维护” +- Redis 使用范围已从商家侧扩展到总台侧 + +## 建议下一步 +1. 给分类增加唯一性校验与更友好的错误提示 +2. 给总台商品 / 订单列表增加分页 +3. 总台、站点、商家三层后台进一步补权限颗粒(owner / operator / viewer) +4. 给配置页增加编辑能力并补审计日志 diff --git a/docs/REDIS_CACHE_PLAN.md b/docs/REDIS_CACHE_PLAN.md new file mode 100644 index 0000000..3c8c24d --- /dev/null +++ b/docs/REDIS_CACHE_PLAN.md @@ -0,0 +1,40 @@ +# Redis 缓存接入计划 + +## 当前阶段 +- 默认缓存驱动已切换为 `redis` +- 优先接入商家后台高频读场景 + - 商家仪表盘统计 + - 商家商品列表 + +## 当前缓存 Key 设计 +- `merchant:{merchantId}:dashboard:stats` +- `merchant:{merchantId}:products:list` + +配合 `.env` 中的: +- `CACHE_STORE=redis` +- `CACHE_PREFIX=saasshop-cache-` + +实际 Redis 中最终 key 会叠加 Laravel 前缀,避免与其他项目冲突。 + +## TTL 策略 +- 商家仪表盘统计:10 分钟 +- 商家商品列表:10 分钟 + +## 失效策略 +- 商品新增 / 更新 / 删除: + - 清理 `merchant:{merchantId}:products:list` + - 清理 `merchant:{merchantId}:dashboard:stats` +- 订单状态更新: + - 清理 `merchant:{merchantId}:dashboard:stats` + +## 设计原则 +1. key 必须带 merchant 维度,避免跨商家串缓存 +2. 优先缓存读多写少页面,不先缓存复杂写流程 +3. 失效优先正确,再追求更细粒度 +4. 后续可扩展:分类列表缓存、总台管理统计缓存、配置缓存预热 + +## 下一步建议 +1. 给商品分类列表增加单独缓存 +2. 给总台管理仪表盘增加缓存层 +3. 评估是否引入 tag(如果后续缓存驱动与场景需要) +4. 后续把订单列表也纳入缓存,但要先设计分页和筛选条件 key 规范 diff --git a/docs/SITE_ADMIN_PLAN.md b/docs/SITE_ADMIN_PLAN.md new file mode 100644 index 0000000..0cdc53d --- /dev/null +++ b/docs/SITE_ADMIN_PLAN.md @@ -0,0 +1,230 @@ +# 站点后台最小落地方案(第一阶段) + +## 目标 +在当前已存在 **总台管理(/admin)** 与 **商家后台(/merchant-admin)** 的基础上,新增一层 **站点后台(/site-admin)**,让业务结构从“总台 + 商家”演进到更清晰的四级结构: + +- 总台管理 +- 站点后台 +- 商家后台 +- 买家侧前台 / 多端 + +第一阶段目标不是一次性做完整第三套后台,而是先把 **信息架构、路由骨架、权限边界、最小可见页面** 搭起来,避免后续继续把“站点”只当成总台里的一个列表概念。 + +--- + +## 当前判断 + +### 1. 现有 `Merchant` 可作为第一阶段“站点承接层” +当前系统中的 `Merchant` 已经关联: +- 管理员 +- 用户 +- 商品 +- 订单 +- 商品分类 +- 导入历史 + +因此第一阶段无需立刻新增 `sites` 表或把所有 `merchant_id` 重构为 `site_id`。 + +### 2. 第一阶段采用“显示层先站点化,底层暂借 Merchant 承接” +建议策略: +- **对外显示 / 页面文案 / 信息架构**:使用“站点”口径 +- **底层代码 / 数据库 / 外键字段**:暂时保留 `Merchant / merchant_id / merchants` + +这样可以在不打散现有稳定基线的前提下,先把“站点后台”独立成一层真实后台。 + +--- + +## 推荐落地方式 + +### 新增独立后台前缀 +建议新增: +- 登录页:`/site-admin/login` +- 后台首页:`/site-admin` + +而不是继续把站点能力塞在 `/admin/merchants` 页面里硬扩。 + +### 原因 +如果继续只放在 `/admin/merchants`: +- 它更像总台中的一个管理页面 +- 难以形成独立后台边界 +- 后续站点仪表盘、站点商品、站点订单、站点商家治理容易继续堆在总台下,结构会越来越混 + +新增 `/site-admin` 的收益: +- 总台 / 站点 / 商家 三层后台职责更清晰 +- 权限边界更容易测试 +- 路由与布局更稳定,后续扩页面更自然 +- 可先做骨架,后续逐页填能力 + +--- + +## 第一阶段职责边界 + +### 总台管理(/admin) +面向平台运营方,负责: +- 平台级配置 +- 多站点治理 +- 全站点级巡检与统计 +- 站点开通 / 状态维护 +- 渠道与系统能力 + +### 站点后台(/site-admin) +面向站点运营方,负责: +- 当前站点仪表盘 +- 当前站点范围的商品巡检 +- 当前站点范围的订单监控 +- 当前站点范围的商家管理 +- 站点级运营入口与治理入口 + +### 商家后台(/merchant-admin) +面向商家经营方,负责: +- 当前商家商品经营 +- 当前商家订单处理 +- 当前商家用户查看 +- 当前商家导入导出与日常运营 + +--- + +## 第一阶段建议页面 + +### 必做骨架页 +1. `/site-admin/login` - 站点后台登录页 +2. `/site-admin` - 站点仪表盘 +3. `/site-admin/merchants` - 站点商家管理(占位 / 首版列表) +4. `/site-admin/products` - 站点商品巡检(占位 / 首版列表) +5. `/site-admin/orders` - 站点订单监控(占位 / 首版列表) + +### 可后置 +- `/site-admin/users` +- `/site-admin/product-categories` +- `/site-admin/settings/*` +- `/site-admin/products/import-histories` + +第一阶段重点是把后台层级立起来,不要求所有运营能力一次补齐。 + +--- + +## 第一阶段技术实现建议 + +### 1. 路由 +建议新增路由分组: +- `Route::prefix('site-admin')` +- 登录 / 退出登录 +- `Route::middleware('site.admin.auth')` + +### 2. 控制器命名空间 +建议新增: +- `App\Http\Controllers\SiteAdmin\AuthController` +- `App\Http\Controllers\SiteAdmin\DashboardController` +- `App\Http\Controllers\SiteAdmin\MerchantController` +- `App\Http\Controllers\SiteAdmin\ProductController` +- `App\Http\Controllers\SiteAdmin\OrderController` + +第一阶段可复用总台控制器中的部分查询逻辑,但不要直接共用视图布局。 + +### 3. 视图目录 +建议新增: +- `resources/views/site_admin/layouts/app.blade.php` +- `resources/views/site_admin/auth/login.blade.php` +- `resources/views/site_admin/dashboard.blade.php` +- `resources/views/site_admin/merchants/index.blade.php` +- `resources/views/site_admin/products/index.blade.php` +- `resources/views/site_admin/orders/index.blade.php` + +### 4. 中间件与上下文 +建议新增: +- `App\Http\Middleware\SiteAdminAuth` +- `App\Http\Controllers\Concerns\ResolvesSiteContext` + +第一阶段建议: +- session 中新增 `admin_scope = site` +- session 中预留 `admin_site_id` +- 在底层尚未拆 `sites` 表之前,可暂时把 `admin_site_id` 映射到当前 `merchant_id` 或映射到“当前选中站点”上下文 + +--- + +## 第一阶段数据策略 + +### 结论 +**不立刻改表,不立刻拆实体。** + +当前继续沿用: +- `merchants` +- `merchant_id` +- `Merchant` 模型 + +同时在设计上约定: +- 第一阶段 `Merchant` = 站点承接层 +- 第二阶段如需独立“站点 -> 商家”两层实体关系,再单开数据结构重构方案 + +这样做的优点: +- 不打断现有商品 / 订单 / 分类 / 导入历史闭环 +- 不破坏当前测试基线 +- 可以先把三层后台信息架构跑起来 + +--- + +## 第一阶段账号与权限建议 + +### 建议新增角色语义 +- 总台管理员:平台级 +- 站点管理员:站点级 +- 商家管理员:商家级 + +### 第一阶段最小实现建议 +现阶段先不做完整 RBAC,先做边界: +- 总台管理员不能登录站点后台(除非显式赋权) +- 商家管理员不能登录站点后台 +- 站点管理员不能进入总台管理 +- 站点管理员进入站点后台后,所有查询都受当前站点作用域限制 + +--- + +## 第一阶段测试建议 + +新增 Feature 测试: +1. `SiteAdminAccessTest.php` + - 站点后台登录页可访问 + - 站点管理员可登录 `/site-admin` +2. `SiteAdminProtectedPagesTest.php` + - 未登录访问 `/site-admin/*` 跳转 `/site-admin/login` + - 错作用域账号访问返回 403 +3. `SiteAdminBusinessPagesTest.php` + - 仪表盘 / 站点商家 / 站点商品 / 站点订单页可访问 + - 页面关键标题与入口存在 + +--- + +## 推荐实施顺序 + +### 第 1 步 +先搭骨架: +- route +- controller +- middleware +- layout +- login +- dashboard + +### 第 2 步 +补最小站点业务页: +- merchants +- products +- orders + +### 第 3 步 +补访问测试与权限边界测试 + +### 第 4 步 +再决定是否把总台 `/admin/merchants` 页面逐步改造成“站点入口页 / 站点开通页” + +--- + +## 当前推荐结论 + +**站点后台应正式提上,且推荐以 `/site-admin` 作为独立后台层新增;第一阶段先复用 `Merchant` 作为站点承接层,不急着动数据库实体拆分。** + +这条路线最符合当前项目节奏: +- 先打底 +- 先把层级立住 +- 先保证稳定 +- 再逐步把站点能力做实 diff --git a/package.json b/package.json new file mode 100644 index 0000000..7686b29 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d703241 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/css/admin-base.css b/public/css/admin-base.css new file mode 100644 index 0000000..a7f1ff5 --- /dev/null +++ b/public/css/admin-base.css @@ -0,0 +1,133 @@ +body{ + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; + color:#e5e7eb; + margin:0; +} + +a{color:#60a5fa;text-decoration:none;} +a:hover{text-decoration:underline;} + +.layout{display:grid;min-height:100vh;} +.sidebar{padding:24px;} +.sidebar-title,.sidebar-brand{margin-top:0;} +.sidebar a, +.sidebar-link{display:block;color:#cbd5e1;text-decoration:none;padding:10px 12px;border-radius:10px;margin-bottom:8px;} +.sidebar a:hover, +.sidebar-link:hover{text-decoration:none;} +.group-title{font-size:12px;color:#64748b;text-transform:uppercase;letter-spacing:.08em;margin:18px 0 8px;} +.sidebar-logout,.logout-form{margin-top:18px;} + +.content{padding:28px;} +.top{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:20px;} +.page-title{margin:0;} + +.card{border:1px solid #334155;border-radius:14px;padding:16px;} +.card + .card{margin-top:20px;} +.card-spaced{margin-bottom:20px;} + +.grid{display:grid;gap:16px;} +.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;} +.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;} +.grid-5{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;} +.grid-6{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;} +.grid-align-end{align-items:end;} +.two-col{display:grid;grid-template-columns:1.3fr 1fr;gap:16px;} +.span-2{grid-column:span 2;} +.span-3{grid-column:span 3;} + +.num{font-size:28px;font-weight:700;} +.num-md{font-size:20px;font-weight:700;} +.num-sm{font-size:18px;font-weight:700;} + +/* 兼容:早期页面使用 metric-number 作为指标数字样式 */ +.metric-number{font-size:28px;font-weight:700;} + +.text-md{font-size:16px;display:block;} +.muted{color:#94a3b8;} +.muted-tight{margin-top:0;} +.muted-xs{margin-top:6px;font-size:12px;} + +.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:12px;margin-top:8px;} + +table{width:100%;border-collapse:collapse;} +th,td{padding:10px;border-bottom:1px solid #334155;text-align:left;vertical-align:top;} + +input,select,button,textarea, +.form-grid input, +.form-grid select, +.login-form input, +.login-form button{ + padding:10px 12px; + border-radius:10px; + border:1px solid #475569; + background:#0f172a; + color:#fff; + box-sizing:border-box; +} +button{cursor:pointer;} +textarea.w-full,input.w-full{width:100%;} +input.w-140{width:140px;} +input.w-90{width:90px;} +.block{display:block;} +.inline{display:inline;} +.inline-form{display:inline;} + +.mb-0{margin-bottom:0;} +.mb-6{margin-bottom:6px;} +.mb-8{margin-bottom:8px;} +.mb-10{margin-bottom:10px;} +.mb-12{margin-bottom:12px;} +.mb-16{margin-bottom:16px;} +.mb-20{margin-bottom:20px;} +.mt-0{margin-top:0;} +.mt-6{margin-top:6px;} +.mt-8{margin-top:8px;} +.mt-10{margin-top:10px;} +.mt-12{margin-top:12px;} +.mt-16{margin-top:16px;} +.my-16{margin:16px 0;} +.my-20{margin:20px 0;} +.list-indent,.ml-18{margin:8px 0 0 18px;} + +.flash{background:#052e16;color:#bbf7d0;padding:12px 14px;border-radius:10px;margin-bottom:16px;} +.warning{background:#78350f;color:#fde68a;padding:12px 14px;border-radius:10px;margin-bottom:16px;} +.error-box{background:#7f1d1d;color:#fecaca;padding:12px 14px;border-radius:10px;margin-bottom:16px;} +.error,.error-inline{color:#fca5a5;margin-top:10px;} +.filter-error{margin-bottom:12px;padding:12px;border:1px solid #fecaca;background:#fef2f2;color:#991b1b;border-radius:8px;} + +.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;} +.actions-spread{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;} +.inline-links,.card-link-list{display:flex;gap:8px;flex-wrap:wrap;} +.focus-box,.nav-box,.profile-box{padding:12px;border:1px solid #334155;border-radius:8px;} +.summary-box,.stat-box,.status-card,.status-link{padding:12px;border:1px solid #334155;border-radius:8px;} +.status-card,.status-link{color:inherit;text-decoration:none;display:block;} +.status-card:hover,.status-link:hover{text-decoration:none;} +.pagination-wrap{margin-top:16px;} +.text-center,.table-empty{text-align:center;} +.section-dark{background:#0f172a;} +.stat-box-light,.status-link-light{padding:12px;border:1px solid #e5e7eb;border-radius:8px;} +.status-link-light{color:inherit;text-decoration:none;display:block;} +.is-active-dark,.status-card-active{background:#1e293b;border-color:#60a5fa;} +.is-active-light{background:#f5faff;border-color:#93c5fd;} +.result-warning{color:#f59e0b;} +.result-success{color:#22c55e;} +.info-warning{margin-bottom:12px;padding:10px 12px;border:1px solid #f59e0b;border-radius:8px;color:#f59e0b;} +.helper-text{margin:12px 0 0;} + +.login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;} +.card-login,.login-card{width:420px;border-radius:18px;padding:28px;} +.login-form input, +.login-form button{width:100%;padding:12px;margin-top:10px;} + +@media (max-width: 1200px){ + .grid,.grid-3,.grid-4,.grid-5,.grid-6{grid-template-columns:repeat(2,1fr);} + .two-col{grid-template-columns:1fr;} +} + +@media (max-width: 768px){ + .layout{grid-template-columns:1fr !important;} + .sidebar{border-right:none !important;border-bottom:1px solid #22384f;} + .content{padding:16px;} + .top,.actions-spread{flex-direction:column;} + .grid,.grid-3,.grid-4,.grid-5,.grid-6{grid-template-columns:1fr;} +} diff --git a/public/css/admin-components.css b/public/css/admin-components.css new file mode 100644 index 0000000..41abe67 --- /dev/null +++ b/public/css/admin-components.css @@ -0,0 +1,14 @@ +/* + * SaaSShop Admin Components + * 说明:用于承接 Blade 中零散的 inline style,便于后续统一美化与治理。 + */ + +.form-inline-row{ + display:flex; + align-items:center; + gap:8px; +} + +.text-danger{ + color:#b42318; +} diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..57297f1 --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1 @@ +body{background:#020617}a{color:#60a5fa}.layout{grid-template-columns:260px 1fr}.sidebar{background:#0f172a;border-right:1px solid #1e293b}.sidebar a:hover{background:#1e293b}.card{background:#111827}.grid{grid-template-columns:repeat(5,1fr)}.badge{background:#1d4ed8;color:#dbeafe}.danger{background:#7f1d1d;color:#fecaca}.button-danger{background:#7f1d1d;color:#fecaca}.card-login{background:#111827;border:1px solid #334155} \ No newline at end of file diff --git a/public/css/merchant-admin.css b/public/css/merchant-admin.css new file mode 100644 index 0000000..bea2756 --- /dev/null +++ b/public/css/merchant-admin.css @@ -0,0 +1 @@ +body{background:#0f172a}a{color:#60a5fa}.layout{grid-template-columns:240px 1fr}.sidebar{background:#111827;border-right:1px solid #1f2937}.sidebar a:hover{background:#1f2937}.card{background:#111827}.grid{grid-template-columns:repeat(4,1fr)}.badge{background:#065f46;color:#d1fae5}.card-login{background:#111827;border:1px solid #334155} \ No newline at end of file diff --git a/public/css/public-pages.css b/public/css/public-pages.css new file mode 100644 index 0000000..3c71ec4 --- /dev/null +++ b/public/css/public-pages.css @@ -0,0 +1 @@ +body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0}.dark-page{background:#0b1020;color:#e5e7eb;padding:32px}.public-wrap{max-width:1100px;margin:0 auto;background:#111827;border:1px solid #263042;border-radius:18px;padding:28px}.public-wrap-narrow{max-width:980px}.muted{color:#94a3b8}.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:20px}.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:20px}.platforms{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-top:20px}.card{background:#0f172a;border:1px solid #1e293b;border-radius:14px;padding:16px}.mt-20{margin-top:20px}.tag{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;background:#1e293b;color:#cbd5e1;margin-left:8px}code{background:#020617;padding:2px 6px;border-radius:6px}ul{line-height:1.8}.ok{color:#22c55e}.bad{color:#ef4444}.pc-body,.h5-body{background:#f8fafc;color:#0f172a}.pc-top{background:#0f172a;color:#fff;padding:16px 32px}.pc-wrap{max-width:1180px;margin:0 auto;padding:24px}.pc-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}.shop-card{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:16px;box-shadow:0 4px 14px rgba(15,23,42,.04)}.h5-header{background:#111827;color:#fff;padding:14px 16px;font-size:18px}.h5-wrap{padding:16px}.h5-card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:14px;margin-bottom:12px}.price{color:#dc2626;font-weight:700;font-size:20px}.price-sm{color:#dc2626;font-size:18px;font-weight:700}a{color:#2563eb;text-decoration:none} \ No newline at end of file diff --git a/public/css/site-admin.css b/public/css/site-admin.css new file mode 100644 index 0000000..13f8f05 --- /dev/null +++ b/public/css/site-admin.css @@ -0,0 +1 @@ +body{background:#0b1220}a{color:#7dd3fc}.layout{grid-template-columns:240px 1fr}.sidebar{background:#102033;border-right:1px solid #22384f}.sidebar-link{color:#d0deea}.sidebar-link:hover{background:#19314a}.sidebar-brand{margin-top:0}.card{background:#111827}.card-dark{background:#0f172a}.card-dashed{border-style:dashed}.grid{grid-template-columns:repeat(5,1fr)}.badge-scope{background:#0c4a6e;color:#bae6fd}.badge-success{background:#dcfce7;color:#166534}.badge-info{background:#dbeafe;color:#1d4ed8}.focus-box,.nav-box,.profile-box{padding:12px;border:1px solid #334155;border-radius:8px}.login-page{background:#031525}.login-card{background:#102033;border:1px solid #2f4a68}.login-form input,.login-form button{border:1px solid #4c6a88;background:#0b1827}.login-form button{background:#0ea5e9;border:none} \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..3e6abea --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/admin/auth/login.blade.php b/resources/views/admin/auth/login.blade.php new file mode 100644 index 0000000..20e4d1a --- /dev/null +++ b/resources/views/admin/auth/login.blade.php @@ -0,0 +1,25 @@ + + + + + + 总台管理登录 - SaaSShop + + + + + + + diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php new file mode 100644 index 0000000..b6e6100 --- /dev/null +++ b/resources/views/admin/dashboard.blade.php @@ -0,0 +1,32 @@ +@extends('admin.layouts.app') + +@section('title', '总台管理仪表盘') +@section('page_title', '总台管理仪表盘') + +@section('content') +
+
+

欢迎回来,{{ $adminName }}。当前入口已明确为 总台管理,用于平台运营方统一查看站点、渠道、全局配置和平台级业务数据。

+

当前平台上下文已通过封装统一解析,不再依赖控制器里零散读取 session。

+

仪表盘统计已接入缓存:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+
+
+

平台定位

+ + + + + + + +
后台角色{{ $platformOverview['system_role'] }}
当前视角{{ $platformOverview['current_scope'] }}
商家模式{{ $platformOverview['merchant_mode'] }}
渠道数{{ $platformOverview['channel_count'] }}
活跃商家{{ $platformOverview['active_merchants'] }}
待处理订单{{ $platformOverview['pending_orders'] }}
+
+
+
+
站点
{{ $stats['merchants'] }}
+
管理员
{{ $stats['admins'] }}
+
用户
{{ $stats['users'] }}
+
商品
{{ $stats['products'] }}
+
订单
{{ $stats['orders'] }}
+
+@endsection diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php new file mode 100644 index 0000000..c9fe6d9 --- /dev/null +++ b/resources/views/admin/layouts/app.blade.php @@ -0,0 +1,68 @@ + + + + + + @yield('title', 'SaaSShop 总台管理') + + + + + +
+ +
+
+
+

@yield('page_title', '总台管理')

+
当前登录:{{ session('admin_name') }}({{ session('admin_email') }}) / 角色:{{ session('admin_role', 'unknown') }}
+
登录范围:{{ session('admin_scope', 'platform') === 'platform' ? '总台管理员' : '商家管理员' }}
+
+
+ @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('warning')) +
{{ session('warning') }}
+ @endif + @if(session('error')) +
{{ session('error') }}
+ @endif + @if($errors->any()) +
+ 提交失败: +
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + @yield('content') +
+
+ + diff --git a/resources/views/admin/merchants/index.blade.php b/resources/views/admin/merchants/index.blade.php new file mode 100644 index 0000000..f4ba696 --- /dev/null +++ b/resources/views/admin/merchants/index.blade.php @@ -0,0 +1,50 @@ +@extends('admin.layouts.app') + +@section('title', '站点管理') +@section('page_title', '站点管理') + +@section('content') +
+

这里是总台视角的站点管理入口,用于开通、查看和维护 SaaS 站点主体。

+

当前站点列表已接入缓存:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+

新增站点

+
+ @csrf +
+ + + + + + +
+
+
+
+
+ +
+

站点列表

+ + + + @foreach($merchants as $merchant) + + + + + + + + + + @endforeach + +
ID站点名称Slug套餐状态联系人操作
{{ $merchant->id }}{{ $merchant->name }}{{ $merchant->slug }}{{ $merchant->plan }}{{ $merchant->status }}{{ $merchant->contact_name }} / {{ $merchant->contact_phone }} + 进入站点后台 +
当前阶段请使用该站点管理员账号登录
+
+
+ +
{{ $merchants->links() }}
+@endsection diff --git a/resources/views/admin/orders/index.blade.php b/resources/views/admin/orders/index.blade.php new file mode 100644 index 0000000..5f0fbce --- /dev/null +++ b/resources/views/admin/orders/index.blade.php @@ -0,0 +1,171 @@ +@extends('admin.layouts.app') + +@section('title', '订单监控') +@section('page_title', '订单监控') + +@section('content') +
+

当前页面用于总台视角查看全局订单状态,后续商家后台将承接商家侧订单处理动作。

+

当前订单列表已接入缓存:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+ @php + $exportQuery = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '')); + @endphp + +

筛选条件

+ @if(!empty($filters['validation_errors'])) +
+ 筛选条件有误: +
    + @foreach($filters['validation_errors'] as $validationError) +
  • {{ $validationError }}
  • + @endforeach +
+
+ @endif +
+
+ + + + + + + + + + + + +
重置
+
+
+
+ +
+

当前筛选摘要

+
+ @foreach($activeFilterSummary as $summaryLabel => $summaryValue) +
{{ $summaryLabel }}
{{ $summaryValue }}
+ @endforeach +
+
+ +
+

订单汇总

+
+
订单总数
{{ $summaryStats['total_orders'] ?? 0 }}
+
实付总额
¥{{ number_format($summaryStats['total_pay_amount'] ?? 0, 2) }}
+
平均客单价
¥{{ number_format($summaryStats['average_order_amount'] ?? 0, 2) }}
+
待支付金额
¥{{ number_format($summaryStats['unpaid_pay_amount'] ?? 0, 2) }}
+
已支付金额
¥{{ number_format($summaryStats['paid_pay_amount'] ?? 0, 2) }}
+
已支付订单数
{{ $summaryStats['paid_orders'] ?? 0 }}
+
支付率
{{ number_format($summaryStats['payment_rate'] ?? 0, 2) }}%
+
退款订单数
{{ $summaryStats['refunded_orders'] ?? 0 }}
+
退款率
{{ number_format($summaryStats['refund_rate'] ?? 0, 2) }}%
+
待发货订单数
{{ $summaryStats['pending_shipment_orders'] ?? 0 }}
+
完成率
{{ number_format($summaryStats['completion_rate'] ?? 0, 2) }}%
+
支付失败订单
{{ $summaryStats['failed_payment_orders'] ?? 0 }}
+
已取消订单
{{ $summaryStats['cancelled_orders'] ?? 0 }}
+
取消率
{{ number_format($summaryStats['cancellation_rate'] ?? 0, 2) }}%
+
+
+ +
+

时间趋势指标

+
+
今日订单数
{{ $trendStats['today_orders'] ?? 0 }}
+
今日实付金额
¥{{ number_format($trendStats['today_pay_amount'] ?? 0, 2) }}
+
近7天订单数
{{ $trendStats['last_7_days_orders'] ?? 0 }}
+
近7天实付金额
¥{{ number_format($trendStats['last_7_days_pay_amount'] ?? 0, 2) }}
+
+
+ +
+

运营关注项

+
+
+
订单盘面
+ {{ $operationsFocus['headline'] ?? '当前总台订单运营信息已就绪。' }} +
建议动作
+ +
+
+
当前信号
+
+ @foreach(($operationsFocus['signals'] ?? []) as $label => $value) +
{{ $label }}
{{ $value }}
+ @endforeach +
+
+
+
工作台导航
+ +
+
+
+ +
+

状态统计

+ @php + $baseQuery = [ + 'payment_status' => $filters['payment_status'] ?: null, + 'platform' => $filters['platform'] ?: null, + 'device_type' => $filters['device_type'] ?: null, + 'payment_channel' => $filters['payment_channel'] ?: null, + 'keyword' => $filters['keyword'] ?: null, + 'start_date' => $filters['start_date'] ?: null, + 'end_date' => $filters['end_date'] ?: null, + 'min_pay_amount' => $filters['min_pay_amount'] ?: null, + 'max_pay_amount' => $filters['max_pay_amount'] ?: null, + 'time_range' => ($filters['time_range'] ?? 'all') !== 'all' ? $filters['time_range'] : null, + 'sort' => ($filters['sort'] ?? 'latest') !== 'latest' ? $filters['sort'] : null, + ]; + @endphp +
+
全部
{{ $statusStats['all'] ?? 0 }}
+ @foreach($filterOptions['statuses'] as $status) +
{{ $statusLabels[$status] ?? $status }}
{{ $statusStats[$status] ?? 0 }}
+ @endforeach +
+
+ +
+

订单列表

+ + + + @forelse($orders as $order) + + + + + + + + + + + + + + + + @empty + + @endforelse + +
ID商家订单号平台买家支付金额创建时间支付时间发货时间完成时间状态操作
{{ $order->id }}{{ $order->merchant?->name ?? ('商家#'.$order->merchant_id) }}{{ $order->order_no }}{{ $platformLabels[$order->platform] ?? $order->platform }}{{ $order->buyer_name }}
{{ $paymentChannelLabels[$order->payment_channel] ?? $order->payment_channel }}
{{ $paymentStatusLabels[$order->payment_status] ?? $order->payment_status }}
¥{{ number_format($order->pay_amount, 2) }}{{ $order->created_at?->format('Y-m-d H:i') }}{{ $order->paid_at?->format('Y-m-d H:i') ?? '-' }}{{ $order->shipped_at?->format('Y-m-d H:i') ?? '-' }}{{ $order->completed_at?->format('Y-m-d H:i') ?? '-' }}{{ $statusLabels[$order->status] ?? $order->status }}
@csrf
暂无订单
+
+ +
{{ $orders->links() }}
+@endsection diff --git a/resources/views/admin/orders/show.blade.php b/resources/views/admin/orders/show.blade.php new file mode 100644 index 0000000..5e671ce --- /dev/null +++ b/resources/views/admin/orders/show.blade.php @@ -0,0 +1,50 @@ +@extends('admin.layouts.app') + +@section('title', '平台订单详情') +@section('page_title', '平台订单详情') + +@section('content') +
+

订单 {{ $order->order_no }}

+ + + + + + + + + + + + + + + + +
ID{{ $order->id }}
商家{{ $order->merchant?->name ?? ('商家#'.$order->merchant_id) }}
平台{{ $order->platform }}
订单状态{{ $order->status }}
支付渠道{{ $order->payment_channel }}
支付状态{{ $order->payment_status }}
买家{{ $order->buyer_name }}
手机{{ $order->buyer_phone }}
邮箱{{ $order->buyer_email }}
商品金额¥{{ number_format($order->product_amount, 2) }}
优惠金额¥{{ number_format($order->discount_amount, 2) }}
运费¥{{ number_format($order->shipping_amount, 2) }}
应付金额¥{{ number_format($order->pay_amount, 2) }}
备注{{ $order->remark }}
创建时间{{ $order->created_at }}
+
+ +
+

订单明细

+ + + + @forelse($order->items as $item) + + + + + + + + + + @empty + + @endforelse + +
ID商品SKU单价数量小计快照
{{ $item->id }}{{ $item->product_title }}{{ $item->product_sku }}¥{{ number_format($item->product_price, 2) }}{{ $item->quantity }}¥{{ number_format($item->line_total_amount, 2) }}{{ $item->snapshot['category'] ?? '-' }}
暂无订单明细
+

返回订单列表

+
+@endsection diff --git a/resources/views/admin/plans/form.blade.php b/resources/views/admin/plans/form.blade.php new file mode 100644 index 0000000..8855d3a --- /dev/null +++ b/resources/views/admin/plans/form.blade.php @@ -0,0 +1,74 @@ +@extends('admin.layouts.app') + +@section('title', $plan->exists ? '编辑套餐' : '新建套餐') +@section('page_title', $plan->exists ? '编辑套餐' : '新建套餐') + +@section('content') +
+

套餐是平台授权与收费的基础单位,这里维护套餐的基本信息与售卖口径。

+

当前阶段先提交套餐主数据,后续再补授权项、配额与订阅联动。

+
+ +
+ @csrf + + + + + + + + + + + + + + + + + + + +
+ 返回 + +
+
+@endsection diff --git a/resources/views/admin/plans/index.blade.php b/resources/views/admin/plans/index.blade.php new file mode 100644 index 0000000..7f882cb --- /dev/null +++ b/resources/views/admin/plans/index.blade.php @@ -0,0 +1,139 @@ +@extends('admin.layouts.app') + +@section('title', '套餐管理') +@section('page_title', '套餐管理') + +@section('content') +
+

这里是总台视角的套餐目录页,用于沉淀平台可售卖的标准能力包。

+

当前阶段先完成套餐主数据可见、可筛与口径收拢,后续再接授权项、售价规则与上下架动作。

+
+ +
+

工具

+
+ + + + + +
+
+ +
+

筛选条件

+
+ + + + +
+ +
+
+
+ +
+
+

套餐总数

+
{{ $summaryStats['total_plans'] ?? 0 }}
+
+
+

启用中套餐

+
{{ $summaryStats['active_plans'] ?? 0 }}
+
+
+

月付套餐

+
{{ $summaryStats['monthly_plans'] ?? 0 }}
+
+
+

年付套餐

+
{{ $summaryStats['yearly_plans'] ?? 0 }}
+
+
+

已发布

+
{{ $summaryStats['published_plans'] ?? 0 }}
+
+
+

未发布

+
{{ $summaryStats['unpublished_plans'] ?? 0 }}
+
+
+ +
+
+
+

套餐列表

+

后续将从这里进入套餐详情、授权项与订阅联动。

+
+ 新建套餐 +
+ + + + + + + + + + + + + + + + + @forelse($plans as $plan) + + + + + + + + + + + + + @empty + + + + @endforelse + +
ID套餐名称编码计费周期售价划线价状态排序发布时间操作
{{ $plan->id }} + {{ $plan->name }} +
{{ $plan->description ?: '暂无说明' }}
+
{{ $plan->code }}{{ $billingCycleLabels[$plan->billing_cycle] ?? $plan->billing_cycle }}¥{{ number_format((float) $plan->price, 2) }}¥{{ number_format((float) $plan->list_price, 2) }}{{ $statusLabels[$plan->status] ?? $plan->status }}{{ $plan->sort }}{{ optional($plan->published_at)->format('Y-m-d H:i:s') ?: '-' }} + 编辑 + +
+ @csrf + + +
+
暂无套餐数据,当前阶段先把套餐主表与总台目录立起来,后续可继续接套餐创建、授权项与订阅关联。
+
+ +
{{ $plans->links() }}
+@endsection diff --git a/resources/views/admin/platform_orders/index.blade.php b/resources/views/admin/platform_orders/index.blade.php new file mode 100644 index 0000000..92fc3e7 --- /dev/null +++ b/resources/views/admin/platform_orders/index.blade.php @@ -0,0 +1,298 @@ +@extends('admin.layouts.app') + +@section('title', '平台订单') +@section('page_title', '平台订单') + +@section('content') +
+

这里是总台视角的平台收费主链骨架页,当前阶段先承接套餐订购 / 续费 / 生效跟踪。

+

本页先提供可访问列表、基础筛选与摘要卡,后续再补详情、导出、支付记录与退款轨迹。

+
+ +
+

筛选条件

+
+ + + + + + + +
+ +
+
+
+ +
+
+

平台订单总数

+
{{ $summaryStats['total_orders'] ?? 0 }}
+
+
+

已支付 / 已生效

+
{{ $summaryStats['paid_orders'] ?? 0 }} / {{ $summaryStats['activated_orders'] ?? 0 }}
+
+
+

已同步 / 未同步

+
{{ $summaryStats['synced_orders'] ?? 0 }} / {{ $summaryStats['unsynced_orders'] ?? 0 }}
+
+
+

同步失败数

+
{{ $summaryStats['failed_sync_orders'] ?? 0 }}
+
+
+

同步失败原因 TOP5

+ @php $failedReasonStats = $failedReasonStats ?? []; @endphp + @if(count($failedReasonStats) > 0) +
+ @foreach($failedReasonStats as $item) +
{{ $item['reason'] }} ({{ $item['count'] }})
+ @endforeach +
+ @else +
暂无失败原因聚合数据
+ @endif +
+
+ +
+

工具

+
清除仅影响订单 meta 中的失败标记,不改变订单/订阅状态。
+ +
+ + + + + + + + + + + +
+ +
+ @csrf + + + + + + + + + + + +
提示:建议先勾选筛选条件「只看可同步」,再执行批量同步。
+ +
+ +
+ @csrf + + + + +
+ +
+ @csrf + + + + + + + + + +
+ +
+ @csrf + + +
+
+ +
+

平台订单列表

+ + + + + + + + + + + + + + + + + + + + + + + + @forelse($orders as $order) + + + + + + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
ID订单号站点套餐订单类型订单状态支付状态应付金额已付金额下单时间支付时间同步状态订阅号订阅到期同步时间最近批量同步操作
{{ $order->id }}{{ $order->order_no }}{{ $order->merchant?->name ?? '未关联站点' }}{{ $order->plan_name ?: ($order->plan?->name ?? '未设置') }}{{ $order->order_type }} + {{ $statusLabels[$order->status] ?? $order->status }} +
{{ $order->status }}
+
+ {{ $paymentStatusLabels[$order->payment_status] ?? $order->payment_status }} +
{{ $order->payment_status }}
+
¥{{ number_format((float) $order->payable_amount, 2) }}¥{{ number_format((float) $order->paid_amount, 2) }}{{ optional($order->placed_at)->format('Y-m-d H:i:s') ?: '-' }}{{ optional($order->paid_at)->format('Y-m-d H:i:s') ?: '-' }} + @php + $syncedId = (int) data_get($order->meta, 'subscription_activation.subscription_id', 0); + $syncErr = (string) (data_get($order->meta, 'subscription_activation_error.message') ?? ''); + @endphp + @if($syncedId > 0) + 已同步 + @elseif($syncErr !== '') + 同步失败 + @else + 未同步 + @endif + {{ $order->siteSubscription?->subscription_no ?: '-' }}{{ optional($order->siteSubscription?->ends_at)->format('Y-m-d H:i:s') ?: '-' }}{{ data_get($order->meta, 'subscription_activation.synced_at') ?: '-' }} + @php + // 优先使用扁平字段 meta.batch_activation(便于筛选/统计,也避免遍历 audit) + $batchActivation = (array) (data_get($order->meta, 'batch_activation', []) ?? []); + $batchAt = (string) (data_get($batchActivation, 'at') ?? ''); + $batchAdminId = data_get($batchActivation, 'admin_id'); + + // 兼容旧数据:若尚未写入 batch_activation,则回退从 meta.audit[] 中取最近一次批量同步记录 + $audit = (array) (data_get($order->meta, 'audit', []) ?? []); + $lastBatch = null; + if ($batchAt === '' && count($audit) > 0) { + foreach (array_reverse($audit) as $item) { + if ((string) data_get($item, 'action') === 'batch_activate_subscription') { + $lastBatch = $item; + break; + } + } + } + + if ($batchAt === '' && $lastBatch) { + $batchAt = (string) (data_get($lastBatch, 'at') ?? ''); + $batchAdminId = data_get($lastBatch, 'admin_id'); + } + @endphp + @if($batchAt !== '') +
{{ $batchAt }}
+
管理员:{{ $batchAdminId ?: '-' }}
+ @else + - + @endif +
+ @php + $canActivate = ($order->payment_status === 'paid') && ($order->status === 'activated'); + $alreadySynced = (int) data_get($order->meta, 'subscription_activation.subscription_id', 0) > 0; + $syncError = data_get($order->meta, 'subscription_activation_error.message'); + $syncErrorAt = data_get($order->meta, 'subscription_activation_error.at'); + @endphp + @php + $canMarkPaid = ($order->payment_status !== 'paid') || ($order->status !== 'activated'); + @endphp + +
+ @csrf + +
+ +
+ @csrf + +
+ + @if(! $canActivate) +
需已支付且订单状态为已生效
+ @elseif($alreadySynced) +
该订单已完成同步(幂等保护)
+ @endif + + @if($syncError) +
同步失败:{{ $syncError }}{{ $syncErrorAt ? '(' . $syncErrorAt . ')' : '' }}
+ @endif +
暂无平台订单,当前阶段骨架已就位,可继续补套餐下单、支付回执与订阅生效链路。
+
+ +
{{ $orders->links() }}
+@endsection diff --git a/resources/views/admin/platform_orders/show.blade.php b/resources/views/admin/platform_orders/show.blade.php new file mode 100644 index 0000000..679d930 --- /dev/null +++ b/resources/views/admin/platform_orders/show.blade.php @@ -0,0 +1,158 @@ +@extends('admin.layouts.app') + +@section('title', '平台订单详情') +@section('page_title', '平台订单详情') + +@section('content') +
+

这里用于运营排查:订单核心字段、关联订阅、以及订阅同步元数据(meta)。

+
+ +
+

订单信息

+ + + + + + + + + + + + + + +
ID{{ $order->id }}
订单号{{ $order->order_no }}
站点{{ $order->merchant?->name ?? '未关联站点' }}
套餐{{ $order->plan_name ?: ($order->plan?->name ?? '-') }}
订单类型{{ $order->order_type }}
订单状态{{ $statusLabels[$order->status] ?? $order->status }} ({{ $order->status }})
支付状态{{ $paymentStatusLabels[$order->payment_status] ?? $order->payment_status }} ({{ $order->payment_status }})
应付/已付¥{{ number_format((float) $order->payable_amount, 2) }} / ¥{{ number_format((float) $order->paid_amount, 2) }}
下单时间{{ optional($order->placed_at)->format('Y-m-d H:i:s') ?: '-' }}
支付时间{{ optional($order->paid_at)->format('Y-m-d H:i:s') ?: '-' }}
生效时间{{ optional($order->activated_at)->format('Y-m-d H:i:s') ?: '-' }}
+ + @php + $canActivate = ($order->payment_status === 'paid') && ($order->status === 'activated'); + $alreadySynced = (int) data_get($order->meta, 'subscription_activation.subscription_id', 0) > 0; + $canMarkPaid = ($order->payment_status !== 'paid') || ($order->status !== 'activated'); + $syncError = data_get($order->meta, 'subscription_activation_error.message'); + $syncErrorAt = data_get($order->meta, 'subscription_activation_error.at'); + @endphp + +
+
+ @csrf + +
+ +
+ @csrf + +
+
+ + @if(! $canActivate) +
同步订阅需满足:已支付 + 订单状态已生效
+ @elseif($alreadySynced) +
该订单已完成同步(幂等保护)
+ @endif + + @if($syncError) +
最近同步失败:{{ $syncError }}{{ $syncErrorAt ? '(' . $syncErrorAt . ')' : '' }}
+ @endif +
+ +
+

关联订阅

+ @if($order->siteSubscription) + + + + + + + + +
订阅ID{{ $order->siteSubscription->id }}
订阅号{{ $order->siteSubscription->subscription_no }}
订阅管理打开订阅管理(按订阅号筛选)
状态{{ $order->siteSubscription->status }}
开始/到期{{ optional($order->siteSubscription->starts_at)->format('Y-m-d H:i:s') ?: '-' }} / {{ optional($order->siteSubscription->ends_at)->format('Y-m-d H:i:s') ?: '-' }}
+ @else +

该订单尚未关联订阅(site_subscription_id 为空)。

+ @endif +
+ +@php + $activation = data_get($order->meta, 'subscription_activation'); + $activationError = data_get($order->meta, 'subscription_activation_error'); + $audit = (array) (data_get($order->meta, 'audit', []) ?? []); +@endphp + +
+

订阅同步记录

+ @if($activation) + + + + + + +
订阅ID{{ data_get($activation, 'subscription_id') }}
同步时间{{ data_get($activation, 'synced_at') ?: '-' }}
操作管理员{{ data_get($activation, 'admin_id') ?: '-' }}
+ @else +

暂无同步记录。

+ @endif +
+ +
+

最近一次同步失败

+ @if($activationError) + + + + + + +
失败原因{{ data_get($activationError, 'message') }}
失败时间{{ data_get($activationError, 'at') ?: '-' }}
操作管理员{{ data_get($activationError, 'admin_id') ?: '-' }}
+ @else +

暂无失败记录。

+ @endif +
+ +
+

审计记录(最近 20 条)

+ @if(count($audit) > 0) + @php + $auditItems = array_slice(array_reverse($audit), 0, 20); + $auditActionLabels = [ + 'clear_sync_error' => '清除同步失败标记', + 'batch_activate_subscription' => '批量同步订阅', + ]; + @endphp + + + + + + + + + + + + @foreach($auditItems as $item) + + + + + + + + @endforeach + +
动作范围时间管理员备注
{{ $auditActionLabels[data_get($item, 'action')] ?? (data_get($item, 'action') ?: '-') }}{{ data_get($item, 'scope') ?: '-' }}{{ data_get($item, 'at') ?: '-' }}{{ data_get($item, 'admin_id') ?: '-' }}{{ data_get($item, 'note') ?: '' }}
+ @else +

暂无审计记录。

+ @endif +
+ +
+

原始 meta(JSON)

+
{{ json_encode($order->meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+
+ + +@endsection diff --git a/resources/views/admin/product_categories/index.blade.php b/resources/views/admin/product_categories/index.blade.php new file mode 100644 index 0000000..cbd9c35 --- /dev/null +++ b/resources/views/admin/product_categories/index.blade.php @@ -0,0 +1,70 @@ +@extends('admin.layouts.app') + +@section('title', '商品分类') +@section('page_title', '商品分类') + +@section('content') +
+

这里是总台视角的商品分类巡检与维护入口,可跨站点查看分类结构。

+

当前分类列表已接入缓存:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+

新增分类

+
+ @csrf +
+ + + + + + +
+
+
+
+ +
+

分类列表

+ + + + @foreach($categories as $category) + + + + + + + + + + + @endforeach + +
ID商家名称Slug状态排序说明操作
{{ $category->id }}{{ $category->merchant?->name ?? ('商家#'.$category->merchant_id) }} +
+ @csrf + +
+ + + + +
+ @csrf + +
+
+
+ +
{{ $categories->links() }}
+@endsection diff --git a/resources/views/admin/products/import_histories.blade.php b/resources/views/admin/products/import_histories.blade.php new file mode 100644 index 0000000..31a6fa7 --- /dev/null +++ b/resources/views/admin/products/import_histories.blade.php @@ -0,0 +1,109 @@ +@extends('admin.layouts.app') + +@section('title', '平台商品导入历史') +@section('page_title', '平台商品导入历史') + +@section('content') +
+

这里集中查看平台视角的商品导入历史,适合运营侧复盘批量导入结果、失败明细与清理后文件状态。

+ + @php + $exportHistoryQuery = http_build_query(array_filter([ + 'merchant_id' => $importHistoryFilters['merchant_id'] ?? '', + 'import_result_status' => $importHistoryFilters['result_status'] ?? 'all', + 'import_time_range' => $importHistoryFilters['time_range'] ?? 'all', + 'start_date' => $importHistoryFilters['start_date'] ?? '', + 'end_date' => $importHistoryFilters['end_date'] ?? '', + 'import_sort' => $importHistoryFilters['sort'] ?? 'latest', + ], fn ($value, $key) => match ($key) { + 'start_date', 'end_date' => $value !== null && $value !== '', + default => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest', + }, ARRAY_FILTER_USE_BOTH)); + @endphp +
+
+ + + + + + + + + 导出当前筛选 CSV + 清空筛选 + 返回商品页 +
+
+ + @if(!empty($importHistoryFilters['date_errors'])) +
+ @foreach($importHistoryFilters['date_errors'] as $dateError) +
{{ $dateError }}
+ @endforeach +
+ @endif + +
+
累计导入批次
{{ $importHistoryStats['total_imports'] ?? 0 }}
+
累计成功商品
{{ $importHistoryStats['total_success'] ?? 0 }}
+
累计失败商品
{{ $importHistoryStats['total_failed'] ?? 0 }}
+
含失败批次
{{ $importHistoryStats['warning_imports'] ?? 0 }}
+
+ + + + + @forelse($importHistories as $history) + + + + + + + + + + + + @empty + + @endforelse + +
ID导入时间商家上传文件结果成功失败操作者失败明细
{{ $history->id }}{{ $history->imported_at?->format('Y-m-d H:i:s') }}{{ $history->merchant?->name ?? '平台批量导入' }}{{ $history->file_name }} + @if(($history->failed_count ?? 0) > 0) + 部分失败 + @else + 成功 + @endif + {{ $history->success_count }}{{ $history->failed_count }}{{ $history->admin?->name ?? '-' }} + @if($history->failure_file && ($history->failure_file_available ?? false)) + 下载 failure CSV + @elseif($history->failure_file) + 文件已过保留期 + @else + + @endif +
暂无导入记录
+ +
{{ $importHistories->links() }}
+
+@endsection diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php new file mode 100644 index 0000000..1ebe2a6 --- /dev/null +++ b/resources/views/admin/products/index.blade.php @@ -0,0 +1,225 @@ +@extends('admin.layouts.app') + +@section('title', '商品巡检') +@section('page_title', '商品巡检') + +@section('content') +
+

当前页面作为总台视角的商品巡检与演示管理入口。后续商家商品运营将拆分到独立商家后台。

+

当前商品列表已接入缓存:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+ @php + $exportQuery = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '')); + @endphp +
+

批量导入商品

+

先下载模板,按模板填写后上传 CSV。平台模板首列需要填写 `merchant_id`,分类通过 `category_slug` 匹配。

+ + @if(session('import_result')) + @php $importResult = session('import_result'); @endphp +
+
导入结果:成功 {{ $importResult['success'] ?? 0 }} 条,失败 {{ $importResult['failed'] ?? 0 }} 条。
+ @if(!empty($importResult['messages'])) +
失败原因预览:
+
    + @foreach($importResult['messages'] as $message) +
  • {{ $message }}
  • + @endforeach +
+ @endif + @if(!empty($importResult['failure_file'])) + + @endif +
+ @endif +
+ @csrf +
+
+ +
+
+
+

导入历史摘要

+
商品页仅保留摘要与最近 5 条记录;完整筛选、分页与复盘请前往独立导入历史页。
+
+ 进入完整导入历史页 +
+
+
累计导入批次
{{ $importHistoryStats['total_imports'] ?? 0 }}
+
累计成功商品
{{ $importHistoryStats['total_success'] ?? 0 }}
+
累计失败商品
{{ $importHistoryStats['total_failed'] ?? 0 }}
+
含失败批次
{{ $importHistoryStats['warning_imports'] ?? 0 }}
+
+ + + + @forelse($importHistories->take(5) as $history) + + + + + + + + + @empty + + @endforelse + +
ID导入时间商家上传文件结果失败明细
{{ $history->id }}{{ $history->imported_at?->format('Y-m-d H:i:s') }}{{ $history->merchant?->name ?? '平台批量导入' }}{{ $history->file_name }}@if(($history->failed_count ?? 0) > 0)部分失败({{ $history->failed_count }})@else成功@endif + @if($history->failure_file && ($history->failure_file_available ?? false)) + 下载 + @elseif($history->failure_file) + 已过保留期 + @else + + @endif +
暂无导入记录
+
+
+

筛选条件

+
+
+ + + + + + + + + +
+
重置
+
+

当前筛选摘要

@foreach($activeFilterSummary as $summaryLabel => $summaryValue)
{{ $summaryLabel }}
{{ $summaryValue }}
@endforeach
+ +

商品运营汇总

商品总数
{{ $summaryStats['total_products'] ?? 0 }}
总库存
{{ $summaryStats['total_stock'] ?? 0 }}
总货值
¥{{ number_format($summaryStats['total_stock_value'] ?? 0, 2) }}
平均售价
¥{{ number_format($summaryStats['average_price'] ?? 0, 2) }}
+ +
+

运营关注项

+
+
+
商品盘面
+ {{ $operationsFocus['headline'] ?? '当前总台商品运营信息已就绪。' }} +
建议动作
+ +
+
+
当前信号
+
+ @foreach(($operationsFocus['signals'] ?? []) as $label => $value) +
{{ $label }}
{{ $value }}
+ @endforeach +
+
+
+
工作台导航
+ +
+
+
+ +
+

商品状态统计

+ @php + $productBaseQuery = [ + 'merchant_id' => $filters['merchant_id'] ?: null, + 'category_id' => $filters['category_id'] ?: null, + 'keyword' => $filters['keyword'] ?: null, + 'min_price' => $filters['min_price'] ?: null, + 'max_price' => $filters['max_price'] ?: null, + 'min_stock' => $filters['min_stock'] ?: null, + 'max_stock' => $filters['max_stock'] ?: null, + 'sort' => $filters['sort'] ?: 'latest', + ]; + @endphp +
+
全部
{{ $statusStats['all'] ?? 0 }}
+ @foreach($filterOptions['statuses'] as $status) +
{{ $statusLabels[$status] ?? $status }}
{{ $statusStats[$status] ?? 0 }}
+ @endforeach +
+
+ +

新增商品

+
+ @csrf +
+ + + + + + + +
+
+
+
+
+ +
+

商品列表

+
+ @csrf + +
+ + + + +
+
+

平台侧批量改分类会校验所选分类是否属于被勾选商品对应商家;若混选了不同商家的商品,请选择各自可用的分类或先分批处理。

+ + + + @foreach($products as $product) + + + + + + + + + + + + + + + @endforeach + +
ID商家标题分类SKU售价/原价库存创建时间更新时间状态操作
{{ $product->id }}{{ $product->merchant?->name ?? ('商家#'.$product->merchant_id) }}
@csrf
原价:¥{{ number_format((float) $product->original_price, 2) }}
{{ $product->created_at?->format('Y-m-d H:i') }}{{ $product->updated_at?->format('Y-m-d H:i') }}
@csrf
+
+ + +
{{ $products->links() }}
+@endsection diff --git a/resources/views/admin/settings/channels.blade.php b/resources/views/admin/settings/channels.blade.php new file mode 100644 index 0000000..22a4356 --- /dev/null +++ b/resources/views/admin/settings/channels.blade.php @@ -0,0 +1,98 @@ +@extends('admin.layouts.app') + +@section('title', '渠道配置') +@section('page_title', '渠道配置') + +@section('content') +
+

渠道配置已经切到数据库读取,当前用于承接多端渠道和平台支付能力的基线定义,并已支持基础编辑。

+
当前渠道与支付配置概览已接入缓存读取。
+
当前平台商家数:{{ $merchantCount }}
+
+ +
+

渠道基线

+ + + + + + + + + + + + + + + + + @foreach($channels as $channel) + + + @csrf + + + + + + + + + + + + + @endforeach + +
渠道编码渠道名称类型状态入口登录支付分享备注操作
{{ $channel->channel_code }} + +
+
+ +
+

支付配置基线

+ + + + + + + + + + + + + + + @foreach($paymentConfigs as $payment) + + + @csrf + + + + + + + + + + + @endforeach + +
支付编码支付名称提供方状态沙箱退款备注操作
{{ $payment->payment_code }} + +
+
+@endsection diff --git a/resources/views/admin/settings/system.blade.php b/resources/views/admin/settings/system.blade.php new file mode 100644 index 0000000..ec1c92e --- /dev/null +++ b/resources/views/admin/settings/system.blade.php @@ -0,0 +1,79 @@ +@extends('admin.layouts.app') + +@section('title', '系统配置') +@section('page_title', '系统配置') + +@section('content') +
+

系统配置已经从静态骨架切到数据库读取,当前已支持基础编辑、保存和缓存刷新。

+
当前系统配置列表已接入缓存读取。
+
当前配置分组数:{{ $groupedCount }}
+
+ +@foreach($systemSettings->groupBy('group') as $group => $items) +
+

配置分组:{{ $group }}

+ + + + + + + + + + + + + + @foreach($items as $item) + @php + $isEditing = $editingConfigId === $item->id; + $configName = $isEditing ? old('config_name', $item->config_name) : $item->config_name; + $configValue = $isEditing ? old('config_value', $item->config_value) : $item->config_value; + $valueType = $isEditing ? old('value_type', $item->value_type) : $item->value_type; + $autoload = $isEditing ? old('autoload', $item->autoload) : $item->autoload; + $remark = $isEditing ? old('remark', $item->remark) : $item->remark; + @endphp + + + @csrf + + + + + + + + + + @endforeach + +
配置键名称配置值类型自动加载备注操作
{{ $item->config_key }} + @if($valueType === 'json') + + @if($isEditing && $errors->has('config_value')) + + @endif + @elseif($valueType === 'boolean') + + @elseif($valueType === 'number') + + @else + + @endif + + + + +
+
+@endforeach +@endsection diff --git a/resources/views/admin/site_subscriptions/index.blade.php b/resources/views/admin/site_subscriptions/index.blade.php new file mode 100644 index 0000000..1a185b2 --- /dev/null +++ b/resources/views/admin/site_subscriptions/index.blade.php @@ -0,0 +1,144 @@ +@extends('admin.layouts.app') + +@section('title', '订阅管理') +@section('page_title', '订阅管理') + +@section('content') +
+

这里是总台视角的订阅目录页,承接“套餐 -> 订阅 -> 平台订单”的收费主链中间层。

+

当前阶段先做到:可访问列表、可筛选、统计摘要;后续再接:订阅激活服务 / 续费 / 取消 / 对账。

+
+ +
+

筛选条件

+
+ + + + + +
+ +
+
+
+ +
+
+

订阅总数

+
{{ $summaryStats['total_subscriptions'] ?? 0 }}
+
+
+

已生效

+
{{ $summaryStats['activated_subscriptions'] ?? 0 }}
+
+
+

待生效

+
{{ $summaryStats['pending_subscriptions'] ?? 0 }}
+
+
+

已取消

+
{{ $summaryStats['cancelled_subscriptions'] ?? 0 }}
+
+
+

已过期(按到期时间)

+
{{ $summaryStats['expired_subscriptions'] ?? 0 }}
+
+
+

7天内到期

+
{{ $summaryStats['expiring_7d_subscriptions'] ?? 0 }}
+
+
+ +
+

工具

+
+ + + + + + +
+
+ +
+

订阅列表

+ + + + + + + + + + + + + + + + + + + @forelse($subscriptions as $subscription) + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
ID订阅号站点套餐状态计费周期周期(月)金额开始时间到期时间到期状态生效时间
{{ $subscription->id }}{{ $subscription->subscription_no }}{{ $subscription->merchant?->name ?? '未关联站点' }}{{ $subscription->plan_name ?: ($subscription->plan?->name ?? '未设置') }}{{ ($statusLabels[$subscription->status] ?? $subscription->status) }} ({{ $subscription->status }}){{ $subscription->billing_cycle ?: '-' }}{{ $subscription->period_months }}¥{{ number_format((float) $subscription->amount, 2) }}{{ optional($subscription->starts_at)->format('Y-m-d H:i:s') ?: '-' }}{{ optional($subscription->ends_at)->format('Y-m-d H:i:s') ?: '-' }} + @php + $endsAt = $subscription->ends_at; + $expiryLabel = '无到期'; + if ($endsAt) { + if ($endsAt->lt(now())) { + $expiryLabel = '已过期'; + } elseif ($endsAt->lt(now()->addDays(7))) { + $expiryLabel = '7天内到期'; + } else { + $expiryLabel = '未到期'; + } + } + @endphp + {{ $expiryLabel }} + {{ optional($subscription->activated_at)->format('Y-m-d H:i:s') ?: '-' }}
暂无订阅数据,当前阶段先把订阅主表与总台目录立起来,后续再接订阅创建/激活/续费链路。
+
+ +
{{ $subscriptions->links() }}
+@endsection diff --git a/resources/views/admin/tenants/index.blade.php b/resources/views/admin/tenants/index.blade.php new file mode 100644 index 0000000..b4becc9 --- /dev/null +++ b/resources/views/admin/tenants/index.blade.php @@ -0,0 +1 @@ +@extends('admin.merchants.index') diff --git a/resources/views/front/h5/index.blade.php b/resources/views/front/h5/index.blade.php new file mode 100644 index 0000000..d5cda47 --- /dev/null +++ b/resources/views/front/h5/index.blade.php @@ -0,0 +1,23 @@ + + + + + + H5 商城首页 - SaaSShop + + + +
SaaSShop H5 商城首页
+
+

这是 H5 端模板入口,后续可扩展底部导航、个人中心、下单与支付流程。

+

返回项目首页

+ @foreach($products as $product) +
+

{{ $product->title }}

+

{{ $product->summary }}

+

¥{{ number_format($product->price, 2) }}

+
+ @endforeach +
+ + diff --git a/resources/views/front/pc/index.blade.php b/resources/views/front/pc/index.blade.php new file mode 100644 index 0000000..a9fb5a3 --- /dev/null +++ b/resources/views/front/pc/index.blade.php @@ -0,0 +1,26 @@ + + + + + + PC 商城首页 - SaaSShop + + + +
SaaSShop PC 端商城首页
+
+

这是 PC 端模板入口,后续可扩展导航、分类、搜索、购物车与会员中心。

+

返回项目首页

+
+ @foreach($products as $product) +
+

{{ $product->title }}

+

{{ $product->summary }}

+

¥{{ number_format($product->price, 2) }}

+

SKU: {{ $product->sku }}

+
+ @endforeach +
+
+ + diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php new file mode 100644 index 0000000..e43aa8b --- /dev/null +++ b/resources/views/home.blade.php @@ -0,0 +1,45 @@ + + + + + + SaaSShop 首页 + + + +
+

SaaSShop 多端项目首页

+

当前基础框架已按多端方向铺开:PC、H5 可直接访问,微信生态与 APP 接口层已预留入口;后台当前已形成总台管理、站点后台、商家后台三层入口骨架。

+
+

商家数

{{ $merchantCount }}

+

商品数

{{ $productCount }}

+

订单数

{{ $orderCount }}

+
+ +
+

管理与系统入口

+ +

项目目录:/var/www/sites/app

+
+ +
+ @foreach($platforms as $platform) +
+

{{ $platform['name'] }} {{ $platform['status'] }}

+

访问入口 {{ $platform['path'] }}

+
+ @endforeach +
+
+ + diff --git a/resources/views/merchant_admin/auth/login.blade.php b/resources/views/merchant_admin/auth/login.blade.php new file mode 100644 index 0000000..8e998f6 --- /dev/null +++ b/resources/views/merchant_admin/auth/login.blade.php @@ -0,0 +1,25 @@ + + + + + + 商家后台登录 - SaaSShop + + + + + + + diff --git a/resources/views/merchant_admin/dashboard.blade.php b/resources/views/merchant_admin/dashboard.blade.php new file mode 100644 index 0000000..bbeed38 --- /dev/null +++ b/resources/views/merchant_admin/dashboard.blade.php @@ -0,0 +1,19 @@ +@extends('merchant_admin.layouts.app') + +@section('title', '商家后台仪表盘') +@section('page_title', '商家后台仪表盘') + +@section('content') +
+

当前商家:{{ $merchant->name }}({{ $merchant->slug }})。这里是商家运营视角后台,只展示当前商家范围内的数据。

+
+
+
缓存状态:当前仪表盘统计已接入缓存,缓存驱动 {{ $cacheMeta['store'] }},TTL {{ $cacheMeta['ttl'] }}。
+
+
+
商家用户
{{ $stats['users'] }}
+
商家商品
{{ $stats['products'] }}
+
商家订单
{{ $stats['orders'] }}
+
待处理订单
{{ $stats['pending_orders'] }}
+
+@endsection diff --git a/resources/views/merchant_admin/layouts/app.blade.php b/resources/views/merchant_admin/layouts/app.blade.php new file mode 100644 index 0000000..c8c3002 --- /dev/null +++ b/resources/views/merchant_admin/layouts/app.blade.php @@ -0,0 +1,58 @@ + + + + + + @yield('title', 'SaaSShop 商家后台') + + + + +
+ +
+
+
+

@yield('page_title', '商家后台')

+
当前登录:{{ session('admin_name') }}({{ session('admin_email') }}) / 商家 ID:{{ session('admin_merchant_id') }}
+
当前作用域:商家管理员
+
+
+ @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('warning')) +
{{ session('warning') }}
+ @endif + @if($errors->any()) +
+ 提交失败: +
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + @yield('content') +
+
+ + diff --git a/resources/views/merchant_admin/orders/index.blade.php b/resources/views/merchant_admin/orders/index.blade.php new file mode 100644 index 0000000..a792a61 --- /dev/null +++ b/resources/views/merchant_admin/orders/index.blade.php @@ -0,0 +1,117 @@ +@extends('merchant_admin.layouts.app') + +@section('title', '商家订单管理') +@section('page_title', '商家订单管理') + +@section('content') +
+

当前订单列表已按登录商家过滤,商家管理员只能处理自己店铺的数据。

+

当前订单列表已接入缓存:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+ @php + $exportQuery = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '')); + @endphp + +

筛选条件

+ @if(!empty($filters['validation_errors'])) +
筛选条件有误:
    @foreach($filters['validation_errors'] as $validationError)
  • {{ $validationError }}
  • @endforeach
+ @endif +
+
+ + + + + + + + + + + + +
重置
+
+
+
+ +

当前筛选摘要

@foreach($activeFilterSummary as $summaryLabel => $summaryValue)
{{ $summaryLabel }}
{{ $summaryValue }}
@endforeach
+ +

订单汇总

订单总数
{{ $summaryStats['total_orders'] ?? 0 }}
实付总额
¥{{ number_format($summaryStats['total_pay_amount'] ?? 0, 2) }}
平均客单价
¥{{ number_format($summaryStats['average_order_amount'] ?? 0, 2) }}
待支付金额
¥{{ number_format($summaryStats['unpaid_pay_amount'] ?? 0, 2) }}
已支付金额
¥{{ number_format($summaryStats['paid_pay_amount'] ?? 0, 2) }}
已支付订单数
{{ $summaryStats['paid_orders'] ?? 0 }}
支付率
{{ number_format($summaryStats['payment_rate'] ?? 0, 2) }}%
退款订单数
{{ $summaryStats['refunded_orders'] ?? 0 }}
退款率
{{ number_format($summaryStats['refund_rate'] ?? 0, 2) }}%
待发货订单数
{{ $summaryStats['pending_shipment_orders'] ?? 0 }}
完成率
{{ number_format($summaryStats['completion_rate'] ?? 0, 2) }}%
支付失败订单
{{ $summaryStats['failed_payment_orders'] ?? 0 }}
已取消订单
{{ $summaryStats['cancelled_orders'] ?? 0 }}
取消率
{{ number_format($summaryStats['cancellation_rate'] ?? 0, 2) }}%
+ +

时间趋势指标

今日订单数
{{ $trendStats['today_orders'] ?? 0 }}
今日实付金额
¥{{ number_format($trendStats['today_pay_amount'] ?? 0, 2) }}
近7天订单数
{{ $trendStats['last_7_days_orders'] ?? 0 }}
近7天实付金额
¥{{ number_format($trendStats['last_7_days_pay_amount'] ?? 0, 2) }}
+ +
+

运营关注项

+
+
+
订单盘面
+ {{ $operationsFocus['headline'] ?? '当前商家订单运营信息已就绪。' }} +
建议动作
+ +
+
+
当前信号
+
+ @foreach(($operationsFocus['signals'] ?? []) as $label => $value) +
{{ $label }}
{{ $value }}
+ @endforeach +
+
+
+
工作台导航
+ +
+
+
+ +
+

状态统计

+ @php + $baseQuery = [ + 'payment_status' => $filters['payment_status'] ?: null, + 'platform' => $filters['platform'] ?: null, + 'device_type' => $filters['device_type'] ?: null, + 'payment_channel' => $filters['payment_channel'] ?: null, + 'keyword' => $filters['keyword'] ?: null, + 'start_date' => $filters['start_date'] ?: null, + 'end_date' => $filters['end_date'] ?: null, + 'min_pay_amount' => $filters['min_pay_amount'] ?: null, + 'max_pay_amount' => $filters['max_pay_amount'] ?: null, + 'time_range' => ($filters['time_range'] ?? 'all') !== 'all' ? $filters['time_range'] : null, + 'sort' => ($filters['sort'] ?? 'latest') !== 'latest' ? $filters['sort'] : null, + ]; + @endphp +
+
全部
{{ $statusStats['all'] ?? 0 }}
+ @foreach($filterOptions['statuses'] as $status) +
{{ $statusLabels[$status] ?? $status }}
{{ $statusStats[$status] ?? 0 }}
+ @endforeach +
+
+ +
+

订单列表

+ + + + @forelse($orders as $order) + + + + @empty + + @endforelse + +
ID订单号平台买家支付金额创建时间支付时间发货时间完成时间状态操作
{{ $order->id }}{{ $order->order_no }}{{ $platformLabels[$order->platform] ?? $order->platform }}{{ $order->buyer_name }}
{{ $paymentChannelLabels[$order->payment_channel] ?? $order->payment_channel }}
{{ $paymentStatusLabels[$order->payment_status] ?? $order->payment_status }}
¥{{ number_format($order->pay_amount, 2) }}{{ $order->created_at?->format('Y-m-d H:i') }}{{ $order->paid_at?->format('Y-m-d H:i') ?? '-' }}{{ $order->shipped_at?->format('Y-m-d H:i') ?? '-' }}{{ $order->completed_at?->format('Y-m-d H:i') ?? '-' }}{{ $statusLabels[$order->status] ?? $order->status }}
@csrf
暂无订单
+
+ +
{{ $orders->links() }}
+@endsection diff --git a/resources/views/merchant_admin/orders/show.blade.php b/resources/views/merchant_admin/orders/show.blade.php new file mode 100644 index 0000000..2b6e0d3 --- /dev/null +++ b/resources/views/merchant_admin/orders/show.blade.php @@ -0,0 +1,46 @@ +@extends('merchant_admin.layouts.app') + +@section('title', '商家订单详情') +@section('page_title', '商家订单详情') + +@section('content') +
+

订单 {{ $order->order_no }}

+ + + + + + + + + + + + +
ID{{ $order->id }}
平台{{ $order->platform }}
订单状态{{ $order->status }}
支付渠道{{ $order->payment_channel }}
支付状态{{ $order->payment_status }}
买家{{ $order->buyer_name }}
手机{{ $order->buyer_phone }}
邮箱{{ $order->buyer_email }}
应付金额¥{{ number_format($order->pay_amount, 2) }}
备注{{ $order->remark }}
创建时间{{ $order->created_at }}
+
+ +
+

订单明细

+ + + + @forelse($order->items as $item) + + + + + + + + + + @empty + + @endforelse + +
ID商品SKU单价数量小计快照
{{ $item->id }}{{ $item->product_title }}{{ $item->product_sku }}¥{{ number_format($item->product_price, 2) }}{{ $item->quantity }}¥{{ number_format($item->line_total_amount, 2) }}@if(!empty($item->snapshot)) 分类:{{ $item->snapshot['category'] ?? '-' }} @else - @endif
暂无订单明细
+

返回订单列表

+
+@endsection diff --git a/resources/views/merchant_admin/product_categories/index.blade.php b/resources/views/merchant_admin/product_categories/index.blade.php new file mode 100644 index 0000000..88d69c8 --- /dev/null +++ b/resources/views/merchant_admin/product_categories/index.blade.php @@ -0,0 +1,48 @@ +@extends('merchant_admin.layouts.app') + +@section('title', '商家商品分类') +@section('page_title', '商家商品分类') + +@section('content') +
+

这里是当前商家的商品分类管理入口,分类数据天然受商家作用域限制。

+

当前分类列表已接入缓存:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+

新增分类

+
+ @csrf +
+ + + + + +
+
+
+
+ +
+

分类列表

+ + + + @foreach($categories as $category) + + + + + + + + + + @endforeach + +
ID名称Slug状态排序说明操作
{{ $category->id }}
@csrf
@csrf
+
+ +
{{ $categories->links() }}
+@endsection diff --git a/resources/views/merchant_admin/products/import_histories.blade.php b/resources/views/merchant_admin/products/import_histories.blade.php new file mode 100644 index 0000000..1a818d1 --- /dev/null +++ b/resources/views/merchant_admin/products/import_histories.blade.php @@ -0,0 +1,101 @@ +@extends('merchant_admin.layouts.app') + +@section('title', '商家商品导入历史') +@section('page_title', '商家商品导入历史') + +@section('content') +
+

这里集中查看当前商家范围内的商品导入历史,便于复盘成功率、失败批次与 failure CSV 保留情况。

+ + @php + $exportHistoryQuery = http_build_query(array_filter([ + 'import_result_status' => $importHistoryFilters['result_status'] ?? 'all', + 'import_time_range' => $importHistoryFilters['time_range'] ?? 'all', + 'start_date' => $importHistoryFilters['start_date'] ?? '', + 'end_date' => $importHistoryFilters['end_date'] ?? '', + 'import_sort' => $importHistoryFilters['sort'] ?? 'latest', + ], fn ($value, $key) => match ($key) { + 'start_date', 'end_date' => $value !== null && $value !== '', + default => $value !== null && $value !== '' && $value !== 'all' && $value !== 'latest', + }, ARRAY_FILTER_USE_BOTH)); + @endphp +
+
+ + + + + + + + 导出当前筛选 CSV + 清空筛选 + 返回商品页 +
+
+ + @if(!empty($importHistoryFilters['date_errors'])) +
+ @foreach($importHistoryFilters['date_errors'] as $dateError) +
{{ $dateError }}
+ @endforeach +
+ @endif + +
+
累计导入批次
{{ $importHistoryStats['total_imports'] ?? 0 }}
+
累计成功商品
{{ $importHistoryStats['total_success'] ?? 0 }}
+
累计失败商品
{{ $importHistoryStats['total_failed'] ?? 0 }}
+
含失败批次
{{ $importHistoryStats['warning_imports'] ?? 0 }}
+
+ + + + + @forelse($importHistories as $history) + + + + + + + + + + + @empty + + @endforelse + +
ID导入时间上传文件结果成功失败操作者失败明细
{{ $history->id }}{{ $history->imported_at?->format('Y-m-d H:i:s') }}{{ $history->file_name }} + @if(($history->failed_count ?? 0) > 0) + 部分失败 + @else + 成功 + @endif + {{ $history->success_count }}{{ $history->failed_count }}{{ $history->admin?->name ?? '-' }} + @if($history->failure_file && ($history->failure_file_available ?? false)) + 下载 failure CSV + @elseif($history->failure_file) + 文件已过保留期 + @else + + @endif +
暂无导入记录
+ +
{{ $importHistories->links() }}
+
+@endsection diff --git a/resources/views/merchant_admin/products/index.blade.php b/resources/views/merchant_admin/products/index.blade.php new file mode 100644 index 0000000..d95a9eb --- /dev/null +++ b/resources/views/merchant_admin/products/index.blade.php @@ -0,0 +1,203 @@ +@extends('merchant_admin.layouts.app') + +@section('title', '商家商品管理') +@section('page_title', '商家商品管理') + +@section('content') +
+

当前商品列表已按登录商家过滤,新增、编辑、删除商品都会限制在当前商家作用域内。

+

当前已接入缓存:商品列表使用 {{ $cacheMeta['store'] }},TTL {{ $cacheMeta['ttl'] }};商品变更后会自动失效。

+ @php + $exportQuery = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '')); + @endphp +
+

批量导入商品

+

先下载模板,按模板填写后上传 CSV。商家模板不需要填写 `merchant_id`,系统会自动归属到当前登录商家。

+ + @if(session('import_result')) + @php $importResult = session('import_result'); @endphp +
+
导入结果:成功 {{ $importResult['success'] ?? 0 }} 条,失败 {{ $importResult['failed'] ?? 0 }} 条。
+ @if(!empty($importResult['messages'])) +
失败原因预览:
+
    @foreach($importResult['messages'] as $message)
  • {{ $message }}
  • @endforeach
+ @endif + @if(!empty($importResult['failure_file'])) + + @endif +
+ @endif +
@csrf
+ +
+
+

导入历史摘要

商品页仅保留摘要与最近 5 条记录;完整筛选、分页与复盘请前往独立导入历史页。
+ 进入完整导入历史页 +
+
+
累计导入批次
{{ $importHistoryStats['total_imports'] ?? 0 }}
+
累计成功商品
{{ $importHistoryStats['total_success'] ?? 0 }}
+
累计失败商品
{{ $importHistoryStats['total_failed'] ?? 0 }}
+
含失败批次
{{ $importHistoryStats['warning_imports'] ?? 0 }}
+
+ + + + @forelse($importHistories->take(5) as $history) + + + + + @empty + + @endforelse + +
ID导入时间上传文件结果失败明细
{{ $history->id }}{{ $history->imported_at?->format('Y-m-d H:i:s') }}{{ $history->file_name }}@if(($history->failed_count ?? 0) > 0)部分失败({{ $history->failed_count }})@else成功@endif@if($history->failure_file && ($history->failure_file_available ?? false))下载@elseif($history->failure_file)已过保留期@else@endif
暂无导入记录
+
+
+

商品分类

+ + + + @forelse($categories as $category) + + @empty + + @endforelse + +
ID分类名称Slug状态排序说明
{{ $category->id }}{{ $category->name }}{{ $category->slug }}{{ $category->status }}{{ $category->sort }}{{ $category->description }}
暂无分类
+ +

筛选条件

+
+
+ + + + + + + + +
+
重置
+
+ +

当前筛选摘要

@foreach($activeFilterSummary as $summaryLabel => $summaryValue)
{{ $summaryLabel }}
{{ $summaryValue }}
@endforeach
+ +

商品运营汇总

商品总数
{{ $summaryStats['total_products'] ?? 0 }}
总库存
{{ $summaryStats['total_stock'] ?? 0 }}
总货值
¥{{ number_format($summaryStats['total_stock_value'] ?? 0, 2) }}
平均售价
¥{{ number_format($summaryStats['average_price'] ?? 0, 2) }}
+ +
+

运营关注项

+
+
+
商品盘面
+ {{ $operationsFocus['headline'] ?? '当前商家商品运营信息已就绪。' }} +
建议动作
+ +
+
+
当前信号
+
+ @foreach(($operationsFocus['signals'] ?? []) as $label => $value) +
{{ $label }}
{{ $value }}
+ @endforeach +
+
+
+
工作台导航
+ +
+
+
+ +
+

商品状态统计

+ @php + $productBaseQuery = [ + 'category_id' => $filters['category_id'] ?: null, + 'keyword' => $filters['keyword'] ?: null, + 'min_price' => $filters['min_price'] ?: null, + 'max_price' => $filters['max_price'] ?: null, + 'min_stock' => $filters['min_stock'] ?: null, + 'max_stock' => $filters['max_stock'] ?: null, + 'sort' => $filters['sort'] ?: 'latest', + ]; + @endphp +
+
全部
{{ $statusStats['all'] ?? 0 }}
+ @foreach($filterOptions['statuses'] as $status) +
{{ $statusLabels[$status] ?? $status }}
{{ $statusStats[$status] ?? 0 }}
+ @endforeach +
+
+ +

新增商品

+
+ @csrf +
+ + + + + + + +
+
+
+
+ +
+

商品列表

+
@csrf
+

批量操作只会作用于当前登录商家可见商品,越权或已删除数据会被拦截。

+ + + + @foreach($products as $product) + + + + + + + + + + + + + + @endforeach + +
ID标题分类SKU售价/原价库存创建时间更新时间状态操作
{{ $product->id }}
@csrf
原价:¥{{ number_format((float) $product->original_price, 2) }}
{{ $product->created_at?->format('Y-m-d H:i') }}{{ $product->updated_at?->format('Y-m-d H:i') }}
@csrf
+
+ + +
{{ $products->links() }}
+@endsection diff --git a/resources/views/merchant_admin/users/index.blade.php b/resources/views/merchant_admin/users/index.blade.php new file mode 100644 index 0000000..1d20118 --- /dev/null +++ b/resources/views/merchant_admin/users/index.blade.php @@ -0,0 +1,30 @@ +@extends('merchant_admin.layouts.app') + +@section('title', '商家用户管理') +@section('page_title', '商家用户管理') + +@section('content') +
+

当前用户列表已按登录商家过滤,用于查看商家侧用户资产和注册来源。

+

当前用户列表已接入缓存:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+

用户列表

+ + + + @foreach($users as $user) + + + + + + + + + + @endforeach + +
ID姓名邮箱手机状态注册来源最近登录来源
{{ $user->id }}{{ $user->name }}{{ $user->email }}{{ $user->phone }}{{ $user->status }}{{ $user->register_source }}{{ $user->last_login_source }}
+
+ +
{{ $users->links() }}
+@endsection diff --git a/resources/views/site_admin/auth/login.blade.php b/resources/views/site_admin/auth/login.blade.php new file mode 100644 index 0000000..f42a535 --- /dev/null +++ b/resources/views/site_admin/auth/login.blade.php @@ -0,0 +1,25 @@ + + + + + + 站点后台登录 - SaaSShop + + + + + + + diff --git a/resources/views/site_admin/dashboard.blade.php b/resources/views/site_admin/dashboard.blade.php new file mode 100644 index 0000000..1da00d6 --- /dev/null +++ b/resources/views/site_admin/dashboard.blade.php @@ -0,0 +1,18 @@ +@extends('site_admin.layouts.app') + +@section('title', '站点后台仪表盘') +@section('page_title', '站点后台仪表盘') + +@section('content') +
+

当前站点:{{ $site->name }}({{ $site->slug }})。这里是站点运营视角后台,第一阶段先提供站点级总览、商品、订单与商家入口骨架。

+

缓存状态:{{ $cacheMeta['store'] }} / TTL {{ $cacheMeta['ttl'] }}。

+
+
+
站点管理员
{{ $stats['admins'] }}
+
站点用户
{{ $stats['users'] }}
+
站点商品
{{ $stats['products'] }}
+
站点订单
{{ $stats['orders'] }}
+
待处理订单
{{ $stats['pending_orders'] }}
+
+@endsection diff --git a/resources/views/site_admin/layouts/app.blade.php b/resources/views/site_admin/layouts/app.blade.php new file mode 100644 index 0000000..96caf2d --- /dev/null +++ b/resources/views/site_admin/layouts/app.blade.php @@ -0,0 +1,61 @@ + + + + + + @yield('title', 'SaaSShop 站点后台') + + + + +
+ +
+
+
+

@yield('page_title', '站点后台')

+
当前登录:{{ session('admin_name') }}({{ session('admin_email') }}) / 站点 ID:{{ session('admin_site_id') }}
+
当前作用域:站点管理员
+
+
+ @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('warning')) +
{{ session('warning') }}
+ @endif + @if($errors->any()) +
+ 提交失败: +
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + @yield('content') +
+
+ + diff --git a/resources/views/site_admin/merchants/index.blade.php b/resources/views/site_admin/merchants/index.blade.php new file mode 100644 index 0000000..367c64b --- /dev/null +++ b/resources/views/site_admin/merchants/index.blade.php @@ -0,0 +1,308 @@ +@extends('site_admin.layouts.app') + +@section('title', '站点商家') +@section('page_title', '站点商家') + +@section('content') +@php + $productWorkbenchUrl = '/site-admin/products?sort=stock_desc&status=published'; + $lowStockWorkbenchUrl = '/site-admin/products?sort=stock_asc&status=published'; + $latestProductWorkbenchUrl = '/site-admin/products?sort=latest'; + $orderWorkbenchUrl = '/site-admin/orders?sort=pay_amount_desc&payment_status=paid'; + $pendingOrderWorkbenchUrl = '/site-admin/orders?sort=latest&payment_status=unpaid'; + $failedOrderWorkbenchUrl = '/site-admin/orders?sort=latest&payment_status=failed'; +@endphp +
+

当前阶段继续复用 merchant 承接站点关系,但页面已先补最小运营查看能力,方便后续从“承接记录”平滑过渡到真实站点治理。

+ @php + $merchantExportQuery = http_build_query(array_filter([ + 'keyword' => $filters['keyword'] ?? '', + 'status' => $filters['status'] ?? '', + 'plan' => $filters['plan'] ?? '', + 'sort' => $filters['sort'] ?? 'latest', + ], fn ($value) => $value !== null && $value !== '')); + @endphp + +

筛选条件

+
+
+ + + + +
+
+ + 重置 +
+
+
+ +
+

当前筛选摘要

+
+ @foreach(($activeFilterSummary ?? []) as $label => $value) +
+
{{ $label }}
+ {{ $value }} +
+ @endforeach +
+
+ +
+

站点运营摘要

+
+
+
承接站点数
+ {{ $summaryStats['site_count'] ?? 0 }} +
+
+
启用中站点
+ {{ $summaryStats['active_site_count'] ?? 0 }} +
+
+
站点管理员数
+ {{ $summaryStats['admin_count'] ?? 0 }} +
+
+
站点用户数
+ {{ $summaryStats['user_count'] ?? 0 }} +
+
+
站点商品数
+ {{ $summaryStats['product_count'] ?? 0 }} +
+
+
站点订单数
+ {{ $summaryStats['order_count'] ?? 0 }} +
+
+
商品分类数
+ {{ $summaryStats['category_count'] ?? 0 }} +
+
+
+ +
+

运营关注项

+
+
+
商品侧
+ + @if(($summaryStats['product_count'] ?? 0) <= 0) + 当前站点暂无商品,建议优先补齐基础商品数据。 + @elseif(($summaryStats['product_count'] ?? 0) < 3) + 当前站点商品仍较少,建议优先查看最近新增与基础信息是否完整。 + @else + 当前站点已有较稳定商品沉淀,建议优先巡检低库存与高库存商品结构。 + @endif + +
建议动作
+ +
+
+
订单侧
+ + @if(($summaryStats['order_count'] ?? 0) <= 0) + 当前站点暂无订单,建议先确认交易链路与下单链路是否已完成联调。 + @elseif(($summaryStats['order_count'] ?? 0) < 5) + 当前站点已有少量订单沉淀,建议先查看待支付与最近订单走势。 + @else + 当前站点订单已形成基础规模,建议优先关注待支付、支付失败与高金额订单。 + @endif + +
建议动作
+ +
+
+
联系信息
+ + @if(! $site->contact_name && ! $site->contact_phone && ! $site->contact_email) + 联系信息仍为空,建议尽快补齐联系人、手机与邮箱,避免后续运营跟进断点。 + @elseif(! $site->contact_name) + 当前缺少联系人姓名,建议先补联系人主体,再继续做日常跟进。 + @elseif(! $site->contact_phone && ! $site->contact_email) + 当前缺少联系方式,建议至少补齐手机号或邮箱中的一项。 + @else + 联系人信息已具备,可继续作为站点日常跟进入口。 + @endif + +
建议动作
+ +
+
+
+ +
+

工作台导航

+ +
+ +
+

当前站点资料

+
+
+
站点名称
+ {{ $site->name }} +
+
+
站点标识
+ {{ $site->slug }} +
+
+
当前状态
+ {{ $statusLabels[$site->status] ?? $site->status }} +
+
+
当前套餐
+ {{ $planLabels[$site->plan] ?? ($site->plan ?: '未设置') }} +
+
+
联系人
+ {{ $site->contact_name ?: '未设置' }} +
+
+
联系电话
+ {{ $site->contact_phone ?: '未设置' }} +
+
+
联系邮箱
+ {{ $site->contact_email ?: '未设置' }} +
+
+
激活时间
+ {{ $site->activated_at?->format('Y-m-d H:i') ?? '未激活' }} +
+
+
站点承接说明
+ 当前阶段由站点后台承接商品、订单与联系人信息查看;后续若拆出“站点 -> 商家”实体关系,这里将平滑升级为真正的站点运营台。 +
+
+ + +

快捷入口默认带入更适合运营查看的排序与状态条件:商品优先看已上架高库存,订单优先看已支付高金额;同时也补了低库存、待支付、支付失败等常用运营视角。

+
+ +
+

当前站点承接记录

+ + + + @forelse($merchants as $merchant) + + + + + + + + + + + + + + @empty + + + + @endforelse + +
ID名称Slug状态套餐联系人管理员数用户数商品数订单数操作
{{ $merchant->id }}{{ $merchant->name }}{{ $merchant->slug }}{{ $statusLabels[$merchant->status] ?? $merchant->status }}{{ $planLabels[$merchant->plan] ?? ($merchant->plan ?: '未设置') }}{{ ($merchant->contact_name ?: '未设置') }} / {{ ($merchant->contact_phone ?: '未设置') }}{{ $merchant->admins_count ?? 0 }}{{ $merchant->users_count ?? 0 }}{{ $merchant->products_count ?? 0 }}{{ $merchant->orders_count ?? 0 }} + +
暂无站点承接记录
+
+@endsection diff --git a/resources/views/site_admin/orders/index.blade.php b/resources/views/site_admin/orders/index.blade.php new file mode 100644 index 0000000..ea9b537 --- /dev/null +++ b/resources/views/site_admin/orders/index.blade.php @@ -0,0 +1,206 @@ +@extends('site_admin.layouts.app') + +@section('title', '站点订单') +@section('page_title', '站点订单') + +@section('content') +
+

当前页面展示站点范围内的订单列表,已补最小筛选与导出入口,便于站点侧先做基础运营查看。

+ @if(!empty($filters['validation_errors'] ?? [])) +
+ 筛选条件有误: +
    + @foreach(($filters['validation_errors'] ?? []) as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + @php + $orderExportQuery = http_build_query(array_filter([ + 'keyword' => $filters['keyword'] ?? '', + 'status' => $filters['status'] ?? '', + 'payment_status' => $filters['payment_status'] ?? '', + 'platform' => $filters['platform'] ?? '', + 'device_type' => $filters['device_type'] ?? '', + 'payment_channel' => $filters['payment_channel'] ?? '', + 'min_pay_amount' => $filters['min_pay_amount'] ?? '', + 'max_pay_amount' => $filters['max_pay_amount'] ?? '', + 'sort' => $filters['sort'] ?? 'latest', + ], fn ($value) => $value !== null && $value !== '')); + @endphp +

筛选条件

+
+
+ + + + + + + + + +
+
+ + 重置 + 导出当前筛选结果 CSV +
+
+
+ +
+

当前筛选摘要

+
+ @foreach(($activeFilterSummary ?? []) as $label => $value) +
+
{{ $label }}
+ {{ $value }} +
+ @endforeach +
+
+ +
+

订单汇总

+
+
+
订单总数
+ {{ $summaryStats['total_orders'] ?? 0 }} +
+
+
实付总额
+ ¥{{ number_format($summaryStats['total_pay_amount'] ?? 0, 2) }} +
+
+
平均客单价
+ ¥{{ number_format($summaryStats['average_order_amount'] ?? 0, 2) }} +
+
+
已支付订单数
+ {{ $summaryStats['paid_orders'] ?? 0 }} +
+
+
支付失败订单
+ {{ $summaryStats['failed_payment_orders'] ?? 0 }} +
+
+
+ +
+

运营关注项

+
+
+
订单盘面
+ {{ $operationsFocus['headline'] ?? '当前站点订单运营信息已就绪。' }} +
建议动作
+ +
+
+
当前信号
+
+ @foreach(($operationsFocus['signals'] ?? []) as $label => $value) +
+
{{ $label }}
+ {{ $value }} +
+ @endforeach +
+
+
+
工作台导航
+ +
+
+
+ +
+

订单状态统计

+ @php + $orderBaseQuery = [ + 'payment_status' => ($filters['payment_status'] ?? '') ?: null, + 'platform' => ($filters['platform'] ?? '') ?: null, + 'device_type' => ($filters['device_type'] ?? '') ?: null, + 'payment_channel' => ($filters['payment_channel'] ?? '') ?: null, + 'keyword' => ($filters['keyword'] ?? '') ?: null, + 'min_pay_amount' => ($filters['min_pay_amount'] ?? '') ?: null, + 'max_pay_amount' => ($filters['max_pay_amount'] ?? '') ?: null, + 'sort' => ($filters['sort'] ?? 'latest') !== 'latest' ? ($filters['sort'] ?? 'latest') : null, + ]; + @endphp + +
+ +
+

订单列表

+ + + + @forelse($orders as $order) + + + + + + + + + + + @empty + + @endforelse + +
ID订单号状态支付状态平台设备支付渠道实付金额
{{ $order->id }}{{ $order->order_no }}{{ $statusLabels[$order->status] ?? $order->status }}{{ $paymentStatusLabels[$order->payment_status] ?? $order->payment_status }}{{ $platformLabels[$order->platform] ?? $order->platform }}{{ $deviceTypeLabels[$order->device_type] ?? $order->device_type }}{{ $paymentChannelLabels[$order->payment_channel] ?? $order->payment_channel }}{{ number_format((float) $order->pay_amount, 2) }}
暂无订单
+
+
{{ $orders->links() }}
+@endsection diff --git a/resources/views/site_admin/products/index.blade.php b/resources/views/site_admin/products/index.blade.php new file mode 100644 index 0000000..562a635 --- /dev/null +++ b/resources/views/site_admin/products/index.blade.php @@ -0,0 +1,183 @@ +@extends('site_admin.layouts.app') + +@section('title', '站点商品') +@section('page_title', '站点商品') + +@section('content') +
+

当前页面展示站点范围内的商品列表,已补最小筛选与导出入口,便于站点侧先做基础运营查看。

+ @if(!empty($filters['validation_errors'] ?? [])) +
+ 筛选条件有误: +
    + @foreach(($filters['validation_errors'] ?? []) as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + @php + $productExportQuery = http_build_query(array_filter([ + 'keyword' => $filters['keyword'] ?? '', + 'status' => $filters['status'] ?? '', + 'category_id' => $filters['category_id'] ?? '', + 'min_price' => $filters['min_price'] ?? '', + 'max_price' => $filters['max_price'] ?? '', + 'min_stock' => $filters['min_stock'] ?? '', + 'max_stock' => $filters['max_stock'] ?? '', + 'sort' => $filters['sort'] ?? 'latest', + ], fn ($value) => $value !== null && $value !== '')); + @endphp +

筛选条件

+
+
+ + + + + + + + +
+
+ + 重置 + 导出当前筛选结果 CSV +
+
+
+ +
+

当前筛选摘要

+
+ @foreach(($activeFilterSummary ?? []) as $label => $value) +
+
{{ $label }}
+ {{ $value }} +
+ @endforeach +
+
+ +
+

商品运营汇总

+
+
+
商品总数
+ {{ $summaryStats['total_products'] ?? 0 }} +
+
+
总库存
+ {{ $summaryStats['total_stock'] ?? 0 }} +
+
+
总货值
+ ¥{{ number_format($summaryStats['total_stock_value'] ?? 0, 2) }} +
+
+
平均售价
+ ¥{{ number_format($summaryStats['average_price'] ?? 0, 2) }} +
+
+
+ +
+

运营关注项

+
+
+
商品盘面
+ {{ $operationsFocus['headline'] ?? '当前站点商品运营信息已就绪。' }} +
建议动作
+ +
+
+
当前信号
+
+ @foreach(($operationsFocus['signals'] ?? []) as $label => $value) +
+
{{ $label }}
+ {{ $value }} +
+ @endforeach +
+
+
+
工作台导航
+ +
+
+
+ +
+

商品状态统计

+ @php + $productBaseQuery = [ + 'keyword' => ($filters['keyword'] ?? '') ?: null, + 'category_id' => ($filters['category_id'] ?? '') ?: null, + 'min_price' => ($filters['min_price'] ?? '') ?: null, + 'max_price' => ($filters['max_price'] ?? '') ?: null, + 'min_stock' => ($filters['min_stock'] ?? '') ?: null, + 'max_stock' => ($filters['max_stock'] ?? '') ?: null, + 'sort' => ($filters['sort'] ?? 'latest') !== 'latest' ? ($filters['sort'] ?? 'latest') : null, + ]; + @endphp + +
+ +
+

商品列表

+ + + + @forelse($products as $product) + + + + + + + + + + @empty + + @endforelse + +
ID标题SKU分类价格库存状态
{{ $product->id }}{{ $product->title }}{{ $product->sku }}{{ $product->category?->name ?? '-' }}{{ number_format((float) $product->price, 2) }}{{ $product->stock }}{{ $statusLabels[$product->status] ?? $product->status }}
暂无商品
+
+
{{ $products->links() }}
+@endsection diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..23d6cca --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,36 @@ + + + + + + {{ config('app.name', 'SaaSShop') }} + + + +
+

{{ config('app.name', 'SaaSShop') }}

+

Laravel 基础框架已启动。当前项目已切到 SaaS 电商多端底座方向,默认欢迎页已简化为可维护版本,样式统一走外置 CSS。

+
+ +
+

接口入口

+ +
+
+
+ + diff --git a/resources/views/welcome_status.blade.php b/resources/views/welcome_status.blade.php new file mode 100644 index 0000000..14ee7b3 --- /dev/null +++ b/resources/views/welcome_status.blade.php @@ -0,0 +1,30 @@ + + + + + + SaaSShop Laravel 初始化页 + + + +
+

SaaSShop Laravel 初始化页

+

访问地址:http://192.168.10.199:9001/

+
+

框架

Laravel 已部署

+

PHP

PHP {{ $phpVersion }}

+

数据库

{{ $dbMsg }}

+

Redis

{{ $redisMsg }}

+
+
+

当前已完成

+
    +
  • LNMP + Redis 环境已就绪
  • +
  • Laravel 项目已安装到 /var/www/sites/app
  • +
  • 数据库已切换为 appdb
  • +
  • Redis 已接入 Laravel 作为队列连接
  • +
+
+
+ + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..9f3b098 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,21 @@ +group(function () { + Route::get('/ping', [SystemController::class, 'ping']); + Route::get('/platforms', [SystemController::class, 'platforms']); + + Route::post('/auth/login', [AuthController::class, 'login']); + Route::post('/auth/channel-login', [AuthController::class, 'wechatPlaceholder']); + + Route::get('/products', [ProductController::class, 'index']); + Route::get('/products/{id}', [ProductController::class, 'show']); + + Route::get('/orders', [OrderController::class, 'index']); + Route::post('/orders', [OrderController::class, 'store']); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..ff54025 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,177 @@ +db_name . ' / ' . $row->now_time; + } catch (Throwable $e) { + $dbMsg = $e->getMessage(); + } + + $redisOk = false; + $redisMsg = '未检测'; + try { + $pong = Redis::ping(); + $redisOk = true; + $redisMsg = 'Redis PING => ' . var_export($pong, true); + } catch (Throwable $e) { + $redisMsg = $e->getMessage(); + } + + return response()->view('welcome_status', [ + 'phpVersion' => PHP_VERSION, + 'dbOk' => $dbOk, + 'dbMsg' => $dbMsg, + 'redisOk' => $redisOk, + 'redisMsg' => $redisMsg, + ]); +}); + +Route::prefix('admin')->group(function () { + Route::get('/login', [AdminAuthController::class, 'showLogin']); + Route::post('/login', [AdminAuthController::class, 'login']); + Route::post('/logout', [AdminAuthController::class, 'logout']); + + Route::middleware('admin.auth')->group(function () { + Route::get('/', [DashboardController::class, 'index']); + + Route::get('/merchants', [AdminMerchantController::class, 'index']); + Route::post('/merchants', [AdminMerchantController::class, 'store']); + + Route::get('/products', [AdminProductController::class, 'index']); + Route::get('/products/import-histories', [AdminProductController::class, 'importHistories']); + Route::get('/products/import-histories/export', [AdminProductController::class, 'exportImportHistories']); + Route::get('/products/export', [AdminProductController::class, 'export']); + Route::get('/products/import-template', [AdminProductController::class, 'downloadImportTemplate']); + Route::post('/products/import', [AdminProductController::class, 'import']); + Route::get('/products/import-failures/{file}', [AdminProductController::class, 'downloadImportFailures']); + Route::post('/products/batch', [AdminProductController::class, 'batchUpdate']); + Route::post('/products', [AdminProductController::class, 'store']); + Route::post('/products/{id}', [AdminProductController::class, 'update']); + Route::post('/products/{id}/delete', [AdminProductController::class, 'destroy']); + + Route::get('/product-categories', [AdminProductCategoryController::class, 'index']); + Route::post('/product-categories', [AdminProductCategoryController::class, 'store']); + Route::post('/product-categories/{id}', [AdminProductCategoryController::class, 'update']); + Route::post('/product-categories/{id}/delete', [AdminProductCategoryController::class, 'destroy']); + + Route::get('/orders', [AdminOrderController::class, 'index']); + Route::get('/orders/export', [AdminOrderController::class, 'export']); + Route::get('/orders/{id}', [AdminOrderController::class, 'show']); + Route::post('/orders/{id}/status', [AdminOrderController::class, 'updateStatus']); + + Route::get('/platform-orders', [PlatformOrderController::class, 'index']); + Route::get('/platform-orders/export', [PlatformOrderController::class, 'export']); + Route::post('/platform-orders/batch-activate-subscriptions', [PlatformOrderController::class, 'batchActivateSubscriptions']); + Route::post('/platform-orders/clear-sync-errors', [PlatformOrderController::class, 'clearSyncErrors']); + Route::get('/platform-orders/{order}', [PlatformOrderController::class, 'show']); + Route::post('/platform-orders/{order}/activate-subscription', [PlatformOrderController::class, 'activateSubscription']); + Route::post('/platform-orders/{order}/mark-paid-and-activate', [PlatformOrderController::class, 'markPaidAndActivate']); + + Route::get('/site-subscriptions', [SiteSubscriptionController::class, 'index']); + Route::get('/site-subscriptions/export', [SiteSubscriptionController::class, 'export']); + + Route::get('/plans', [PlanController::class, 'index']); + Route::get('/plans/export', [PlanController::class, 'export']); + Route::get('/plans/create', [PlanController::class, 'create']); + Route::post('/plans', [PlanController::class, 'store']); + Route::get('/plans/{plan}/edit', [PlanController::class, 'edit']); + Route::post('/plans/{plan}', [PlanController::class, 'update']); + Route::post('/plans/{plan}/set-status', [PlanController::class, 'setStatus']); + + Route::get('/settings/system', [PlatformSettingController::class, 'system']); + Route::post('/settings/system/{id}', [PlatformSettingController::class, 'updateSystem']); + Route::get('/settings/channels', [PlatformSettingController::class, 'channels']); + Route::post('/settings/channels/{id}', [PlatformSettingController::class, 'updateChannel']); + Route::post('/settings/payments/{id}', [PlatformSettingController::class, 'updatePayment']); + }); +}); + +Route::prefix('merchant-admin')->group(function () { + Route::get('/login', [MerchantAdminAuthController::class, 'showLogin']); + Route::post('/login', [MerchantAdminAuthController::class, 'login']); + Route::post('/logout', [MerchantAdminAuthController::class, 'logout']); + + Route::middleware('merchant.admin.auth')->group(function () { + Route::get('/', [MerchantDashboardController::class, 'index']); + + Route::get('/products', [MerchantProductController::class, 'index']); + Route::get('/products/import-histories', [MerchantProductController::class, 'importHistories']); + Route::get('/products/import-histories/export', [MerchantProductController::class, 'exportImportHistories']); + Route::get('/products/export', [MerchantProductController::class, 'export']); + Route::get('/products/import-template', [MerchantProductController::class, 'downloadImportTemplate']); + Route::post('/products/import', [MerchantProductController::class, 'import']); + Route::get('/products/import-failures/{file}', [MerchantProductController::class, 'downloadImportFailures']); + Route::post('/products/batch', [MerchantProductController::class, 'batchUpdate']); + Route::post('/products', [MerchantProductController::class, 'store']); + Route::post('/products/{id}', [MerchantProductController::class, 'update']); + Route::post('/products/{id}/delete', [MerchantProductController::class, 'destroy']); + + Route::get('/product-categories', [MerchantProductCategoryController::class, 'index']); + Route::post('/product-categories', [MerchantProductCategoryController::class, 'store']); + Route::post('/product-categories/{id}', [MerchantProductCategoryController::class, 'update']); + Route::post('/product-categories/{id}/delete', [MerchantProductCategoryController::class, 'destroy']); + + Route::get('/users', [MerchantUserController::class, 'index']); + + Route::get('/orders', [MerchantOrderController::class, 'index']); + Route::get('/orders/export', [MerchantOrderController::class, 'export']); + Route::get('/orders/{id}', [MerchantOrderController::class, 'show']); + Route::post('/orders/{id}/status', [MerchantOrderController::class, 'updateStatus']); + }); +}); + +Route::prefix('site-admin')->group(function () { + Route::get('/login', [SiteAdminAuthController::class, 'showLogin']); + Route::post('/login', [SiteAdminAuthController::class, 'login']); + Route::post('/logout', [SiteAdminAuthController::class, 'logout']); + + Route::middleware('site.admin.auth')->group(function () { + Route::get('/', [SiteDashboardController::class, 'index']); + Route::get('/merchants', [SiteMerchantController::class, 'index']); + Route::get('/merchants/export', [SiteMerchantController::class, 'export']); + Route::get('/products', [SiteProductController::class, 'index']); + Route::get('/products/export', [SiteProductController::class, 'export']); + Route::get('/orders', [SiteOrderController::class, 'index']); + Route::get('/orders/export', [SiteOrderController::class, 'export']); + }); +}); diff --git a/scripts/sql_migrate.php b/scripts/sql_migrate.php new file mode 100644 index 0000000..77bdcea --- /dev/null +++ b/scripts/sql_migrate.php @@ -0,0 +1,138 @@ + .env +$getEnv = function (string $key, string $default = '') use ($env): string { + $v = getenv($key); + if ($v !== false && $v !== '') { + return (string) $v; + } + return (string) ($env[$key] ?? $default); +}; + +$driver = $getEnv('DB_CONNECTION', 'mysql'); +$host = $getEnv('DB_HOST', '127.0.0.1'); +$port = $getEnv('DB_PORT', $driver === 'pgsql' ? '5432' : ($driver === 'sqlite' ? '' : '3306')); +$db = $getEnv('DB_DATABASE', ''); +$user = $getEnv('DB_USERNAME', ''); +$pass = $getEnv('DB_PASSWORD', ''); + +if ($driver === 'sqlite') { + // sqlite: DB_DATABASE 可能是绝对路径 + $dsn = $db !== '' ? "sqlite:" . $db : 'sqlite::memory:'; +} elseif ($driver === 'pgsql') { + $dsn = "pgsql:host={$host};port={$port};dbname={$db}"; +} else { + // mysql + $charset = $getEnv('DB_CHARSET', 'utf8mb4'); + $dsn = "mysql:host={$host};port={$port};dbname={$db};charset={$charset}"; +} + +try { + $pdo = new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} catch (Throwable $e) { + fwrite(STDERR, "DB connect failed: {$e->getMessage()}\n"); + exit(2); +} + +// 确保迁移记录表存在 +// applied_at 使用 TEXT,确保 sqlite/mysql 下都可用(避免 DATETIME 在部分方言下不兼容) +$pdo->exec("CREATE TABLE IF NOT EXISTS schema_sql_migrations (\n version VARCHAR(50) PRIMARY KEY,\n description VARCHAR(255) NULL,\n applied_at TEXT NOT NULL\n)"); + +$applied = []; +foreach ($pdo->query('SELECT version FROM schema_sql_migrations') as $row) { + $applied[(string) $row['version']] = true; +} + +$files = glob($baseDir . '/V*__*.sql'); +sort($files); + +$pending = []; +foreach ($files as $file) { + $name = basename($file); + if (!preg_match('/^(V\d+)__.+\.sql$/', $name, $m)) { + continue; + } + $version = $m[1]; + if (isset($applied[$version])) { + continue; + } + $pending[] = ['version' => $version, 'file' => $file, 'name' => $name]; +} + +if (count($pending) === 0) { + fwrite(STDOUT, "No pending SQL migrations.\n"); + exit(0); +} + +foreach ($pending as $item) { + $sql = file_get_contents($item['file']); + $sql = $sql === false ? '' : $sql; + + fwrite(STDOUT, "Applying {$item['name']} ...\n"); + + $pdo->beginTransaction(); + try { + // 简单处理:按分号执行(对于包含存储过程等复杂语法的脚本,需拆分策略升级) + $statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql))); + foreach ($statements as $stmt) { + if ($stmt === '' || str_starts_with(ltrim($stmt), '--')) { + continue; + } + $pdo->exec($stmt); + } + + $desc = preg_replace('/^V\d+__/', '', $item['name']); + $desc = preg_replace('/\.sql$/', '', $desc); + + $stmt = $pdo->prepare('INSERT INTO schema_sql_migrations(version, description, applied_at) VALUES(?, ?, ?)'); + $stmt->execute([$item['version'], $desc, date('Y-m-d H:i:s')]); + + $pdo->commit(); + } catch (Throwable $e) { + $pdo->rollBack(); + fwrite(STDERR, "Failed {$item['name']}: {$e->getMessage()}\n"); + exit(3); + } +} + +fwrite(STDOUT, "SQL migrations applied: " . count($pending) . "\n"); diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100755 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100755 index 0000000..05c4471 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100755 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/AdminAccessTest.php b/tests/Feature/AdminAccessTest.php new file mode 100644 index 0000000..57025e2 --- /dev/null +++ b/tests/Feature/AdminAccessTest.php @@ -0,0 +1,55 @@ +get('/admin/login'); + + $response->assertOk(); + $response->assertSee('总台管理登录'); + $response->assertSee('登录总台管理'); + } + + public function test_platform_admin_can_login_and_open_dashboard_with_new_naming(): void + { + $this->seed(); + + $response = $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ]); + + $response->assertRedirect('/admin'); + + $dashboard = $this->get('/admin'); + $dashboard->assertOk(); + $dashboard->assertSee('总台管理'); + $dashboard->assertSee('总台仪表盘'); + $dashboard->assertSee('站点管理'); + $dashboard->assertSee('订单监控'); + $dashboard->assertSee('商品巡检'); + $dashboard->assertSee('商品分类'); + } + + public function test_merchant_admin_cannot_login_from_admin_entry(): void + { + $this->seed(); + + $response = $this->from('/admin/login')->post('/admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ]); + + $response->assertRedirect('/admin/login'); + + $this->get('/admin/login')->assertSee('当前账号是商家管理员,请从商家后台入口登录'); + } +} diff --git a/tests/Feature/AdminBusinessPagesTest.php b/tests/Feature/AdminBusinessPagesTest.php new file mode 100644 index 0000000..946f961 --- /dev/null +++ b/tests/Feature/AdminBusinessPagesTest.php @@ -0,0 +1,349 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_admin_merchants_page_displays_site_management_content(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/merchants') + ->assertOk() + ->assertSee('站点管理') + ->assertSee('新增站点') + ->assertSee('站点列表') + ->assertSee('创建站点'); + } + + public function test_admin_orders_page_displays_filters_and_export_entry(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/orders?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190&sort=pay_amount_desc') + ->assertOk() + ->assertSee('订单监控') + ->assertSee('筛选条件') + ->assertSee('导出当前筛选结果 CSV') + ->assertSee('当前筛选摘要') + ->assertSee('运营关注项') + ->assertSee('当前信号') + ->assertSee('工作台导航') + ->assertSee('已发货') + ->assertSee('已支付') + ->assertSee('微信小程序') + ->assertSee('小程序环境') + ->assertSee('微信支付') + ->assertSee('订单汇总'); + } + + public function test_admin_order_operations_focus_can_follow_current_filters(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/orders?platform=wechat_mini&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。') + ->assertSee('继续查看微信小程序订单'); + + $this->get('/admin/orders?payment_channel=wechat_pay&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。') + ->assertSee('继续查看微信支付订单'); + + $this->get('/admin/orders?device_type=mini-program&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。') + ->assertSee('继续查看小程序环境订单'); + + $this->get('/admin/orders?device_type=mobile-webview&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。') + ->assertSee('继续查看微信内网页订单'); + + $this->get('/admin/orders?device_type=mobile&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。') + ->assertSee('继续查看移动浏览器订单'); + + $this->get('/admin/orders?device_type=desktop&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。') + ->assertSee('继续查看桌面浏览器订单'); + + $this->get('/admin/orders?payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。') + ->assertSee('继续查看支付失败订单'); + + $this->get('/admin/orders?status=completed&sort=latest') + ->assertOk() + ->assertSee('当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。') + ->assertSee('继续查看已完成订单'); + } + + public function test_admin_products_page_displays_import_and_history_entries(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/products?status=published&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('商品巡检') + ->assertSee('批量导入商品') + ->assertSee('下载导入模板') + ->assertSee('导出当前筛选结果 CSV') + ->assertSee('进入完整导入历史页') + ->assertSee('导入历史摘要') + ->assertSee('当前筛选摘要') + ->assertSee('运营关注项') + ->assertSee('当前信号') + ->assertSee('工作台导航') + ->assertSee('已上架') + ->assertSee('价格从高到低'); + } + + public function test_admin_product_operations_focus_can_follow_current_filters(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/products?status=draft&sort=latest') + ->assertOk() + ->assertSee('当前正在查看草稿商品,建议优先补齐标题、分类、价格与库存后再安排上架。') + ->assertSee('继续查看当前草稿'); + + $this->get('/admin/products?status=published&min_stock=0&max_stock=20&sort=stock_asc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架库存视角,建议优先确认低库存补货节奏,并同步观察高库存结构是否健康。') + ->assertSee('继续查看当前库存视角'); + + $this->get('/admin/products?status=published&category_id=1&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“演示店铺 / 默认分类”分类下关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对在售商品命名、分类承接、价格梯度与站点承接是否一致,并同步观察库存结构与站点覆盖是否健康。') + ->assertSee('继续查看当前已上架分类关键词价格带商品') + ->assertSee('去看当前已上架分类关键词商品'); + + $this->get('/admin/products?status=published&category_id=1&keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“演示店铺 / 默认分类”分类下关键词“演示”命中的商品,建议优先核对在售商品命名、分类承接与站点承接是否一致,并同步观察价格带、库存结构与站点覆盖是否健康。') + ->assertSee('继续查看当前已上架分类关键词商品'); + + $this->get('/admin/products?status=published&category_id=1&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“演示店铺 / 默认分类”分类下价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对该分类在售商品的价格结构、库存分布与站点覆盖是否协调。') + ->assertSee('继续查看当前已上架分类价格带商品'); + + $this->get('/admin/products?status=published&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架商品中关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 结果,建议优先核对在售商品命名、卖点表达与价格梯度是否一致,并同步观察库存结构、站点承接与站点覆盖是否健康。') + ->assertSee('继续查看当前已上架关键词价格带商品') + ->assertSee('去看当前已上架关键词商品'); + + $this->get('/admin/products?status=published&keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架商品中关键词“演示”命中的结果,建议优先核对在售商品命名、卖点表达与站点承接是否一致,并同步观察价格带、库存结构与站点覆盖是否健康。') + ->assertSee('继续查看当前已上架关键词商品'); + + $this->get('/admin/products?category_id=1&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦“演示店铺 / 默认分类”分类下关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对分类承接、命名卖点与价格梯度是否一致,并同步观察库存结构、站点承接与站点覆盖是否健康。') + ->assertSee('继续查看当前分类关键词价格带商品') + ->assertSee('去看当前分类关键词商品'); + + $this->get('/admin/products?category_id=1&keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦“演示店铺 / 默认分类”分类下关键词“演示”命中的商品,建议优先核对分类承接、命名卖点与站点承接是否一致,并同步观察相关商品的价格带、库存结构与站点覆盖。') + ->assertSee('继续查看当前分类关键词商品'); + + $this->get('/admin/products?status=published&category_id=1&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“演示店铺 / 默认分类”分类商品,建议优先核对该分类在售商品的价格带、库存结构与站点覆盖是否均衡。') + ->assertSee('继续查看当前已上架分类商品'); + + $this->get('/admin/products?category_id=1&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦“演示店铺 / 默认分类”分类下价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对该分类价格结构是否连贯,并同步观察库存分布、转化表现与站点覆盖是否健康。') + ->assertSee('继续查看当前分类价格带商品'); + + $this->get('/admin/products?category_id=1&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦“演示店铺 / 默认分类”分类商品,建议优先核对分类承接是否准确,并同步观察价格带、库存结构与站点覆盖是否均衡。') + ->assertSee('继续查看当前分类商品'); + + $this->get('/admin/products?keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦关键词“演示”命中的商品,建议优先核对命名、卖点与站点承接是否一致,并同步观察相关商品的价格带与库存结构。') + ->assertSee('继续查看当前关键词商品'); + + $this->get('/admin/products?min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对定价梯度是否连贯,并同步观察库存结构、转化表现与站点覆盖是否匹配。') + ->assertSee('继续查看当前价格带商品'); + } + + public function test_admin_import_histories_page_displays_filter_and_export_entries(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/products/import-histories') + ->assertOk() + ->assertSee('平台商品导入历史') + ->assertSee('筛选导入历史') + ->assertSee('导出当前筛选 CSV') + ->assertSee('返回商品页'); + } + + public function test_admin_product_summary_stats_match_export_summary_for_same_filters(): void + { + $this->loginAsPlatformAdmin(); + + $page = $this->get('/admin/products?merchant_id=1&keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published&min_price=150&max_price=220'); + $page->assertOk()->assertViewHas('summaryStats', function (array $summaryStats) { + return ($summaryStats['total_products'] ?? null) === 1 + && ($summaryStats['total_stock'] ?? null) === 100 + && (float) ($summaryStats['total_stock_value'] ?? 0) === 19900.0 + && (float) ($summaryStats['average_price'] ?? 0) === 199.0; + }); + + $export = $this->get('/admin/products/export?merchant_id=1&keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published&min_price=150&max_price=220'); + $export->assertOk(); + $content = $export->streamedContent(); + + assertStringContainsString('导出信息,总台商品导出', $content); + assertStringContainsString('站点,"1 / 演示店铺"', $content); + assertStringContainsString('状态,已上架', $content); + assertStringContainsString('最低价格,¥150.00', $content); + assertStringContainsString('最高价格,¥220.00', $content); + assertStringContainsString('导出商品数,1', $content); + assertStringContainsString('导出总库存,100', $content); + assertStringContainsString('导出总货值,19900.00', $content); + assertStringContainsString('导出平均售价,199.00', $content); + assertStringContainsString('演示商品', $content); + } + + public function test_admin_order_summary_stats_match_export_summary_for_same_filters(): void + { + $this->loginAsPlatformAdmin(); + + $page = $this->get('/admin/orders?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190'); + $page->assertOk()->assertViewHas('summaryStats', function (array $summaryStats) { + return ($summaryStats['total_orders'] ?? null) === 1 + && (float) ($summaryStats['total_pay_amount'] ?? 0) === 189.0 + && (float) ($summaryStats['average_order_amount'] ?? 0) === 189.0 + && ($summaryStats['paid_orders'] ?? null) === 1 + && ($summaryStats['failed_payment_orders'] ?? null) === 0; + }); + + $export = $this->get('/admin/orders/export?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190'); + $export->assertOk(); + $content = $export->streamedContent(); + + assertStringContainsString('导出信息,总台订单导出', $content); + assertStringContainsString('订单状态,已发货', $content); + assertStringContainsString('支付状态,已支付', $content); + assertStringContainsString('平台,微信小程序', $content); + assertStringContainsString('设备类型,小程序环境', $content); + assertStringContainsString('支付渠道,微信支付', $content); + assertStringContainsString('最低实付金额,¥180.00', $content); + assertStringContainsString('最高实付金额,¥190.00', $content); + assertStringContainsString('导出订单数,1', $content); + assertStringContainsString('导出实付总额,189.00', $content); + assertStringContainsString('导出平均客单价,189.00', $content); + assertStringContainsString('导出已支付订单数,1', $content); + assertStringContainsString('导出支付失败订单,0', $content); + assertStringContainsString('ORD202603080003', $content); + } + + public function test_admin_products_batch_status_update_changes_selected_products(): void + { + $this->loginAsPlatformAdmin(); + + $products = Product::query()->orderBy('id')->take(2)->get(); + + $this->post('/admin/products/batch', [ + 'product_ids' => $products->pluck('id')->all(), + 'action' => 'change_status', + 'status' => 'offline', + ])->assertRedirect('/admin/products') + ->assertSessionHas('success', '批量操作已完成,本次更新 2 条商品。'); + + foreach ($products as $product) { + $this->assertSame('offline', $product->fresh()->status); + } + } + + public function test_admin_products_batch_change_category_blocks_cross_merchant_operation(): void + { + $this->loginAsPlatformAdmin(); + + $secondMerchant = Merchant::query()->create([ + 'name' => '第二站点', + 'slug' => 'merchant-2-demo', + 'domain' => null, + 'contact_name' => '测试人', + 'contact_phone' => '13800000001', + 'contact_email' => 'merchant2@example.com', + 'plan' => 'basic', + 'status' => 'active', + 'activated_at' => now(), + 'settings' => ['currency' => 'CNY'], + ]); + + $secondCategory = ProductCategory::query()->create([ + 'merchant_id' => $secondMerchant->id, + 'name' => '第二站点分类', + 'slug' => 'merchant-2-default', + 'status' => 'active', + 'sort' => 10, + 'description' => '用于跨商家分类拦截测试', + ]); + + $secondProduct = Product::query()->create([ + 'merchant_id' => $secondMerchant->id, + 'category_id' => $secondCategory->id, + 'title' => '第二站点商品', + 'slug' => 'merchant-2-product', + 'sku' => 'SKU-MERCHANT-2-001', + 'summary' => '用于跨商家批量改分类测试', + 'content' => 'test', + 'price' => 88, + 'original_price' => 99, + 'stock' => 10, + 'status' => 'published', + 'images' => [], + ]); + + $firstProduct = Product::query()->orderBy('id')->firstOrFail(); + $firstMerchantCategory = ProductCategory::query()->where('merchant_id', $firstProduct->merchant_id)->orderBy('id')->firstOrFail(); + + $response = $this->from('/admin/products')->post('/admin/products/batch', [ + 'product_ids' => [$firstProduct->id, $secondProduct->id], + 'action' => 'change_category', + 'category_id' => $firstMerchantCategory->id, + ]); + + $response->assertRedirect('/admin/products'); + $response->assertSessionHasErrors(['category_id']); + $this->assertStringContainsString('所选分类不属于商家 #', session('errors')->first('category_id')); + + $this->assertSame($secondCategory->id, $secondProduct->fresh()->category_id); + } +} diff --git a/tests/Feature/AdminPlanExportTest.php b/tests/Feature/AdminPlanExportTest.php new file mode 100644 index 0000000..54fe669 --- /dev/null +++ b/tests/Feature/AdminPlanExportTest.php @@ -0,0 +1,57 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_export_plans_csv(): void + { + $this->loginAsPlatformAdmin(); + + Plan::query()->create([ + 'code' => 'plan_export_01', + 'name' => '套餐导出测试', + 'billing_cycle' => 'monthly', + 'price' => 9, + 'list_price' => 19, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + 'description' => '用于导出断言', + ]); + + $res = $this->get('/admin/plans/export'); + + $res->assertOk(); + $res->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $content = $res->streamedContent(); + $this->assertStringContainsString('套餐名称', $content); + $this->assertStringContainsString('套餐导出测试', $content); + $this->assertStringContainsString('plan_export_01', $content); + } + + public function test_guest_cannot_export_plans_csv(): void + { + $this->seed(); + + $this->get('/admin/plans/export') + ->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminPlanSetStatusTest.php b/tests/Feature/AdminPlanSetStatusTest.php new file mode 100644 index 0000000..f437357 --- /dev/null +++ b/tests/Feature/AdminPlanSetStatusTest.php @@ -0,0 +1,72 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_set_plan_status(): void + { + $this->loginAsPlatformAdmin(); + + $plan = Plan::query()->create([ + 'code' => 'status_toggle_test', + 'name' => '状态切换测试', + 'billing_cycle' => 'monthly', + 'price' => 9, + 'list_price' => 9, + 'status' => 'draft', + 'sort' => 10, + 'published_at' => null, + ]); + + $this->post('/admin/plans/' . $plan->id . '/set-status', [ + 'status' => 'active', + ])->assertRedirect(); + + $plan->refresh(); + $this->assertSame('active', $plan->status); + $this->assertNotNull($plan->published_at); + + $this->post('/admin/plans/' . $plan->id . '/set-status', [ + 'status' => 'inactive', + ])->assertRedirect(); + + $plan->refresh(); + $this->assertSame('inactive', $plan->status); + } + + public function test_guest_cannot_set_plan_status(): void + { + $this->seed(); + + $plan = Plan::query()->create([ + 'code' => 'status_toggle_guest_test', + 'name' => '状态切换测试(guest)', + 'billing_cycle' => 'monthly', + 'price' => 9, + 'list_price' => 9, + 'status' => 'draft', + 'sort' => 10, + ]); + + $this->post('/admin/plans/' . $plan->id . '/set-status', [ + 'status' => 'active', + ])->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminPlanTest.php b/tests/Feature/AdminPlanTest.php new file mode 100644 index 0000000..c933fe7 --- /dev/null +++ b/tests/Feature/AdminPlanTest.php @@ -0,0 +1,144 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_open_plans_page(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/plans') + ->assertOk() + ->assertSee('套餐管理') + ->assertSee('筛选条件') + ->assertSee('套餐列表'); + } + + public function test_guest_cannot_open_plans_page(): void + { + $this->get('/admin/plans')->assertRedirect('/admin/login'); + } + + public function test_plans_page_can_filter_by_status_and_billing_cycle(): void + { + $this->loginAsPlatformAdmin(); + + Plan::query()->create([ + 'code' => 'basic_monthly', + 'name' => '基础版(月付)', + 'billing_cycle' => 'monthly', + 'price' => 99, + 'list_price' => 99, + 'status' => 'active', + 'sort' => 20, + 'description' => '适合起步商家', + 'published_at' => now(), + ]); + + Plan::query()->create([ + 'code' => 'enterprise_yearly_draft', + 'name' => '企业版(年付草稿)', + 'billing_cycle' => 'yearly', + 'price' => 2999, + 'list_price' => 3999, + 'status' => 'draft', + 'sort' => 30, + 'description' => '仅供内部评审', + 'published_at' => null, + ]); + + $this->get('/admin/plans?status=active&billing_cycle=monthly') + ->assertOk() + ->assertSee('基础版(月付)') + ->assertDontSee('企业版(年付草稿)') + ->assertSee('月付'); + + // 发布状态筛选(按 published_at):基础版已发布,企业版未发布 + $this->get('/admin/plans?published=published') + ->assertOk() + ->assertSee('基础版(月付)') + ->assertDontSee('企业版(年付草稿)'); + + $this->get('/admin/plans?published=unpublished') + ->assertOk() + ->assertSee('企业版(年付草稿)') + ->assertDontSee('基础版(月付)'); + } + + public function test_platform_admin_can_create_plan(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/plans/create') + ->assertOk() + ->assertSee('新建套餐') + ->assertSee('套餐名称'); + + $this->post('/admin/plans', [ + 'code' => 'pro_monthly_form', + 'name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'price' => 199, + 'list_price' => 299, + 'status' => 'active', + 'sort' => 5, + 'description' => '主要面向成长型站点', + 'published_at' => now()->format('Y-m-d H:i:s'), + ])->assertRedirect('/admin/plans'); + + $this->get('/admin/plans') + ->assertSee('专业版(月付)') + ->assertSee('主要面向成长型站点'); + } + + public function test_platform_admin_can_update_plan(): void + { + $this->loginAsPlatformAdmin(); + + $plan = Plan::query()->create([ + 'code' => 'update_test', + 'name' => '更新前套餐', + 'billing_cycle' => 'monthly', + 'price' => 50, + 'list_price' => 80, + 'status' => 'draft', + 'sort' => 11, + ]); + + $this->get('/admin/plans/' . $plan->id . '/edit') + ->assertOk() + ->assertSee('编辑套餐'); + + $this->post('/admin/plans/' . $plan->id, [ + 'code' => 'update_test', + 'name' => '更新后套餐', + 'billing_cycle' => 'monthly', + 'price' => 66, + 'list_price' => 88, + 'status' => 'active', + 'sort' => 10, + 'description' => '通过编辑表单更新', + ])->assertRedirect('/admin/plans'); + + $this->get('/admin/plans') + ->assertSee('更新后套餐') + ->assertSee('¥66.00'); + } +} diff --git a/tests/Feature/AdminPlatformOrderActivateSubscriptionErrorMetaTest.php b/tests/Feature/AdminPlatformOrderActivateSubscriptionErrorMetaTest.php new file mode 100644 index 0000000..77168c3 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderActivateSubscriptionErrorMetaTest.php @@ -0,0 +1,114 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_activation_failure_will_write_error_meta(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'activate_error_meta_test', + 'name' => '同步失败记录测试', + 'billing_cycle' => 'monthly', + 'price' => 9, + 'list_price' => 9, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + // 构造一个“不满足 paid+activated”的订单,触发失败 + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_ACT_ERR_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 9, + 'paid_amount' => 0, + 'placed_at' => now(), + ]); + + $this->post('/admin/platform-orders/' . $order->id . '/activate-subscription') + ->assertRedirect(); + + $order->refresh(); + $this->assertNotEmpty(data_get($order->meta, 'subscription_activation_error.message')); + $this->assertNotEmpty(data_get($order->meta, 'subscription_activation_error.at')); + } + + public function test_activation_success_will_clear_error_meta(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'activate_error_meta_test2', + 'name' => '同步成功清理失败记录测试', + 'billing_cycle' => 'monthly', + 'price' => 9, + 'list_price' => 9, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_ACT_ERR_0002', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 9, + 'paid_amount' => 9, + 'placed_at' => now(), + 'paid_at' => now(), + 'activated_at' => now(), + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => '历史错误', + 'at' => now()->subDay()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + $this->post('/admin/platform-orders/' . $order->id . '/activate-subscription') + ->assertRedirect(); + + $order->refresh(); + $this->assertEmpty(data_get($order->meta, 'subscription_activation_error')); + $this->assertNotNull($order->site_subscription_id); + } +} diff --git a/tests/Feature/AdminPlatformOrderActivateSubscriptionTest.php b/tests/Feature/AdminPlatformOrderActivateSubscriptionTest.php new file mode 100644 index 0000000..b72e3e1 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderActivateSubscriptionTest.php @@ -0,0 +1,104 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_activate_subscription_from_order(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'activate_btn_test', + 'name' => '按钮激活测试(月付)', + 'billing_cycle' => 'monthly', + 'price' => 66, + 'list_price' => 66, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BTN_ACT_0001', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'list_amount' => 66, + 'discount_amount' => 0, + 'payable_amount' => 66, + 'paid_amount' => 66, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(5), + 'activated_at' => now()->subMinutes(1), + ]); + + $this->post('/admin/platform-orders/' . $order->id . '/activate-subscription') + ->assertRedirect(); + + $order->refresh(); + $this->assertNotNull($order->site_subscription_id); + } + + public function test_guest_cannot_activate_subscription_from_order(): void + { + $this->seed(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'activate_btn_test_guest', + 'name' => '按钮激活测试(游客)', + 'billing_cycle' => 'monthly', + 'price' => 66, + 'list_price' => 66, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BTN_ACT_0002', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 66, + 'paid_amount' => 66, + 'placed_at' => now()->subMinutes(10), + ]); + + $this->post('/admin/platform-orders/' . $order->id . '/activate-subscription') + ->assertStatus(302) + ->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminPlatformOrderBatchActivateSubscriptionsTest.php b/tests/Feature/AdminPlatformOrderBatchActivateSubscriptionsTest.php new file mode 100644 index 0000000..e583cbd --- /dev/null +++ b/tests/Feature/AdminPlatformOrderBatchActivateSubscriptionsTest.php @@ -0,0 +1,374 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_batch_activate_subscriptions_in_filtered_scope(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'batch_activate_plan', + 'name' => '批量同步测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + // 可同步(命中):已支付 + 已生效 + 未同步 + $syncable = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_SYNC_0001', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(9), + 'activated_at' => now()->subMinutes(8), + ]); + + // 不可同步(不应命中):未支付 + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_SYNC_0002', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 0, + 'placed_at' => now()->subMinutes(5), + ]); + + $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'filtered', + 'syncable_only' => '1', + ])->assertRedirect(); + + $syncable->refresh(); + $this->assertNotNull($syncable->site_subscription_id); + $this->assertNotEmpty(data_get($syncable->meta, 'subscription_activation.subscription_id')); + $this->assertSame('batch_activate_subscription', data_get($syncable->meta, 'audit.0.action')); + } + + public function test_platform_admin_batch_activate_records_failure_reason_and_summary(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'batch_activate_fail_plan', + 'name' => '批量同步失败治理测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $ok = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_FAIL_0001', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(9), + 'activated_at' => now()->subMinutes(8), + ]); + + // 强制失败:构造一条同样“可同步”的订单,然后在测试中用 DI 绑定一个假的 SubscriptionActivationService, + // 让它在处理该订单时抛异常,从而验证失败原因落库与 Top 汇总。 + $bad = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_FAIL_0002', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(7), + 'paid_at' => now()->subMinutes(6), + 'activated_at' => now()->subMinutes(5), + ]); + + $badId = $bad->id; + $this->app->bind(SubscriptionActivationService::class, function () use ($badId) { + return new class($badId) extends SubscriptionActivationService { + public function __construct(private int $badId) {} + + public function activateOrder(int $orderId, ?int $adminId = null): SiteSubscription + { + if ($orderId === $this->badId) { + throw new \RuntimeException('模拟失败:订阅同步异常'); + } + + return parent::activateOrder($orderId, $adminId); + } + }; + }); + + $res = $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'filtered', + 'syncable_only' => '1', + 'limit' => 50, + ]); + + $res->assertRedirect()->assertSessionHas('success'); + + $ok->refresh(); + $bad->refresh(); + + $this->assertNotNull($ok->site_subscription_id); + $this->assertNotEmpty(data_get($ok->meta, 'subscription_activation.subscription_id')); + + $this->assertNotEmpty(data_get($bad->meta, 'subscription_activation_error.message')); + $this->assertNotEmpty(data_get($bad->meta, 'subscription_activation_error.at')); + + // 批量结果摘要应包含失败原因Top + $msg = (string) $res->getSession()->get('success'); + $this->assertStringContainsString('失败原因Top', $msg); + $this->assertStringContainsString('模拟失败:订阅同步异常', $msg); + } + + public function test_platform_admin_batch_activate_respects_limit(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'batch_activate_limit_plan', + 'name' => '批量同步限额测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $o1 = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_LIMIT_0001', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(9), + 'activated_at' => now()->subMinutes(8), + ]); + + $o2 = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_LIMIT_0002', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(7), + 'paid_at' => now()->subMinutes(6), + 'activated_at' => now()->subMinutes(5), + ]); + + $o3 = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_LIMIT_0003', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(4), + 'paid_at' => now()->subMinutes(3), + 'activated_at' => now()->subMinutes(2), + ]); + + $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'filtered', + 'syncable_only' => '1', + 'limit' => 1, + ])->assertRedirect(); + + $o1->refresh(); + $o2->refresh(); + $o3->refresh(); + + // 当前实现按 id 倒序优先处理最新订单,因此 limit=1 时应只处理 o3 + $this->assertNull($o1->site_subscription_id); + $this->assertNull($o2->site_subscription_id); + $this->assertNotNull($o3->site_subscription_id); + } + + public function test_platform_admin_batch_activate_requires_syncable_only_filter(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'batch_activate_guard_plan', + 'name' => '批量同步防误操作测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $syncable = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_GUARD_0001', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(9), + 'activated_at' => now()->subMinutes(8), + ]); + + $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'filtered', + // 故意不传 syncable_only + ])->assertRedirect()->assertSessionHas('warning'); + + $syncable->refresh(); + $this->assertNull($syncable->site_subscription_id); + } + + public function test_platform_admin_batch_activate_scope_all_requires_confirm_yes(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'batch_activate_all_guard_plan', + 'name' => '批量同步全量二次确认测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $syncable = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BATCH_ALL_GUARD_0001', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(9), + 'activated_at' => now()->subMinutes(8), + ]); + + // 未确认:不应执行 + $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'all', + 'limit' => 50, + ])->assertRedirect()->assertSessionHas('warning'); + + $syncable->refresh(); + $this->assertNull($syncable->site_subscription_id); + + // 确认 YES:应执行 + $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'all', + 'confirm' => 'YES', + 'limit' => 50, + ])->assertRedirect()->assertSessionHas('success'); + + $syncable->refresh(); + $this->assertNotNull($syncable->site_subscription_id); + } + + public function test_guest_cannot_batch_activate_subscriptions(): void + { + $this->seed(); + + $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'filtered', + ])->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminPlatformOrderClearSyncErrorsFilteredTest.php b/tests/Feature/AdminPlatformOrderClearSyncErrorsFilteredTest.php new file mode 100644 index 0000000..6697140 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderClearSyncErrorsFilteredTest.php @@ -0,0 +1,102 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_clear_sync_errors_only_in_filtered_scope(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'clear_sync_err_filtered_test', + 'name' => '清理失败标记(范围)测试', + 'billing_cycle' => 'monthly', + 'price' => 1, + 'list_price' => 1, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $failedA = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_CLEAR_ERR_F_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 1, + 'paid_amount' => 0, + 'placed_at' => now(), + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => '模拟失败A', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + $failedB = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_CLEAR_ERR_F_0002', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 1, + 'paid_amount' => 1, + 'placed_at' => now(), + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => '模拟失败B', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + // 仅清理“status=pending”范围内的失败标记:应该只清掉 failedA + $this->post('/admin/platform-orders/clear-sync-errors', [ + 'scope' => 'filtered', + 'status' => 'pending', + ])->assertRedirect(); + + $failedA->refresh(); + $failedB->refresh(); + + $this->assertEmpty(data_get($failedA->meta, 'subscription_activation_error')); + $this->assertNotEmpty(data_get($failedA->meta, 'audit.0.action')); + $this->assertSame('clear_sync_error', data_get($failedA->meta, 'audit.0.action')); + + $this->assertNotEmpty(data_get($failedB->meta, 'subscription_activation_error.message')); + } +} diff --git a/tests/Feature/AdminPlatformOrderClearSyncErrorsTest.php b/tests/Feature/AdminPlatformOrderClearSyncErrorsTest.php new file mode 100644 index 0000000..ef21d14 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderClearSyncErrorsTest.php @@ -0,0 +1,78 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_clear_sync_errors(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'clear_sync_err_test', + 'name' => '清理失败标记测试', + 'billing_cycle' => 'monthly', + 'price' => 1, + 'list_price' => 1, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_CLEAR_ERR_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 1, + 'paid_amount' => 0, + 'placed_at' => now(), + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => '模拟失败', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + $this->post('/admin/platform-orders/clear-sync-errors', ['scope' => 'all']) + ->assertRedirect(); + + $order->refresh(); + $this->assertEmpty(data_get($order->meta, 'subscription_activation_error')); + } + + public function test_guest_cannot_clear_sync_errors(): void + { + $this->seed(); + + $this->post('/admin/platform-orders/clear-sync-errors', ['scope' => 'all']) + ->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminPlatformOrderExportTest.php b/tests/Feature/AdminPlatformOrderExportTest.php new file mode 100644 index 0000000..c4afcb8 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderExportTest.php @@ -0,0 +1,148 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_export_platform_orders_csv(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'export_order_test', + 'name' => '导出测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_EXPORT_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 0, + 'placed_at' => now(), + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => '模拟失败', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + // batch_synced_24h 筛选导出:构造一条 24h 内、一条超 24h 的订单 + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_EXPORT_BATCH_RECENT', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now(), + 'paid_at' => now(), + 'activated_at' => now(), + 'meta' => [ + 'batch_activation' => [ + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + 'scope' => 'filtered', + ], + ], + ]); + + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_EXPORT_BATCH_OLD', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subHours(30), + 'paid_at' => now()->subHours(30), + 'activated_at' => now()->subHours(30), + 'meta' => [ + 'batch_activation' => [ + 'at' => now()->subHours(30)->toDateTimeString(), + 'admin_id' => 1, + 'scope' => 'filtered', + ], + ], + ]); + + $res = $this->get('/admin/platform-orders/export'); + + $res->assertOk(); + $res->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + // StreamedResponse 在测试环境下需用 streamedContent() 获取内容 + $content = $res->streamedContent(); + $this->assertStringContainsString('订单号', $content); + $this->assertStringContainsString('PO_EXPORT_0001', $content); + $this->assertStringContainsString('同步失败原因', $content); + + // include_meta=1 时应包含 meta(JSON) 列 + $res2 = $this->get('/admin/platform-orders/export?include_meta=1'); + $res2->assertOk(); + $content2 = $res2->streamedContent(); + $this->assertStringContainsString('原始meta(JSON)', $content2); + $this->assertStringContainsString('subscription_activation_error', $content2); + + // batch_synced_24h=1 导出应只包含 24h 内批量同步过的订单 + $res3 = $this->get('/admin/platform-orders/export?batch_synced_24h=1'); + $res3->assertOk(); + $content3 = $res3->streamedContent(); + $this->assertStringContainsString('PO_EXPORT_BATCH_RECENT', $content3); + $this->assertStringNotContainsString('PO_EXPORT_BATCH_OLD', $content3); + } + + public function test_guest_cannot_export_platform_orders_csv(): void + { + $this->seed(); + + $this->get('/admin/platform-orders/export') + ->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminPlatformOrderMarkPaidAndActivateTest.php b/tests/Feature/AdminPlatformOrderMarkPaidAndActivateTest.php new file mode 100644 index 0000000..91c9414 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderMarkPaidAndActivateTest.php @@ -0,0 +1,103 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_mark_order_paid_and_activated_and_sync_subscription(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'mark_paid_test', + 'name' => '标记支付测试(月付)', + 'billing_cycle' => 'monthly', + 'price' => 30, + 'list_price' => 30, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_MARK_PAID_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 30, + 'paid_amount' => 0, + 'placed_at' => now(), + ]); + + $this->post('/admin/platform-orders/' . $order->id . '/mark-paid-and-activate') + ->assertRedirect(); + + $order->refresh(); + $this->assertSame('paid', $order->payment_status); + $this->assertSame('activated', $order->status); + $this->assertNotNull($order->paid_at); + $this->assertNotNull($order->activated_at); + $this->assertNotNull($order->site_subscription_id); + } + + public function test_guest_cannot_mark_order_paid_and_activated(): void + { + $this->seed(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'mark_paid_test_guest', + 'name' => '标记支付测试(游客)', + 'billing_cycle' => 'monthly', + 'price' => 30, + 'list_price' => 30, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_MARK_PAID_0002', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 30, + 'paid_amount' => 0, + 'placed_at' => now(), + ]); + + $this->post('/admin/platform-orders/' . $order->id . '/mark-paid-and-activate') + ->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminPlatformOrderShowTest.php b/tests/Feature/AdminPlatformOrderShowTest.php new file mode 100644 index 0000000..701b0c2 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderShowTest.php @@ -0,0 +1,123 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_open_platform_order_show_page(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'show_order_test', + 'name' => '订单详情测试', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_SHOW_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 0, + 'placed_at' => now(), + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => 'demo', + 'at' => now()->toDateTimeString(), + ], + 'audit' => [ + [ + 'action' => 'clear_sync_error', + 'scope' => 'filtered', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + [ + 'action' => 'batch_activate_subscription', + 'scope' => 'filtered', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ], + ]); + + $this->get('/admin/platform-orders/' . $order->id) + ->assertOk() + ->assertSee('平台订单详情') + ->assertSee('PO_SHOW_0001') + ->assertSee('标记支付并生效') + ->assertSee('同步订阅') + ->assertSee('订阅同步元数据') + ->assertSee('审计记录') + ->assertSee('清除同步失败标记'); + } + + public function test_guest_cannot_open_platform_order_show_page(): void + { + $this->seed(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'show_order_test_guest', + 'name' => '订单详情测试(guest)', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_SHOW_0002', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 0, + 'placed_at' => now(), + ]); + + $this->get('/admin/platform-orders/' . $order->id) + ->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminPlatformOrderTest.php b/tests/Feature/AdminPlatformOrderTest.php new file mode 100644 index 0000000..3100587 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderTest.php @@ -0,0 +1,319 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_open_platform_orders_page(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/platform-orders') + ->assertOk() + ->assertSee('平台订单') + ->assertSee('筛选条件') + ->assertSee('平台订单列表') + ->assertSee('最近批量同步') + ->assertSee('最近24小时批量同步过'); + } + + public function test_guest_cannot_open_platform_orders_page(): void + { + $this->get('/admin/platform-orders') + ->assertRedirect('/admin/login'); + } + + public function test_platform_orders_page_can_filter_by_status_and_payment_status(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'pro_monthly_test', + 'name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'price' => 199, + 'list_price' => 199, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO202603100101', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'list_amount' => 199, + 'discount_amount' => 0, + 'payable_amount' => 199, + 'paid_amount' => 199, + 'placed_at' => now()->subHour(), + 'paid_at' => now()->subMinutes(30), + 'activated_at' => now()->subMinutes(20), + ]); + + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO202603100102', + 'order_type' => 'renewal', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'list_amount' => 199, + 'discount_amount' => 0, + 'payable_amount' => 199, + 'paid_amount' => 0, + 'placed_at' => now()->subMinutes(10), + ]); + + $this->get('/admin/platform-orders?status=activated&payment_status=paid') + ->assertOk() + ->assertSee('PO202603100101') + ->assertDontSee('PO202603100102') + ->assertSee('专业版(月付)'); + + // 只看同步失败:构造一条带有 subscription_activation_error 的订单 + PlatformOrder::query()->where('order_no', 'PO202603100102')->update([ + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => '模拟失败', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + // 额外构造一条同原因失败,用于失败原因聚合统计 + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO202603100104', + 'order_type' => 'renewal', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 199, + 'paid_amount' => 0, + 'placed_at' => now()->subMinutes(9), + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => '模拟失败', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + // 再构造一条不同原因失败 + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO202603100105', + 'order_type' => 'renewal', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 199, + 'paid_amount' => 0, + 'placed_at' => now()->subMinutes(8), + 'meta' => [ + 'subscription_activation_error' => [ + 'message' => '余额不足', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + $this->get('/admin/platform-orders?fail_only=1') + ->assertOk() + ->assertSee('PO202603100102') + ->assertDontSee('PO202603100101'); + + // 失败原因聚合(Top5)应可见 + $this->get('/admin/platform-orders') + ->assertOk() + ->assertSee('同步失败原因 TOP5') + ->assertSee('模拟失败') + ->assertSee('余额不足'); + + // 只看已同步:构造一条带有 subscription_activation 的订单 + 批量同步审计(用于列表展示) + PlatformOrder::query()->where('order_no', 'PO202603100101')->update([ + 'meta' => [ + 'subscription_activation' => [ + 'subscription_id' => 123, + 'synced_at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + 'audit' => [ + [ + 'action' => 'batch_activate_subscription', + 'scope' => 'filtered', + 'at' => '2026-03-10 00:00:00', + 'admin_id' => 1, + ], + ], + ], + ]); + + $this->get('/admin/platform-orders') + ->assertOk() + ->assertSee('2026-03-10 00:00:00') + ->assertSee('管理员:1'); + + // 最近24小时批量同步筛选:构造一条近24h,一条超过24h + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO202603100107', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 199, + 'paid_amount' => 199, + 'placed_at' => now()->subMinutes(3), + 'paid_at' => now()->subMinutes(2), + 'activated_at' => now()->subMinutes(1), + 'meta' => [ + 'batch_activation' => [ + 'at' => now()->subHours(1)->format('Y-m-d H:i:s'), + 'admin_id' => 1, + 'scope' => 'filtered', + ], + ], + ]); + + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO202603100108', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 199, + 'paid_amount' => 199, + 'placed_at' => now()->subMinutes(13), + 'paid_at' => now()->subMinutes(12), + 'activated_at' => now()->subMinutes(11), + 'meta' => [ + 'batch_activation' => [ + 'at' => now()->subHours(30)->format('Y-m-d H:i:s'), + 'admin_id' => 1, + 'scope' => 'filtered', + ], + ], + ]); + + $this->get('/admin/platform-orders?batch_synced_24h=1') + ->assertOk() + ->assertSee('PO202603100107') + ->assertDontSee('PO202603100108'); + + $this->get('/admin/platform-orders?synced_only=1') + ->assertOk() + ->assertSee('PO202603100101') + ->assertDontSee('PO202603100102'); + + // sync_status 下拉筛选:synced / failed / unsynced + $this->get('/admin/platform-orders?sync_status=synced') + ->assertOk() + ->assertSee('PO202603100101') + ->assertDontSee('PO202603100102'); + + $this->get('/admin/platform-orders?sync_status=failed') + ->assertOk() + ->assertSee('PO202603100102') + ->assertDontSee('PO202603100101'); + + // unsynced:构造一条既无 subscription_activation 也无 error 的订单 + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO202603100103', + 'order_type' => 'renewal', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 199, + 'paid_amount' => 0, + 'placed_at' => now()->subMinutes(5), + ]); + + $this->get('/admin/platform-orders?sync_status=unsynced') + ->assertOk() + ->assertSee('PO202603100103') + ->assertDontSee('PO202603100101') + ->assertDontSee('PO202603100102'); + + // 只看可同步:已支付 + 已生效 + 未同步 + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO202603100106', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => '专业版(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 199, + 'paid_amount' => 199, + 'placed_at' => now()->subMinutes(4), + 'paid_at' => now()->subMinutes(3), + 'activated_at' => now()->subMinutes(2), + ]); + + $this->get('/admin/platform-orders?syncable_only=1') + ->assertOk() + ->assertSee('PO202603100106') + ->assertDontSee('PO202603100101') + ->assertDontSee('PO202603100102'); + } +} diff --git a/tests/Feature/AdminProtectedPagesTest.php b/tests/Feature/AdminProtectedPagesTest.php new file mode 100644 index 0000000..af90ef4 --- /dev/null +++ b/tests/Feature/AdminProtectedPagesTest.php @@ -0,0 +1,51 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_guest_is_redirected_to_admin_login_for_protected_pages(): void + { + foreach (['/admin', '/admin/merchants', '/admin/orders', '/admin/products', '/admin/product-categories'] as $path) { + $this->get($path)->assertRedirect('/admin/login'); + } + } + + public function test_platform_admin_can_access_key_admin_pages(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin')->assertOk()->assertSee('总台仪表盘'); + $this->get('/admin/merchants')->assertOk()->assertSee('站点管理'); + $this->get('/admin/orders')->assertOk()->assertSee('订单监控'); + $this->get('/admin/products')->assertOk()->assertSee('商品巡检'); + $this->get('/admin/product-categories')->assertOk()->assertSee('商品分类'); + } + + public function test_merchant_admin_is_forbidden_from_admin_pages(): void + { + $this->seed(); + + $this->post('/merchant-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ])->assertRedirect('/merchant-admin'); + + $this->get('/admin')->assertForbidden()->assertSee('当前账号没有总台管理访问权限'); + } +} diff --git a/tests/Feature/AdminSiteSubscriptionExportTest.php b/tests/Feature/AdminSiteSubscriptionExportTest.php new file mode 100644 index 0000000..bb7d90e --- /dev/null +++ b/tests/Feature/AdminSiteSubscriptionExportTest.php @@ -0,0 +1,79 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_export_site_subscriptions_csv(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'export_sub_test', + 'name' => '导出订阅测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 88, + 'list_price' => 88, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + SiteSubscription::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'status' => 'activated', + 'source' => 'manual', + 'subscription_no' => 'SUB_EXPORT_0001', + 'plan_name' => $plan->name, + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'amount' => 88, + 'starts_at' => now()->subDays(1), + 'ends_at' => now()->addDays(29), + 'activated_at' => now()->subDays(1), + ]); + + $res = $this->get('/admin/site-subscriptions/export'); + + $res->assertOk(); + $res->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $content = $res->streamedContent(); + $this->assertStringContainsString('订阅号', $content); + $this->assertStringContainsString('SUB_EXPORT_0001', $content); + $this->assertStringContainsString('到期时间', $content); + $this->assertStringContainsString('到期状态', $content); + $this->assertStringContainsString('未到期', $content); + + // 状态导出应为“中文标签 + 原始值” + $this->assertStringContainsString('已生效 (activated)', $content); + } + + public function test_guest_cannot_export_site_subscriptions_csv(): void + { + $this->seed(); + + $this->get('/admin/site-subscriptions/export') + ->assertRedirect('/admin/login'); + } +} diff --git a/tests/Feature/AdminSiteSubscriptionTest.php b/tests/Feature/AdminSiteSubscriptionTest.php new file mode 100644 index 0000000..ab85458 --- /dev/null +++ b/tests/Feature/AdminSiteSubscriptionTest.php @@ -0,0 +1,143 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_open_site_subscriptions_page(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/site-subscriptions') + ->assertOk() + ->assertSee('订阅管理') + ->assertSee('筛选条件') + ->assertSee('订阅列表') + ->assertSee('到期状态'); + } + + public function test_guest_cannot_open_site_subscriptions_page(): void + { + $this->get('/admin/site-subscriptions') + ->assertRedirect('/admin/login'); + } + + public function test_site_subscriptions_page_can_filter_by_status_and_keyword(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'sub_test_monthly', + 'name' => '订阅测试套餐(月付)', + 'billing_cycle' => 'monthly', + 'price' => 88, + 'list_price' => 88, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + SiteSubscription::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'status' => 'activated', + 'source' => 'manual', + 'subscription_no' => 'SUB202603100101', + 'plan_name' => '订阅测试套餐(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'amount' => 88, + 'starts_at' => now()->subDays(2), + 'ends_at' => now()->addDays(28), + 'activated_at' => now()->subDays(2), + ]); + + SiteSubscription::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'status' => 'pending', + 'source' => 'manual', + 'subscription_no' => 'SUB202603100102', + 'plan_name' => '订阅测试套餐(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'amount' => 88, + 'starts_at' => now(), + 'ends_at' => now()->addMonth(), + ]); + + $this->get('/admin/site-subscriptions?status=activated&keyword=SUB202603100101') + ->assertOk() + ->assertSee('SUB202603100101') + ->assertDontSee('SUB202603100102'); + + // 到期筛选:构造“已过期”和“7天内到期”的订阅 + SiteSubscription::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'status' => 'activated', + 'source' => 'manual', + 'subscription_no' => 'SUB202603100103', + 'plan_name' => '订阅测试套餐(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'amount' => 88, + 'starts_at' => now()->subDays(40), + 'ends_at' => now()->subDays(1), + 'activated_at' => now()->subDays(40), + ]); + + SiteSubscription::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'status' => 'activated', + 'source' => 'manual', + 'subscription_no' => 'SUB202603100104', + 'plan_name' => '订阅测试套餐(月付)', + 'billing_cycle' => 'monthly', + 'period_months' => 1, + 'amount' => 88, + 'starts_at' => now()->subDays(25), + 'ends_at' => now()->addDays(3), + 'activated_at' => now()->subDays(25), + ]); + + $this->get('/admin/site-subscriptions?expiry=expired') + ->assertOk() + ->assertSeeInOrder(['SUB202603100103', '已过期']) + ->assertDontSee('SUB202603100104'); + + $this->get('/admin/site-subscriptions?expiry=expiring_7d') + ->assertOk() + ->assertSeeInOrder(['SUB202603100104', '7天内到期']) + ->assertDontSee('SUB202603100103'); + + // 新增筛选:按站点/套餐 + $this->get('/admin/site-subscriptions?merchant_id=' . $merchant->id) + ->assertOk() + ->assertSee('订阅列表'); + + $this->get('/admin/site-subscriptions?plan_id=' . $plan->id) + ->assertOk() + ->assertSee('订阅列表'); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..22ef0c5 --- /dev/null +++ b/tests/Feature/ExampleTest.php @@ -0,0 +1,21 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/ExportEndpointsTest.php b/tests/Feature/ExportEndpointsTest.php new file mode 100644 index 0000000..70ea4e1 --- /dev/null +++ b/tests/Feature/ExportEndpointsTest.php @@ -0,0 +1,177 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + protected function loginAsMerchantAdmin(): void + { + $this->seed(); + + $this->post('/merchant-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ])->assertRedirect('/merchant-admin'); + } + + protected function loginAsSiteAdmin(): void + { + $this->seed(); + + $this->post('/site-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ])->assertRedirect('/site-admin'); + } + + public function test_admin_order_and_product_exports_return_csv_response(): void + { + $this->loginAsPlatformAdmin(); + + $this->get('/admin/orders/export') + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $this->get('/admin/products/export') + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $this->get('/admin/products/import-histories/export') + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + } + + public function test_admin_product_export_uses_chinese_detail_headers(): void + { + $this->loginAsPlatformAdmin(); + + $response = $this->get('/admin/products/export'); + $response->assertOk(); + $content = $response->streamedContent(); + + assertStringContainsString('ID,站点ID,站点名称,分类ID,分类名称,分类标识,商品标题,商品Slug,SKU,售价,划线价,库存,状态,商品简介,创建时间,更新时间', $content); + } + + public function test_merchant_order_and_product_exports_return_csv_response(): void + { + $this->loginAsMerchantAdmin(); + + $this->get('/merchant-admin/orders/export') + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $this->get('/merchant-admin/products/export') + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $this->get('/merchant-admin/products/import-histories/export') + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + } + + public function test_merchant_product_export_uses_chinese_detail_headers(): void + { + $this->loginAsMerchantAdmin(); + + $response = $this->get('/merchant-admin/products/export'); + $response->assertOk(); + $content = $response->streamedContent(); + + assertStringContainsString('ID,分类ID,分类名称,分类标识,商品标题,商品Slug,SKU,售价,划线价,库存,状态,商品简介,创建时间,更新时间', $content); + } + + public function test_admin_order_export_uses_chinese_detail_headers(): void + { + $this->loginAsPlatformAdmin(); + + $response = $this->get('/admin/orders/export'); + $response->assertOk(); + $content = $response->streamedContent(); + + assertStringContainsString('ID,商家ID,商家名称,用户ID,订单号,订单状态,支付状态,平台,设备类型,支付渠道,买家姓名,买家手机,买家邮箱,商品金额,优惠金额,运费,实付金额,商品行数,商品件数,商品摘要,创建时间,支付时间,发货时间,完成时间,备注', $content); + assertStringContainsString('设备类型,全部', $content); + assertStringContainsString('支付渠道,全部', $content); + assertStringContainsString('桌面浏览器,微信支付', $content); + assertStringContainsString('移动浏览器,支付宝', $content); + assertStringContainsString('小程序环境,微信支付', $content); + assertStringContainsString('微信内网页,微信支付', $content); + assertStringContainsString('"APP 接口预留","APP 接口",微信支付', $content); + assertStringNotContainsString('ID,merchant_id,merchant_name', $content); + assertStringNotContainsString(',desktop,wechat_pay,', $content); + assertStringNotContainsString(',mobile,alipay,', $content); + assertStringNotContainsString(',mini-program,wechat_pay,', $content); + } + + public function test_merchant_order_export_uses_chinese_detail_headers(): void + { + $this->loginAsMerchantAdmin(); + + $response = $this->get('/merchant-admin/orders/export'); + $response->assertOk(); + $content = $response->streamedContent(); + + assertStringContainsString('ID,用户ID,订单号,订单状态,支付状态,平台,设备类型,支付渠道,买家姓名,买家手机,买家邮箱,商品金额,优惠金额,运费,实付金额,商品行数,商品件数,商品摘要,创建时间,支付时间,发货时间,完成时间,备注', $content); + assertStringContainsString('设备类型,全部', $content); + assertStringContainsString('支付渠道,全部', $content); + assertStringContainsString('桌面浏览器,微信支付', $content); + assertStringContainsString('移动浏览器,支付宝', $content); + assertStringContainsString('小程序环境,微信支付', $content); + assertStringContainsString('微信内网页,微信支付', $content); + assertStringContainsString('"APP 接口预留","APP 接口",微信支付', $content); + assertStringNotContainsString('ID,user_id,order_no,status,payment_status,platform', $content); + assertStringNotContainsString(',desktop,wechat_pay,', $content); + assertStringNotContainsString(',mobile,alipay,', $content); + assertStringNotContainsString(',mini-program,wechat_pay,', $content); + } + + public function test_site_product_export_uses_chinese_detail_headers(): void + { + $this->loginAsSiteAdmin(); + + $response = $this->get('/site-admin/products/export'); + $response->assertOk(); + $content = $response->streamedContent(); + + assertStringContainsString('ID,分类ID,分类名称,商品标题,商品Slug,SKU,售价,划线价,库存,状态,商品简介,创建时间,更新时间', $content); + assertStringNotContainsString('ID,category_id,category_name,title,slug,sku,price,original_price,stock,status,summary,created_at,updated_at', $content); + } + + public function test_site_order_export_uses_chinese_detail_headers(): void + { + $this->loginAsSiteAdmin(); + + $response = $this->get('/site-admin/orders/export'); + $response->assertOk(); + $content = $response->streamedContent(); + + assertStringContainsString('ID,订单号,订单状态,支付状态,平台,设备类型,支付渠道,买家姓名,买家手机,买家邮箱,商品金额,优惠金额,运费,实付金额,创建时间,支付时间,完成时间,备注', $content); + assertStringContainsString('设备类型,全部', $content); + assertStringContainsString('支付渠道,全部', $content); + assertStringContainsString('桌面浏览器,微信支付', $content); + assertStringContainsString('移动浏览器,支付宝', $content); + assertStringContainsString('小程序环境,微信支付', $content); + assertStringContainsString('微信内网页,微信支付', $content); + assertStringContainsString('"APP 接口预留","APP 接口",微信支付', $content); + assertStringNotContainsString('ID,order_no,status,payment_status,platform,device_type,payment_channel', $content); + assertStringNotContainsString(',desktop,wechat_pay,', $content); + assertStringNotContainsString(',mobile,alipay,', $content); + assertStringNotContainsString(',mini-program,wechat_pay,', $content); + } +} diff --git a/tests/Feature/MerchantAdminAccessTest.php b/tests/Feature/MerchantAdminAccessTest.php new file mode 100644 index 0000000..62a6e4a --- /dev/null +++ b/tests/Feature/MerchantAdminAccessTest.php @@ -0,0 +1,52 @@ +get('/merchant-admin/login'); + + $response->assertOk(); + $response->assertSee('商家后台'); + } + + public function test_merchant_admin_can_login_and_open_dashboard(): void + { + $this->seed(); + + $response = $this->post('/merchant-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ]); + + $response->assertRedirect('/merchant-admin'); + + $dashboard = $this->get('/merchant-admin'); + $dashboard->assertOk(); + $dashboard->assertSee('商家仪表盘'); + $dashboard->assertSee('商品管理'); + $dashboard->assertSee('订单管理'); + $dashboard->assertSee('总台管理'); + } + + public function test_platform_admin_cannot_login_from_merchant_admin_entry(): void + { + $this->seed(); + + $response = $this->from('/merchant-admin/login')->post('/merchant-admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ]); + + $response->assertRedirect('/merchant-admin/login'); + + $this->get('/merchant-admin/login')->assertSee('当前账号不是商家管理员,不能登录商家后台'); + } +} diff --git a/tests/Feature/MerchantBusinessPagesTest.php b/tests/Feature/MerchantBusinessPagesTest.php new file mode 100644 index 0000000..aa7e4ae --- /dev/null +++ b/tests/Feature/MerchantBusinessPagesTest.php @@ -0,0 +1,377 @@ +seed(); + + $this->post('/merchant-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ])->assertRedirect('/merchant-admin'); + } + + public function test_merchant_products_page_displays_import_and_history_entries(): void + { + $this->loginAsMerchantAdmin(); + + $this->get('/merchant-admin/products?status=published&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('商家商品管理') + ->assertSee('批量导入商品') + ->assertSee('下载导入模板') + ->assertSee('导出当前筛选结果 CSV') + ->assertSee('进入完整导入历史页') + ->assertSee('导入历史摘要') + ->assertSee('当前筛选摘要') + ->assertSee('运营关注项') + ->assertSee('当前信号') + ->assertSee('工作台导航') + ->assertSee('已上架') + ->assertSee('价格从高到低'); + } + + public function test_merchant_product_operations_focus_can_follow_current_filters(): void + { + $this->loginAsMerchantAdmin(); + + $this->get('/merchant-admin/products?status=draft&sort=latest') + ->assertOk() + ->assertSee('当前正在查看草稿商品,建议优先补齐标题、分类、价格与库存后再安排上架。') + ->assertSee('继续查看当前草稿'); + + $this->get('/merchant-admin/products?status=published&min_stock=0&max_stock=20&sort=stock_asc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架库存视角,建议优先确认低库存补货节奏,并同步观察高库存结构是否健康。') + ->assertSee('继续查看当前库存视角'); + + $this->get('/merchant-admin/products?status=published&category_id=1&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“默认分类”分类下关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对在售商品命名、分类承接、价格梯度与搜索结果是否一致,并同步观察库存结构与转化表现是否健康。') + ->assertSee('继续查看当前已上架分类关键词价格带商品') + ->assertSee('去看当前已上架分类关键词商品'); + + $this->get('/merchant-admin/products?status=published&category_id=1&keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“默认分类”分类下关键词“演示”命中的商品,建议优先核对在售商品命名、分类承接与搜索结果是否一致,并同步观察价格带与库存结构是否健康。') + ->assertSee('继续查看当前已上架分类关键词商品'); + + $this->get('/merchant-admin/products?status=published&category_id=1&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“默认分类”分类下价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对该分类在售商品的价格结构、库存分布与转化表现是否协调。') + ->assertSee('继续查看当前已上架分类价格带商品'); + + $this->get('/merchant-admin/products?status=published&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架商品中关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 结果,建议优先核对在售商品命名、卖点表达与价格梯度是否一致,并同步观察库存结构、转化表现与搜索承接是否健康。') + ->assertSee('继续查看当前已上架关键词价格带商品') + ->assertSee('去看当前已上架关键词商品'); + + $this->get('/merchant-admin/products?status=published&keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架商品中关键词“演示”命中的结果,建议优先核对在售商品命名、卖点表达与搜索承接是否一致,并同步观察价格带与库存结构是否健康。') + ->assertSee('继续查看当前已上架关键词商品'); + + $this->get('/merchant-admin/products?category_id=1&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦“默认分类”分类下关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对分类承接、命名卖点与价格梯度是否一致,并同步观察库存结构、转化表现与搜索承接是否健康。') + ->assertSee('继续查看当前分类关键词价格带商品') + ->assertSee('去看当前分类关键词商品'); + + $this->get('/merchant-admin/products?category_id=1&keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦“默认分类”分类下关键词“演示”命中的商品,建议优先核对分类承接、命名卖点与搜索结果是否一致,并同步观察相关商品的价格带与库存结构。') + ->assertSee('继续查看当前分类关键词商品'); + + $this->get('/merchant-admin/products?status=published&category_id=1&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“默认分类”分类商品,建议优先核对该分类在售商品的价格带、库存结构与转化表现是否均衡。') + ->assertSee('继续查看当前已上架分类商品'); + + $this->get('/merchant-admin/products?category_id=1&min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦“默认分类”分类下价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对该分类价格结构是否连贯,并同步观察库存分布与转化表现是否健康。') + ->assertSee('继续查看当前分类价格带商品'); + + $this->get('/merchant-admin/products?category_id=1&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦“默认分类”分类商品,建议优先核对分类承接是否准确,并同步观察价格带与库存结构是否均衡。') + ->assertSee('继续查看当前分类商品'); + + $this->get('/merchant-admin/products?keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦关键词“演示”命中的商品,建议优先核对命名、卖点与搜索承接是否一致,并同步观察相关商品的价格带与库存结构。') + ->assertSee('继续查看当前关键词商品'); + + $this->get('/merchant-admin/products?min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对定价梯度是否连贯,并同步观察库存结构与转化表现是否匹配。') + ->assertSee('继续查看当前价格带商品'); + } + + public function test_merchant_orders_page_displays_filters_and_export_entry(): void + { + $this->loginAsMerchantAdmin(); + + $this->get('/merchant-admin/orders?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190&sort=pay_amount_desc') + ->assertOk() + ->assertSee('商家订单管理') + ->assertSee('筛选条件') + ->assertSee('导出当前筛选结果 CSV') + ->assertSee('当前筛选摘要') + ->assertSee('运营关注项') + ->assertSee('当前信号') + ->assertSee('工作台导航') + ->assertSee('已发货') + ->assertSee('已支付') + ->assertSee('微信小程序') + ->assertSee('小程序环境') + ->assertSee('微信支付') + ->assertSee('订单汇总'); + } + + public function test_merchant_order_operations_focus_can_follow_current_filters(): void + { + $this->loginAsMerchantAdmin(); + + $this->get('/merchant-admin/orders?platform=wechat_mini&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。') + ->assertSee('继续查看微信小程序订单'); + + $this->get('/merchant-admin/orders?payment_channel=wechat_pay&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。') + ->assertSee('继续查看微信支付订单'); + + $this->get('/merchant-admin/orders?device_type=mini-program&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。') + ->assertSee('继续查看小程序环境订单'); + + $this->get('/merchant-admin/orders?device_type=mobile-webview&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。') + ->assertSee('继续查看微信内网页订单'); + + $this->get('/merchant-admin/orders?device_type=mobile&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。') + ->assertSee('继续查看移动浏览器订单'); + + $this->get('/merchant-admin/orders?device_type=desktop&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。') + ->assertSee('继续查看桌面浏览器订单'); + + $this->get('/merchant-admin/orders?payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。') + ->assertSee('继续查看支付失败订单'); + + $this->get('/merchant-admin/orders?status=completed&sort=latest') + ->assertOk() + ->assertSee('当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。') + ->assertSee('继续查看已完成订单'); + } + + public function test_merchant_categories_and_users_pages_display_expected_content(): void + { + $this->loginAsMerchantAdmin(); + + $this->get('/merchant-admin/product-categories') + ->assertOk() + ->assertSee('商家商品分类') + ->assertSee('新增分类') + ->assertSee('分类列表'); + + $this->get('/merchant-admin/users') + ->assertOk() + ->assertSee('商家用户管理') + ->assertSee('用户列表'); + } + + public function test_merchant_import_histories_page_displays_filter_and_export_entries(): void + { + $this->loginAsMerchantAdmin(); + + $this->get('/merchant-admin/products/import-histories') + ->assertOk() + ->assertSee('商家商品导入历史') + ->assertSee('筛选导入历史') + ->assertSee('导出当前筛选 CSV') + ->assertSee('返回商品页'); + } + + public function test_merchant_product_summary_stats_match_export_summary_for_same_filters(): void + { + $this->loginAsMerchantAdmin(); + + $page = $this->get('/merchant-admin/products?keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published&min_price=150&max_price=220'); + $page->assertOk()->assertViewHas('summaryStats', function (array $summaryStats) { + return ($summaryStats['total_products'] ?? null) === 1 + && ($summaryStats['total_stock'] ?? null) === 100 + && (float) ($summaryStats['total_stock_value'] ?? 0) === 19900.0 + && (float) ($summaryStats['average_price'] ?? 0) === 199.0; + }); + + $export = $this->get('/merchant-admin/products/export?keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published&min_price=150&max_price=220'); + $export->assertOk(); + $content = $export->streamedContent(); + + assertStringContainsString('导出信息,商家商品导出', $content); + assertStringContainsString('状态,已上架', $content); + assertStringContainsString('最低价格,¥150.00', $content); + assertStringContainsString('最高价格,¥220.00', $content); + assertStringContainsString('导出商品数,1', $content); + assertStringContainsString('导出总库存,100', $content); + assertStringContainsString('导出总货值,19900.00', $content); + assertStringContainsString('导出平均售价,199.00', $content); + assertStringContainsString('演示商品', $content); + } + + public function test_merchant_order_summary_stats_match_export_summary_for_same_filters(): void + { + $this->loginAsMerchantAdmin(); + + $page = $this->get('/merchant-admin/orders?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190'); + $page->assertOk()->assertViewHas('summaryStats', function (array $summaryStats) { + return ($summaryStats['total_orders'] ?? null) === 1 + && (float) ($summaryStats['total_pay_amount'] ?? 0) === 189.0 + && (float) ($summaryStats['average_order_amount'] ?? 0) === 189.0 + && ($summaryStats['paid_orders'] ?? null) === 1 + && ($summaryStats['failed_payment_orders'] ?? null) === 0; + }); + + $export = $this->get('/merchant-admin/orders/export?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190'); + $export->assertOk(); + $content = $export->streamedContent(); + + assertStringContainsString('导出信息,商家订单导出', $content); + assertStringContainsString('订单状态,已发货', $content); + assertStringContainsString('支付状态,已支付', $content); + assertStringContainsString('平台,微信小程序', $content); + assertStringContainsString('设备类型,小程序环境', $content); + assertStringContainsString('支付渠道,微信支付', $content); + assertStringContainsString('最低实付金额,¥180.00', $content); + assertStringContainsString('最高实付金额,¥190.00', $content); + assertStringContainsString('导出订单数,1', $content); + assertStringContainsString('导出实付总额,189.00', $content); + assertStringContainsString('导出平均客单价,189.00', $content); + assertStringContainsString('导出已支付订单数,1', $content); + assertStringContainsString('导出支付失败订单,0', $content); + assertStringContainsString('ORD202603080003', $content); + } + + public function test_merchant_products_batch_update_blocks_out_of_scope_products(): void + { + $this->loginAsMerchantAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $foreignMerchant = Merchant::query()->create([ + 'name' => '外部站点', + 'slug' => 'foreign-merchant-demo', + 'domain' => null, + 'contact_name' => '测试人', + 'contact_phone' => '13800000002', + 'contact_email' => 'foreign@example.com', + 'plan' => 'basic', + 'status' => 'active', + 'activated_at' => now(), + 'settings' => ['currency' => 'CNY'], + ]); + + $foreignCategory = ProductCategory::query()->create([ + 'merchant_id' => $foreignMerchant->id, + 'name' => '外部分类', + 'slug' => 'foreign-default', + 'status' => 'active', + 'sort' => 10, + 'description' => '用于越权测试', + ]); + + $foreignProduct = Product::query()->create([ + 'merchant_id' => $foreignMerchant->id, + 'category_id' => $foreignCategory->id, + 'title' => '外部商品', + 'slug' => 'foreign-product', + 'sku' => 'SKU-FOREIGN-001', + 'summary' => '用于越权测试', + 'content' => 'test', + 'price' => 66, + 'original_price' => 88, + 'stock' => 12, + 'status' => 'published', + 'images' => [], + ]); + + $localProduct = Product::query()->forMerchant($merchant->id)->orderBy('id')->firstOrFail(); + + $response = $this->from('/merchant-admin/products')->post('/merchant-admin/products/batch', [ + 'product_ids' => [$localProduct->id, $foreignProduct->id], + 'action' => 'change_status', + 'status' => 'offline', + ]); + + $response->assertRedirect('/merchant-admin/products'); + $response->assertSessionHasErrors(['product_ids']); + $response->assertSessionHasErrors(['product_ids' => '勾选商品中存在越权或已删除的数据,请刷新后重试。']); + + $this->assertSame('published', $localProduct->fresh()->status); + $this->assertSame('published', $foreignProduct->fresh()->status); + } + + public function test_merchant_products_batch_change_category_blocks_invalid_category(): void + { + $this->loginAsMerchantAdmin(); + + $foreignMerchant = Merchant::query()->create([ + 'name' => '外部分站', + 'slug' => 'foreign-category-merchant-demo', + 'domain' => null, + 'contact_name' => '测试人', + 'contact_phone' => '13800000003', + 'contact_email' => 'foreign-category@example.com', + 'plan' => 'basic', + 'status' => 'active', + 'activated_at' => now(), + 'settings' => ['currency' => 'CNY'], + ]); + + $foreignCategory = ProductCategory::query()->create([ + 'merchant_id' => $foreignMerchant->id, + 'name' => '外部分站分类', + 'slug' => 'foreign-category', + 'status' => 'active', + 'sort' => 10, + 'description' => '用于非法分类测试', + ]); + + $product = Product::query()->where('merchant_id', Merchant::query()->firstOrFail()->id)->orderBy('id')->firstOrFail(); + $originalCategoryId = $product->category_id; + + $response = $this->from('/merchant-admin/products')->post('/merchant-admin/products/batch', [ + 'product_ids' => [$product->id], + 'action' => 'change_category', + 'category_id' => $foreignCategory->id, + ]); + + $response->assertRedirect('/merchant-admin/products'); + $response->assertSessionHasErrors(['category_id']); + $response->assertSessionHasErrors(['category_id' => '所选分类不存在或不属于当前商家。']); + + $this->assertSame($originalCategoryId, $product->fresh()->category_id); + } +} diff --git a/tests/Feature/MerchantProtectedPagesTest.php b/tests/Feature/MerchantProtectedPagesTest.php new file mode 100644 index 0000000..abed4ed --- /dev/null +++ b/tests/Feature/MerchantProtectedPagesTest.php @@ -0,0 +1,51 @@ +seed(); + + $this->post('/merchant-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ])->assertRedirect('/merchant-admin'); + } + + public function test_guest_is_redirected_to_merchant_admin_login_for_protected_pages(): void + { + foreach (['/merchant-admin', '/merchant-admin/products', '/merchant-admin/orders', '/merchant-admin/product-categories', '/merchant-admin/users'] as $path) { + $this->get($path)->assertRedirect('/merchant-admin/login'); + } + } + + public function test_merchant_admin_can_access_key_merchant_pages(): void + { + $this->loginAsMerchantAdmin(); + + $this->get('/merchant-admin')->assertOk()->assertSee('商家后台仪表盘'); + $this->get('/merchant-admin/products')->assertOk()->assertSee('商家商品管理'); + $this->get('/merchant-admin/orders')->assertOk()->assertSee('商家订单管理'); + $this->get('/merchant-admin/product-categories')->assertOk()->assertSee('商家商品分类'); + $this->get('/merchant-admin/users')->assertOk()->assertSee('商家用户管理'); + } + + public function test_platform_admin_is_forbidden_from_merchant_admin_pages(): void + { + $this->seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + + $this->get('/merchant-admin')->assertForbidden()->assertSee('当前账号未绑定商家后台访问权限'); + } +} diff --git a/tests/Feature/SiteAdminAccessTest.php b/tests/Feature/SiteAdminAccessTest.php new file mode 100644 index 0000000..824b822 --- /dev/null +++ b/tests/Feature/SiteAdminAccessTest.php @@ -0,0 +1,54 @@ +get('/site-admin/login'); + + $response->assertOk(); + $response->assertSee('站点后台登录'); + $response->assertSee('登录站点后台'); + } + + public function test_site_admin_can_login_and_open_dashboard(): void + { + $this->seed(); + + $response = $this->post('/site-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ]); + + $response->assertRedirect('/site-admin'); + + $dashboard = $this->get('/site-admin'); + $dashboard->assertOk(); + $dashboard->assertSee('站点后台仪表盘'); + $dashboard->assertSee('站点仪表盘'); + $dashboard->assertSee('站点商品'); + $dashboard->assertSee('站点订单'); + $dashboard->assertSee('站点商家'); + } + + public function test_platform_admin_cannot_login_from_site_admin_entry(): void + { + $this->seed(); + + $response = $this->from('/site-admin/login')->post('/site-admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ]); + + $response->assertRedirect('/site-admin/login'); + + $this->get('/site-admin/login')->assertSee('当前账号不是站点管理员,不能登录站点后台'); + } +} diff --git a/tests/Feature/SiteAdminBusinessPagesTest.php b/tests/Feature/SiteAdminBusinessPagesTest.php new file mode 100644 index 0000000..3f11046 --- /dev/null +++ b/tests/Feature/SiteAdminBusinessPagesTest.php @@ -0,0 +1,733 @@ +seed(); + + $this->post('/site-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ])->assertRedirect('/site-admin'); + } + + public function test_site_dashboard_displays_site_scope_content(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin') + ->assertOk() + ->assertSee('站点后台仪表盘') + ->assertSee('站点管理员') + ->assertSee('站点用户') + ->assertSee('站点商品') + ->assertSee('站点订单'); + } + + public function test_site_business_pages_display_expected_entries(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/merchants') + ->assertOk() + ->assertSee('站点商家') + ->assertSee('筛选条件') + ->assertSee('导出当前筛选结果 CSV') + ->assertSee('当前筛选摘要') + ->assertSee('站点运营摘要') + ->assertSee('运营关注项') + ->assertSee('工作台导航') + ->assertSee('商品工作台') + ->assertSee('订单工作台') + ->assertSee('站点信息') + ->assertSee('当前站点资料') + ->assertSee('站点承接说明') + ->assertSee('运营建议') + ->assertSee('查看站点商品') + ->assertSee('查看站点订单') + ->assertSee('查看低库存商品') + ->assertSee('查看待支付订单') + ->assertSee('高库存已上架商品') + ->assertSee('低库存补货视角') + ->assertSee('最近新增商品') + ->assertSee('高金额已支付订单') + ->assertSee('待支付订单跟进') + ->assertSee('支付失败订单排查') + ->assertSee('查看当前站点资料') + ->assertSee('查看承接记录') + ->assertSee('返回当前筛选视图') + ->assertSee('建议动作') + ->assertSee('去看最近新增商品') + ->assertSee('去看已上架商品') + ->assertSee('去看待支付订单') + ->assertSee('去看支付失败订单') + ->assertSee('去看站点资料') + ->assertSee('去看承接记录') + ->assertSee('当前站点承接记录') + ->assertSee('查看商品') + ->assertSee('查看订单') + ->assertSee('低库存') + ->assertSee('支付失败') + ->assertSee('快捷入口默认带入更适合运营查看的排序与状态条件') + ->assertSee('/site-admin/products?sort=stock_desc&status=published', false) + ->assertSee('/site-admin/products?sort=stock_asc&status=published', false) + ->assertSee('/site-admin/products?sort=latest', false) + ->assertSee('/site-admin/orders?sort=pay_amount_desc&payment_status=paid', false) + ->assertSee('/site-admin/orders?sort=latest&payment_status=unpaid', false) + ->assertSee('/site-admin/orders?sort=latest&payment_status=failed', false) + ->assertSee('#site-profile', false) + ->assertSee('#site-records', false); + + $this->get('/site-admin/products') + ->assertOk() + ->assertSee('站点商品') + ->assertSee('筛选条件') + ->assertSee('商品运营汇总') + ->assertSee('当前筛选摘要') + ->assertSee('运营关注项') + ->assertSee('工作台导航') + ->assertSee('当前信号') + ->assertSee('建议动作') + ->assertSee('商品状态统计') + ->assertSee('最低价格') + ->assertSee('最高库存') + ->assertSee('导出当前筛选结果 CSV') + ->assertSee('商品列表'); + + $this->get('/site-admin/orders') + ->assertOk() + ->assertSee('站点订单') + ->assertSee('筛选条件') + ->assertSee('订单汇总') + ->assertSee('当前筛选摘要') + ->assertSee('运营关注项') + ->assertSee('工作台导航') + ->assertSee('当前信号') + ->assertSee('建议动作') + ->assertSee('订单状态统计') + ->assertSee('最低实付金额') + ->assertSee('最高实付金额') + ->assertSee('导出当前筛选结果 CSV') + ->assertSee('订单列表'); + } + + public function test_site_merchant_page_can_reflect_current_filters_and_stats(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/merchants?keyword=%E6%BC%94%E7%A4%BA&status=active&plan=pro&sort=name_asc') + ->assertOk() + ->assertSee('演示店铺') + ->assertSee('启用中') + ->assertSee('专业版') + ->assertSee('名称 A-Z') + ->assertSee('站点管理员数') + ->assertSee('站点商品数') + ->assertSee('站点订单数') + ->assertSee('商品分类数') + ->assertSee('运营关注项') + ->assertSee('工作台导航') + ->assertSee('站点承接说明') + ->assertSee('运营建议') + ->assertSee('当前站点商品仍较少,建议优先查看最近新增与基础信息是否完整。') + ->assertSee('当前站点订单已形成基础规模,建议优先关注待支付、支付失败与高金额订单。') + ->assertSee('联系人信息已具备,可继续作为站点日常跟进入口。') + ->assertSee('建议动作') + ->assertSee('去看最近新增商品') + ->assertSee('去看已上架商品') + ->assertSee('去看待支付订单') + ->assertSee('去看支付失败订单') + ->assertSee('去看站点资料') + ->assertSee('去看承接记录') + ->assertSee('/site-admin/products?sort=stock_desc&status=published', false) + ->assertSee('/site-admin/products?sort=stock_asc&status=published', false) + ->assertSee('/site-admin/orders?sort=pay_amount_desc&payment_status=paid', false) + ->assertSee('/site-admin/orders?sort=latest&payment_status=failed', false) + ->assertSee('#site-profile', false) + ->assertSee('#site-records', false); + } + + public function test_site_merchant_export_contains_scope_summary_and_filtered_content(): void + { + $this->loginAsSiteAdmin(); + + $export = $this->get('/site-admin/merchants/export?keyword=%E6%BC%94%E7%A4%BA&status=active&plan=pro&sort=name_asc'); + $export->assertOk(); + $content = $export->streamedContent(); + + $this->assertStringContainsString('导出信息,站点商家导出', $content); + $this->assertStringContainsString('站点ID,1', $content); + $this->assertStringContainsString('站点名称,演示店铺', $content); + $this->assertStringContainsString('关键词,演示', $content); + $this->assertStringContainsString('状态,启用中', $content); + $this->assertStringContainsString('套餐,专业版', $content); + $this->assertStringContainsString('排序', $content); + $this->assertStringContainsString('名称 A-Z', $content); + $this->assertStringContainsString('站点管理员数,1', $content); + $this->assertStringContainsString('站点商品数,2', $content); + $this->assertStringContainsString('站点订单数,5', $content); + $this->assertStringContainsString('当前站点资料,', $content); + $this->assertStringContainsString('当前套餐,专业版', $content); + $this->assertStringContainsString('ID,名称,Slug,状态,套餐,联系人,联系电话,联系邮箱,管理员数,用户数,商品数,订单数,商品分类数,激活时间', $content); + $this->assertStringContainsString('演示店铺,demo-shop,启用中,专业版', $content); + } + + public function test_site_product_filters_can_narrow_results(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products?keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published') + ->assertOk() + ->assertSee('演示商品') + ->assertDontSee('精选礼包'); + } + + public function test_site_product_range_filters_can_narrow_results(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products?min_price=150&max_price=220') + ->assertOk() + ->assertSee('演示商品') + ->assertDontSee('精选礼包'); + + $this->get('/site-admin/products?min_stock=60') + ->assertOk() + ->assertSee('演示商品') + ->assertDontSee('精选礼包'); + } + + public function test_site_product_invalid_ranges_show_chinese_errors_and_empty_state(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products?min_price=300&max_price=100&min_stock=abc') + ->assertOk() + ->assertSee('最低价格不能大于最高价格。') + ->assertSee('最低库存必须为整数。') + ->assertSee('暂无商品'); + } + + public function test_site_product_filter_summary_can_reflect_current_filters(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products?keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published&category_id=1&min_price=150&max_price=220&min_stock=50&max_stock=120&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选摘要') + ->assertSee('演示商品') + ->assertSee('已上架') + ->assertSee('默认分类') + ->assertSee('¥150.00 ~ ¥220.00') + ->assertSee('50 ~ 120 件') + ->assertSee('价格从高到低'); + } + + public function test_site_product_status_cards_can_drive_filtering(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products?status=published') + ->assertOk() + ->assertSee('已上架') + ->assertSee('演示商品') + ->assertSee('精选礼包'); + + $this->get('/site-admin/products?status=draft') + ->assertOk() + ->assertSee('暂无商品'); + } + + public function test_site_order_filters_can_narrow_results(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/orders?status=shipped&platform=wechat_mini') + ->assertOk() + ->assertSee('ORD202603080003') + ->assertDontSee('ORD202603080001'); + } + + public function test_site_order_pay_amount_range_filters_can_narrow_results(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/orders?min_pay_amount=180&max_pay_amount=190') + ->assertOk() + ->assertSee('ORD202603080003') + ->assertDontSee('ORD202603080002') + ->assertDontSee('ORD202603080004'); + } + + public function test_site_order_invalid_ranges_show_chinese_errors_and_empty_state(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/orders?min_pay_amount=300&max_pay_amount=100') + ->assertOk() + ->assertSee('最低实付金额不能大于最高实付金额。') + ->assertSee('暂无订单'); + } + + public function test_site_order_filter_summary_can_reflect_current_filters(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/orders?keyword=ORD202603080003&status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190&sort=pay_amount_desc') + ->assertOk() + ->assertSee('当前筛选摘要') + ->assertSee('ORD202603080003') + ->assertSee('已发货') + ->assertSee('已支付') + ->assertSee('微信小程序') + ->assertSee('小程序环境') + ->assertSee('微信支付') + ->assertSee('¥180.00 ~ ¥190.00') + ->assertSee('实付金额从高到低'); + } + + public function test_site_product_operations_focus_and_workbench_are_displayed(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products') + ->assertOk() + ->assertSee('当前站点商品仍较少,建议优先查看最近新增与已上架商品,确认基础信息是否完整。') + ->assertSee('去看最近新增商品') + ->assertSee('去看已上架商品') + ->assertSee('已上架商品') + ->assertSee('低库存商品') + ->assertSee('分类覆盖数') + ->assertSee('高库存已上架') + ->assertSee('低库存补货') + ->assertSee('最近新增') + ->assertSee('草稿待整理') + ->assertSee('返回当前筛选视图'); + } + + public function test_site_order_operations_focus_and_workbench_are_displayed(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/orders') + ->assertOk() + ->assertSee('当前站点订单已形成基础规模,建议优先关注待支付、支付失败与高金额已支付订单。') + ->assertSee('去看待支付订单') + ->assertSee('去看支付失败订单') + ->assertSee('待支付订单') + ->assertSee('支付失败订单') + ->assertSee('已完成订单') + ->assertSee('高金额已支付') + ->assertSee('待支付跟进') + ->assertSee('支付失败排查') + ->assertSee('最近完成订单') + ->assertSee('返回当前筛选视图'); + } + + public function test_site_product_operations_focus_can_follow_current_filters(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products?status=published&min_stock=0&max_stock=20&sort=stock_asc') + ->assertOk() + ->assertSee('当前筛选已聚焦已上架库存视角,建议优先确认低库存补货节奏,并同步观察高库存结构是否健康。') + ->assertSee('继续查看当前库存视角') + ->assertSee('去看高库存商品') + ->assertSee('/site-admin/products?status=published&min_stock=0&max_stock=20&sort=stock_asc', false); + + $this->get('/site-admin/products?status=draft') + ->assertOk() + ->assertSee('当前正在查看草稿商品,建议优先补齐标题、分类、价格与库存后再安排上架。') + ->assertSee('继续查看当前草稿') + ->assertSee('去看已上架商品') + ->assertSee('/site-admin/products?status=draft&sort=latest', false); + + $response = $this->get('/site-admin/products?status=published&category_id=1&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“默认分类”分类下关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对在售商品命名、分类承接、价格梯度与搜索结果是否一致,并同步观察库存结构是否健康。') + ->assertSee('继续查看当前已上架分类关键词价格带商品') + ->assertSee('去看当前已上架分类关键词商品') + ->assertSee('status=published', false) + ->assertSee('category_id=1', false) + ->assertSee('keyword=%E6%BC%94%E7%A4%BA', false) + ->assertSee('min_price=150', false) + ->assertSee('max_price=220', false) + ->assertSee('sort=price_desc', false); + + $response = $this->get('/site-admin/products?status=published&category_id=1&keyword=%E6%BC%94%E7%A4%BA&sort=latest'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“默认分类”分类下关键词“演示”命中的商品,建议优先核对在售商品命名、分类承接与搜索结果是否一致,并同步观察价格带与库存结构是否健康。') + ->assertSee('继续查看当前已上架分类关键词商品') + ->assertSee('去看当前已上架分类商品') + ->assertSee('status=published', false) + ->assertSee('category_id=1', false) + ->assertSee('keyword=%E6%BC%94%E7%A4%BA', false) + ->assertSee('sort=latest', false); + + $response = $this->get('/site-admin/products?status=published&category_id=1&min_price=150&max_price=220&sort=price_desc'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“默认分类”分类下价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对该分类在售商品的价格结构、库存分布与承接效率是否协调。') + ->assertSee('继续查看当前已上架分类价格带商品') + ->assertSee('去看当前已上架分类商品') + ->assertSee('status=published', false) + ->assertSee('category_id=1', false) + ->assertSee('min_price=150', false) + ->assertSee('max_price=220', false) + ->assertSee('sort=price_desc', false); + + $response = $this->get('/site-admin/products?status=published&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦已上架商品中关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 结果,建议优先核对在售商品命名、卖点表达与价格梯度是否一致,并同步观察库存结构与搜索承接是否健康。') + ->assertSee('继续查看当前已上架关键词价格带商品') + ->assertSee('去看当前已上架关键词商品') + ->assertSee('status=published', false) + ->assertSee('keyword=%E6%BC%94%E7%A4%BA', false) + ->assertSee('min_price=150', false) + ->assertSee('max_price=220', false) + ->assertSee('sort=price_desc', false); + + $response = $this->get('/site-admin/products?status=published&keyword=%E6%BC%94%E7%A4%BA&sort=latest'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦已上架商品中关键词“演示”命中的结果,建议优先核对在售商品命名、卖点表达与搜索承接是否一致,并同步观察价格带与库存结构是否健康。') + ->assertSee('继续查看当前已上架关键词商品') + ->assertSee('去看全部已上架商品') + ->assertSee('status=published', false) + ->assertSee('keyword=%E6%BC%94%E7%A4%BA', false) + ->assertSee('sort=latest', false); + + $response = $this->get('/site-admin/products?category_id=1&keyword=%E6%BC%94%E7%A4%BA&min_price=150&max_price=220&sort=price_desc'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦“默认分类”分类下关键词“演示”命中的价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对分类承接、命名卖点与价格梯度是否一致,并同步观察库存结构与搜索承接是否健康。') + ->assertSee('继续查看当前分类关键词价格带商品') + ->assertSee('去看当前分类关键词商品') + ->assertSee('category_id=1', false) + ->assertSee('keyword=%E6%BC%94%E7%A4%BA', false) + ->assertSee('min_price=150', false) + ->assertSee('max_price=220', false) + ->assertSee('sort=price_desc', false); + + $response = $this->get('/site-admin/products?category_id=1&keyword=%E6%BC%94%E7%A4%BA&sort=latest'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦“默认分类”分类下关键词“演示”命中的商品,建议优先核对分类承接、命名卖点与搜索结果是否一致,并同步观察相关商品的价格带与库存结构。') + ->assertSee('继续查看当前分类关键词商品') + ->assertSee('去看当前分类商品') + ->assertSee('keyword=%E6%BC%94%E7%A4%BA', false) + ->assertSee('category_id=1', false) + ->assertSee('sort=latest', false); + + $response = $this->get('/site-admin/products?status=published&category_id=1&sort=latest'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦已上架的“默认分类”分类商品,建议优先核对该分类在售商品的价格带、库存结构与承接质量是否均衡。') + ->assertSee('继续查看当前已上架分类商品') + ->assertSee('去看当前分类商品') + ->assertSee('status=published', false) + ->assertSee('category_id=1', false) + ->assertSee('sort=latest', false); + + $response = $this->get('/site-admin/products?category_id=1&min_price=150&max_price=220&sort=price_desc'); + $response + ->assertOk() + ->assertSee('当前筛选已聚焦“默认分类”分类下价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对该分类价格结构是否连贯,并同步观察库存分布与承接效率是否健康。') + ->assertSee('继续查看当前分类价格带商品') + ->assertSee('去看当前分类商品') + ->assertSee('category_id=1', false) + ->assertSee('min_price=150', false) + ->assertSee('max_price=220', false) + ->assertSee('sort=price_desc', false); + + $this->get('/site-admin/products?category_id=1&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦“默认分类”分类商品,建议优先核对分类承接是否准确,并同步观察价格带与库存结构是否均衡。') + ->assertSee('继续查看当前分类商品') + ->assertSee('去看低库存商品') + ->assertSee('/site-admin/products?category_id=1&sort=latest', false); + + $this->get('/site-admin/products?keyword=%E6%BC%94%E7%A4%BA&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦关键词“演示”命中的商品,建议优先核对命名、卖点与搜索承接是否一致,并同步观察相关商品的价格带与库存结构。') + ->assertSee('继续查看当前关键词商品') + ->assertSee('去看最近新增商品') + ->assertSee('/site-admin/products?keyword=%E6%BC%94%E7%A4%BA&sort=latest', false); + + $this->get('/site-admin/products?min_price=150&max_price=220&sort=price_desc') + ->assertOk() + ->assertSee('当前筛选已聚焦价格带 ¥150.00 ~ ¥220.00 商品,建议优先核对定价梯度是否连贯,并同步观察库存结构与转化表现是否匹配。') + ->assertSee('继续查看当前价格带商品') + ->assertSee('去看最近新增商品') + ->assertSee('/site-admin/products?min_price=150&max_price=220&sort=price_desc', false); + } + + public function test_site_order_operations_focus_can_follow_current_filters(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/orders?platform=wechat_mini&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信小程序订单,建议优先关注下单到支付转化是否顺畅,并同步排查小程序端支付回流体验。') + ->assertSee('继续查看微信小程序订单') + ->assertSee('去看待支付订单') + ->assertSee('/site-admin/orders?platform=wechat_mini&sort=latest', false); + + $this->get('/site-admin/orders?payment_channel=wechat_pay&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信支付订单,建议优先核对支付成功率、回调稳定性与失败重试转化。') + ->assertSee('继续查看微信支付订单') + ->assertSee('去看支付失败订单') + ->assertSee('/site-admin/orders?payment_channel=wechat_pay&sort=latest', false); + + $this->get('/site-admin/orders?device_type=mini-program&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦小程序环境订单,建议优先核对授权链路、支付唤起表现与下单回流是否顺畅。') + ->assertSee('继续查看小程序环境订单') + ->assertSee('去看微信支付订单') + ->assertSee('/site-admin/orders?payment_status=failed&device_type=mini-program&sort=latest', false); + + $this->get('/site-admin/orders?device_type=mobile-webview&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦微信内网页订单,建议优先关注授权静默登录、页面跳转稳定性与支付拉起后的回流体验。') + ->assertSee('继续查看微信内网页订单') + ->assertSee('去看待支付订单') + ->assertSee('/site-admin/orders?payment_status=failed&device_type=mobile-webview&sort=latest', false); + + $this->get('/site-admin/orders?device_type=mobile&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦移动浏览器订单,建议优先关注 H5 下单链路、页面加载稳定性与支付转化流失点。') + ->assertSee('继续查看移动浏览器订单') + ->assertSee('去看待支付订单') + ->assertSee('/site-admin/orders?payment_status=failed&device_type=mobile&sort=latest', false); + + $this->get('/site-admin/orders?device_type=desktop&payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦桌面浏览器订单,建议优先关注 PC 端下单流程、页面首屏稳定性与高客单转化表现。') + ->assertSee('继续查看桌面浏览器订单') + ->assertSee('去看高金额已支付订单') + ->assertSee('/site-admin/orders?payment_status=failed&device_type=desktop&sort=latest', false); + + $this->get('/site-admin/orders?payment_status=failed&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦支付失败订单,建议优先排查支付渠道、回调结果与用户重试情况。') + ->assertSee('继续查看支付失败订单') + ->assertSee('去看高金额已支付订单') + ->assertSee('/site-admin/orders?payment_status=failed&sort=latest', false); + + $this->get('/site-admin/orders?payment_status=unpaid&sort=latest') + ->assertOk() + ->assertSee('当前筛选已聚焦待支付订单,建议优先跟进下单未支付用户,并观察支付转化时效。') + ->assertSee('继续查看待支付订单') + ->assertSee('去看高金额已支付订单') + ->assertSee('/site-admin/orders?payment_status=unpaid&sort=latest', false); + + $this->get('/site-admin/orders?status=completed&sort=latest') + ->assertOk() + ->assertSee('当前正在查看已完成订单,建议复盘高客单成交与复购来源,沉淀更稳定的转化路径。') + ->assertSee('继续查看已完成订单') + ->assertSee('去看高金额已支付订单') + ->assertSee('/site-admin/orders?status=completed&sort=latest', false); + } + + public function test_site_order_status_cards_can_drive_filtering(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/orders?status=completed') + ->assertOk() + ->assertSee('已完成') + ->assertSee('ORD202603080004') + ->assertDontSee('ORD202603080001'); + + $this->get('/site-admin/orders?status=refunded') + ->assertOk() + ->assertSee('暂无订单'); + } + + public function test_site_business_pages_display_empty_state_when_filters_miss(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products?keyword=no-match-keyword') + ->assertOk() + ->assertSee('暂无商品'); + + $this->get('/site-admin/orders?keyword=no-match-keyword') + ->assertOk() + ->assertSee('暂无订单'); + } + + public function test_site_export_endpoints_return_csv_response(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin/products/export') + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $this->get('/site-admin/orders/export') + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + } + + public function test_site_invalid_product_export_is_blocked_with_redirect_and_error(): void + { + $this->loginAsSiteAdmin(); + + $response = $this->get('/site-admin/products/export?min_price=300&max_price=100'); + $response->assertStatus(302); + $this->assertStringContainsString('/site-admin/products?', $response->headers->get('Location')); + $this->assertStringContainsString('min_price=300', $response->headers->get('Location')); + $this->assertStringContainsString('max_price=100', $response->headers->get('Location')); + $this->assertStringContainsString('sort=latest', $response->headers->get('Location')); + + $this->followRedirects($this->get('/site-admin/products/export?min_price=300&max_price=100')) + ->assertSee('最低价格不能大于最高价格。') + ->assertSee('暂无商品'); + } + + public function test_site_invalid_order_export_is_blocked_with_redirect_and_error(): void + { + $this->loginAsSiteAdmin(); + + $response = $this->get('/site-admin/orders/export?min_pay_amount=300&max_pay_amount=100'); + $response->assertStatus(302); + $this->assertStringContainsString('/site-admin/orders?', $response->headers->get('Location')); + $this->assertStringContainsString('min_pay_amount=300', $response->headers->get('Location')); + $this->assertStringContainsString('max_pay_amount=100', $response->headers->get('Location')); + $this->assertStringContainsString('sort=latest', $response->headers->get('Location')); + + $this->followRedirects($this->get('/site-admin/orders/export?min_pay_amount=300&max_pay_amount=100')) + ->assertSee('最低实付金额不能大于最高实付金额。') + ->assertSee('暂无订单'); + } + + public function test_site_export_contains_scope_summary_and_filtered_content(): void + { + $this->loginAsSiteAdmin(); + + $productExport = $this->get('/site-admin/products/export?keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published&min_price=150&max_price=220'); + $productExport->assertOk(); + $productContent = $productExport->streamedContent(); + $this->assertStringContainsString('导出信息,站点商品导出', $productContent); + $this->assertStringContainsString('站点ID,1', $productContent); + $this->assertStringContainsString('状态,已上架', $productContent); + $this->assertStringContainsString('分类,全部', $productContent); + $this->assertStringContainsString('最低价格,¥150.00', $productContent); + $this->assertStringContainsString('最高价格,¥220.00', $productContent); + $this->assertStringContainsString('排序,最新创建', $productContent); + $this->assertStringContainsString('导出商品数,1', $productContent); + $this->assertStringContainsString('导出总库存,100', $productContent); + $this->assertStringContainsString('演示商品', $productContent); + $this->assertStringNotContainsString('精选礼包', $productContent); + + $orderExport = $this->get('/site-admin/orders/export?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190'); + $orderExport->assertOk(); + $orderContent = $orderExport->streamedContent(); + $this->assertStringContainsString('导出信息,站点订单导出', $orderContent); + $this->assertStringContainsString('站点ID,1', $orderContent); + $this->assertStringContainsString('订单状态,已发货', $orderContent); + $this->assertStringContainsString('支付状态,已支付', $orderContent); + $this->assertStringContainsString('平台,微信小程序', $orderContent); + $this->assertStringContainsString('设备类型,小程序环境', $orderContent); + $this->assertStringContainsString('支付渠道,微信支付', $orderContent); + $this->assertStringContainsString('最低实付金额,¥180.00', $orderContent); + $this->assertStringContainsString('最高实付金额,¥190.00', $orderContent); + $this->assertStringContainsString('排序,创建时间倒序', $orderContent); + $this->assertStringContainsString('导出订单数,1', $orderContent); + $this->assertStringContainsString('导出已支付订单数,1', $orderContent); + $this->assertStringContainsString('ORD202603080003', $orderContent); + $this->assertStringNotContainsString('ORD202603080001', $orderContent); + } + + public function test_site_product_summary_stats_match_export_summary_for_same_filters(): void + { + $this->loginAsSiteAdmin(); + + $page = $this->get('/site-admin/products?keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published&min_price=150&max_price=220'); + $page->assertOk()->assertViewHas('summaryStats', function (array $summaryStats) { + return ($summaryStats['total_products'] ?? null) === 1 + && ($summaryStats['total_stock'] ?? null) === 100 + && (float) ($summaryStats['total_stock_value'] ?? 0) === 19900.0 + && (float) ($summaryStats['average_price'] ?? 0) === 199.0; + }); + + $page->assertSee('商品总数') + ->assertSee('总库存') + ->assertSee('总货值') + ->assertSee('平均售价') + ->assertSee('19,900.00') + ->assertSee('199.00'); + + $export = $this->get('/site-admin/products/export?keyword=%E6%BC%94%E7%A4%BA%E5%95%86%E5%93%81&status=published&min_price=150&max_price=220'); + $export->assertOk(); + $content = $export->streamedContent(); + + $this->assertStringContainsString('导出商品数,1', $content); + $this->assertStringContainsString('导出总库存,100', $content); + $this->assertStringContainsString('导出总货值,19900.00', $content); + $this->assertStringContainsString('导出平均售价,199.00', $content); + } + + public function test_site_order_summary_stats_match_export_summary_for_same_filters(): void + { + $this->loginAsSiteAdmin(); + + $page = $this->get('/site-admin/orders?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190'); + $page->assertOk()->assertViewHas('summaryStats', function (array $summaryStats) { + return ($summaryStats['total_orders'] ?? null) === 1 + && (float) ($summaryStats['total_pay_amount'] ?? 0) === 189.0 + && (float) ($summaryStats['average_order_amount'] ?? 0) === 189.0 + && ($summaryStats['paid_orders'] ?? null) === 1 + && ($summaryStats['failed_payment_orders'] ?? null) === 0; + }); + + $page->assertSee('订单总数') + ->assertSee('实付总额') + ->assertSee('平均客单价') + ->assertSee('已支付订单数') + ->assertSee('支付失败订单') + ->assertSee('小程序环境') + ->assertSee('微信支付') + ->assertSee('189.00'); + + $export = $this->get('/site-admin/orders/export?status=shipped&payment_status=paid&platform=wechat_mini&device_type=mini-program&payment_channel=wechat_pay&min_pay_amount=180&max_pay_amount=190'); + $export->assertOk(); + $content = $export->streamedContent(); + + $this->assertStringContainsString('设备类型,小程序环境', $content); + $this->assertStringContainsString('支付渠道,微信支付', $content); + $this->assertStringContainsString('导出订单数,1', $content); + $this->assertStringContainsString('导出实付总额,189.00', $content); + $this->assertStringContainsString('导出平均客单价,189.00', $content); + $this->assertStringContainsString('导出已支付订单数,1', $content); + $this->assertStringContainsString('导出支付失败订单,0', $content); + } + + public function test_platform_site_list_displays_site_admin_entry_link(): void + { + $this->seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + + $this->get('/admin/merchants') + ->assertOk() + ->assertSee('进入站点后台') + ->assertSee('当前阶段请使用该站点管理员账号登录'); + } +} diff --git a/tests/Feature/SiteAdminProtectedPagesTest.php b/tests/Feature/SiteAdminProtectedPagesTest.php new file mode 100644 index 0000000..c929e22 --- /dev/null +++ b/tests/Feature/SiteAdminProtectedPagesTest.php @@ -0,0 +1,53 @@ +seed(); + + $this->post('/site-admin/login', [ + 'email' => 'merchant.admin@demo.local', + 'password' => 'Merchant@123456', + ])->assertRedirect('/site-admin'); + } + + public function test_guest_is_redirected_to_site_admin_login_for_protected_pages(): void + { + foreach (['/site-admin', '/site-admin/merchants', '/site-admin/merchants/export', '/site-admin/products', '/site-admin/products/export', '/site-admin/orders', '/site-admin/orders/export'] as $path) { + $this->get($path)->assertRedirect('/site-admin/login'); + } + } + + public function test_site_admin_can_access_key_site_pages(): void + { + $this->loginAsSiteAdmin(); + + $this->get('/site-admin')->assertOk()->assertSee('站点后台仪表盘'); + $this->get('/site-admin/merchants')->assertOk()->assertSee('站点商家'); + $this->get('/site-admin/merchants/export')->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); + $this->get('/site-admin/products')->assertOk()->assertSee('站点商品'); + $this->get('/site-admin/products/export')->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); + $this->get('/site-admin/orders')->assertOk()->assertSee('站点订单'); + $this->get('/site-admin/orders/export')->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); + } + + public function test_platform_admin_is_forbidden_from_site_admin_pages(): void + { + $this->seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + + $this->get('/site-admin')->assertForbidden()->assertSee('当前账号未绑定站点后台访问权限'); + } +} diff --git a/tests/Feature/SubscriptionActivationServiceTest.php b/tests/Feature/SubscriptionActivationServiceTest.php new file mode 100644 index 0000000..a84061f --- /dev/null +++ b/tests/Feature/SubscriptionActivationServiceTest.php @@ -0,0 +1,177 @@ +seed(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'activate_test_monthly', + 'name' => '激活测试(月付)', + 'billing_cycle' => 'monthly', + 'price' => 99, + 'list_price' => 99, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_ACTIVATE_0001', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'list_amount' => 99, + 'discount_amount' => 0, + 'payable_amount' => 99, + 'paid_amount' => 99, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(5), + 'activated_at' => now()->subMinutes(1), + ]); + + $service = new SubscriptionActivationService(); + $subscription = $service->activateOrder($order->id, 1); + + // 二次执行应为幂等:不重复续期/不重复创建,直接返回同一个订阅 + $again = $service->activateOrder($order->id, 1); + + $this->assertInstanceOf(SiteSubscription::class, $subscription); + $this->assertSame($subscription->id, $again->id); + $this->assertSame('activated', $subscription->status); + $this->assertSame($merchant->id, $subscription->merchant_id); + $this->assertSame($plan->id, $subscription->plan_id); + $this->assertNotNull($subscription->starts_at); + $this->assertNotNull($subscription->ends_at); + $this->assertTrue($subscription->ends_at->greaterThan($subscription->starts_at)); + + $order->refresh(); + $this->assertSame($subscription->id, $order->site_subscription_id); + } + + public function test_activate_order_extends_existing_subscription(): void + { + $this->seed(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'renew_test_monthly', + 'name' => '续费测试(月付)', + 'billing_cycle' => 'monthly', + 'price' => 120, + 'list_price' => 120, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $subscription = SiteSubscription::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'status' => 'activated', + 'source' => 'manual', + 'subscription_no' => 'SUB_EXIST_0001', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'amount' => 120, + 'starts_at' => now()->subDays(10), + 'ends_at' => now()->addDays(20), + 'activated_at' => now()->subDays(10), + ]); + + $oldEndsAt = $subscription->ends_at->copy(); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'site_subscription_id' => $subscription->id, + 'order_no' => 'PO_RENEW_0001', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'list_amount' => 120, + 'discount_amount' => 0, + 'payable_amount' => 120, + 'paid_amount' => 120, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(5), + 'activated_at' => now()->subMinutes(1), + ]); + + $service = new SubscriptionActivationService(); + $updated = $service->activateOrder($order->id, 1); + + $updated->refresh(); + $this->assertTrue($updated->ends_at->greaterThan($oldEndsAt)); + $this->assertSame('activated', $updated->status); + + // 再次执行应为幂等:ends_at 不应继续增长 + $endsAtAfterFirst = $updated->ends_at->copy(); + $again = $service->activateOrder($order->id, 1); + $again->refresh(); + $this->assertTrue($again->ends_at->equalTo($endsAtAfterFirst)); + } + + public function test_activate_order_requires_paid_and_activated(): void + { + $this->seed(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'invalid_test', + 'name' => '无效测试', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_INVALID_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 0, + 'placed_at' => now(), + ]); + + $this->expectException(\InvalidArgumentException::class); + + $service = new SubscriptionActivationService(); + $service->activateOrder($order->id, 1); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..f35b4e7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});