diff --git a/.phpunit.result.cache b/.phpunit.result.cache index fa1572d..2212027 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":8,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":7,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":7,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":7,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":7,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":5,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":7,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":7},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.003,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.019,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.021,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.009,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.009,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.008,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.01,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.012,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.011,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.015,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.024,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.031,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.009,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.037,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.09,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.025,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.028,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.019,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.044,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.041,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.04,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.038,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.038,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.02,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.018,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.023,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.013,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.004,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.015,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.006,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.006,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.004,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.062,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.019,"Tests\\Feature\\ExportTest::test_export_pdf":1.413,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.028,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.024,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.003,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.008,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.011,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.008,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.007,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.007,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.008,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.013,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.013,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.009,"Tests\\Feature\\CartTest::test_update_quantity":0.01,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.011,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.012,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.007,"Tests\\Feature\\CartTest::test_delete_item":0.009,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.014,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.013,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_fixed_and_percent":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_edge_cases":0.002,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.01,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.007,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.01,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.013,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.019,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.021,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.011,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.011,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.01,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.011,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.01,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_uses_min_actual_price_across_types":0.016}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":7,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":8,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":8,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":7,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":7,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":5,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":7,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":7,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":8,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":8,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":7,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":8,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":8,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":8,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":8,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":8},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.003,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.012,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.013,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.014,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.015,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.014,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.014,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.021,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.02,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.036,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.038,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.016,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.042,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.08,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.023,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.024,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.017,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.057,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.061,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.048,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.053,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.061,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.021,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.029,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.032,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.056,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.005,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.005,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.001,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.008,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.005,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.067,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.024,"Tests\\Feature\\ExportTest::test_export_pdf":1.449,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.043,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.032,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.006,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.01,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.009,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.008,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.006,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.008,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.014,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.009,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.011,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.011,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.007,"Tests\\Feature\\CartTest::test_update_quantity":0.008,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.015,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.01,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.008,"Tests\\Feature\\CartTest::test_delete_item":0.013,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.014,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.021,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.003,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.016,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_fixed_and_percent":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_edge_cases":0.002,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.012,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.013,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.019,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.014,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.024,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.02,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.012,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.012,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.011,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.013,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.012,"Tests\\Feature\\PurchaseGenerateTest::test_generate_uses_min_actual_price_across_types":0.024,"Tests\\Feature\\ProductPriceTest::test_store_without_level_sees_products_without_price":0.013,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.017,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.016,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.004,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.019,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.005,"Tests\\Feature\\MiniAuthTest::test_register_rejects_duplicate_openid":0.005}} \ No newline at end of file diff --git a/app/Helpers/functions.php b/app/Helpers/functions.php new file mode 100644 index 0000000..1bb92d7 --- /dev/null +++ b/app/Helpers/functions.php @@ -0,0 +1,18 @@ +exists()); + + return $code; + } +} diff --git a/app/Http/Controllers/Customer/MiniUserController.php b/app/Http/Controllers/Customer/MiniUserController.php index 04c0bdf..d113ce3 100644 --- a/app/Http/Controllers/Customer/MiniUserController.php +++ b/app/Http/Controllers/Customer/MiniUserController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\Customer; use App\Exceptions\RepositoryException; use App\Http\Requests\Customer\MiniUserBindRequest; use App\Models\StoreModel; -use App\Models\SupplierModel; use App\Models\UserModel; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -21,9 +20,7 @@ use Modules\Common\Http\Controllers\BaseController; class MiniUserController extends BaseController { protected array $searchField = [ - 'type' => '=', 'store_id' => '=', - 'supplier_id' => '=', 'status' => '=', ]; @@ -35,7 +32,7 @@ class MiniUserController extends BaseController { $params = $request->all(); $pageSize = $params['pageSize'] ?? 10; - $data = $this->buildSearch($params, UserModel::query()->with('store:id,name', 'supplier:id,name')) + $data = $this->buildSearch($params, UserModel::query()->with('store:id,name')) ->orderBy('id', 'desc') ->paginate($pageSize) ->toArray(); @@ -43,7 +40,7 @@ class MiniUserController extends BaseController } /** - * 绑定门店/供应商(一个门店可绑多个账号,一个账号只绑一个主体) + * 绑定门店(一个门店可绑多个账号,一个账号只绑一个门店) */ #[PutRoute(route: '/{id}/bind', authorize: 'bind', where: ['id' => '[0-9]+'])] public function bind(int $id, MiniUserBindRequest $request): JsonResponse @@ -53,29 +50,16 @@ class MiniUserController extends BaseController if (empty($user)) { throw new RepositoryException('用户不存在'); } - - if ((int) $validated['type'] === UserModel::TYPE_STORE) { - $store = StoreModel::find((int) $validated['store_id']); - if (empty($store)) { - throw new RepositoryException('门店不存在'); - } - $user->type = UserModel::TYPE_STORE; - $user->store_id = $store->id; - $user->supplier_id = 0; - } else { - $supplier = SupplierModel::find((int) $validated['supplier_id']); - if (empty($supplier)) { - throw new RepositoryException('供应商不存在'); - } - $user->type = UserModel::TYPE_SUPPLIER; - $user->supplier_id = $supplier->id; - $user->store_id = 0; + $store = StoreModel::find((int) $validated['store_id']); + if (empty($store)) { + throw new RepositoryException('门店不存在'); } + $user->store_id = $store->id; $user->save(); return $this->success(); } - /** 启用/停用(停用后登录时检查 status 拒绝,token 鉴权拦截) */ + /** 启用/停用 */ #[PutRoute(route: '/{id}/status', authorize: 'update', where: ['id' => '[0-9]+'])] public function status(int $id, Request $request): JsonResponse { diff --git a/app/Http/Controllers/Customer/StoreController.php b/app/Http/Controllers/Customer/StoreController.php index c3ce446..bee77ee 100644 --- a/app/Http/Controllers/Customer/StoreController.php +++ b/app/Http/Controllers/Customer/StoreController.php @@ -46,7 +46,9 @@ class StoreController extends BaseController #[PostRoute(authorize: 'create')] public function create(StoreFormRequest $request): JsonResponse { - StoreModel::create($request->validated()); + $validated = $request->validated(); + $validated['code'] = generate_unique_code(StoreModel::class); + StoreModel::create($validated); return $this->success(); } diff --git a/app/Http/Controllers/Mini/AuthController.php b/app/Http/Controllers/Mini/AuthController.php index 36c5145..f90bd34 100644 --- a/app/Http/Controllers/Mini/AuthController.php +++ b/app/Http/Controllers/Mini/AuthController.php @@ -3,134 +3,125 @@ namespace App\Http\Controllers\Mini; use App\Exceptions\RepositoryException; +use App\Http\Requests\UserUpdateInfoRequest; use App\Models\StoreModel; -use App\Models\SupplierModel; use App\Models\UserModel; use App\Services\WechatService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\PostRoute; +use Modules\AnnoRoute\Attribute\PutRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; /** - * 小程序认证(微信登录 / 手机号绑定 / 当前用户信息) - * - * authGuard: users(provider 指向 UserModel,与后台 sys_users 天然隔离); - * authorize: true 仅要求登录(sanctum + authGuard:users),不做细粒度权限点; - * token abilities ['mini'] 作来源标记。 + * 小程序认证 */ #[RequestAttribute('/mini', 'mini', authGuard: 'users')] class AuthController extends BaseMiniController { - /** 小程序登录:wx.login 的 code → openid → 自动注册/登录 → 签发 token */ + /** 小程序登录 */ #[PostRoute('/auth/login', authorize: false)] public function login(Request $request): JsonResponse { - $data = $request->validate([ - 'code' => 'required|string', + $validated = $request->validate([ + 'code' => 'required|string' ], [ - 'code.required' => '缺少登录凭证 code', + 'code.required' => '登录参数格式错误', + 'code.string' => '登录参数格式错误', ]); - $session = app(WechatService::class)->code2Session($data['code']); + $session = app(WechatService::class)->code2Session($validated['code']); - $user = UserModel::firstOrNew(['openid' => $session['openid']]); - $isNew = ! $user->exists; + $user = UserModel::where('openid', $session['openid'])->first(); - if ($user->status === UserModel::STATUS_DISABLED) { - throw new RepositoryException('账号已被停用,请联系客服'); + if ($user === null || $user->status === UserModel::STATUS_DISABLED) { + return $this->error('账号不存在或已被停用'); } - if ($isNew) { - $user->type = UserModel::TYPE_PENDING; - $user->status = UserModel::STATUS_NORMAL; - } - if (! empty($session['unionid'])) { - $user->unionid = $session['unionid']; - } - $user->last_login_at = now(); + + $user->last_login_at = date('Y-m-d H:i:s'); $user->save(); - - $token = $user->createToken('mini', ['mini'])->plainTextToken; - + $token = $user->createToken($user->openid)->toArray(); return $this->success([ - 'token' => $token, - 'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])), - ], $isNew ? '注册成功' : '登录成功'); + 'token' => $token['plainTextToken'], + 'user' => $user->toArray(), + ], __('user.login_success')); + } - /** - * 绑定手机号:phoneCode 换手机号 → 按手机号自动匹配门店/供应商 - * (命中门店 → type=1+store_id;命中供应商 → type=2+supplier_id;都不命中 → 保持待绑定,后台人工处理) - */ - #[PostRoute('/auth/phone', authorize: true)] - public function phone(Request $request): JsonResponse + /** 小程序注册 */ + #[PostRoute('/auth/register', authorize: false)] + public function register(Request $request): JsonResponse { - $data = $request->validate([ + $validated = $request->validate([ + 'code' => 'required|string', 'phoneCode' => 'required|string', + 'storeCode' => 'required|string' ], [ - 'phoneCode.required' => '缺少手机号授权凭证 phoneCode', + 'code.required' => '注册参数格式错误', + 'code.string' => '注册参数格式错误', + 'phoneCode.required' => '注册参数格式错误', + 'phoneCode.string' => '注册参数格式错误', + 'storeCode.required' => '门店编码必须填写', + 'storeCode.string' => '注册参数格式错误', ]); - $phone = app(WechatService::class)->getPhone($data['phoneCode']); - - $user = $this->currentUser($request); - $user->phone = $phone; - - if (! $user->isBound()) { - $store = StoreModel::where('phone', $phone) - ->where('status', StoreModel::STATUS_NORMAL) - ->first(); - if ($store !== null) { - $user->type = UserModel::TYPE_STORE; - $user->store_id = $store->id; - $user->supplier_id = 0; - } else { - $supplier = SupplierModel::where('phone', $phone) - ->where('status', SupplierModel::STATUS_NORMAL) - ->first(); - if ($supplier !== null) { - $user->type = UserModel::TYPE_SUPPLIER; - $user->supplier_id = $supplier->id; - $user->store_id = 0; - } - } + $store = StoreModel::where('code', $validated['storeCode'])->first(); + if (!$store) { + return $this->error('门店不存在!'); } - $user->save(); + + // 通过 code 换取 openid、session_key、unionid + $session = app(WechatService::class)->code2Session($validated['code']); + + $user = UserModel::where('openid', $session['openid'])->first(); + + if ($user) { + return $this->error('你的微信已经注册,请直接登录!'); + } + + $userData = [ + 'openid' => $session['openid'], + 'unionid' => $session['unionid'] ?? '', + 'username' => 'wx_'.uniqid(), + 'nickname' => '微信用户', + 'store_id' => $store->id, + 'avatar' => '', + 'password' => '', + 'last_login_at' => date('Y-m-d H:i:s'), + ]; + + $phone = app(WechatService::class)->getPhone($validated['phoneCode']); + $userData['phone'] = $phone ?? ''; + + $user = UserModel::create($userData); + + $token = $user->createToken($user->username)->toArray(); return $this->success([ - 'user' => $this->formatUser($user->fresh(['store.level:id,name', 'supplier:id,name'])), - ]); + 'token' => $token['plainTextToken'], + 'user' => $user->toArray(), + ], __('user.login_success')); } - /** 当前用户信息(含门店客户等级 —— 全局价格体系依据 / 供应商信息) */ - #[GetRoute('/auth/info', authorize: true)] + /** 当前用户信息 */ + #[GetRoute('/auth/info')] public function info(Request $request): JsonResponse { - $user = UserModel::with(['store.level:id,name', 'supplier:id,name']) + $user = UserModel::with(['store.level:id,name']) ->find($request->user()->id); if ($user === null) { throw new RepositoryException('账号不存在'); } - return $this->success(['user' => $this->formatUser($user)]); + return $this->success($user->toArray()); } - /** - * 小程序端用户信息输出结构 - * - * @return array - */ - private function formatUser(UserModel $user): array + #[PutRoute('auth/info')] + public function setUserInfo(UserUpdateInfoRequest $request): JsonResponse { - return [ - 'id' => $user->id, - 'nickname' => $user->nickname, - 'avatar' => $user->avatar, - 'phone' => $user->phone, - 'type' => $user->type, - 'store' => $user->store, - 'supplier' => $user->supplier, - ]; + UserModel::where('user_id', auth('user')->id())->update($request->validated()); + + return $this->error('更新成功'); } } diff --git a/app/Http/Controllers/Mini/BaseMiniController.php b/app/Http/Controllers/Mini/BaseMiniController.php index 62d0fc9..5ae9ab3 100644 --- a/app/Http/Controllers/Mini/BaseMiniController.php +++ b/app/Http/Controllers/Mini/BaseMiniController.php @@ -4,7 +4,6 @@ namespace App\Http\Controllers\Mini; use App\Exceptions\RepositoryException; use App\Models\StoreModel; -use App\Models\SupplierModel; use App\Models\UserModel; use Illuminate\Http\Request; use Modules\Common\Http\Controllers\BaseController; @@ -13,7 +12,7 @@ use Modules\Common\Http\Controllers\BaseController; * 小程序端控制器基类 * * 无 #[RequestAttribute],不会被 AnnoRoute 注册为路由。 - * 提供当前用户获取与门店/供应商绑定前置校验。 + * 提供当前用户获取与门店绑定前置校验。 */ abstract class BaseMiniController extends BaseController { @@ -26,7 +25,6 @@ abstract class BaseMiniController extends BaseController if ($user === null || $user->status === UserModel::STATUS_DISABLED) { throw new RepositoryException('账号不存在或已被停用'); } - return $user; } @@ -35,7 +33,7 @@ abstract class BaseMiniController extends BaseController */ protected function ensureStoreBound(UserModel $user): StoreModel { - if ($user->type !== UserModel::TYPE_STORE || $user->store_id <= 0) { + if ($user->store_id <= 0) { throw new RepositoryException('尚未绑定门店,请联系客服处理'); } $store = StoreModel::find($user->store_id); @@ -47,18 +45,20 @@ abstract class BaseMiniController extends BaseController } /** - * 供应商端前置校验:type=供应商 且 supplier_id>0 且供应商正常 + * 用户当前绑定的正常门店(未绑定/已停用返回 null,不抛错) + * + * 供商品浏览等「弱前置」场景使用:未绑定门店仍可浏览商品,仅价格不可见。 */ - protected function ensureSupplierBound(UserModel $user): SupplierModel + protected function boundStore(UserModel $user): ?StoreModel { - if ($user->type !== UserModel::TYPE_SUPPLIER || $user->supplier_id <= 0) { - throw new RepositoryException('尚未绑定供应商,请联系客服处理'); + if ($user->store_id <= 0) { + return null; } - $supplier = SupplierModel::find($user->supplier_id); - if ($supplier === null || $supplier->status !== SupplierModel::STATUS_NORMAL) { - throw new RepositoryException('供应商不存在或已停用,请联系客服处理'); + $store = StoreModel::find($user->store_id); + if ($store === null || $store->status !== StoreModel::STATUS_NORMAL) { + return null; } - return $supplier; + return $store; } } diff --git a/app/Http/Controllers/Mini/CartController.php b/app/Http/Controllers/Mini/CartController.php index 976d346..0e80271 100644 --- a/app/Http/Controllers/Mini/CartController.php +++ b/app/Http/Controllers/Mini/CartController.php @@ -16,34 +16,34 @@ use Modules\AnnoRoute\Attribute\PostRoute; use Modules\AnnoRoute\Attribute\PutRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\SystemTool\Models\SysFileModel; +use Throwable; /** - * 小程序购物车(门店订货车:加购 / 列表 / 改数量 / 删项 / 清空) - * 提交订货单复用 POST /mini/order,购物车仅作前置编辑容器 + * 小程序购物车 */ #[RequestAttribute('/mini', 'mini', authGuard: 'users')] class CartController extends BaseMiniController { /** decimal(10,2) 上限 */ - private const MAX_QUANTITY = '99999999.99'; + private const string MAX_QUANTITY = '99999999.99'; /** - * 加购:商品上架 + 门店有等级价(与下单一致 fail-fast),同商品合并累加 + * 加购 + * @throws Throwable */ - #[PostRoute('/cart', authorize: true)] + #[PostRoute('/cart')] public function store(MiniCartRequest $request): JsonResponse { $user = $this->currentUser($request); $store = $this->ensureStoreBound($user); if ($store->level_id <= 0) { - throw new RepositoryException('门店未设置客户等级,无法加购,请联系客服'); + return $this->error('门店未设置客户等级,无法加购,请联系客服'); } $productId = (int) $request->validated('product_id'); - // Eloquent 查询自带 SoftDeletes 全局作用域:软删除/下架一并在内 $product = ProductModel::where('status', ProductModel::STATUS_ON)->find($productId); if ($product === null) { - throw new RepositoryException('商品不存在或已下架,请刷新后重试'); + return $this->error('商品不存在或已下架,请刷新后重试'); } // 存在性校验与计价类型无关(百分比行 price 可能为 0 也能加购) $hasPrice = ProductPriceModel::query() @@ -51,7 +51,7 @@ class CartController extends BaseMiniController ->where('level_id', $store->level_id) ->exists(); if (! $hasPrice) { - throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法加购'); + return $this->error('商品「' . $product->name . '」价格未设置,无法加购'); } $quantity = (string) $request->validated('quantity'); @@ -88,8 +88,7 @@ class CartController extends BaseMiniController } /** - * 购物车列表:当前用户全部项 + 实时等级价,逐项服务端 bcmul 算金额; - * status=1 可购 / 0 商品下架、缺失或未设等级价;汇总只统计可购项 + * 购物车列表 */ #[GetRoute('/cart', authorize: true)] public function index(Request $request): JsonResponse diff --git a/app/Http/Controllers/Mini/ProductController.php b/app/Http/Controllers/Mini/ProductController.php index 751c9c6..e949fe2 100644 --- a/app/Http/Controllers/Mini/ProductController.php +++ b/app/Http/Controllers/Mini/ProductController.php @@ -12,7 +12,10 @@ use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; /** - * 小程序商品(分类树 + 列表,价格 = 当前门店客户等级价) + * 小程序商品(分类树 + 列表) + * + * 商品浏览仅需登录:未绑定门店/门店未设客户等级的用户也可查看商品,仅价格不可见(price=null); + * 加购、下单仍由购物车/订单前置校验拦截,要求绑定门店并已设客户等级。 */ #[RequestAttribute('/mini', 'mini', authGuard: 'users')] class ProductController extends BaseMiniController @@ -53,22 +56,23 @@ class ProductController extends BaseMiniController } /** - * 商品列表:价格取当前门店客户等级价(未绑等级的门店报错); + * 商品列表:价格取当前门店客户等级价; + * 未绑定门店/门店未设客户等级 → 仍可浏览,price 为 null(不可见价格,加购/下单另由前置校验拦截); * ?category_id=&keyword=&page=&pageSize= */ #[GetRoute('/product/list', authorize: true)] public function products(Request $request): JsonResponse { $user = $this->currentUser($request); - $store = $this->ensureStoreBound($user); - if ($store->level_id <= 0) { - throw new RepositoryException('门店未设置客户等级,无法展示价格,请联系客服'); - } + $levelId = $this->boundStore($user)?->level_id ?? 0; $query = ProductModel::query() ->where('status', ProductModel::STATUS_ON) - ->with('category:id,name') - ->with(['prices' => static fn ($q) => $q->where('level_id', $store->level_id)]); + ->with('category:id,name'); + + if ($levelId > 0) { + $query->with(['prices' => static fn ($q) => $q->where('level_id', $levelId)]); + } $categoryId = (int) $request->input('category_id', 0); if ($categoryId > 0) { @@ -88,7 +92,8 @@ class ProductController extends BaseMiniController ->paginate($pageSize) ->toArray(); - // 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价;未设等级价为 null) + // 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价; + // 未绑定门店或未设等级价为 null——未加载 prices 时 ?? null 兜底) // unset prices 同时移除 price_type/percent,门店端无法反推成本;cost_price 已被 $hidden 过滤 foreach ($data['data'] as &$row) { $row['price'] = $row['prices'][0]['actual_price'] ?? null; diff --git a/app/Http/Controllers/Mini/SupplierController.php b/app/Http/Controllers/Mini/SupplierController.php deleted file mode 100644 index 4acb133..0000000 --- a/app/Http/Controllers/Mini/SupplierController.php +++ /dev/null @@ -1,104 +0,0 @@ -currentUser($request); - $supplier = $this->ensureSupplierBound($user); - - $purchaseIds = PurchaseOrderItemModel::query() - ->where('supplier_id', $supplier->id) - ->where('is_sent', PurchaseOrderItemModel::SENT) - ->distinct() - ->pluck('purchase_id'); - - $data = PurchaseOrderModel::query() - ->whereIn('id', $purchaseIds) - ->orderBy('purchase_date', 'desc') - ->orderBy('id', 'desc') - ->paginate((int) $request->input('pageSize', 10)) - ->toArray(); - - return $this->success($data); - } - - /** 采购单明细:仅本供应商且已发送的明细行 */ - #[GetRoute('/supplier/purchases/{id}', authorize: true, where: ['id' => '[0-9]+'])] - public function detail(int $id, Request $request): JsonResponse - { - $user = $this->currentUser($request); - $supplier = $this->ensureSupplierBound($user); - - $purchase = PurchaseOrderModel::find($id); - if ($purchase === null) { - throw new RepositoryException('采购单不存在'); - } - - $items = $purchase->items() - ->where('supplier_id', $supplier->id) - ->where('is_sent', PurchaseOrderItemModel::SENT) - ->get(); - if ($items->isEmpty()) { - throw new RepositoryException('该采购单无贵司的采购明细'); - } - - return $this->success([ - 'id' => $purchase->id, - 'purchase_no' => $purchase->purchase_no, - 'purchase_date' => $purchase->purchase_date?->toDateString(), - 'remark' => $purchase->remark, - 'items' => $items->toArray(), - ]); - } - - /** 确认接单:本供应商已发送明细批量记录 supplier_confirmed_at(幂等) */ - #[PutRoute('/supplier/purchases/{id}/confirm', authorize: true, where: ['id' => '[0-9]+'])] - public function confirm(int $id, Request $request): JsonResponse - { - $user = $this->currentUser($request); - $supplier = $this->ensureSupplierBound($user); - - $purchase = PurchaseOrderModel::find($id); - if ($purchase === null) { - throw new RepositoryException('采购单不存在'); - } - - $items = $purchase->items() - ->where('supplier_id', $supplier->id) - ->where('is_sent', PurchaseOrderItemModel::SENT) - ->get(); - if ($items->isEmpty()) { - throw new RepositoryException('该采购单无贵司的采购明细'); - } - - $now = now(); - $confirmed = 0; - foreach ($items as $item) { - if ($item->supplier_confirmed_at === null) { - $item->supplier_confirmed_at = $now; - $item->save(); - $confirmed++; - } - } - - return $this->success(['confirmed' => $confirmed], '已确认接单'); - } -} diff --git a/app/Http/Controllers/Order/StoreOrderController.php b/app/Http/Controllers/Order/StoreOrderController.php index 8d140b9..af98c41 100644 --- a/app/Http/Controllers/Order/StoreOrderController.php +++ b/app/Http/Controllers/Order/StoreOrderController.php @@ -146,7 +146,6 @@ class StoreOrderController extends BaseController private function notifyStore(StoreOrderModel $order): void { $userIds = UserModel::query() - ->where('type', UserModel::TYPE_STORE) ->where('store_id', $order->store_id) ->where('status', UserModel::STATUS_NORMAL) ->pluck('id'); diff --git a/app/Http/Controllers/Product/ProductController.php b/app/Http/Controllers/Product/ProductController.php index 39f05e1..b893ab4 100644 --- a/app/Http/Controllers/Product/ProductController.php +++ b/app/Http/Controllers/Product/ProductController.php @@ -257,7 +257,6 @@ class ProductController extends BaseController // 受影响门店:客户等级在受影响等级范围内的正常门店,通知其绑定的正常用户 $userIds = UserModel::query() - ->where('type', UserModel::TYPE_STORE) ->where('status', UserModel::STATUS_NORMAL) ->whereIn('store_id', function ($q) use ($percentLevelIds) { $q->select('id') diff --git a/app/Http/Requests/Customer/MiniUserBindRequest.php b/app/Http/Requests/Customer/MiniUserBindRequest.php index 0d35c89..663017c 100644 --- a/app/Http/Requests/Customer/MiniUserBindRequest.php +++ b/app/Http/Requests/Customer/MiniUserBindRequest.php @@ -5,7 +5,7 @@ namespace App\Http\Requests\Customer; use Modules\Common\Http\Requests\BaseFormRequest; /** - * 小程序用户绑定 验证(type=1 门店需 store_id,type=2 供应商需 supplier_id) + * 小程序用户绑定 验证(绑定门店) */ class MiniUserBindRequest extends BaseFormRequest { @@ -14,21 +14,15 @@ class MiniUserBindRequest extends BaseFormRequest public function rules(): array { return [ - 'type' => 'required|integer|in:1,2', - 'store_id' => 'required_if:type,1|nullable|integer|min:1', - 'supplier_id' => 'required_if:type,2|nullable|integer|min:1', + 'store_id' => 'required|integer|min:1', ]; } public function messages(): array { return [ - 'type.required' => '请选择用户类型', - 'type.in' => '用户类型只能是门店或供应商', - 'store_id.required_if' => '绑定门店时必须选择门店', + 'store_id.required' => '绑定门店时必须选择门店', 'store_id.min' => '门店ID不正确', - 'supplier_id.required_if' => '绑定供应商时必须选择供应商', - 'supplier_id.min' => '供应商ID不正确', ]; } } diff --git a/app/Http/Requests/Customer/StoreFormRequest.php b/app/Http/Requests/Customer/StoreFormRequest.php index ac4e2b6..c502eea 100644 --- a/app/Http/Requests/Customer/StoreFormRequest.php +++ b/app/Http/Requests/Customer/StoreFormRequest.php @@ -14,14 +14,8 @@ class StoreFormRequest extends BaseFormRequest public function rules(): array { - $unique = Rule::unique('store', 'code'); - if ($this->isUpdate()) { - $unique = $unique->ignore($this->route('id')); - } - return [ 'name' => 'required|string|max:100', - 'code' => ['required', 'string', 'max:50', $unique], 'level_id' => 'required|integer|exists:customer_level,id', 'contact' => 'nullable|string|max:50', 'phone' => 'nullable|string|max:20', @@ -37,8 +31,6 @@ class StoreFormRequest extends BaseFormRequest return [ 'name.required' => '门店名称不能为空', 'name.max' => '门店名称最长 100 个字符', - 'code.required' => '门店编码不能为空', - 'code.unique' => '门店编码已存在', 'level_id.required' => '请选择客户等级', 'level_id.exists' => '客户等级不存在', 'payment_cycle_days.integer' => '回款周期必须为整数', diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index c3386a1..36d43fd 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -15,17 +15,10 @@ class UserModel extends Authenticatable { use HasApiTokens, HasFactory, Notifiable; - /** 用户类型:待绑定(手机号未匹配到门店/供应商,需后台人工绑定) */ - public const TYPE_PENDING = 0; - /** 用户类型:门店 */ - public const TYPE_STORE = 1; - /** 用户类型:供应商 */ - public const TYPE_SUPPLIER = 2; - /** 状态:停用 */ - public const STATUS_DISABLED = 0; + public const int STATUS_DISABLED = 0; /** 状态:正常 */ - public const STATUS_NORMAL = 1; + public const int STATUS_NORMAL = 1; protected $table = 'user'; @@ -45,38 +38,26 @@ class UserModel extends Authenticatable 'unionid', 'phone', 'avatar', - 'type', 'store_id', - 'supplier_id', 'status', 'last_login_at', ]; protected $casts = [ - 'email_verified_at' => 'datetime', - 'last_login_at' => 'datetime', - 'type' => 'integer', + 'email_verified_at' => 'datetime:Y-m-d H:i:s', + 'last_login_at' => 'datetime:Y-m-d H:i:s', + 'created_at' => 'datetime:Y-m-d H:i:s', 'store_id' => 'integer', - 'supplier_id' => 'integer', 'status' => 'integer', ]; /** - * 关联门店(type=1 时有效) + * 关联门店 */ public function store(): BelongsTo { return $this->belongsTo(StoreModel::class, 'store_id', 'id'); } - - /** - * 关联供应商(type=2 时有效) - */ - public function supplier(): BelongsTo - { - return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id'); - } - /** * 用户通知 */ @@ -84,13 +65,4 @@ class UserModel extends Authenticatable { return $this->hasMany(NoticeModel::class, 'user_id', 'id'); } - - /** - * 是否已绑定业务主体(门店或供应商) - */ - public function isBound(): bool - { - return $this->type === self::TYPE_STORE && $this->store_id > 0 - || $this->type === self::TYPE_SUPPLIER && $this->supplier_id > 0; - } } diff --git a/composer.json b/composer.json index 6d1c5cd..6c502c2 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "Database\\Seeders\\": "database/seeders/" }, "files": [ - "modules/Common/helpers.php" + "modules/Common/helpers.php", + "app/Helpers/functions.php" ] }, "autoload-dev": { diff --git a/database/factories/UserModelFactory.php b/database/factories/UserModelFactory.php index b3d5115..5f5d2d8 100644 --- a/database/factories/UserModelFactory.php +++ b/database/factories/UserModelFactory.php @@ -29,9 +29,7 @@ class UserModelFactory extends Factory 'unionid' => '', 'phone' => '', 'avatar' => '', - 'type' => UserModel::TYPE_PENDING, 'store_id' => 0, - 'supplier_id' => 0, 'status' => UserModel::STATUS_NORMAL, 'last_login_at' => null, ]; @@ -43,22 +41,10 @@ class UserModelFactory extends Factory public function forStore(int $storeId): static { return $this->state(fn () => [ - 'type' => UserModel::TYPE_STORE, 'store_id' => $storeId, ]); } - /** - * 已绑定供应商的供应商用户 - */ - public function forSupplier(int $supplierId): static - { - return $this->state(fn () => [ - 'type' => UserModel::TYPE_SUPPLIER, - 'supplier_id' => $supplierId, - ]); - } - /** * 停用账号 */ diff --git a/database/migrations/2025_01_01_000008_create_user_table.php b/database/migrations/2025_01_01_000008_create_user_table.php deleted file mode 100644 index dc80942..0000000 --- a/database/migrations/2025_01_01_000008_create_user_table.php +++ /dev/null @@ -1,47 +0,0 @@ -increments('id')->comment('用户ID'); - $table->string('username', 20)->nullable()->unique()->comment('用户名(微信注册用户可为空)'); - $table->string('password', 100)->nullable()->comment('密码(微信注册用户可为空)'); - $table->string('nickname', 20)->default('')->comment('昵称'); - $table->string('email', 50)->default('')->comment('邮箱'); - $table->timestamp('email_verified_at')->nullable(); - // 小程序用户扩展字段(微信授权登录,自动识别门店/供应商身份) - $table->string('openid', 64)->nullable()->unique()->comment('微信OpenID(小程序用户唯一标识)'); - $table->string('unionid', 64)->default('')->comment('微信UnionID'); - $table->string('phone', 20)->default('')->comment('手机号(微信授权获取,用于匹配门店/供应商)'); - $table->string('avatar', 255)->default('')->comment('头像'); - $table->integer('type')->default(0)->comment('用户类型(0待绑定 1门店 2供应商)'); - $table->integer('store_id')->default(0)->comment('关联门店ID(type=1时有效)'); - $table->integer('supplier_id')->default(0)->comment('关联供应商ID(type=2时有效)'); - $table->integer('status')->default(1)->comment('状态(1正常 0停用)'); - $table->timestamp('last_login_at')->nullable()->comment('最后登录时间'); - $table->rememberToken(); - $table->timestamps(); - $table->index(['type', 'store_id'], 'user_type_store_index'); - $table->comment('APP用户表(含小程序用户)'); - }); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('user'); - } -}; diff --git a/database/migrations/2026_07_23_030608_create_store_table.php b/database/migrations/2026_07_23_030608_create_store_table.php index 042d140..5ecbd53 100644 --- a/database/migrations/2026_07_23_030608_create_store_table.php +++ b/database/migrations/2026_07_23_030608_create_store_table.php @@ -8,10 +8,34 @@ return new class extends Migration { /** * Run the migrations. - * 基础档案:客户等级、门店、供应商、消息通知(小程序用户已并入 user 表) + * 基础档案 */ public function up(): void { + if (! Schema::hasTable('user')) { + Schema::create('user', function (Blueprint $table) { + $table->increments('id')->comment('用户ID'); + $table->string('username', 20)->nullable()->unique()->comment('用户名'); + $table->string('password', 100)->nullable()->comment('密码'); + $table->string('nickname', 20)->default('')->comment('昵称'); + $table->string('email', 50)->default('')->comment('邮箱'); + $table->timestamp('email_verified_at')->nullable(); + // 小程序用户扩展字段 + $table->string('openid', 64)->nullable()->unique()->comment('微信OpenID'); + $table->string('unionid', 64)->default('')->comment('微信UnionID'); + $table->string('phone', 20)->default('')->comment('手机号'); + $table->string('avatar', 255)->default('')->comment('头像'); + $table->integer('store_id')->default(0)->comment('关联门店ID'); + $table->integer('status')->default(1)->comment('状态(1正常 0停用)'); + $table->timestamp('last_login_at')->nullable()->comment('最后登录时间'); + $table->rememberToken(); + $table->timestamps(); + $table->index('store_id', 'user_type_index'); + $table->index('openid', 'user_openid_index'); + $table->comment('APP用户表(含小程序用户)'); + }); + } + // 客户等级表(价格体系按等级定价) if (! Schema::hasTable('customer_level')) { Schema::create('customer_level', function (Blueprint $table) { @@ -35,7 +59,7 @@ return new class extends Migration $table->string('contact', 50)->default('')->comment('联系人'); $table->string('phone', 20)->default('')->comment('联系电话'); $table->string('address', 255)->default('')->comment('门店地址'); - $table->integer('payment_cycle_days')->default(0)->comment('回款周期(天),门店可自行修改,影响对账单应结算日期'); + $table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)'); $table->integer('status')->default(1)->comment('状态(1正常 0停用)'); $table->string('remark', 255)->default('')->comment('备注'); $table->timestamps(); @@ -62,8 +86,7 @@ return new class extends Migration }); } - // 消息通知表(订单状态变更、价格调整等通知) - // 小程序用户已并入 user 表,不再单独建 mini_user 表 + // 消息通知表 if (! Schema::hasTable('notice')) { Schema::create('notice', function (Blueprint $table) { $table->increments('id')->comment('通知ID'); @@ -86,6 +109,7 @@ return new class extends Migration */ public function down(): void { + Schema::dropIfExists('user'); Schema::dropIfExists('customer_level'); Schema::dropIfExists('store'); Schema::dropIfExists('supplier'); diff --git a/tests/Feature/MiniAuthTest.php b/tests/Feature/MiniAuthTest.php index 9a54d86..ed5c5f5 100644 --- a/tests/Feature/MiniAuthTest.php +++ b/tests/Feature/MiniAuthTest.php @@ -12,7 +12,7 @@ use Symfony\Component\HttpClient\Response\MockResponse; /** * 小程序认证:EasyWeChat MockHttpClient 拦截微信调用、 - * 登录自动注册、手机号绑定自动匹配、停用拒绝、双端 token 隔离 + * 注册绑定门店、登录签发 token、停用拒绝、双端 token 隔离 */ class MiniAuthTest extends ProcurementTestCase { @@ -56,9 +56,11 @@ class MiniAuthTest extends ProcurementTestCase ); } - /** wx.login code → 自动注册用户并签发 token */ - public function test_login_creates_user_and_issues_token(): void + /** wx.login code → 已注册用户签发 token */ + public function test_login_issues_token_for_registered_user(): void { + $store = StoreModel::factory()->create(); + $user = UserModel::factory()->forStore($store->id)->create(['openid' => 'openid_test_001']); $this->mockWechat(fn () => new MockResponse((string) json_encode([ 'openid' => 'openid_test_001', 'session_key' => 'session_key_x', @@ -67,14 +69,101 @@ class MiniAuthTest extends ProcurementTestCase $response = $this->postJson('/mini/auth/login', ['code' => 'wx_code']); $response->assertOk() ->assertJsonPath('success', true) - ->assertJsonStructure(['data' => ['token', 'user' => ['id', 'type', 'store', 'supplier']]]); + ->assertJsonStructure(['data' => ['token', 'user' => ['id']]]); $this->assertNotEmpty($response->json('data.token')); + $this->assertNotNull($user->fresh()->last_login_at); + } - $user = UserModel::where('openid', 'openid_test_001')->first(); - $this->assertNotNull($user, '应按 openid 自动创建用户'); - $this->assertSame(UserModel::TYPE_PENDING, $user->type); - $this->assertNotNull($user->last_login_at); + /** openid 未注册 → 拒绝登录 */ + public function test_login_rejects_unregistered_openid(): void + { + $this->mockWechat(fn () => new MockResponse((string) json_encode([ + 'openid' => 'openid_unknown', + 'session_key' => 'sk', + ]))); + + $this->postJson('/mini/auth/login', ['code' => 'wx_code']) + ->assertOk() + ->assertJsonPath('success', false); + $this->assertSame(0, UserModel::count()); + } + + /** 注册:门店编码绑定门店并签发 token */ + public function test_register_binds_store_and_issues_token(): void + { + $store = StoreModel::factory()->create(['code' => 'ST001', 'phone' => '13800138000']); + $this->mockWechat(function (string $method, string $url): MockResponse { + if (str_contains($url, 'jscode2session')) { + return new MockResponse((string) json_encode([ + 'openid' => 'openid_reg_001', + 'session_key' => 'sk', + ])); + } + if (str_contains($url, 'cgi-bin/token')) { + return new MockResponse((string) json_encode([ + 'access_token' => 'mock_access_token', + 'expires_in' => 7200, + ])); + } + + return new MockResponse((string) json_encode([ + 'errcode' => 0, + 'phone_info' => ['phoneNumber' => '13800138000'], + ])); + }); + + $response = $this->postJson('/mini/auth/register', [ + 'code' => 'wx_code', + 'phoneCode' => 'phone_code', + 'storeCode' => 'ST001', + ]); + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonStructure(['data' => ['token', 'user' => ['id', 'store_id']]]); + $this->assertNotEmpty($response->json('data.token')); + + $user = UserModel::where('openid', 'openid_reg_001')->first(); + $this->assertNotNull($user, '应按 openid 注册用户'); + $this->assertSame($store->id, $user->store_id); + $this->assertSame('13800138000', $user->phone); + } + + /** 注册:门店编码不存在 → 拒绝 */ + public function test_register_rejects_unknown_store(): void + { + $this->mockWechat(fn () => new MockResponse((string) json_encode([ + 'openid' => 'openid_reg_002', + 'session_key' => 'sk', + ]))); + + $this->postJson('/mini/auth/register', [ + 'code' => 'wx_code', + 'phoneCode' => 'phone_code', + 'storeCode' => 'NOT_EXIST', + ]) + ->assertOk() + ->assertJsonPath('success', false); + $this->assertSame(0, UserModel::count()); + } + + /** 注册:openid 已注册 → 拒绝重复注册 */ + public function test_register_rejects_duplicate_openid(): void + { + UserModel::factory()->create(['openid' => 'openid_dup']); + $this->mockWechat(fn () => new MockResponse((string) json_encode([ + 'openid' => 'openid_dup', + 'session_key' => 'sk', + ]))); + + $this->postJson('/mini/auth/register', [ + 'code' => 'wx_code', + 'phoneCode' => 'phone_code', + 'storeCode' => 'ST001', + ]) + ->assertOk() + ->assertJsonPath('success', false); + $this->assertSame(1, UserModel::count()); } /** 停用账号拒绝登录 */ @@ -105,62 +194,6 @@ class MiniAuthTest extends ProcurementTestCase $this->assertSame(0, UserModel::count()); } - /** 手机号绑定:按手机号自动匹配门店 */ - public function test_phone_binding_matches_store(): void - { - $store = StoreModel::factory()->create(['phone' => '13800138000']); - $user = UserModel::factory()->create(); - $this->actingAsMiniUser($user); - - $this->mockWechat(function (string $method, string $url): MockResponse { - if (str_contains($url, '/cgi-bin/token')) { - return new MockResponse((string) json_encode([ - 'access_token' => 'mock_access_token', - 'expires_in' => 7200, - ])); - } - - return new MockResponse((string) json_encode([ - 'errcode' => 0, - 'phone_info' => ['phoneNumber' => '13800138000'], - ])); - }); - - $this->postJson('/mini/auth/phone', ['phoneCode' => 'phone_code']) - ->assertOk() - ->assertJsonPath('success', true); - - $user = $user->fresh(); - $this->assertSame('13800138000', $user->phone); - $this->assertSame(UserModel::TYPE_STORE, $user->type); - $this->assertSame($store->id, $user->store_id); - } - - /** 手机号无匹配主体 → 保持待绑定 */ - public function test_phone_no_match_stays_pending(): void - { - $user = UserModel::factory()->create(); - $this->actingAsMiniUser($user); - - $this->mockWechat(function (string $method, string $url): MockResponse { - if (str_contains($url, '/cgi-bin/token')) { - return new MockResponse((string) json_encode([ - 'access_token' => 'mock_access_token', - 'expires_in' => 7200, - ])); - } - - return new MockResponse((string) json_encode([ - 'errcode' => 0, - 'phone_info' => ['phoneNumber' => '19999999999'], - ])); - }); - - $this->postJson('/mini/auth/phone', ['phoneCode' => 'phone_code']) - ->assertJsonPath('success', true); - $this->assertSame(UserModel::TYPE_PENDING, $user->fresh()->type); - } - /** 跨端隔离:后台 token 访问小程序接口 → 401 */ public function test_sys_token_cannot_access_mini(): void { diff --git a/tests/Feature/ProductPriceTest.php b/tests/Feature/ProductPriceTest.php index 67477cb..36d6159 100644 --- a/tests/Feature/ProductPriceTest.php +++ b/tests/Feature/ProductPriceTest.php @@ -34,15 +34,41 @@ class ProductPriceTest extends ProcurementTestCase $this->assertSame(5.50, (float) $row['price'], '应返回门店所在等级的价格'); } - /** 门店未设置客户等级时拒绝展示价格 */ - public function test_store_without_level_is_rejected(): void + /** 门店未设置客户等级:仍可浏览商品,但价格不可见(price=null) */ + public function test_store_without_level_sees_products_without_price(): void { $store = StoreModel::factory()->create(['level_id' => 0]); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); - $this->getJson('/mini/product/list') + $response = $this->getJson('/mini/product/list'); + $response->assertOk()->assertJsonPath('success', true); + + $row = collect($response->json('data.data'))->firstWhere('id', $product->id); + $this->assertNotNull($row, '未设等级的门店仍应能看到商品'); + $this->assertNull($row['price'], '未设等级时价格应为 null'); + } + + /** 未绑定门店:可浏览商品与分类,但价格不可见;加购仍被拦截(购买前置校验不变) */ + public function test_unbound_user_can_browse_products_without_price_but_cannot_cart(): void + { + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); + $this->actingAsMiniUser(UserModel::factory()->create()); // type=0 待绑定 + + $list = $this->getJson('/mini/product/list'); + $list->assertOk()->assertJsonPath('success', true); + + $row = collect($list->json('data.data'))->firstWhere('id', $product->id); + $this->assertNotNull($row, '未绑定门店应能看到商品'); + $this->assertNull($row['price'], '未绑定门店不得看到价格'); + + $this->getJson('/mini/product/categories') ->assertOk() - ->assertJsonPath('success', false); + ->assertJsonPath('success', true); + + $this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1]) + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '尚未绑定门店,请联系客服处理'); } /** 批量调价:事务写入 + 通知受影响门店用户(不影响无关门店) */ diff --git a/web/api/customer/miniUser.ts b/web/api/customer/miniUser.ts index 62dc912..10464dc 100644 --- a/web/api/customer/miniUser.ts +++ b/web/api/customer/miniUser.ts @@ -1,13 +1,10 @@ import createAxios from '@/utils/request'; export interface MiniUserBindParams { - /** 1门店 2供应商 */ - type: number; store_id?: number; - supplier_id?: number; } -/** 绑定门店/供应商 */ +/** 绑定门店 */ export async function bindMiniUser(id: number, data: MiniUserBindParams) { return createAxios({ url: `/customer/miniUser/${id}/bind`, diff --git a/web/domain/iMiniUser.ts b/web/domain/iMiniUser.ts index 2f70d0f..6c2d40c 100644 --- a/web/domain/iMiniUser.ts +++ b/web/domain/iMiniUser.ts @@ -1,5 +1,4 @@ import type IStore from '@/domain/iStore.ts'; -import type ISupplier from '@/domain/iSupplier.ts'; /** 小程序用户 */ export default interface IMiniUser { @@ -7,23 +6,13 @@ export default interface IMiniUser { nickname?: string; phone?: string; avatar?: string; - /** 0待绑定 1门店 2供应商 */ - type?: number; store_id?: number; - supplier_id?: number; store?: IStore; - supplier?: ISupplier; status?: number; last_login_at?: string; created_at?: string; } -export const MINI_USER_TYPE_MAP: Record = { - 0: { text: '待绑定', color: 'default' }, - 1: { text: '门店', color: 'blue' }, - 2: { text: '供应商', color: 'purple' }, -}; - export const MINI_USER_STATUS_MAP: Record = { 0: { text: '停用', color: 'error' }, 1: { text: '正常', color: 'success' }, diff --git a/web/pages/customer/mini-user.tsx b/web/pages/customer/mini-user.tsx index 40aea51..f54584f 100644 --- a/web/pages/customer/mini-user.tsx +++ b/web/pages/customer/mini-user.tsx @@ -5,7 +5,6 @@ import { message, Modal, Popconfirm, - Radio, Select, Space, Tag, @@ -18,20 +17,16 @@ import type { XinTableProps, } from '@/components/XinTable/typings.ts'; import type IMiniUser from '@/domain/iMiniUser.ts'; -import { MINI_USER_STATUS_MAP, MINI_USER_TYPE_MAP } from '@/domain/iMiniUser.ts'; +import { MINI_USER_STATUS_MAP } from '@/domain/iMiniUser.ts'; import type IStore from '@/domain/iStore.ts'; -import type ISupplier from '@/domain/iSupplier.ts'; import { getStoreOptions } from '@/api/customer/store.ts'; -import { getSupplierOptions } from '@/api/customer/supplier.ts'; import { bindMiniUser, toggleMiniUserStatus } from '@/api/customer/miniUser.ts'; import AuthButton from '@/components/AuthButton'; const { Title, Text } = Typography; interface BindFormValues { - type: number; - store_id?: number; - supplier_id?: number; + store_id: number; } /** @@ -40,7 +35,6 @@ interface BindFormValues { const MiniUserPage: React.FC = () => { const tableRef = useRef>(null); const [stores, setStores] = useState([]); - const [suppliers, setSuppliers] = useState([]); // 绑定弹窗 const [bindOpen, setBindOpen] = useState(false); @@ -50,15 +44,12 @@ const MiniUserPage: React.FC = () => { useEffect(() => { getStoreOptions().then((res) => setStores(res.data.data ?? [])); - getSupplierOptions().then((res) => setSuppliers(res.data.data ?? [])); }, []); const openBind = (record: IMiniUser) => { setBindTarget(record); bindForm.setFieldsValue({ - type: record.type && record.type > 0 ? record.type : undefined, store_id: record.store_id || undefined, - supplier_id: record.supplier_id || undefined, }); setBindOpen(true); }; @@ -70,9 +61,7 @@ const MiniUserPage: React.FC = () => { setBindLoading(true); try { await bindMiniUser(bindTarget.id, { - type: values.type, - store_id: values.type === 1 ? values.store_id : undefined, - supplier_id: values.type === 2 ? values.supplier_id : undefined, + store_id: values.store_id, }); message.success('绑定成功'); setBindOpen(false); @@ -118,36 +107,11 @@ const MiniUserPage: React.FC = () => { render: (_, record) => record.phone || 未绑定, }, { - title: '用户类型', - dataIndex: 'type', - valueType: 'select', - fieldProps: { - options: [ - { value: 0, label: '待绑定' }, - { value: 1, label: '门店' }, - { value: 2, label: '供应商' }, - ], - }, - render: (_, record) => { - const item = MINI_USER_TYPE_MAP[record.type ?? 0]; - return {item?.text}; - }, - align: 'center', - }, - { - title: '绑定主体', - dataIndex: 'bound_name', + title: '绑定门店', + dataIndex: 'store_id', hideInSearch: true, render: (_, record) => { - if (record.type === 1) { - return {record.store?.name ?? `门店#${record.store_id}`}; - } - if (record.type === 2) { - return ( - {record.supplier?.name ?? `供应商#${record.supplier_id}`} - ); - } - return 待后台绑定; + return {record.store?.name ?? `门店#${record.store_id}`}; }, }, { @@ -173,6 +137,12 @@ const MiniUserPage: React.FC = () => { align: 'center', render: (_, record) => record.last_login_at ?? 从未登录, }, + { + title: '注册时间', + dataIndex: 'created_at', + hideInSearch: true, + align: 'center', + }, ]; const operateRender: XinTableProps['operateRender'] = (record) => [ @@ -210,7 +180,7 @@ const MiniUserPage: React.FC = () => {
小程序用户 - 用户由微信小程序登录自动创建;手机号授权后按手机号自动匹配门店/供应商,未命中的需在此人工绑定。 + 用户由微信小程序登录自动创建;
{...tableProps} /> @@ -231,60 +201,21 @@ const MiniUserPage: React.FC = () => { className="mt-4" > - ({ + label: `${s.name}(${s.code})`, + value: s.id, + }))} /> - prev.type !== cur.type}> - {({ getFieldValue }) => { - const type = getFieldValue('type'); - if (type === 1) { - return ( - - ({ label: s.name, value: s.id }))} - /> - - ); - } - return null; - }} - diff --git a/web/pages/customer/store.tsx b/web/pages/customer/store.tsx index 12c7563..ad6861a 100644 --- a/web/pages/customer/store.tsx +++ b/web/pages/customer/store.tsx @@ -41,8 +41,7 @@ const StorePage: React.FC = () => { title: '门店编码', dataIndex: 'code', valueType: 'text', - required: true, - rules: [{ required: true, message: '请输入门店编码' }], + hideInForm: true }, { title: '客户等级', @@ -70,6 +69,14 @@ const StorePage: React.FC = () => { valueType: 'text', hideInSearch: true, }, + { + title: '门店地址', + dataIndex: 'address', + valueType: 'textarea', + hideInSearch: true, + fieldProps: { rows: 1 }, + colProps: { span: 24 } + }, { title: '回款周期(天)', dataIndex: 'payment_cycle_days', @@ -95,14 +102,6 @@ const StorePage: React.FC = () => { return {item?.text}; }, }, - { - title: '门店地址', - dataIndex: 'address', - valueType: 'textarea', - hideInSearch: true, - hideInTable: true, - fieldProps: { rows: 2 }, - }, { title: '备注', dataIndex: 'remark', @@ -110,6 +109,7 @@ const StorePage: React.FC = () => { hideInSearch: true, hideInTable: true, fieldProps: { rows: 2 }, + colProps: { span: 24 } }, ]; @@ -121,6 +121,7 @@ const StorePage: React.FC = () => { formProps: { grid: true, colProps: { span: 12 }, + rowProps: { gutter: 20 }, layout: 'vertical', }, modalProps: { width: 720 }, diff --git a/小程序端API文档.md b/小程序端API文档.md new file mode 100644 index 0000000..9ad57d0 --- /dev/null +++ b/小程序端API文档.md @@ -0,0 +1,613 @@ +# 订货采购系统 · 小程序端 API 文档 + +> 版本:V1.1 更新日期:2026-08-07 +> 适用:微信小程序门店端 / 供应商端;接口由后端 `app/Http/Controllers/Mini/` 提供(Laravel 12 + Sanctum)。 + +## 1. 通用说明 + +### 1.1 基础信息 + +| 项目 | 说明 | +|------|------| +| Base URL | `http://localhost:8000`(生产域名待定,通常为 HTTPS) | +| 数据格式 | JSON(请求/响应均 `Content-Type: application/json`) | +| 金额字段 | 后端统一 `decimal` 字符串返回(如 `"13.00"`),下单/购物车金额**一律服务端重算**,前端传的金额字段会被忽略 | + +### 1.2 认证方式 + +除「登录」接口外,全部接口需携带 `Authorization: Bearer `(登录接口返回的 token,Sanctum plainTextToken)。 + +```http +Authorization: Bearer 1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +token 附带 `abilities: ["mini"]` 仅作来源标记;后端按 `users` guard 解析用户。 + +### 1.3 统一响应格式 + +成功: + +```json +{ "success": true, "data": { ... } } +``` + +成功带提示: + +```json +{ "success": true, "data": { ... }, "msg": "下单成功" } +``` + +失败(业务错误/验证错误均返回 HTTP 200,`success=false`): + +```json +{ "success": false, "msg": "尚未绑定门店,请联系客服处理", "showType": 1 } +``` + +分页数据统一结构(`data` 字段内): + +```json +{ + "success": true, + "data": { + "data": [ ... ], + "total": 35, + "pageSize": 10, + "current": 1 + } +} +``` + +### 1.4 角色前置校验 + +| 接口域 | 前置要求 | 未满足时提示 | +|--------|----------|--------------| +| 商品浏览(分类树/列表) | 仅需登录 | 无(未绑定门店也可浏览,`price` 为 null) | +| 购物车/订单/对账单/门店设置 | 用户 `type=门店(1)` 且已绑定正常门店 | 「尚未绑定门店,请联系客服处理」 | +| 供应商采购单 | 用户 `type=供应商(2)` 且已绑定正常供应商 | 「尚未绑定供应商,请联系客服处理」 | +| 商品价格展示 | 门店已设置客户等级(`store.level_id > 0`) | 未满足时 `price` 为 null,不影响浏览商品 | +| 加购/下单计价 | 门店已设置客户等级 | 「门店未设置客户等级,无法展示价格,请联系客服」 | + +> 登录后未绑定身份的用户 `type=0`(待绑定):**可浏览商品分类与列表(价格不可见)**,但加购、下单等购买行为仍要求绑定门店;注册流程按门店编码(storeCode)直接绑定门店,或由后台人工绑定。 + +### 1.5 价格体系 + +- 商品价格按「门店客户等级」展示,同一商品不同等级价格不同 +- 等级价格支持两种计价类型: + - **固定价**(`price_type=0`):直接存储实际单价 + - **成本百分比**(`price_type=1`):实际价 = 成本价 × (100 + 上浮百分点) / 100 +- 小程序端接口返回的 `price` 均为**换算后的实际价**;成本价为商业敏感数据,**不会**下发到小程序端 + +--- + +## 2. 认证 + +> 登录/注册均以 `wx.login()` 的 code 换取 openid 鉴权;注册流程同步绑定门店(storeCode)与手机号(phoneCode)。 + +### 2.1 微信登录 + +`POST /mini/auth/login` + +用 `wx.login()` 获取的 code 换 openid。**仅已注册用户可登录**,未注册用户须先走 2.2 注册。 + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| code | string | 是 | `wx.login` 的临时凭证 | + +响应(`data`): + +| 字段 | 类型 | 说明 | +|------|------|------| +| token | string | Bearer 令牌(后续请求头携带) | +| user | object | 用户信息(字段见下表) | + +`user` 字段(user 表实际返回字段): + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 用户ID | +| username | string | 用户名(注册时生成 `wx_xxxx`) | +| nickname | string | 昵称(注册默认「微信用户」) | +| avatar | string | 头像 | +| phone | string | 手机号(未绑定为空) | +| store_id | int | 绑定门店ID(0 未绑定) | +| status | int | 1 正常 / 0 停用 | +| openid / unionid / email | string | 微信标识 / 邮箱 | +| last_login_at / created_at / updated_at | string\|null | 时间 | + +响应示例: + +```json +{ + "success": true, + "data": { + "token": "1|abc...", + "user": { + "id": 5, "username": "wx_66f1a2b3c4d5", "nickname": "微信用户", "avatar": "", + "phone": "", "store_id": 2, "status": 1, + "openid": "oXXXX", "unionid": "", "email": "", + "last_login_at": null, "created_at": "...", "updated_at": "..." + } + }, + "msg": "登录成功" +} +``` + +错误: +- `code` 缺失 → 「登录参数格式错误」 +- openid 未注册 → 「用户不存在!」 +- code 无效 / 微信未配置 → 「微信登录失败:...」 + +> 注意:登录接口**不校验用户状态**,被停用用户仍可登录(其余接口由 `currentUser()` 前置校验拦截)。 + +### 2.2 微信注册 + +`POST /mini/auth/register` + +新用户注册流程:code 换 openid → 校验未注册 → 按 storeCode 查门店 → phoneCode 换手机号 → 创建用户(绑定该门店)并返回 token。 + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| code | string | 是 | `wx.login` 的临时凭证 | +| phoneCode | string | 是 | `wx.getPhoneNumber` 授权得到的 code | +| storeCode | string | 是 | 门店编码(后台门店管理维护) | + +响应(`data`):`token` + `user`,结构同 2.1,`msg` 为「登录成功」。 + +错误: +- `code`/`phoneCode` 缺失 → 「注册参数格式错误」;`storeCode` 缺失 → 「门店编码必须填写」 +- 该微信已注册 → 「你的微信已经注册,请直接登录!」 +- 门店编码不存在 → 「门店不存在!」 +- 手机号换取失败 → 「获取手机号失败:...」(会中断注册流程) + +> ⚠️ 现状说明:换取到的手机号写入 `mobile` 字段,但 user 表列为 `phone` 且不在模型 `$fillable` 白名单,**实际不会保存**(注册返回的 `user.phone` 为空)。注册即绑定门店(`store_id`),不存在「待绑定」用户状态。 + +### 2.3 当前用户信息 + +`GET /mini/auth/info`(需登录) + +响应(`data`):`user` 对象(字段同 2.1)+ 关联门店(**仅本接口**加载 `store`,登录/注册响应不含): + +| 字段 | 类型 | 说明 | +|------|------|------| +| user.store | object\|null | 绑定门店(未绑定为 null) | + +`user.store` 字段(store 表): + +| 字段 | 类型 | 说明 | +|------|------|------| +| id / name / code | - | 门店ID / 名称 / 编码 | +| level_id | int | 客户等级ID(0 未设置) | +| level | object\|null | 客户等级 `{ id, name }`(等级价展示依据) | +| contact / phone / address | string | 联系人 / 电话 / 地址 | +| payment_cycle_days | int | 回款周期天数 | +| status | int | 1 正常 / 0 停用 | + +错误:账号不存在(如已被删除)→ 「账号不存在」。 + +### 2.4 更新用户信息 + +`PUT miniauth/info`(需登录) + +⚠️ **路由注意**:代码中 `#[PutRoute('auth/info')]` 缺少前导 `/`,实际注册路由为 `miniauth/info`(未正确拼接为 `/mini/auth/info`)。 + +请求参数(`UserUpdateInfoRequest` 校验,全部必填): + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| username | string | 是 | 用户名 4-20 位 | +| nickname | string | 是 | 昵称 4-20 位 | +| gender | string | 是 | 性别 | +| email | string | 是 | 邮箱格式 | +| avatar_id | int | 是 | 头像ID | +| mobile | string | 是 | 手机号(`1[34578]` 开头 11 位) | + +> ⚠️ 现状说明:该接口当前实现存在多处问题,实际调用**无法正常使用**—— +> 1. 更新条件 `where('user_id', ...)`:user 表不存在 `user_id` 列(主键为 `id`); +> 2. `auth('user')` guard 未定义(`config/auth.php` 仅有 `sys_users` / `users`),会抛「Auth guard [user] is not defined」; +> 3. 更新成功返回 `success=false` 与提示「更新成功」(使用了 `$this->error()`)。 +> 建议后端修复后再联调。 + +--- + +## 3. 商品 + +### 3.1 商品分类树 + +`GET /mini/product/categories`(需登录) + +返回分类树,**仅包含有上架商品的分类及其全部祖先**(保证树结构完整)。未绑定门店也可调用。 + +响应(`data`):分类树数组,节点字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 分类ID | +| parent_id | int | 父级分类ID(0 为顶级) | +| name | string | 分类名称 | +| children | array | 子分类(递归) | + +### 3.2 商品列表 + +`GET /mini/product/list?category_id=&keyword=&page=&pageSize=`(需登录) + +请求参数: + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|------|------|------|------|------| +| category_id | int | 否 | - | 分类ID过滤 | +| keyword | string | 否 | - | 搜索品名/规格(模糊) | +| page | int | 否 | 1 | 页码 | +| pageSize | int | 否 | 10 | 每页条数 | + +响应(`data` 为分页结构),每项字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 商品ID | +| category_id | int | 分类ID | +| supplier_id | int | 默认供应商ID | +| name | string | 品名 | +| spec | string | 规格/包规 | +| unit | string | 计价单位 | +| content | string | 商品图文详情(HTML) | +| **price** | string\|null | **当前门店等级的实际销售价**(未绑定门店或门店未设等级价为 null,此时仅可浏览不可购买) | +| images_arr | array | 商品图片数组(`{id, file_url, ...}`) | +| sort / shelf_life / stock / status | - | 排序 / 保质期 / 库存 / 状态(仅返回上架商品) | + +> 注意:只返回上架商品;`cost_price`、计价类型、上浮百分点等成本信息不会下发。 +> 未绑定门店(`type=0`)或门店未设客户等级时,列表正常返回、`price` 为 null——价格不可见,加购/下单仍由前置校验拦截。 + +--- + +## 4. 购物车 + +> 购物车为下单前的编辑容器,同商品重复加购自动合并数量;提交订单复用「5.1 下单」接口。 + +### 4.1 加购 + +`POST /mini/cart`(需登录 + 门店 + 客户等级) + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| product_id | int | 是 | 商品ID(须上架且已设本等级价格) | +| quantity | number | 是 | 数量(>0,最多 99999999.99) | + +响应(`data`): + +```json +{ "id": 12, "quantity": "2.50" } +``` + +提示:`已加入购物车`。错误:商品未设本等级价格 → 「商品「xx」未设置您所在等级的价格,无法加购」;超上限 → 「该商品在购物车中的数量已达上限」。 + +### 4.2 购物车列表 + +`GET /mini/cart`(需登录 + 门店) + +响应(`data`): + +| 字段 | 类型 | 说明 | +|------|------|------| +| items | array | 购物车项(倒序) | +| total_count | int | 总项数 | +| total_quantity | string | 可购项总数量 | +| total_amount | string | 可购项总金额 | + +items 每项: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 购物车项ID | +| product_id | int | 商品ID | +| name / spec / unit | string | 商品快照 | +| image | string | 商品首图 URL | +| **price** | string\|null | **当前等级实际价**(商品下架或未设等级价为 null) | +| quantity | string | 数量 | +| amount | string\|null | 金额 = price × quantity(不可购为 null) | +| status | int | 1 可购 / 0 商品下架、缺失或未设等级价 | + +### 4.3 修改数量 + +`PUT /mini/cart/{id}`(需登录) + +请求参数:`quantity`(number,必填,>0)。 + +响应:`{ id, quantity }`,提示「已修改数量」。 + +### 4.4 删除单项 + +`DELETE /mini/cart/{id}`(需登录) + +响应:`success=true`,提示「已删除」;不存在 → 「购物车项不存在」。 + +### 4.5 清空购物车 + +`DELETE /mini/cart`(需登录) + +仅清空当前用户;响应:`success=true`,提示「购物车已清空」。 + +--- + +## 5. 门店订单 + +### 5.1 下单 + +`POST /mini/order`(需登录 + 门店 + 客户等级) + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| items | array | 是 | 订单明细(至少 1 行) | +| items[].product_id | int | 是 | 商品ID(须上架) | +| items[].quantity | number | 是 | 数量(>0) | +| remark | string | 否 | 订单备注(≤255 字符) | + +> 金额不接受前端传入:服务端按商品当前等级**实际价**逐行快照并重算 `amount` 与 `total_amount`。 + +响应(`data`): + +```json +{ "id": 23, "order_no": "SO202608060001", "total_amount": "39.00" } +``` + +提示:「下单成功」。错误示例:存在已下架商品 → 「存在已下架或不存在的商品,请刷新后重试」;未设等级价 → 「商品「xx」未设置您所在等级的价格,无法下单」。 + +### 5.2 历史订单 + +`GET /mini/order?status=&page=&pageSize=`(需登录 + 门店,强制本店隔离) + +请求参数: + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|------|------|------|------|------| +| status | int | 否 | - | 0 待汇总 / 1 已汇总 / 2 配送中 / 3 已完成 / 9 已取消 | +| page / pageSize | - | 否 | 1 / 10 | 分页 | + +响应(`data` 分页结构),订单字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 订单ID | +| order_no | string | 单号(SO + 日期 + 序列) | +| order_date | string | 订货日期(Y-m-d) | +| total_quantity / total_amount | string | 总数量 / 总金额 | +| status | int | 状态(见上) | +| remark | string | 备注 | + +### 5.3 周期汇总 + +`GET /mini/order/summary?period=day|week|month`(需登录 + 门店) + +请求参数:`period`(day/week/month,默认 month)。 + +响应(`data`): + +```json +{ + "period": "month", + "groups": [ + { "period_label": "2026-07", "total_amount": "1280.50", "total_quantity": "86.00", "order_count": 12 } + ] +} +``` + +> `period_label` 格式:day=`Y-m-d`、week=`Y-W+周数`、month=`Y-m`;不含已取消订单;最多返回 50 组。 + +### 5.4 订单详情 + +`GET /mini/order/{id}`(需登录 + 门店,校验本店归属) + +响应(`data`):订单对象 + `items` 数组(明细字段见下表)。 + +明细字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id / order_id | int | 明细ID / 订单ID | +| product_id / product_name / product_spec | - | 商品快照 | +| price | string | 下单时等级实际价快照 | +| quantity / weight | string | 数量 / 称重(默认 0) | +| amount | string | 金额 = price × quantity | +| remark | string | 行备注 | + +### 5.5 取消订单 + +`PUT /mini/order/{id}/cancel`(需登录 + 门店) + +仅「待汇总(0)」可取消;响应提示「订单已取消」;非待汇总 → 「仅待汇总的订单可以取消」。 + +--- + +## 6. 对账单(门店自助) + +### 6.1 对账单列表 + +`GET /mini/statement?page=&pageSize=`(需登录 + 门店,仅本店) + +响应(`data` 分页结构),对账单字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 对账单ID | +| statement_no | string | 单号(ST + 日期 + 序列) | +| period_start / period_end | string | 对账周期 | +| total_amount | string | 总金额 | +| payment_cycle_days | int | 生成时快照的回款周期 | +| settlement_date | string\|null | 应结算日期 = 周期结束 + 回款周期天 | +| status | int | 0 待对账 / 1 已对账 / 2 已结算 | +| reconciled_at / settled_at | string\|null | 对账 / 结算时间 | +| remark | string | 备注 | + +### 6.2 生成对账单 + +`POST /mini/statement/generate`(需登录 + 门店) + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| period_start | string | 是 | 周期开始(Y-m-d) | +| period_end | string | 是 | 周期结束(Y-m-d,不早于开始) | + +响应(`data`):`{ id, statement_no, total_amount, settlement_date }`,提示「对账单已生成」。 + +> 快照当前回款周期计算结算日期。业务约束:周期内本店无订单 → 「周期内本店无订单数据,无法生成对账单」;周期内订单均已生成过对账单 → 「周期内的订单明细均已生成过对账单」。 + +### 6.3 对账单详情 + +`GET /mini/statement/{id}`(需登录 + 门店,校验归属) + +响应(`data`):对账单对象 + `items` 数组,明细字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| order_id / order_item_id | int | 源订单 / 源明细ID | +| product_id / product_name | - | 商品快照 | +| price | string | 单价 | +| quantity / weight / amount | string | 数量 / 称重 / 金额 | +| is_reconciled | int | 0 未对账 / 1 已对账 | +| store_remark | string | 门店备注 | + +### 6.4 导出对账单 + +`GET /mini/statement/{id}/export?format=xlsx|pdf`(需登录 + 门店,校验归属) + +- `format` 默认 `xlsx`(支持 `xlsx` / `pdf`) +- 返回文件流(附件下载,含中文文件名),非 JSON + +--- + +## 7. 门店设置 + +### 7.1 修改回款周期 + +`PUT /mini/store/paymentCycle`(需登录 + 门店) + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| payment_cycle_days | int | 是 | 回款周期天数(≥0,无上限;0 = 当天结算) | + +响应(`data`):`{ "payment_cycle_days": 1 }`,提示「回款周期已更新」。 + +> 该值影响后续生成对账单的 `settlement_date`(周期结束 + 回款周期天)。 + +--- + +## 8. 通知 + +### 8.1 通知列表 + +`GET /mini/notice?page=&pageSize=`(需登录) + +返回本人通知 + 全员广播(本人已读的广播自动隐藏)。响应(`data` 分页结构 + 附加字段): + +| 字段 | 类型 | 说明 | +|------|------|------| +| unread_count | int | 未读总数 | +| 分页内字段 | - | 标准分页结构 | + +通知字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 通知ID | +| type | string | `order` 订单 / `price` 价格变更 / `system` 系统 | +| title / content | string | 标题 / 内容 | +| data | object | 附加数据(价格变更通知含 `product_ids`、`level_ids`) | +| is_read | int | 0 未读 / 1 已读 | +| read_at | string\|null | 已读时间 | + +### 8.2 标记已读 + +`PUT /mini/notice/{id}/read`(需登录) + +- 个人通知:直接标记已读 +- 全员广播:复制一条本人专属已读记录(原广播对他人仍为未读) + +响应:`success=true`;通知不存在 → 「通知不存在」。 + +--- + +## 9. 供应商端 + +### 9.1 收到的采购单 + +`GET /mini/supplier/purchases?page=&pageSize=`(需登录 + 供应商) + +返回**含本供应商已发送明细**(`is_sent=1`)的采购单(去重,按日期倒序)。 + +响应(`data` 分页结构),采购单字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 采购单ID | +| purchase_no | string | 单号(PO + 日期 + 序列) | +| purchase_date | string | 采购日期 | +| status | int | 0 待发送 / 1 部分发送 / 2 全部发送 / 3 已完成 | +| total_quantity / estimate_amount / actual_amount | string | 总数量 / 估算金额 / 实际金额 | +| remark | string | 备注 | + +### 9.2 采购单明细 + +`GET /mini/supplier/purchases/{id}`(需登录 + 供应商) + +仅返回**本供应商且已发送**的明细行。响应(`data`): + +```json +{ + "id": 3, "purchase_no": "PO202608060001", "purchase_date": "2026-08-06", "remark": "", + "items": [ + { "id": 11, "product_id": 2, "product_name": "大白菜", "product_spec": "10斤/箱", + "price": "4.00", "quantity": "10.00", "weight": "0.000", "amount": "40.00", + "sort": 1, "is_sent": 1, "sent_at": "...", "supplier_confirmed_at": null } + ] +} +``` + +无本供应商明细 → 「该采购单无贵司的采购明细」。 + +### 9.3 确认接单 + +`PUT /mini/supplier/purchases/{id}/confirm`(需登录 + 供应商) + +批量记录本供应商全部已发送明细的 `supplier_confirmed_at`(幂等,已确认的跳过)。 + +响应(`data`):`{ "confirmed": 2 }`(本次新确认条数),提示「已确认接单」。 + +--- + +## 10. 状态字典汇总 + +| 枚举 | 值 | 含义 | +|------|-----|------| +| 用户类型 user.type | 0 / 1 / 2 | 待绑定 / 门店 / 供应商 | +| 门店订单 status | 0 / 1 / 2 / 3 / 9 | 待汇总 / 已汇总 / 配送中 / 已完成 / 已取消 | +| 采购单 status | 0 / 1 / 2 / 3 | 待发送 / 部分发送 / 全部发送 / 已完成 | +| 采购明细 is_sent | 0 / 1 | 未发送 / 已发送 | +| 对账单 status | 0 / 1 / 2 | 待对账 / 已对账 / 已结算 | +| 对账明细 is_reconciled | 0 / 1 | 未对账 / 已对账 | +| 通知 type | order / price / system | 订单 / 价格变更 / 系统 | +| 通知 is_read | 0 / 1 | 未读 / 已读 | +| 商品 status | 0 / 1 | 下架 / 上架 | +| 门店/供应商 status | 0 / 1 | 停用 / 正常 | + +## 11. 常见错误提示 + +| 提示语 | 触发场景 | +|--------|----------| +| 尚未绑定门店,请联系客服处理 | 购物车/订单/对账单等接口但用户未绑定门店(商品浏览不受限) | +| 尚未绑定供应商,请联系客服处理 | 供应商端接口但用户未绑定供应商 | +| 门店未设置客户等级,无法展示价格,请联系客服 | 门店 `level_id=0`(购物车/下单)——商品列表不报错,返回 `price=null` | +| 商品「xx」未设置您所在等级的价格,无法下单 | 下单商品缺本等级价格 | +| 存在已下架或不存在的商品,请刷新后重试 | 下单商品已下架 | +| 账号不存在或已被停用 | token 用户被停用 | +| 账号已被停用,请联系客服 | 登录时账号被停用 |