From 969eabcd11e730eeae28617b1e3c6d68026cf261 Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Fri, 14 Aug 2026 16:31:18 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=88=86=E7=B1=BB=E5=9B=BE?= =?UTF-8?q?=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .phpunit.result.cache | 2 +- .../Controllers/Mini/ProductController.php | 34 +--- .../Product/ProductCategoryController.php | 48 ++--- .../Product/ProductCategoryFormRequest.php | 4 - .../Requests/Product/ProductFormRequest.php | 6 +- app/Models/ProductCategoryModel.php | 27 +-- ...2026_07_23_030609_create_product_table.php | 1 - tests/Feature/ProductCategoryTest.php | 182 ++++++++++++++++++ web/domain/iProductCategory.ts | 9 +- web/pages/product/category.tsx | 77 ++++---- web/pages/product/goods.tsx | 28 ++- 11 files changed, 288 insertions(+), 130 deletions(-) create mode 100644 tests/Feature/ProductCategoryTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index e900824..f75f386 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":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,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":8,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":8,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":8,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":7,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":7,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":7,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":7,"Tests\\Feature\\ExportTest::test_export_pdf":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":7,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":7,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":7,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":7,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":7,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":7,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":7,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":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.011,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.01,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.007,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.007,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.007,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.018,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.01,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.009,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.018,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.019,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.076,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.022,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.028,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.021,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.019,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.022,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.019,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.02,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.036,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.011,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.013,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.016,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.056,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.003,"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.005,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.004,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.076,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.021,"Tests\\Feature\\ExportTest::test_export_pdf":0.015,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.016,"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.046,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.01,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.006,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.006,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.006,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.01,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.006,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.01,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.011,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.005,"Tests\\Feature\\CartTest::test_update_quantity":0.008,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.01,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.008,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.006,"Tests\\Feature\\CartTest::test_delete_item":0.008,"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.009,"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.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.009,"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.009,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.009,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.007,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.008,"Tests\\Feature\\PurchaseGenerateTest::test_generate_uses_min_actual_price_across_types":0.014,"Tests\\Feature\\ProductPriceTest::test_store_without_level_sees_products_without_price":0.006,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.009,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.01,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.003,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.011,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.002,"Tests\\Feature\\MiniAuthTest::test_register_rejects_duplicate_openid":0.003,"Tests\\Feature\\MiniStoreTest::test_info_returns_bound_store":0.004,"Tests\\Feature\\MiniStoreTest::test_update_info":0.005,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.005,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.006,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.005,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":0.012,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":0.022,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":0.019,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":0.02,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":0.014,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_accepted_orders_fails":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.021,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.01,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.009,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.014,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":0.012,"Tests\\Feature\\StoreOrderItemTest::test_update_item_rejected_when_completed_or_cancelled":0.011,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":0.01,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":0.013,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":0.011,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":0.01,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":0.017,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.019,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.042,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.012,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.021,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.021,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.021,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.018,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.02,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.021,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.021,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.022,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.018,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.011,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.019,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.017,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.02,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.018,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.019,"Tests\\Feature\\ExportTest::test_export_invalid_type_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_missing_purchase_rejected":0.005,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_container_summary":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_container_syncs_order_amounts":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejected_when_purchase_completed":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejects_incomplete_or_foreign_orders":0.026,"Tests\\Feature\\PurchaseEditTest::test_update_container_validates_negative_and_duplicate_orders":0.018}} \ 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,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":8,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":8,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":8,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":7,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":7,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":7,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":7,"Tests\\Feature\\ExportTest::test_export_pdf":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":7,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":7,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":7,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":7,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":7,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":7,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":7,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":8,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":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.008,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.011,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.007,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.007,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.006,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.01,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.009,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.032,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.019,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.076,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.022,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.028,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.021,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.019,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.022,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.019,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.02,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.036,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.011,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.013,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.016,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.056,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.003,"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.005,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.003,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.072,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.018,"Tests\\Feature\\ExportTest::test_export_pdf":0.015,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.016,"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.045,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.009,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.006,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.006,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.006,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.01,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.006,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.01,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.011,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.005,"Tests\\Feature\\CartTest::test_update_quantity":0.008,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.008,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.006,"Tests\\Feature\\CartTest::test_delete_item":0.007,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.008,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.013,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.009,"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.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.008,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.006,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.009,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.009,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.011,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.009,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.006,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.008,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.008,"Tests\\Feature\\PurchaseGenerateTest::test_generate_uses_min_actual_price_across_types":0.014,"Tests\\Feature\\ProductPriceTest::test_store_without_level_sees_products_without_price":0.004,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.009,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.008,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.009,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.002,"Tests\\Feature\\MiniAuthTest::test_register_rejects_duplicate_openid":0.002,"Tests\\Feature\\MiniStoreTest::test_info_returns_bound_store":0.005,"Tests\\Feature\\MiniStoreTest::test_update_info":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.006,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.005,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":0.012,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":0.022,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":0.019,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":0.02,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":0.014,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_accepted_orders_fails":0.012,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.021,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.008,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.01,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.014,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":0.012,"Tests\\Feature\\StoreOrderItemTest::test_update_item_rejected_when_completed_or_cancelled":0.011,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":0.01,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":0.011,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":0.012,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":0.01,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":0.016,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.019,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.041,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.012,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.021,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.019,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.02,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.018,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.026,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.021,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.021,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.018,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.02,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.017,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.02,"Tests\\Feature\\ExportTest::test_export_invalid_type_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_missing_purchase_rejected":0.006,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_container_summary":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_container_syncs_order_amounts":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejected_when_purchase_completed":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejects_incomplete_or_foreign_orders":0.026,"Tests\\Feature\\PurchaseEditTest::test_update_container_validates_negative_and_duplicate_orders":0.018,"Tests\\Feature\\ProductCategoryTest::test_create_child_category_under_top_level":0.044,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.008,"Tests\\Feature\\ProductCategoryTest::test_update_second_level_move_to_another_top":0.007,"Tests\\Feature\\ProductCategoryTest::test_update_parent_with_children_cannot_move_under_category":0.006,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.009,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.007,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.007}} \ No newline at end of file diff --git a/app/Http/Controllers/Mini/ProductController.php b/app/Http/Controllers/Mini/ProductController.php index 299efde..5e68b92 100644 --- a/app/Http/Controllers/Mini/ProductController.php +++ b/app/Http/Controllers/Mini/ProductController.php @@ -17,39 +17,11 @@ use Modules\AnnoRoute\Attribute\RequestAttribute; #[RequestAttribute('/mini', 'mini', authGuard: 'users')] class ProductController extends BaseMiniController { - /** 分类树(仅含上架商品的分类及其祖先,保证树结构完整) */ + /** 分类树(全部启用分类,含暂无商品的分类) */ #[GetRoute('/product/categories', authorize: true)] public function categories(): JsonResponse { - $activeCategoryIds = ProductModel::query() - ->where('status', ProductModel::STATUS_ON) - ->distinct() - ->pluck('category_id') - ->map(static fn ($id) => (int) $id) - ->filter(static fn (int $id) => $id > 0); - - $categories = ProductCategoryModel::query() - ->where('status', ProductCategoryModel::STATUS_NORMAL) - ->get() - ->keyBy('id'); - - // 保留有上架商品的分类 + 其全部祖先 - $keep = []; - foreach ($activeCategoryIds as $categoryId) { - $cursor = $categoryId; - $guard = 0; - while ($cursor > 0 && $guard++ < 20 && $categories->has($cursor)) { - $keep[$cursor] = true; - $cursor = (int) $categories[$cursor]->parent_id; - } - } - - $filtered = array_values(array_filter( - $categories->toArray(), - static fn (array $item) => isset($keep[$item['id']]) - )); - - return $this->success(ProductCategoryModel::buildTree($filtered)); + return $this->success(ProductCategoryModel::getTreeData(['*'], true)); } /** @@ -88,7 +60,7 @@ class ProductController extends BaseMiniController if (!$user) return $this->success($data); // 门店 $store = $this->boundStore($user); - if(!$store) return $this->success('12313'); + if(!$store) return $this->success($data); if ($store->level_id > 0) { foreach ($data['data'] as &$row) { diff --git a/app/Http/Controllers/Product/ProductCategoryController.php b/app/Http/Controllers/Product/ProductCategoryController.php index 506fcce..fe173fd 100644 --- a/app/Http/Controllers/Product/ProductCategoryController.php +++ b/app/Http/Controllers/Product/ProductCategoryController.php @@ -7,15 +7,12 @@ use App\Http\Requests\Product\ProductCategoryFormRequest; use App\Models\ProductCategoryModel; use App\Models\ProductModel; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Modules\AnnoRoute\Attribute\DeleteRoute; use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\PostRoute; use Modules\AnnoRoute\Attribute\PutRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\Common\Http\Controllers\BaseController; -use Modules\SystemTool\Services\SysFileService; /** * 商品分类管理(多级分类:蔬菜/水果/其他) @@ -37,34 +34,17 @@ class ProductCategoryController extends BaseController return $this->success(ProductCategoryModel::getTreeData(['id', 'name', 'parent_id'], true)); } - /** 上传商品分类图片文件 */ - #[PostRoute('/upload', 'create')] - public function uploadImage(Request $request, SysFileService $service): JsonResponse - { - $data = $request->validate(['file' => 'required|file']); - $result = $service->upload( - $data['file'], - 9, - 20, - Auth::id() - ); - return $this->success($result); - } - /** 创建分类 */ #[PostRoute(authorize: 'create')] public function create(ProductCategoryFormRequest $request): JsonResponse { $validated = $request->validated(); - $parentId = (int) $validated['parent_id']; - if ($parentId > 0 && ! ProductCategoryModel::whereKey($parentId)->exists()) { - throw new RepositoryException('父级分类不存在'); - } + $this->assertParentIsTopLevel((int) $validated['parent_id']); ProductCategoryModel::create($validated); return $this->success(); } - /** 编辑分类(防自引用成环) */ + /** 编辑分类(防自引用成环;分类最多二级) */ #[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] public function update(int $id, ProductCategoryFormRequest $request): JsonResponse { @@ -73,7 +53,12 @@ class ProductCategoryController extends BaseController throw new RepositoryException('分类不存在'); } $validated = $request->validated(); - $this->assertNoCycle($id, (int) $validated['parent_id']); + $parentId = (int) $validated['parent_id']; + $this->assertNoCycle($id, $parentId); + $this->assertParentIsTopLevel($parentId); + if ($parentId > 0 && ProductCategoryModel::where('parent_id', $id)->exists()) { + throw new RepositoryException('该分类下存在子分类,不能调整为子分类'); + } $model->update($validated); return $this->success(); } @@ -96,6 +81,23 @@ class ProductCategoryController extends BaseController return $this->success(); } + /** + * 校验父级分类:存在且必须为顶级分类(分类最多二级) + */ + private function assertParentIsTopLevel(int $parentId): void + { + if ($parentId === 0) { + return; + } + $parent = ProductCategoryModel::whereKey($parentId)->first(['id', 'parent_id']); + if ($parent === null) { + throw new RepositoryException('父级分类不存在'); + } + if ((int) $parent->parent_id !== 0) { + throw new RepositoryException('最多支持二级分类,不能选择子分类作为上级'); + } + } + /** * 沿父链向上检查,防止 parent_id 指向自身或子孙分类形成环 */ diff --git a/app/Http/Requests/Product/ProductCategoryFormRequest.php b/app/Http/Requests/Product/ProductCategoryFormRequest.php index ec952b5..2ee5744 100644 --- a/app/Http/Requests/Product/ProductCategoryFormRequest.php +++ b/app/Http/Requests/Product/ProductCategoryFormRequest.php @@ -2,9 +2,7 @@ namespace App\Http\Requests\Product; -use Illuminate\Validation\Rules\Exists; use Modules\Common\Http\Requests\BaseFormRequest; -use Modules\SystemTool\Models\SysFileModel; /** * 商品分类 创建/编辑 验证 @@ -24,7 +22,6 @@ class ProductCategoryFormRequest extends BaseFormRequest 'name' => 'required|string|max:50', 'parent_id' => 'required|integer|min:0', 'sort' => 'nullable|integer', - 'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')], 'status' => 'nullable|integer|in:0,1', ]; } @@ -36,7 +33,6 @@ class ProductCategoryFormRequest extends BaseFormRequest 'name.max' => '分类名称最长 50 个字符', 'parent_id.min' => '父级分类ID不正确', 'status.in' => '状态值不正确', - 'icon_id.exists' => '请重新上传图片' ]; } } diff --git a/app/Http/Requests/Product/ProductFormRequest.php b/app/Http/Requests/Product/ProductFormRequest.php index a84ef1f..cc410e9 100644 --- a/app/Http/Requests/Product/ProductFormRequest.php +++ b/app/Http/Requests/Product/ProductFormRequest.php @@ -44,13 +44,17 @@ class ProductFormRequest extends BaseFormRequest } /** - * 交叉校验:按成本百分比计价(price_type=1)时上浮百分点必填 + * 交叉校验:商品只能挂在末级分类;按成本百分比计价(price_type=1)时上浮百分点必填 * (Laravel 12 FormRequest 的 after() 需返回单个 Closure,由容器 call 后注册到 Validator) */ public function after(): Closure { return function (Validator $validator): void { $data = (array) $validator->getData(); + $categoryId = (int) ($data['category_id'] ?? 0); + if ($categoryId > 0 && ProductCategoryModel::where('parent_id', $categoryId)->exists()) { + $validator->errors()->add('category_id', '该分类下存在子分类,请选择末级分类'); + } foreach ((array) ($data['prices'] ?? []) as $index => $row) { $priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED); if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT diff --git a/app/Models/ProductCategoryModel.php b/app/Models/ProductCategoryModel.php index df9608a..bb78e8d 100644 --- a/app/Models/ProductCategoryModel.php +++ b/app/Models/ProductCategoryModel.php @@ -5,8 +5,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasOne; -use Modules\SystemTool\Models\SysFileModel; /** * 商品分类模型(蔬菜/水果/其他,多级分类自关联) @@ -25,40 +23,17 @@ class ProductCategoryModel extends Model 'parent_id', 'name', 'sort', - 'status', - 'icon_id' + 'status' ]; protected $casts = [ 'parent_id' => 'integer', 'sort' => 'integer', 'status' => 'integer', - 'icon_id' => 'integer', 'created_at' => 'datetime:Y-m-d H:i:s', 'updated_at' => 'datetime:Y-m-d H:i:s', ]; - protected $appends = ['icon_url']; - - protected $with = ['icon']; - - /** - * 关联图标 - */ - public function icon(): HasOne - { - return $this->hasOne(SysFileModel::class, 'id', 'icon_id'); - } - - // 图标链接 - public function getIconUrlAttribute() - { - if($this->icon) { - return $this->icon->preview_url; - } - return null; - } - /** * 父分类 */ diff --git a/database/migrations/2026_07_23_030609_create_product_table.php b/database/migrations/2026_07_23_030609_create_product_table.php index ad87731..d84aca4 100644 --- a/database/migrations/2026_07_23_030609_create_product_table.php +++ b/database/migrations/2026_07_23_030609_create_product_table.php @@ -17,7 +17,6 @@ return new class extends Migration Schema::create('product_category', function (Blueprint $table) { $table->increments('id')->comment('分类ID'); $table->integer('parent_id')->default(0)->comment('父级分类ID(0为顶级)'); - $table->integer('icon_id')->nullable()->comment('商品图标'); $table->string('name', 50)->comment('分类名称'); $table->integer('sort')->default(0)->comment('排序(采购单导出按此排序)'); $table->integer('status')->default(1)->comment('状态(1正常 0停用)'); diff --git a/tests/Feature/ProductCategoryTest.php b/tests/Feature/ProductCategoryTest.php new file mode 100644 index 0000000..aa7b320 --- /dev/null +++ b/tests/Feature/ProductCategoryTest.php @@ -0,0 +1,182 @@ + 0, + 'name' => $name, + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_NORMAL, + ]); + } + + /** 顶级分类下可创建子分类(二级) */ + public function test_create_child_category_under_top_level(): void + { + $this->actingAsSysUser(); + $top = $this->makeTopCategory(); + + $this->postJson('/product/category', [ + 'parent_id' => $top->id, + 'name' => '叶菜类', + ])->assertOk()->assertJsonPath('success', true); + + $this->assertDatabaseHas('product_category', ['name' => '叶菜类', 'parent_id' => $top->id]); + } + + /** 二级分类不可作为父级(最多二级) */ + public function test_create_category_under_second_level_rejected(): void + { + $this->actingAsSysUser(); + $top = $this->makeTopCategory(); + $child = ProductCategoryModel::create([ + 'parent_id' => $top->id, + 'name' => '叶菜类', + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_NORMAL, + ]); + + $this->postJson('/product/category', [ + 'parent_id' => $child->id, + 'name' => '菠菜', + ])->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '最多支持二级分类,不能选择子分类作为上级'); + } + + /** 编辑:二级分类可平级移动到另一个顶级分类下 */ + public function test_update_second_level_move_to_another_top(): void + { + $this->actingAsSysUser(); + $topA = $this->makeTopCategory('蔬菜'); + $topB = $this->makeTopCategory('水果'); + $child = ProductCategoryModel::create([ + 'parent_id' => $topA->id, + 'name' => '叶菜类', + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_NORMAL, + ]); + + $this->putJson('/product/category/' . $child->id, [ + 'parent_id' => $topB->id, + 'name' => '叶菜类', + ])->assertOk()->assertJsonPath('success', true); + + $this->assertSame($topB->id, (int) $child->fresh()->parent_id); + } + + /** 编辑:含子分类的分类不能调整为子分类(否则子分类变三级) */ + public function test_update_parent_with_children_cannot_move_under_category(): void + { + $this->actingAsSysUser(); + $top = $this->makeTopCategory('蔬菜'); + $other = $this->makeTopCategory('水果'); + ProductCategoryModel::create([ + 'parent_id' => $top->id, + 'name' => '叶菜类', + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_NORMAL, + ]); + + $this->putJson('/product/category/' . $top->id, [ + 'parent_id' => $other->id, + 'name' => '蔬菜', + ])->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '该分类下存在子分类,不能调整为子分类'); + } + + /** 编辑:父级不存在时提示 */ + public function test_update_with_missing_parent_rejected(): void + { + $this->actingAsSysUser(); + $top = $this->makeTopCategory(); + + $this->putJson('/product/category/' . $top->id, [ + 'parent_id' => 99999, + 'name' => '蔬菜', + ])->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '父级分类不存在'); + } + + /** 商品只能挂在末级分类:选择有子分类的分类创建商品 → 验证失败 */ + public function test_create_product_with_parent_category_rejected(): void + { + $this->actingAsSysUser(); + $top = $this->makeTopCategory('蔬菜'); + ProductCategoryModel::create([ + 'parent_id' => $top->id, + 'name' => '叶菜类', + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_NORMAL, + ]); + + $this->postJson('/product/goods', [ + 'category_id' => $top->id, + 'name' => '大白菜', + ])->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '该分类下存在子分类,请选择末级分类'); + } + + /** 商品挂在末级分类(无子分类)可正常创建 */ + public function test_create_product_with_leaf_category_ok(): void + { + $this->actingAsSysUser(); + $top = $this->makeTopCategory('蔬菜'); + $leaf = ProductCategoryModel::create([ + 'parent_id' => $top->id, + 'name' => '叶菜类', + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_NORMAL, + ]); + + $this->postJson('/product/goods', [ + 'category_id' => $leaf->id, + 'name' => '大白菜', + 'content' => '图文详情', + ])->assertOk()->assertJsonPath('success', true); + } + + /** 小程序分类树:返回全部启用分类(含无商品的分类),停用分类不返回 */ + public function test_mini_categories_returns_all_enabled_categories(): void + { + $top = $this->makeTopCategory('蔬菜'); + $empty = $this->makeTopCategory('水产'); // 无任何商品 + ProductCategoryModel::create([ + 'parent_id' => $top->id, + 'name' => '叶菜类', + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_NORMAL, + ]); + $disabled = ProductCategoryModel::create([ + 'parent_id' => 0, + 'name' => '停用分类', + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_DISABLED, + ]); + + $this->actingAsMiniUser(UserModel::factory()->create()); + + $response = $this->getJson('/mini/product/categories'); + $response->assertOk()->assertJsonPath('success', true); + + $ids = collect($response->json('data'))->pluck('id'); + $this->assertContains($top->id, $ids); + $this->assertContains($empty->id, $ids, '无商品的分类也应返回'); + $this->assertNotContains($disabled->id, $ids, '停用分类不应返回'); + } +} diff --git a/web/domain/iProductCategory.ts b/web/domain/iProductCategory.ts index 469f958..da3b583 100644 --- a/web/domain/iProductCategory.ts +++ b/web/domain/iProductCategory.ts @@ -1,5 +1,3 @@ -import type {ISysFileInfo} from "@/domain/iSysFile.ts"; - /** 商品分类(多级,children 由后端组装) */ export default interface IProductCategory { id?: number; @@ -7,9 +5,6 @@ export default interface IProductCategory { name?: string; sort?: number; status?: number; - icon_id?: number; - icon?: ISysFileInfo; - icon_url?: string; children?: IProductCategory[]; created_at?: string; } @@ -19,6 +14,10 @@ export interface IProductCategoryTree { id?: number; parent_id?: number; name?: string; + /** TreeSelect 节点禁选标记(前端组装:父分类/超层级节点不可选) */ + disabled?: boolean; + /** Tree 节点不可选中标记(前端组装:侧栏父分类仅供展开) */ + selectable?: boolean; children?: IProductCategoryTree[]; } diff --git a/web/pages/product/category.tsx b/web/pages/product/category.tsx index d80bba6..f30210f 100644 --- a/web/pages/product/category.tsx +++ b/web/pages/product/category.tsx @@ -1,5 +1,6 @@ -import React, {useEffect, useState} from 'react'; -import {Button, Image, Tag, Typography} from 'antd'; +import React, {useEffect, useMemo, useState} from 'react'; +import {Button, Form, Tag, TreeSelect, Typography} from 'antd'; +import type {FormInstance} from 'antd'; import {NodeExpandOutlined} from '@ant-design/icons'; import XinTable from '@/components/XinTable'; import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts'; @@ -10,6 +11,45 @@ import {getCategoryTable, getCategoryTree} from '@/api/product/category.ts'; const { Title, Text } = Typography; +/** + * 上级分类选择(分类最多二级): + * - 二级分类禁选(不能作为上级,否则出现三级) + * - 编辑时禁选自身 + * - 编辑的分类含子分类时禁选所有一级分类(只能保持顶级,否则其子分类会变成三级) + */ +const ParentCategorySelect: React.FC<{ + form: FormInstance; + tree: IProductCategoryTree[]; + value?: number; + onChange?: (value: number) => void; +}> = ({ form, tree, value, onChange }) => { + // id/children 由 XinTable 编辑时 setFieldsValue(record) 写入(非表单项,需传 form 监听) + const editingId = Form.useWatch('id', form); + const children = Form.useWatch('children', form); + const hasChildren = Array.isArray(children) && children.length > 0; + + const treeData = useMemo(() => { + const walk = (nodes: IProductCategoryTree[], depth: number): IProductCategoryTree[] => + nodes.map((node) => ({ + ...node, + disabled: depth >= 2 || node.id === editingId || (hasChildren && depth >= 1), + children: node.children?.length ? walk(node.children, depth + 1) : node.children, + })); + return [{ id: 0, name: '顶级分类', children: walk(tree, 1) }]; + }, [tree, editingId, hasChildren]); + + return ( + + ); +}; + /** * 递归收集全部节点 id(用于展开整棵树) */ @@ -52,16 +92,10 @@ const ProductCategoryPage: React.FC = () => { { title: '上级分类', dataIndex: 'parent_id', - valueType: 'treeSelect', hideInTable: true, hideInSearch: true, initialValue: 0, - fieldProps: { - treeData: [{ id: 0, name: '顶级分类', children: categoryTree }], - fieldNames: { label: 'name', value: 'id', children: 'children' }, - placeholder: '默认顶级分类', - treeDefaultExpandAll: true, - }, + fieldRender: (form) => , }, { title: '排序', @@ -90,31 +124,6 @@ const ProductCategoryPage: React.FC = () => { }, align: 'center', }, - { - title: '分类图标', - dataIndex: 'icon_id', - valueType: 'image', - fieldProps: { - action: '/product/category/upload', - mode: 'single', - maxCount: 1, - changeType: "id" - }, - render: (_, record: IProductCategory) => { - const url = record.icon_url - if (!url) return '-'; - return ( - - ); - }, - align: 'center', - hideInSearch: true, - }, { title: '创建时间', dataIndex: 'updated_at', diff --git a/web/pages/product/goods.tsx b/web/pages/product/goods.tsx index 7b507a7..0e334aa 100644 --- a/web/pages/product/goods.tsx +++ b/web/pages/product/goods.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button, Card, Drawer, @@ -41,6 +41,22 @@ const PRICE_TYPE_OPTIONS = [ /** 四舍五入保留两位 */ const round2 = (v: number) => Math.round(v * 100) / 100; +/** 商品表单用分类树:有子分类的节点禁选(商品只能挂在末级分类上) */ +const markParentDisabled = (nodes: IProductCategoryTree[]): IProductCategoryTree[] => + nodes.map((node) => ({ + ...node, + disabled: !!node.children?.length, + children: node.children?.length ? markParentDisabled(node.children) : node.children, + })); + +/** 侧栏用分类树:有子分类的节点不可选中(仅供展开,筛选按末级分类) */ +const markParentUnselectable = (nodes: IProductCategoryTree[]): IProductCategoryTree[] => + nodes.map((node) => ({ + ...node, + selectable: !node.children?.length, + children: node.children?.length ? markParentUnselectable(node.children) : node.children, + })); + /** * 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框; * 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100)) @@ -159,6 +175,10 @@ const ProductGoodsPage: React.FC = () => { const [levels, setLevels] = useState([]); const [suppliers, setSuppliers] = useState([]); const [categoryTree, setCategoryTree] = useState([]); + // 商品表单专用分类树:父分类禁选,仅末级可选(侧栏筛选/价格矩阵仍用原始树) + const formCategoryTree = useMemo(() => markParentDisabled(categoryTree), [categoryTree]); + // 侧栏分类树:父分类不可选中,仅作展开归组 + const sidebarCategoryTree = useMemo(() => markParentUnselectable(categoryTree), [categoryTree]); // ===== 分类侧栏 ===== const [activeCategory, setActiveCategory] = useState(undefined); @@ -476,12 +496,12 @@ const ProductGoodsPage: React.FC = () => { align: "center", rules: [{ required: true, message: '请选择分类' }], fieldProps: { - treeData: categoryTree, + treeData: formCategoryTree, fieldNames: { label: 'name', value: 'id', children: 'children' }, treeDefaultExpandAll: true, showSearch: true, treeNodeFilterProp: 'name', - placeholder: '选择分类', + placeholder: '选择末级分类', }, hideInSearch: true, render: (_, record) => @@ -647,7 +667,7 @@ const ProductGoodsPage: React.FC = () => { showLine blockNode onSelect={(selectedKeys) => setActiveCategory(Number(selectedKeys[0]))} - treeData={categoryTree} + treeData={sidebarCategoryTree} selectedKeys={activeCategory ? [activeCategory] : undefined} fieldNames={{title: 'name', key: 'id', children: 'children'}} />