diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 47bd3c3..354cfb9 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,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":7,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":8,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":8,"Tests\\Feature\\DebugPermissionTest::test_debug_permission":8,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":7,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":7,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":8,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":7,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":7,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":7,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":7,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":7,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":7,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":8,"Tests\\Feature\\DashboardDebugTest::test_debug":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.009,"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.009,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.01,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.019,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.011,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.013,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.025,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.033,"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.049,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.017,"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.015,"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.007,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.009,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.007,"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.006,"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.009,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.01,"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.008,"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.006,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.008,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.01,"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.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.01,"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.005,"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.009,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.008,"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.005,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.005,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.006,"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.016,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.021,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.007,"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.012,"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.011,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":0.01,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":0.018,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.031,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.066,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.021,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.073,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.023,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.023,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.02,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.018,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.03,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.02,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.018,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.029,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.02,"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.019,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.022,"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.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.009,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.007,"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.007,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.008,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.007,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.008,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.013,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.007,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.008,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.018,"Tests\\Feature\\StoreOrderTest::test_order_list_filters_by_order_date_range":0.033,"Tests\\Feature\\StoreOrderTest::test_order_list_rejects_invalid_filters":0.009,"Tests\\Feature\\BillExportTest::test_export_merges_items_across_bills":0.054,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.01,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.025,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.007,"Tests\\Feature\\BillExportTest::test_export_invalid_ids_rejected":0.01,"Tests\\Feature\\DebugPermissionTest::test_debug_permission":0.098,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_bound_store":0.013,"Tests\\Feature\\BillExportTest::test_mini_export_requires_store_binding":0.005,"Tests\\Feature\\MiniNoticeTest::test_notice_list_includes_personal_and_broadcast":0.006,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.008,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.006,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.006,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.024,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.029,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.033,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.021,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.027,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.012,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.011,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_detail_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_detail_percent_price_calculated_from_cost":0.006,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.006,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.006,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.005,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.031,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.013,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.011,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.023,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.018,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.018,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.018,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.007,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.006,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.011,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.008,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.009,"Tests\\Feature\\ContainerReturnTest::test_export_empty_range_rejected":0.007,"Tests\\Feature\\ContainerReturnTest::test_export_requires_export_permission":0.007,"Tests\\Feature\\DashboardTest::test_guest_cannot_access_dashboard":0.002,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":0.006,"Tests\\Feature\\DashboardTest::test_empty_database_returns_zero_structure":0.009,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.01,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.013,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.011,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.011,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.01,"Tests\\Feature\\DashboardTest::test_top_stores_ranked_by_amount":0.01,"Tests\\Feature\\DashboardTest::test_purchase_amount_prefers_actual_over_estimate":0.011,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":0.011,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.011,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.012,"Tests\\Feature\\DashboardDebugTest::test_debug_ability_check":0.044,"Tests\\Feature\\DashboardDebugTest::test_debug":0.045}} \ 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":8,"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,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":8,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":8,"Tests\\Feature\\DebugPermissionTest::test_debug_permission":8,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":7,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":7,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":8,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":7,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":7,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":7,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":7,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":7,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":7,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":8,"Tests\\Feature\\DashboardDebugTest::test_debug":8,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":8,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":8,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":8,"Tests\\Feature\\StoreOrderItemTest::test_update_item_rejected_when_completed_or_cancelled":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.008,"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.01,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.007,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.009,"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.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.02,"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.002,"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.037,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.017,"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.007,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.008,"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.006,"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.01,"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.009,"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.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.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.005,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.008,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.01,"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.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.01,"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.005,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.008,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.01,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.01,"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.005,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.005,"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.022,"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.012,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":0.011,"Tests\\Feature\\StoreOrderItemTest::test_update_item_rejected_when_completed_or_cancelled":0.011,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":0.012,"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.01,"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.021,"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.023,"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.019,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.019,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.02,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.019,"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.019,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.019,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.031,"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.021,"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.008,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.006,"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.008,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.008,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.008,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.008,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.008,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.012,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.007,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.008,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.012,"Tests\\Feature\\StoreOrderTest::test_order_list_filters_by_order_date_range":0.019,"Tests\\Feature\\StoreOrderTest::test_order_list_rejects_invalid_filters":0.008,"Tests\\Feature\\BillExportTest::test_export_merges_items_across_bills":0.064,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.012,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.027,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.008,"Tests\\Feature\\BillExportTest::test_export_invalid_ids_rejected":0.009,"Tests\\Feature\\DebugPermissionTest::test_debug_permission":0.098,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_bound_store":0.013,"Tests\\Feature\\BillExportTest::test_mini_export_requires_store_binding":0.005,"Tests\\Feature\\MiniNoticeTest::test_notice_list_includes_personal_and_broadcast":0.006,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.009,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.006,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.006,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.02,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.026,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.02,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.022,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.027,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.012,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.01,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.004,"Tests\\Feature\\MiniProductTest::test_product_detail_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_detail_percent_price_calculated_from_cost":0.006,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.006,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.006,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.005,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.03,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.014,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.01,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.023,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.018,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.018,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.019,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.008,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.006,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.01,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.007,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.009,"Tests\\Feature\\ContainerReturnTest::test_export_empty_range_rejected":0.007,"Tests\\Feature\\ContainerReturnTest::test_export_requires_export_permission":0.007,"Tests\\Feature\\DashboardTest::test_guest_cannot_access_dashboard":0.002,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":0.006,"Tests\\Feature\\DashboardTest::test_empty_database_returns_zero_structure":0.009,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.011,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.013,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.01,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.012,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.012,"Tests\\Feature\\DashboardTest::test_top_stores_ranked_by_amount":0.011,"Tests\\Feature\\DashboardTest::test_purchase_amount_prefers_actual_over_estimate":0.013,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":0.011,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.012,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.011,"Tests\\Feature\\DashboardDebugTest::test_debug_ability_check":0.044,"Tests\\Feature\\DashboardDebugTest::test_debug":0.045,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_cost_and_notifies_all_stores":0.009,"Tests\\Feature\\ProductPriceTest::test_level_percent_update_notifies_level_stores":0.009,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.007,"Tests\\Feature\\ProductPriceTest::test_level_percent_validation":0.007,"Tests\\Feature\\ProductCostPriceTest::test_calc_level_price":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_level_price_edge_cases":0.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_level_prices":0.008,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_price":0.007,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.01,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_level_price":0.008,"Tests\\Feature\\CartTest::test_store_level_missing_rejected":0.006,"Tests\\Feature\\StoreOrderTest::test_store_level_missing_rejected":0.006}} \ No newline at end of file diff --git a/app/Http/Controllers/Customer/CustomerLevelController.php b/app/Http/Controllers/Customer/CustomerLevelController.php index a72562c..4d1dc2c 100644 --- a/app/Http/Controllers/Customer/CustomerLevelController.php +++ b/app/Http/Controllers/Customer/CustomerLevelController.php @@ -5,9 +5,13 @@ namespace App\Http\Controllers\Customer; use App\Exceptions\RepositoryException; use App\Http\Requests\Customer\CustomerLevelFormRequest; use App\Models\CustomerLevelModel; +use App\Models\NoticeModel; +use App\Models\StoreModel; +use App\Models\UserModel; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Modules\AnnoRoute\Attribute\DeleteRoute; use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\PostRoute; @@ -17,7 +21,7 @@ use Modules\Common\Http\Controllers\BaseController; use Modules\SystemTool\Services\SysFileService; /** - * 客户等级管理(同一商品按客户等级定价) + * 客户等级管理(同一商品按等级上浮比例定价:售价 = 成本价 × (100 + percent) / 100) */ #[RequestAttribute('/customer/level', 'customer.level')] class CustomerLevelController extends BaseController @@ -63,7 +67,7 @@ class CustomerLevelController extends BaseController return $this->success(); } - /** 编辑等级 */ + /** 编辑等级(上浮比例变更时通知该等级下门店用户) */ #[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] public function update(int $id, CustomerLevelFormRequest $request): JsonResponse { @@ -71,7 +75,14 @@ class CustomerLevelController extends BaseController if (empty($model)) { throw new RepositoryException('客户等级不存在'); } - $model->update($request->validated()); + $validated = $request->validated(); + DB::transaction(function () use ($model, $validated) { + $model->update($validated); + // 上浮比例变化 → 该等级下所有商品的售价联动变化,通知受影响门店用户 + if ($model->wasChanged('percent')) { + $this->notifyPriceChange($model); + } + }); return $this->success(); } @@ -86,9 +97,6 @@ class CustomerLevelController extends BaseController if ($model->stores()->exists()) { throw new RepositoryException('该等级下存在门店,无法删除'); } - if ($model->prices()->exists()) { - throw new RepositoryException('该等级下存在商品价格,无法删除'); - } $model->delete(); return $this->success(); } @@ -100,8 +108,41 @@ class CustomerLevelController extends BaseController $data = CustomerLevelModel::query() ->where('status', CustomerLevelModel::STATUS_NORMAL) ->orderBy('sort') - ->get(['id', 'name']) + ->get(['id', 'name', 'percent']) ->toArray(); return $this->success($data); } + + /** + * 上浮比例变更后,给该等级下正常门店绑定的正常用户生成价格变更通知 + */ + private function notifyPriceChange(CustomerLevelModel $level): void + { + $userIds = UserModel::query() + ->where('status', UserModel::STATUS_NORMAL) + ->whereIn('store_id', function ($q) use ($level) { + $q->select('id') + ->from('store') + ->where('status', StoreModel::STATUS_NORMAL) + ->where('level_id', $level->id); + }) + ->pluck('id'); + + $content = mb_substr( + '您所在客户等级「' . $level->name . '」的价格上浮比例已调整为 ' . (float) $level->percent . '%,商品价格将按新比例显示', + 0, + 500 + ); + + foreach ($userIds as $userId) { + NoticeModel::create([ + 'user_id' => $userId, + 'type' => NoticeModel::TYPE_PRICE, + 'title' => '商品价格变更', + 'content' => $content, + 'data' => ['level_id' => $level->id], + 'is_read' => NoticeModel::UNREAD, + ]); + } + } } diff --git a/app/Http/Controllers/Mini/CartController.php b/app/Http/Controllers/Mini/CartController.php index 0e80271..ccedf8b 100644 --- a/app/Http/Controllers/Mini/CartController.php +++ b/app/Http/Controllers/Mini/CartController.php @@ -5,8 +5,8 @@ namespace App\Http\Controllers\Mini; use App\Exceptions\RepositoryException; use App\Http\Requests\Mini\MiniCartRequest; use App\Models\CartModel; +use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -36,7 +36,7 @@ class CartController extends BaseMiniController { $user = $this->currentUser($request); $store = $this->ensureStoreBound($user); - if ($store->level_id <= 0) { + if ($store->level_id <= 0 || $store->level === null) { return $this->error('门店未设置客户等级,无法加购,请联系客服'); } @@ -45,14 +45,6 @@ class CartController extends BaseMiniController if ($product === null) { return $this->error('商品不存在或已下架,请刷新后重试'); } - // 存在性校验与计价类型无关(百分比行 price 可能为 0 也能加购) - $hasPrice = ProductPriceModel::query() - ->where('product_id', $productId) - ->where('level_id', $store->level_id) - ->exists(); - if (! $hasPrice) { - return $this->error('商品「' . $product->name . '」价格未设置,无法加购'); - } $quantity = (string) $request->validated('quantity'); @@ -115,11 +107,8 @@ class CartController extends BaseMiniController ->unique()->values()->all(); $products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id'); - $priceRows = ProductPriceModel::query() - ->where('level_id', $store->level_id) - ->whereIn('product_id', $productIds) - ->get(['product_id', 'price', 'price_type', 'percent']) - ->keyBy('product_id'); + // 门店等级(售价 = 成本价 × (100 + 等级上浮比例) / 100) + $level = $store->level_id > 0 ? $store->level : null; // 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。 // 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值 @@ -141,16 +130,10 @@ class CartController extends BaseMiniController foreach ($rows as $row) { $product = $products->get($row->product_id); $productOn = $product !== null && $product->status === ProductModel::STATUS_ON; - // 实际价(百分比计价行按成本价上浮换算);未设等级价为 null - $priceRow = $priceRows->get($row->product_id); - $price = $priceRow === null + // 实际价(按门店等级上浮比例换算);未设等级为 null + $price = $level === null ? null - : ProductPriceModel::calcActualPrice( - (int) $priceRow->price_type, - $priceRow->price, - $priceRow->percent, - (float) ($product?->cost_price ?? 0), - ); + : CustomerLevelModel::calcLevelPrice((float) ($product?->cost_price ?? 0), $level->percent); $buyable = $productOn && $price !== null; $quantity = (string) $row->quantity; diff --git a/app/Http/Controllers/Mini/OrderController.php b/app/Http/Controllers/Mini/OrderController.php index 91e1ce2..1b0fdf1 100644 --- a/app/Http/Controllers/Mini/OrderController.php +++ b/app/Http/Controllers/Mini/OrderController.php @@ -4,8 +4,8 @@ namespace App\Http\Controllers\Mini; use App\Exceptions\RepositoryException; use App\Http\Requests\Mini\MiniOrderRequest; +use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\StoreOrderItemModel; use App\Models\StoreOrderModel; use App\Services\BillNumberService; @@ -32,14 +32,15 @@ class OrderController extends BaseMiniController { $user = $this->currentUser($request); $store = $this->ensureStoreBound($user); - if ($store->level_id <= 0) { + $level = $store->level_id > 0 ? $store->level : null; + if ($level === null) { throw new RepositoryException('门店未设置客户等级,无法下单,请联系客服'); } $items = $request->validated('items'); $remark = (string) ($request->validated('remark') ?? ''); - $order = DB::transaction(function () use ($store, $items, $remark) { + $order = DB::transaction(function () use ($store, $level, $items, $remark) { $productIds = array_map(static fn ($row) => (int) $row['product_id'], $items); $products = ProductModel::query() @@ -48,12 +49,6 @@ class OrderController extends BaseMiniController ->get() ->keyBy('id'); - $priceRows = ProductPriceModel::query() - ->where('level_id', $store->level_id) - ->whereIn('product_id', $productIds) - ->get(['product_id', 'price', 'price_type', 'percent']) - ->keyBy('product_id'); - $totalQuantity = '0'; $totalAmount = '0'; $now = now(); @@ -64,18 +59,9 @@ class OrderController extends BaseMiniController if ($product === null) { throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试'); } - if (! isset($priceRows[$productId])) { - throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单'); - } - // 实际价(百分比计价行按成本价上浮换算,$products 已含 cost_price) - $priceRow = $priceRows[$productId]; - $price = ProductPriceModel::calcActualPrice( - (int) $priceRow->price_type, - $priceRow->price, - $priceRow->percent, - $product->cost_price, - ); + // 实际价(按门店等级上浮比例换算,$products 已含 cost_price) + $price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent); $quantity = (string) $row['quantity']; $amount = bcmul($price, $quantity, 2); $totalQuantity = bcadd($totalQuantity, $quantity, 2); diff --git a/app/Http/Controllers/Mini/ProductController.php b/app/Http/Controllers/Mini/ProductController.php index 9c349f9..d62db87 100644 --- a/app/Http/Controllers/Mini/ProductController.php +++ b/app/Http/Controllers/Mini/ProductController.php @@ -3,9 +3,9 @@ namespace App\Http\Controllers\Mini; use App\Exceptions\RepositoryException; +use App\Models\CustomerLevelModel; use App\Models\ProductCategoryModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Modules\AnnoRoute\Attribute\GetRoute; @@ -50,30 +50,17 @@ class ProductController extends BaseMiniController ->orderBy('id') ->paginate($pageSize); - // 当前门店的等级价格(一次性取出,避免逐行查询) + // 当前门店的等级(售价 = 成本价 × (100 + 等级上浮比例) / 100) $user = $this->optionalUser($request); $store = $user !== null ? $this->boundStore($user) : null; - $priceRows = collect(); - if ($store !== null && $store->level_id > 0) { - $priceRows = ProductPriceModel::query() - ->where('level_id', $store->level_id) - ->whereIn('product_id', $paginator->getCollection()->pluck('id')) - ->get(['product_id', 'price', 'price_type', 'percent']) - ->keyBy('product_id'); - } + $level = ($store !== null && $store->level_id > 0) ? $store->level : null; $paginator->getCollection()->transform( - static function (ProductModel $product) use ($priceRows): array { + static function (ProductModel $product) use ($level): array { $row = $product->toArray(); - // 实际价(百分比计价行按成本价上浮换算;成本价不随序列化输出) - $priceRow = $priceRows->get($product->id); - $row['price'] = $priceRow !== null - ? ProductPriceModel::calcActualPrice( - (int) $priceRow->price_type, - (string) $priceRow->price, - (string) $priceRow->percent, - (string) $product->cost_price, - ) + // 实际价(按等级上浮比例换算;成本价不随序列化输出) + $row['price'] = $level !== null + ? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent) : null; return $row; } @@ -83,7 +70,7 @@ class ProductController extends BaseMiniController } /** - * 商品详情(免登录浏览;登录门店按等级显示换算价,未登录/未绑店/未设等级 price=null) + * 商品详情(免登录浏览;登录门店按等级上浮比例显示换算价,未登录/未绑店/未设等级 price=null) */ #[GetRoute('/product/{id}', authorize: false, where: ['id' => '[0-9]+'])] public function detail(int $id, Request $request): JsonResponse @@ -99,18 +86,9 @@ class ProductController extends BaseMiniController $price = null; $user = $this->optionalUser($request); $store = $user !== null ? $this->boundStore($user) : null; - if ($store !== null && $store->level_id > 0) { - $priceRow = ProductPriceModel::query() - ->forProductLevel($product->id, $store->level_id) - ->first(['price', 'price_type', 'percent']); - if ($priceRow !== null) { - $price = ProductPriceModel::calcActualPrice( - (int) $priceRow->price_type, - (string) $priceRow->price, - (string) $priceRow->percent, - (string) $product->cost_price, - ); - } + $level = ($store !== null && $store->level_id > 0) ? $store->level : null; + if ($level !== null) { + $price = CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent); } $data = $product->toArray(); diff --git a/app/Http/Controllers/Product/ProductController.php b/app/Http/Controllers/Product/ProductController.php index b893ab4..d77db93 100644 --- a/app/Http/Controllers/Product/ProductController.php +++ b/app/Http/Controllers/Product/ProductController.php @@ -8,7 +8,6 @@ use App\Http\Requests\Product\ProductFormRequest; use App\Models\CustomerLevelModel; use App\Models\NoticeModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\StoreModel; use App\Models\UserModel; use Illuminate\Http\JsonResponse; @@ -24,7 +23,7 @@ use Modules\Common\Http\Controllers\BaseController; use Modules\SystemTool\Services\SysFileService; /** - * 商品档案管理 + * 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行) */ #[RequestAttribute('/product/goods', 'product.goods')] class ProductController extends BaseController @@ -38,7 +37,7 @@ class ProductController extends BaseController protected array $quickSearchField = ['name', 'spec']; - /** A1 商品列表(含分类/供应商/各等级价格;cost_price 在 $hidden 中,后台列表需显式恢复) */ + /** A1 商品列表(含分类/供应商;prices 为按启用等级上浮比例换算的展示价;cost_price 在 $hidden 中,后台列表需显式恢复) */ #[GetRoute(authorize: 'query')] public function query(Request $request): JsonResponse { @@ -46,12 +45,26 @@ class ProductController extends BaseController $pageSize = $params['pageSize'] ?? 10; $data = $this->buildSearch( $params, - ProductModel::query()->with(['category:id,name', 'supplier:id,name', 'prices.level:id,name']) + ProductModel::query()->with(['category:id,name', 'supplier:id,name']) ) ->orderBy('sort') ->orderBy('id', 'desc') ->paginate($pageSize); $data->getCollection()->makeVisible('cost_price'); + + // 按启用等级直接换算展示价(无等级价格表,价格由成本价 × 等级上浮比例得出) + $levels = $this->enabledLevels(); + $data->getCollection()->transform(static function (ProductModel $product) use ($levels) { + $row = $product->toArray(); + $row['prices'] = $levels->map(static fn (CustomerLevelModel $level) => [ + 'level_id' => $level->id, + 'level' => ['id' => $level->id, 'name' => $level->name], + 'percent' => (float) $level->percent, + 'price' => CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent), + ])->values()->all(); + return $row; + }); + return $this->success($data->toArray()); } @@ -70,32 +83,15 @@ class ProductController extends BaseController } - /** 创建商品(事务内建商品 + 同步等级价格) */ + /** 创建商品 */ #[PostRoute(authorize: 'create')] public function create(ProductFormRequest $request): JsonResponse { - $validated = $request->validated(); - $prices = $validated['prices'] ?? []; - unset($validated['prices']); - - $product = DB::transaction(function () use ($validated, $prices) { - $product = ProductModel::create($validated); - foreach ($prices as $row) { - ProductPriceModel::create([ - 'product_id' => $product->id, - 'level_id' => (int) $row['level_id'], - 'price' => $row['price'], - 'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED), - 'percent' => $row['percent'] ?? 0, - ]); - } - return $product; - }); - + $product = ProductModel::create($request->validated()); return $this->success(['id' => $product->id]); } - /** 编辑商品(prices 按 level_id upsert,删除已移除的等级行;未提交 prices 键时保持原价) */ + /** 编辑商品 */ #[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] public function update(int $id, ProductFormRequest $request): JsonResponse { @@ -103,34 +99,11 @@ class ProductController extends BaseController if (empty($product)) { throw new RepositoryException('商品不存在'); } - $validated = $request->validated(); - $prices = $validated['prices'] ?? []; - unset($validated['prices']); - - DB::transaction(function () use ($product, $validated, $prices, $request) { - $product->update($validated); - if ($request->has('prices')) { - $levelIds = []; - foreach ($prices as $row) { - $levelId = (int) $row['level_id']; - $levelIds[] = $levelId; - ProductPriceModel::updateOrCreate( - ['product_id' => $product->id, 'level_id' => $levelId], - [ - 'price' => $row['price'], - 'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED), - 'percent' => $row['percent'] ?? 0, - ], - ); - } - $product->prices()->whereNotIn('level_id', $levelIds)->delete(); - } - }); - + $product->update($request->validated()); return $this->success(); } - /** 删除商品(软删除,连带价格行一并删除) */ + /** 删除商品(软删除) */ #[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])] public function delete(int $id): JsonResponse { @@ -138,22 +111,18 @@ class ProductController extends BaseController if (empty($product)) { throw new RepositoryException('商品不存在'); } - DB::transaction(function () use ($product) { - $product->prices()->delete(); - $product->delete(); - }); + $product->delete(); return $this->success(); } /** * A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页), - * 列=全部启用等级,值=实际销售价(缺失为 null);行内含 cost_price 与每等级 - * price_type_{levelId} / percent_{levelId},供前端判断计价类型与联动重算 + * 列=全部启用等级,值=按等级上浮比例换算的售价(成本价未设置为 null);仅成本价可编辑 */ #[GetRoute('/priceMatrix', 'query')] public function priceMatrix(Request $request): JsonResponse { - $query = ProductModel::query()->with('prices:id,product_id,level_id,price,price_type,percent'); + $query = ProductModel::query(); if (($categoryId = (int) $request->input('category_id', 0)) > 0) { $query->where('category_id', $categoryId); } @@ -168,13 +137,9 @@ class ProductController extends BaseController $page = max(1, (int) $request->input('page', 1)); $products = $query->orderBy('sort')->orderBy('id')->paginate($pageSize, ['*'], 'page', $page); - $levels = CustomerLevelModel::query() - ->where('status', CustomerLevelModel::STATUS_NORMAL) - ->orderBy('sort') - ->get(['id', 'name']); + $levels = $this->enabledLevels(); - $rows = $products->getCollection()->map(function (ProductModel $product) use ($levels) { - $priceMap = $product->prices->keyBy('level_id'); + $rows = $products->getCollection()->map(static function (ProductModel $product) use ($levels) { $row = [ 'id' => $product->id, 'name' => $product->name, @@ -183,31 +148,27 @@ class ProductController extends BaseController 'cost_price' => (float) $product->cost_price, ]; foreach ($levels as $level) { - $price = $priceMap[$level->id] ?? null; - $row['price_' . $level->id] = $price - ? (float) ProductPriceModel::calcActualPrice( - (int) $price->price_type, - $price->price, - $price->percent, - $product->cost_price, - ) + $row['price_' . $level->id] = $product->cost_price > 0 + ? (float) CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent) : null; - $row['price_type_' . $level->id] = $price ? (int) $price->price_type : ProductPriceModel::PRICE_TYPE_FIXED; - $row['percent_' . $level->id] = $price ? (float) $price->percent : 0; } return $row; }); return $this->success([ - 'levels' => $levels->toArray(), + 'levels' => $levels->map(static fn (CustomerLevelModel $level) => [ + 'id' => $level->id, + 'name' => $level->name, + 'percent' => (float) $level->percent, + ])->values()->all(), 'rows' => $rows->values()->toArray(), 'total' => $products->total(), ]); } /** - * A2 批量调价:三类更新行(成本价 / 固定价 / 成本百分比,可混合同一行),事务写入, - * 写完后给受影响门店生成 Notice(type=price) + * A2 批量调价:批量调整成本价(等级售价随之按上浮比例联动),事务写入, + * 写完后给全部正常门店的用户生成 Notice(type=price) */ #[PutRoute('/batchPrice', 'batchPrice')] public function batchPrice(BatchPriceRequest $request): JsonResponse @@ -216,53 +177,24 @@ class ProductController extends BaseController DB::transaction(function () use ($updates) { $productIds = []; - $levelIds = []; foreach ($updates as $row) { $productId = (int) $row['product_id']; $productIds[$productId] = true; - - // 分支1:成本价更新(百分比计价的基数,可与等级价行同在一行) - if (array_key_exists('cost_price', $row) && $row['cost_price'] !== null) { - ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]); - } - - // 分支2/3:等级价格行(固定价或成本百分比,按 price_type 区分) - if (isset($row['level_id'])) { - $levelId = (int) $row['level_id']; - $levelIds[$levelId] = true; - ProductPriceModel::updateOrCreate( - ['product_id' => $productId, 'level_id' => $levelId], - [ - 'price' => $row['price'] ?? 0, - 'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED), - 'percent' => $row['percent'] ?? 0, - ], - ); - } + ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]); } - // 成本价变更只影响「该商品下百分比计价」的等级;受影响门店等级 = 等级更新行 ∪ 百分比行等级 - $percentLevelIds = ProductPriceModel::query() - ->whereIn('product_id', array_keys($productIds)) - ->where('price_type', ProductPriceModel::PRICE_TYPE_PERCENT) - ->pluck('level_id') - ->merge($levelIds) - ->unique() - ->all(); - $productNames = ProductModel::whereIn('id', array_keys($productIds)) ->pluck('name') ->implode('、'); $content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500); - // 受影响门店:客户等级在受影响等级范围内的正常门店,通知其绑定的正常用户 + // 成本价变更影响所有等级的售价:通知全部正常门店绑定的正常用户 $userIds = UserModel::query() ->where('status', UserModel::STATUS_NORMAL) - ->whereIn('store_id', function ($q) use ($percentLevelIds) { + ->whereIn('store_id', function ($q) { $q->select('id') ->from('store') - ->where('status', StoreModel::STATUS_NORMAL) - ->whereIn('level_id', $percentLevelIds); + ->where('status', StoreModel::STATUS_NORMAL); }) ->pluck('id'); @@ -274,7 +206,6 @@ class ProductController extends BaseController 'content' => $content, 'data' => [ 'product_ids' => array_keys($productIds), - 'level_ids' => array_keys($levelIds), ], 'is_read' => NoticeModel::UNREAD, ]); @@ -298,4 +229,17 @@ class ProductController extends BaseController ->toArray(); return $this->success($data); } + + /** + * 启用中的客户等级(列表/矩阵共用的等级列来源) + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private function enabledLevels(): \Illuminate\Database\Eloquent\Collection + { + return CustomerLevelModel::query() + ->where('status', CustomerLevelModel::STATUS_NORMAL) + ->orderBy('sort') + ->get(['id', 'name', 'percent']); + } } diff --git a/app/Http/Requests/Customer/CustomerLevelFormRequest.php b/app/Http/Requests/Customer/CustomerLevelFormRequest.php index fd8efd0..0e139dc 100644 --- a/app/Http/Requests/Customer/CustomerLevelFormRequest.php +++ b/app/Http/Requests/Customer/CustomerLevelFormRequest.php @@ -23,6 +23,7 @@ class CustomerLevelFormRequest extends BaseFormRequest return [ 'name' => ['required', 'string', 'max:50', $unique], + 'percent' => 'nullable|numeric|min:0|max:999.99', 'sort' => 'nullable|integer', 'status' => 'nullable|integer|in:0,1', 'icon_id' => ['nullable', 'integer', new Exists(SysFileModel::class, 'id')], @@ -35,6 +36,9 @@ class CustomerLevelFormRequest extends BaseFormRequest 'name.required' => '等级名称不能为空', 'name.max' => '等级名称最长 50 个字符', 'name.unique' => '等级名称已存在', + 'percent.numeric' => '价格上浮比例必须为数字', + 'percent.min' => '价格上浮比例不能小于 0', + 'percent.max' => '价格上浮比例不能超过 999.99', 'status.in' => '状态值不正确', 'icon_id.exists' => '请重新上传图片' ]; diff --git a/app/Http/Requests/Product/BatchPriceRequest.php b/app/Http/Requests/Product/BatchPriceRequest.php index 836ccea..163b29d 100644 --- a/app/Http/Requests/Product/BatchPriceRequest.php +++ b/app/Http/Requests/Product/BatchPriceRequest.php @@ -2,18 +2,13 @@ namespace App\Http\Requests\Product; -use App\Models\ProductPriceModel; -use Closure; -use Illuminate\Validation\Validator; use Modules\Common\Http\Requests\BaseFormRequest; /** * 批量调价 验证(A2 价格矩阵编辑提交) * - * updates 每行支持三类更新(可混合同一行): + * 等级售价 = 成本价 × (100 + 等级上浮比例) / 100,矩阵仅支持批量调整成本价: * 成本行 {product_id, cost_price} - * 固定价行 {product_id, level_id, price_type?:0, price} - * 百分比行 {product_id, level_id, price_type:1, percent}(price 可选,等价固定价) */ class BatchPriceRequest extends BaseFormRequest { @@ -24,54 +19,10 @@ class BatchPriceRequest extends BaseFormRequest return [ 'updates' => 'required|array|min:1', 'updates.*.product_id' => 'required|integer|exists:product,id', - 'updates.*.cost_price' => 'nullable|numeric|min:0|max:99999999', - 'updates.*.level_id' => 'nullable|integer|exists:customer_level,id', - 'updates.*.price_type' => 'nullable|integer|in:0,1', - 'updates.*.price' => 'nullable|numeric|min:0|max:99999999', - 'updates.*.percent' => 'nullable|numeric|min:0|max:999.99', + 'updates.*.cost_price' => 'required|numeric|min:0|max:99999999', ]; } - /** - * 行级交叉校验(在 after() 内按实际数据逐行判断,避免 required_with* 通配符参数解析不可靠): - * - 每行必须至少包含成本价或等级价格更新 - * - 出现等级字段(price/percent/price_type)时必须带 level_id - * - 等级行必须有 price 或 percent - * - 成本百分比计价(price_type=1)时上浮百分点必填 - */ - public function after(): Closure - { - return function (Validator $validator): void { - $data = (array) $validator->getData(); - foreach ((array) ($data['updates'] ?? []) as $index => $row) { - $row = (array) $row; - $hasCost = array_key_exists('cost_price', $row) && $row['cost_price'] !== null && $row['cost_price'] !== ''; - $hasLevel = isset($row['level_id']); - $hasPrice = array_key_exists('price', $row) && $row['price'] !== null && $row['price'] !== ''; - $hasPercent = array_key_exists('percent', $row) && $row['percent'] !== null && $row['percent'] !== ''; - $hasType = array_key_exists('price_type', $row); - - if (! $hasCost && ! $hasLevel) { - $validator->errors()->add("updates.{$index}", '调价行缺少成本价或等级价格'); - continue; - } - if (($hasPrice || $hasPercent || $hasType) && ! $hasLevel) { - $validator->errors()->add("updates.{$index}.level_id", '等级价格行缺少客户等级'); - continue; - } - if ($hasLevel && ! $hasPrice && ! $hasPercent) { - $validator->errors()->add("updates.{$index}", '等级价格行缺少单价或上浮百分点'); - continue; - } - - $priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED); - if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT && ! $hasPercent) { - $validator->errors()->add("updates.{$index}.percent", '按成本百分比计价时必须填写上浮百分点'); - } - } - }; - } - public function messages(): array { return [ @@ -79,15 +30,9 @@ class BatchPriceRequest extends BaseFormRequest 'updates.min' => '请至少提交一条价格调整', 'updates.*.product_id.required' => '调价行缺少商品', 'updates.*.product_id.exists' => '商品不存在', + 'updates.*.cost_price.required' => '调价行缺少成本价', 'updates.*.cost_price.numeric' => '成本价必须为数字', 'updates.*.cost_price.min' => '成本价不能小于 0', - 'updates.*.level_id.exists' => '客户等级不存在', - 'updates.*.price_type.in' => '计价类型不正确', - 'updates.*.price.numeric' => '单价必须为数字', - 'updates.*.price.min' => '单价不能小于 0', - 'updates.*.percent.numeric' => '上浮百分点必须为数字', - 'updates.*.percent.min' => '上浮百分点不能小于 0', - 'updates.*.percent.max' => '上浮百分点不能超过 999.99', ]; } } diff --git a/app/Http/Requests/Product/ProductFormRequest.php b/app/Http/Requests/Product/ProductFormRequest.php index cc410e9..d2e9f83 100644 --- a/app/Http/Requests/Product/ProductFormRequest.php +++ b/app/Http/Requests/Product/ProductFormRequest.php @@ -3,7 +3,6 @@ namespace App\Http\Requests\Product; use App\Models\ProductCategoryModel; -use App\Models\ProductPriceModel; use App\Models\SupplierModel; use Closure; use Illuminate\Validation\Rules\Exists; @@ -12,7 +11,7 @@ use Modules\Common\Http\Requests\BaseFormRequest; use Modules\SystemTool\Models\SysFileModel; /** - * 商品档案 创建/编辑 验证(含多等级价格 prices 数组) + * 商品档案 创建/编辑 验证(售价按客户等级上浮比例换算,不再维护等级价格行) */ class ProductFormRequest extends BaseFormRequest { @@ -35,16 +34,11 @@ class ProductFormRequest extends BaseFormRequest 'status' => 'nullable|integer|in:0,1', 'cost_price' => 'nullable|numeric|min:0|max:99999999', 'remark' => 'nullable|string|max:255', - 'prices' => 'nullable|array', - 'prices.*.level_id' => 'required|integer|exists:customer_level,id', - 'prices.*.price_type' => 'nullable|integer|in:0,1', - 'prices.*.price' => 'required|numeric|min:0|max:99999999', - 'prices.*.percent' => 'nullable|numeric|min:0|max:999.99', ]; } /** - * 交叉校验:商品只能挂在末级分类;按成本百分比计价(price_type=1)时上浮百分点必填 + * 交叉校验:商品只能挂在末级分类 * (Laravel 12 FormRequest 的 after() 需返回单个 Closure,由容器 call 后注册到 Validator) */ public function after(): Closure @@ -55,13 +49,6 @@ class ProductFormRequest extends BaseFormRequest 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 - && (! array_key_exists('percent', (array) $row) || $row['percent'] === null || $row['percent'] === '')) { - $validator->errors()->add("prices.{$index}.percent", '按成本百分比计价时必须填写上浮百分点'); - } - } }; } @@ -74,17 +61,8 @@ class ProductFormRequest extends BaseFormRequest 'category_id.exists' => '商品分类不存在', 'supplier_id.exists' => '供应商不存在', 'status.in' => '状态值不正确', - 'prices.*.level_id.required' => '价格行缺少客户等级', - 'prices.*.level_id.exists' => '客户等级不存在', - 'prices.*.price.required' => '价格行缺少单价', - 'prices.*.price.numeric' => '单价必须为数字', - 'prices.*.price.min' => '单价不能小于 0', 'cost_price.numeric' => '成本价必须为数字', 'cost_price.min' => '成本价不能小于 0', - 'prices.*.price_type.in' => '计价类型不正确', - 'prices.*.percent.numeric' => '上浮百分点必须为数字', - 'prices.*.percent.min' => '上浮百分点不能小于 0', - 'prices.*.percent.max' => '上浮百分点不能超过 999.99', ]; } } diff --git a/app/Models/CustomerLevelModel.php b/app/Models/CustomerLevelModel.php index 283ac9e..1adc553 100644 --- a/app/Models/CustomerLevelModel.php +++ b/app/Models/CustomerLevelModel.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Modules\SystemTool\Models\SysFileModel; /** - * 客户等级模型(同一商品按客户等级定价) + * 客户等级模型(同一商品按等级上浮比例定价:售价 = 成本价 × (100 + percent) / 100) */ class CustomerLevelModel extends Model { @@ -25,12 +25,14 @@ class CustomerLevelModel extends Model protected $fillable = [ 'name', + 'percent', 'sort', 'status', 'icon_id' ]; protected $casts = [ + 'percent' => 'decimal:2', 'sort' => 'integer', 'status' => 'integer', 'created_at' => 'datetime:Y-m-d H:i:s', @@ -65,10 +67,17 @@ class CustomerLevelModel extends Model } /** - * 该等级下的商品价格 + * 按等级上浮比例计算售价(统一换算入口,金额走 bcmath 保证两位小数精度) + * + * 售价 = 成本价 × (100 + percent) / 100(四舍五入保留两位)。 + * + * @param string|int|float $costPrice 商品成本价(decimal cast 字符串) + * @param string|int|float $percent 价格上浮比例(30 = 上浮 30%) + * @return string 两位小数字符串,如 '13.05' */ - public function prices(): HasMany + public static function calcLevelPrice(string|int|float $costPrice, string|int|float $percent): string { - return $this->hasMany(ProductPriceModel::class, 'level_id', 'id'); + $multiplier = bcadd('100', (string) $percent, 4); + return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2); } } diff --git a/app/Models/ProductModel.php b/app/Models/ProductModel.php index 0bf9080..f23d410 100644 --- a/app/Models/ProductModel.php +++ b/app/Models/ProductModel.php @@ -6,12 +6,11 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Modules\SystemTool\Models\SysFileModel; /** - * 商品档案模型(品名/规格包规/供应商/等级/多等级价格体系) + * 商品档案模型(品名/规格包规/供应商/成本价;售价按客户等级上浮比例换算) */ class ProductModel extends Model { @@ -96,12 +95,4 @@ class ProductModel extends Model { return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id'); } - - /** - * 多等级价格(同一商品按客户等级定价) - */ - public function prices(): HasMany - { - return $this->hasMany(ProductPriceModel::class, 'product_id', 'id'); - } } diff --git a/app/Models/ProductPriceModel.php b/app/Models/ProductPriceModel.php deleted file mode 100644 index a9cad79..0000000 --- a/app/Models/ProductPriceModel.php +++ /dev/null @@ -1,108 +0,0 @@ - 'integer', - 'level_id' => 'integer', - 'price' => 'decimal:2', - 'price_type' => 'integer', - 'percent' => 'decimal:2', - ]; - - /** 序列化时附带实际销售价(后台列表/小程序列表直接展示) */ - protected $appends = ['actual_price']; - - /** - * 所属商品 - */ - public function product(): BelongsTo - { - return $this->belongsTo(ProductModel::class, 'product_id', 'id'); - } - - /** - * 所属客户等级 - */ - public function level(): BelongsTo - { - return $this->belongsTo(CustomerLevelModel::class, 'level_id', 'id'); - } - - /** - * 按商品 + 等级筛选价格 - */ - public function scopeForProductLevel($query, int $productId, int $levelId) - { - return $query->where('product_id', $productId)->where('level_id', $levelId); - } - - /** - * 计算实际销售价(统一换算入口,金额走 bcmath 保证两位小数精度) - * - * 固定价返回 price 原值;成本百分比返回 cost × (100 + percent) / 100(四舍五入保留两位)。 - * - * @param string|int|float $price 固定价(decimal cast 后为 '5.50' 形式字符串) - * @param string|int|float $percent 成本上浮百分点(30 = 上浮 30%) - * @param string|int|float $costPrice 商品成本价(decimal cast 字符串) - * @return string 两位小数字符串,如 '13.05' - */ - public static function calcActualPrice( - int $priceType, - string|int|float $price, - string|int|float $percent, - string|int|float $costPrice, - ): string { - if ($priceType === self::PRICE_TYPE_PERCENT) { - $multiplier = bcadd('100', (string) $percent, 4); - return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2); - } - // 固定价:归一化为两位小数字符串 - return bcadd((string) $price, '0', 2); - } - - /** - * 实际销售价访问器(供 toArray 输出 actual_price) - * - * 依赖 product 关系取成本价;prices 经商品 eager load 加载时逆向关系自动填充,无 N+1。 - * 注意:单独序列化本模型且未加载 product 关系时会触发一次查询,成本价缺失按 0 兜底。 - */ - protected function getActualPriceAttribute(): string - { - return self::calcActualPrice( - (int) $this->price_type, - (string) $this->price, - (string) $this->percent, - (string) ($this->product?->cost_price ?? 0), - ); - } -} diff --git a/database/factories/ProductPriceModelFactory.php b/database/factories/ProductPriceModelFactory.php deleted file mode 100644 index 4d183d5..0000000 --- a/database/factories/ProductPriceModelFactory.php +++ /dev/null @@ -1,31 +0,0 @@ - - */ -class ProductPriceModelFactory extends Factory -{ - protected $model = ProductPriceModel::class; - - private static int $sequence = 0; - - public function definition(): array - { - $seq = ++self::$sequence; - - return [ - 'product_id' => 0, - 'level_id' => 0, - 'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''), - 'price_type' => ProductPriceModel::PRICE_TYPE_FIXED, - 'percent' => 0, - ]; - } -} diff --git a/database/migrations/2026_08_17_000001_update_pricing_to_level_percent.php b/database/migrations/2026_08_17_000001_update_pricing_to_level_percent.php new file mode 100644 index 0000000..119ab45 --- /dev/null +++ b/database/migrations/2026_08_17_000001_update_pricing_to_level_percent.php @@ -0,0 +1,53 @@ +decimal('percent', 5, 2)->default(0)->after('name')->comment('价格上浮比例(%,如 30 = 成本价上浮30%)'); + }); + } + + // 商品等级价格表废弃(历史数据随表删除) + Schema::dropIfExists('product_price'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 恢复商品等级价格表(结构回滚,历史数据不恢复) + if (! Schema::hasTable('product_price')) { + Schema::create('product_price', function (Blueprint $table) { + $table->increments('id')->comment('价格ID'); + $table->integer('product_id')->comment('商品ID'); + $table->integer('level_id')->comment('客户等级ID'); + $table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价(固定价=实际单价;百分比=等价固定价)'); + $table->unsignedTinyInteger('price_type')->default(0)->comment('计价类型(0固定价 1成本百分比)'); + $table->decimal('percent', 5, 2)->default(0)->comment('成本上浮百分点(如 30 = 上浮30%,仅 price_type=1 生效)'); + $table->timestamps(); + $table->unique(['product_id', 'level_id'], 'product_price_product_level_unique'); + $table->comment('商品等级价格表'); + }); + } + + if (Schema::hasColumn('customer_level', 'percent')) { + Schema::table('customer_level', function (Blueprint $table) { + $table->dropColumn('percent'); + }); + } + } +}; diff --git a/tests/Feature/BillPaymentTest.php b/tests/Feature/BillPaymentTest.php index 0191923..830d764 100644 --- a/tests/Feature/BillPaymentTest.php +++ b/tests/Feature/BillPaymentTest.php @@ -6,7 +6,6 @@ use App\Models\BillModel; use App\Models\CustomerLevelModel; use App\Models\PaymentModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; use App\Models\StoreOrderModel; @@ -29,12 +28,7 @@ class BillPaymentTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => '5.00', - ]); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); $user = UserModel::factory()->forStore($store->id)->create(); $this->actingAsMiniUser($user); @@ -85,8 +79,7 @@ class BillPaymentTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '5.00']); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); $user = UserModel::factory()->forStore($store->id)->create(); $this->actingAsMiniUser($user); diff --git a/tests/Feature/CartTest.php b/tests/Feature/CartTest.php index a3ad22d..b91b4c0 100644 --- a/tests/Feature/CartTest.php +++ b/tests/Feature/CartTest.php @@ -5,17 +5,16 @@ namespace Tests\Feature; use App\Models\CartModel; use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\StoreModel; use App\Models\UserModel; /** - * 小程序购物车:加购合并、等级价/金额服务端计算、归属校验、数据隔离、清空 + * 小程序购物车:加购合并、等级上浮价/金额服务端计算、归属校验、数据隔离、清空 */ class CartTest extends ProcurementTestCase { /** - * 造一家门店 + 一个上架商品(含等级价)+ 该店用户 + * 造一家门店 + 一个上架商品(成本价即售价,等级上浮 0%)+ 该店用户 * * @return array{0: StoreModel, 1: ProductModel, 2: UserModel} */ @@ -23,11 +22,9 @@ class CartTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => $price, + $product = ProductModel::factory()->create([ + 'status' => ProductModel::STATUS_ON, + 'cost_price' => $price, ]); return [$store, $product, UserModel::factory()->forStore($store->id)->create()]; @@ -94,15 +91,16 @@ class CartTest extends ProcurementTestCase $this->assertSame(0, CartModel::count()); } - /** 商品未设置门店等级价时拒绝加购 */ - public function test_product_without_level_price_rejected(): void + /** 门店绑定的客户等级被删除时拒绝加购 */ + public function test_store_level_missing_rejected(): void { - [, $product, $user] = $this->makeStoreWithProduct(); - ProductPriceModel::where('product_id', $product->id)->delete(); + [$store, $product, $user] = $this->makeStoreWithProduct(); + CustomerLevelModel::whereKey($store->level_id)->delete(); $this->actingAsMiniUser($user); $this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 1]) - ->assertJsonPath('success', false); + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '门店未设置客户等级,无法加购,请联系客服'); $this->assertSame(0, CartModel::count()); } @@ -129,15 +127,13 @@ class CartTest extends ProcurementTestCase ->assertJsonPath('success', false); } - /** 列表:实时等级价、服务端金额、汇总(同店两商品,新加入在前) */ + /** 列表:实时等级上浮价、服务端金额、汇总(同店两商品,新加入在前) */ public function test_list_with_prices_amounts_and_totals(): void { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - $p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.50']); - ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '3.00']); + $p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.50']); + $p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '3.00']); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); $this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 3]); @@ -165,10 +161,8 @@ class CartTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - $p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.50']); - ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '3.00']); + $p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.50']); + $p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '3.00']); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); $this->postJson('/mini/cart', ['product_id' => $p1->id, 'quantity' => 3]); @@ -283,10 +277,8 @@ class CartTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - $p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $p1->id, 'level_id' => $level->id, 'price' => '5.00']); - ProductPriceModel::factory()->create(['product_id' => $p2->id, 'level_id' => $level->id, 'price' => '5.00']); + $p1 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); + $p2 = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); $userA = UserModel::factory()->forStore($store->id)->create(); $userB = UserModel::factory()->forStore($store->id)->create(); @@ -315,8 +307,7 @@ class CartTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '5.00']); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); $userA = UserModel::factory()->forStore($store->id)->create(); $userB = UserModel::factory()->forStore($store->id)->create(); diff --git a/tests/Feature/ContainerReturnTest.php b/tests/Feature/ContainerReturnTest.php index 361cf2d..f79a970 100644 --- a/tests/Feature/ContainerReturnTest.php +++ b/tests/Feature/ContainerReturnTest.php @@ -7,7 +7,6 @@ use App\Models\BillModel; use App\Models\ContainerReturnModel; use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; use App\Models\StoreOrderModel; @@ -58,12 +57,7 @@ class ContainerReturnTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => '5.00', - ]); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); $user = UserModel::factory()->forStore($store->id)->create(); $this->actingAsMiniUser($user); diff --git a/tests/Feature/ExportTest.php b/tests/Feature/ExportTest.php index 30405e5..5114543 100644 --- a/tests/Feature/ExportTest.php +++ b/tests/Feature/ExportTest.php @@ -5,7 +5,6 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\ProductCategoryModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; use App\Models\StoreOrderModel; @@ -27,10 +26,8 @@ class ExportTest extends ProcurementTestCase $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $veg = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $vegRoot->id]); - $meat = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $meatRoot->id]); - ProductPriceModel::factory()->create(['product_id' => $veg->id, 'level_id' => $level->id, 'price' => '5.00']); - ProductPriceModel::factory()->create(['product_id' => $meat->id, 'level_id' => $level->id, 'price' => '20.00']); + $veg = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $vegRoot->id, 'cost_price' => '5.00']); + $meat = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $meatRoot->id, 'cost_price' => '20.00']); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); $this->postJson('/mini/order', ['items' => [ diff --git a/tests/Feature/MiniProductTest.php b/tests/Feature/MiniProductTest.php index 4887005..c24e98f 100644 --- a/tests/Feature/MiniProductTest.php +++ b/tests/Feature/MiniProductTest.php @@ -4,18 +4,17 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\StoreModel; use App\Models\UserModel; /** - * 小程序商品:商品详情接口(免登录浏览,登录门店按等级显示换算价); - * 商品列表回归:登录门店场景曾数组误用对象访问 + 价格行缺失导致 500 + * 小程序商品:商品详情接口(免登录浏览,登录门店按等级上浮比例显示换算价); + * 商品列表回归:登录门店场景曾数组误用对象访问 + 价格缺失导致 500 */ class MiniProductTest extends ProcurementTestCase { /** - * 造门店 + 上架商品 + 等级价(固定价 5.00) + * 造门店 + 上架商品(成本价即售价,等级上浮 0%) * * @return array{0: StoreModel, 1: ProductModel, 2: UserModel, 3: CustomerLevelModel} */ @@ -23,11 +22,9 @@ class MiniProductTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => $price, + $product = ProductModel::factory()->create([ + 'status' => ProductModel::STATUS_ON, + 'cost_price' => $price, ]); return [$store, $product, UserModel::factory()->forStore($store->id)->create(), $level]; @@ -58,12 +55,11 @@ class MiniProductTest extends ProcurementTestCase $this->assertSame('6.50', $data['price']); } - /** 商品详情:百分比计价按最新成本价上浮换算 */ + /** 商品详情:按门店等级上浮比例换算(成本价上浮 30%) */ public function test_product_detail_percent_price_calculated_from_cost(): void { [, $product, $user, $level] = $this->makeStoreWithProduct(); - ProductPriceModel::where('product_id', $product->id)->where('level_id', $level->id) - ->update(['price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => '30']); + $level->update(['percent' => 30]); $product->update(['cost_price' => '20.00']); $this->actingAsMiniUser($user); diff --git a/tests/Feature/ProductCostPriceTest.php b/tests/Feature/ProductCostPriceTest.php index 9abc53b..13cbecc 100644 --- a/tests/Feature/ProductCostPriceTest.php +++ b/tests/Feature/ProductCostPriceTest.php @@ -6,13 +6,12 @@ use App\Models\CustomerLevelModel; use App\Models\NoticeModel; use App\Models\ProductCategoryModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\StoreModel; use App\Models\UserModel; /** - * 商品成本价 + 等级百分比计价: - * 实际价换算、后台/小程序列表展示、创建编辑切换计价类型、批量调价(成本价/百分比)、成本价防泄漏 + * 商品成本价 + 等级上浮计价: + * 售价换算、后台/小程序列表展示、批量调价(成本价)、成本价防泄漏 */ class ProductCostPriceTest extends ProcurementTestCase { @@ -27,80 +26,41 @@ class ProductCostPriceTest extends ProcurementTestCase ]); } - /** 造门店 + 上架商品(成本价 + 指定计价类型的价格行) + 该店用户 */ + /** 造门店(等级上浮 percent)+ 上架商品(成本价 cost) + 该店用户 */ private function makeStoreWithPercentProduct(float $cost = 10, float $percent = 30): array { - $level = CustomerLevelModel::factory()->create(); + $level = CustomerLevelModel::factory()->create(['percent' => $percent]); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => $cost, ]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => $percent, - 'price' => 13.00, - ]); return [$store, $product, UserModel::factory()->forStore($store->id)->create()]; } - /** 实际价换算:固定价原值;百分比 = 成本 × (100 + percent) / 100 */ - public function test_calc_actual_price_fixed_and_percent(): void + /** 售价换算:成本价 × (100 + percent) / 100 */ + public function test_calc_level_price(): void { - $this->assertSame( - '5.50', - ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_FIXED, '5.50', '0', '10.00'), - '固定价原样返回' - ); - $this->assertSame( - '13.00', - ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30', '10.00'), - '10 元上浮 30% = 13.00' - ); - $this->assertSame( - '13.05', - ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30.50', '10.00'), - '10 元上浮 30.5% = 13.05' - ); + $this->assertSame('13.00', CustomerLevelModel::calcLevelPrice('10.00', '30'), '10 元上浮 30% = 13.00'); + $this->assertSame('13.05', CustomerLevelModel::calcLevelPrice('10.00', '30.50'), '10 元上浮 30.5% = 13.05'); + $this->assertSame('5.50', CustomerLevelModel::calcLevelPrice('5.50', '0'), '上浮 0% = 成本价'); } - /** 换算边界:成本为 0、上浮 0%、非法类型兜底 */ - public function test_calc_actual_price_edge_cases(): void + /** 换算边界:成本为 0 时售价按 0 兜底 */ + public function test_calc_level_price_edge_cases(): void { - $this->assertSame( - '0.00', - ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30', '0'), - '成本为 0 时实际价按 0 兜底' - ); - $this->assertSame( - '10.00', - ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '0', '10.00'), - '上浮 0% = 成本价' - ); - $this->assertSame( - '5.50', - ProductPriceModel::calcActualPrice(99, '5.50', '30', '10.00'), - '非法计价类型按固定价兜底' - ); + $this->assertSame('0.00', CustomerLevelModel::calcLevelPrice('0', '30'), '成本为 0 时售价按 0 兜底'); + $this->assertSame('10.00', CustomerLevelModel::calcLevelPrice('10.00', '0'), '上浮 0% = 成本价'); } - /** 后台商品列表:含成本价列 + 等级价格行的 actual_price */ - public function test_admin_list_contains_cost_price_and_actual_price(): void + /** 后台商品列表:含成本价列 + 各启用等级的换算价 */ + public function test_admin_list_contains_cost_price_and_level_prices(): void { $this->actingAsSysUser(); - $level = CustomerLevelModel::factory()->create(); + $level = CustomerLevelModel::factory()->create(['percent' => 30]); $product = ProductModel::factory()->create(['cost_price' => 10.00]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => 30, - 'price' => 13.00, - ]); $response = $this->getJson('/product/goods'); $response->assertOk()->assertJsonPath('success', true); @@ -108,7 +68,11 @@ class ProductCostPriceTest extends ProcurementTestCase $row = collect($response->json('data.data'))->firstWhere('id', $product->id); $this->assertNotNull($row); $this->assertSame('10.00', (string) $row['cost_price'], '后台列表应含成本价'); - $this->assertSame('13.00', (string) $row['prices'][0]['actual_price'], '后台列表等级价格应为实际价'); + + $priceRow = collect($row['prices'])->firstWhere('level_id', $level->id); + $this->assertNotNull($priceRow, '后台列表应含该等级的换算价'); + $this->assertSame('13.00', (string) $priceRow['price'], '10 元上浮 30% = 13.00'); + $this->assertSame(30.0, (float) $priceRow['percent']); } /** 小程序列表:返回换算后实际价,且成本价不泄漏 */ @@ -122,186 +86,59 @@ class ProductCostPriceTest extends ProcurementTestCase $row = collect($response->json('data.data'))->firstWhere('id', $product->id); $this->assertNotNull($row); - $this->assertSame('13.00', (string) $row['price'], '小程序端应返回百分比换算后的实际价'); + $this->assertSame('13.00', (string) $row['price'], '小程序端应返回按等级上浮换算的实际价'); $this->assertArrayNotHasKey('cost_price', $row, '成本价为商业敏感数据,不得泄漏到小程序端'); - $this->assertArrayNotHasKey('prices', $row, '价格行原始数据(含 percent)也不应下发给小程序'); + $this->assertArrayNotHasKey('prices', $row, '价格明细(含上浮比例)不应下发给小程序'); } - /** 后台创建商品:成本价 + 百分比计价行落库 */ - public function test_create_product_with_cost_and_percent_pricing(): void + /** 后台创建商品:成本价落库(售价由等级上浮比例换算,不再随商品提交价格行) */ + public function test_create_product_with_cost_price(): void { $this->actingAsSysUser(); $category = $this->makeCategory(); - $level = CustomerLevelModel::factory()->create(); $this->postJson('/product/goods', [ 'category_id' => $category->id, - 'name' => '百分比商品', + 'name' => '上浮计价商品', 'content' => '测试图文详情', 'cost_price' => 10, - 'prices' => [ - ['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => 30, 'price' => 13], - ], ])->assertOk()->assertJsonPath('success', true); - $product = ProductModel::where('name', '百分比商品')->first(); + $product = ProductModel::where('name', '上浮计价商品')->first(); $this->assertNotNull($product); $this->assertSame('10.00', (string) $product->cost_price); - - $price = $product->prices->first(); - $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type); - $this->assertSame('30.00', (string) $price->percent); - $this->assertSame('13.00', (string) $price->actual_price, '百分比行实际价应按成本价换算'); } - /** 百分比计价缺少上浮百分点 → 验证失败(XinAdmin 验证错误为 200 + success=false) */ - public function test_create_percent_row_without_percent_fails(): void - { - $this->actingAsSysUser(); - $category = $this->makeCategory(); - $level = CustomerLevelModel::factory()->create(); - - $this->postJson('/product/goods', [ - 'category_id' => $category->id, - 'name' => '缺百分比', - 'content' => '', - 'prices' => [ - // 有单价但缺上浮百分点:after() 交叉校验应拦截 - ['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'price' => 13], - ], - ])->assertOk() - ->assertJsonPath('success', false) - ->assertJsonPath('msg', '按成本百分比计价时必须填写上浮百分点'); - } - - /** 编辑商品:从固定价切换为成本百分比计价 */ - public function test_update_switches_fixed_to_percent(): void - { - $this->actingAsSysUser(); - $category = $this->makeCategory(); - $level = CustomerLevelModel::factory()->create(); - - $product = ProductModel::factory()->create(['category_id' => $category->id, 'cost_price' => 10]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => 5.00, - ]); - - $this->putJson('/product/goods/' . $product->id, [ - 'category_id' => $category->id, - 'name' => $product->name, - 'cost_price' => 10, - 'prices' => [ - ['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => 50, 'price' => 15], - ], - ])->assertOk()->assertJsonPath('success', true); - - $price = ProductPriceModel::forProductLevel($product->id, $level->id)->first(); - $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type); - $this->assertSame('50.00', (string) $price->percent); - $this->assertSame('15.00', (string) $price->actual_price, '10 元上浮 50% = 15.00'); - } - - /** 批量调价-纯成本价行:百分比等级门店收到通知,纯固定价等级门店不通知 */ - public function test_batch_price_cost_only_notifies_percent_level_stores(): void + /** 批量调价-成本价行:更新成本并通知门店;等级售价随上浮比例联动 */ + public function test_batch_price_cost_row_update(): void { $this->actingAsSysUser(); - $percentLevel = CustomerLevelModel::factory()->create(); - $fixedLevel = CustomerLevelModel::factory()->create(); - $percentStore = StoreModel::factory()->create(['level_id' => $percentLevel->id]); - $fixedStore = StoreModel::factory()->create(['level_id' => $fixedLevel->id]); - $percentUser = UserModel::factory()->forStore($percentStore->id)->create(); - $fixedUser = UserModel::factory()->forStore($fixedStore->id)->create(); - + $level = CustomerLevelModel::factory()->create(['percent' => 30]); + $store = StoreModel::factory()->create(['level_id' => $level->id]); + $user = UserModel::factory()->forStore($store->id)->create(); $product = ProductModel::factory()->create(['cost_price' => 10]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $percentLevel->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => 30, - 'price' => 13, - ]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $fixedLevel->id, - 'price' => 8, - ]); $this->putJson('/product/goods/batchPrice', [ 'updates' => [ - ['product_id' => $product->id, 'cost_price' => 12], + ['product_id' => $product->id, 'cost_price' => 20], ], ])->assertOk()->assertJsonPath('success', true); - $this->assertSame('12.00', (string) $product->fresh()->cost_price, '成本价应已更新'); + $this->assertSame('20.00', (string) $product->fresh()->cost_price, '成本价应已更新'); $this->assertSame( 1, - NoticeModel::where('user_id', $percentUser->id)->where('type', NoticeModel::TYPE_PRICE)->count(), - '成本价变更影响百分比计价等级,该等级门店用户应收到通知' - ); - $this->assertSame( - 0, - NoticeModel::where('user_id', $fixedUser->id)->count(), - '固定价等级不受成本价变更影响,不应收到通知' + NoticeModel::where('user_id', $user->id)->where('type', NoticeModel::TYPE_PRICE)->count(), + '门店用户应收到价格变更通知' ); + + // 等级售价联动:20 元上浮 30% = 26.00 + $this->actingAsMiniUser($user); + $row = collect($this->getJson('/mini/product/list')->json('data.data'))->firstWhere('id', $product->id); + $this->assertSame('26.00', (string) $row['price'], '成本变更后小程序端售价应按上浮比例联动'); } - /** 批量调价-百分比行:存 percent 与等价固定价 */ - public function test_batch_price_percent_row_update(): void - { - $this->actingAsSysUser(); - $level = CustomerLevelModel::factory()->create(); - $product = ProductModel::factory()->create(['cost_price' => 10]); - - $this->putJson('/product/goods/batchPrice', [ - 'updates' => [ - [ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => 40, - 'price' => 14, - ], - ], - ])->assertOk()->assertJsonPath('success', true); - - $price = ProductPriceModel::forProductLevel($product->id, $level->id)->first(); - $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type); - $this->assertSame('40.00', (string) $price->percent); - $this->assertSame('14.00', (string) $price->actual_price, '10 元上浮 40% = 14.00'); - } - - /** 批量调价-混合行:同一行同时改成本价与等级价 */ - public function test_batch_price_mixed_row_updates_cost_and_level(): void - { - $this->actingAsSysUser(); - $level = CustomerLevelModel::factory()->create(); - $product = ProductModel::factory()->create(['cost_price' => 10]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 8]); - - $this->putJson('/product/goods/batchPrice', [ - 'updates' => [ - [ - 'product_id' => $product->id, - 'cost_price' => 12, - 'level_id' => $level->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => 25, - 'price' => 15, - ], - ], - ])->assertOk()->assertJsonPath('success', true); - - $this->assertSame('12.00', (string) $product->fresh()->cost_price); - $price = ProductPriceModel::forProductLevel($product->id, $level->id)->first(); - $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type); - $this->assertSame('25.00', (string) $price->percent); - $this->assertSame('15.00', (string) $price->actual_price, '12 元上浮 25% = 15.00'); - } - - /** 批量调价-空行(仅 product_id)→ 验证失败 */ + /** 批量调价-空行(仅 product_id,缺成本价)→ 验证失败 */ public function test_batch_price_empty_row_rejected(): void { $this->actingAsSysUser(); @@ -313,23 +150,17 @@ class ProductCostPriceTest extends ProcurementTestCase ], ])->assertOk() ->assertJsonPath('success', false) - ->assertJsonPath('msg', '调价行缺少成本价或等级价格'); + ->assertJsonPath('msg', '调价行缺少成本价'); } - /** 价格矩阵:行含成本价与每等级的计价类型/百分比,等级格为实际价 */ - public function test_price_matrix_includes_cost_and_percent_columns(): void + /** 价格矩阵:行含成本价,等级格为按上浮比例换算的售价;成本价未设置时等级格为 null */ + public function test_price_matrix_includes_cost_and_level_price_columns(): void { $this->actingAsSysUser(); - $level = CustomerLevelModel::factory()->create(); + $level = CustomerLevelModel::factory()->create(['percent' => 30]); $product = ProductModel::factory()->create(['cost_price' => 10]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => 30, - 'price' => 13, - ]); + $noCostProduct = ProductModel::factory()->create(['cost_price' => 0]); $response = $this->getJson('/product/goods/priceMatrix'); $response->assertOk()->assertJsonPath('success', true); @@ -337,13 +168,15 @@ class ProductCostPriceTest extends ProcurementTestCase $row = collect($response->json('data.rows'))->firstWhere('id', $product->id); $this->assertNotNull($row); $this->assertSame(10.0, (float) $row['cost_price']); - $this->assertSame(13.0, (float) $row['price_' . $level->id], '矩阵等级格应显示实际价'); - $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $row['price_type_' . $level->id]); - $this->assertSame(30.0, (float) $row['percent_' . $level->id]); + $this->assertSame(13.0, (float) $row['price_' . $level->id], '矩阵等级格应显示换算价'); + + $noCostRow = collect($response->json('data.rows'))->firstWhere('id', $noCostProduct->id); + $this->assertNotNull($noCostRow); + $this->assertNull($noCostRow['price_' . $level->id], '成本价未设置时等级格应为 null'); } - /** 小程序下单:百分比计价行按实际价重算金额 */ - public function test_mini_order_uses_actual_price_for_percent_row(): void + /** 小程序下单:按等级上浮换算价重算金额并快照 */ + public function test_mini_order_uses_level_price(): void { [$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30); $this->actingAsMiniUser($user); @@ -355,12 +188,12 @@ class ProductCostPriceTest extends ProcurementTestCase ->assertJsonPath('data.total_amount', '39.00'); $item = $store->orders()->latest('id')->first()->items->first(); - $this->assertSame('13.00', (string) $item->price, '订单明细快照应为实际价'); + $this->assertSame('13.00', (string) $item->price, '订单明细快照应为换算价'); $this->assertSame('39.00', (string) $item->amount); } - /** 小程序购物车:列表金额按百分比换算的实际价计算 */ - public function test_mini_cart_uses_actual_price_for_percent_row(): void + /** 小程序购物车:列表金额按等级上浮换算价计算 */ + public function test_mini_cart_uses_level_price(): void { [$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30); $this->actingAsMiniUser($user); diff --git a/tests/Feature/ProductPriceTest.php b/tests/Feature/ProductPriceTest.php index 36d6159..ce70e12 100644 --- a/tests/Feature/ProductPriceTest.php +++ b/tests/Feature/ProductPriceTest.php @@ -5,33 +5,34 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\NoticeModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\StoreModel; use App\Models\UserModel; /** - * A2 等级价格体系:等级价匹配、批量调价事务与调价通知 + * A2 等级价格体系(等级上浮比例):列表按等级换算价、批量调价(成本价)事务与调价通知、 + * 等级上浮比例变更通知 */ class ProductPriceTest extends ProcurementTestCase { - /** 小程序商品列表返回当前门店等级对应的价格 */ + /** 小程序商品列表按门店等级的上浮比例返回换算价(不同等级不同价) */ public function test_product_list_returns_price_for_store_level(): void { - $levelA = CustomerLevelModel::factory()->create(); - $levelB = CustomerLevelModel::factory()->create(); - $store = StoreModel::factory()->create(['level_id' => $levelA->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelA->id, 'price' => 5.50]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelB->id, 'price' => 3.00]); + $levelA = CustomerLevelModel::factory()->create(['percent' => 30]); + $levelB = CustomerLevelModel::factory()->create(['percent' => 10]); + $storeA = StoreModel::factory()->create(['level_id' => $levelA->id]); + $storeB = StoreModel::factory()->create(['level_id' => $levelB->id]); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => 10.00]); - $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); + $this->actingAsMiniUser(UserModel::factory()->forStore($storeA->id)->create()); + $rowA = collect($this->getJson('/mini/product/list')->assertOk()->json('data.data')) + ->firstWhere('id', $product->id); + $this->assertNotNull($rowA, '商品应出现在列表中'); + $this->assertSame(13.00, (float) $rowA['price'], 'A 等级:10 元上浮 30% = 13.00'); - $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->assertSame(5.50, (float) $row['price'], '应返回门店所在等级的价格'); + $this->actingAsMiniUser(UserModel::factory()->forStore($storeB->id)->create()); + $rowB = collect($this->getJson('/mini/product/list')->assertOk()->json('data.data')) + ->firstWhere('id', $product->id); + $this->assertSame(11.00, (float) $rowB['price'], 'B 等级:10 元上浮 10% = 11.00'); } /** 门店未设置客户等级:仍可浏览商品,但价格不可见(price=null) */ @@ -71,59 +72,123 @@ class ProductPriceTest extends ProcurementTestCase ->assertJsonPath('msg', '尚未绑定门店,请联系客服处理'); } - /** 批量调价:事务写入 + 通知受影响门店用户(不影响无关门店) */ - public function test_batch_price_updates_and_notifies_affected_stores(): void + /** 批量调价(成本价):事务写入 + 通知全部正常门店用户(成本变更影响所有等级售价) */ + public function test_batch_price_updates_cost_and_notifies_all_stores(): void { $this->actingAsSysUser(); - $level = CustomerLevelModel::factory()->create(); - $affectedStore = StoreModel::factory()->create(['level_id' => $level->id]); - $unaffectedStore = StoreModel::factory()->create(['level_id' => CustomerLevelModel::factory()->create()->id]); - $affectedUser = UserModel::factory()->forStore($affectedStore->id)->create(); - $unaffectedUser = UserModel::factory()->forStore($unaffectedStore->id)->create(); + $storeA = StoreModel::factory()->create(['level_id' => CustomerLevelModel::factory()->create()->id]); + $storeB = StoreModel::factory()->create(['level_id' => CustomerLevelModel::factory()->create()->id]); + $userA = UserModel::factory()->forStore($storeA->id)->create(); + $userB = UserModel::factory()->forStore($storeB->id)->create(); - $product = ProductModel::factory()->create(); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]); + $product = ProductModel::factory()->create(['cost_price' => 10]); $this->putJson('/product/goods/batchPrice', [ 'updates' => [ - ['product_id' => $product->id, 'level_id' => $level->id, 'price' => 8.80], + ['product_id' => $product->id, 'cost_price' => 12], ], ])->assertOk()->assertJsonPath('success', true); - $this->assertSame( - 8.80, - (float) ProductPriceModel::forProductLevel($product->id, $level->id)->first()->price, - '等级价格应已更新' - ); + $this->assertSame('12.00', (string) $product->fresh()->cost_price, '成本价应已更新'); $this->assertSame( 1, - NoticeModel::where('user_id', $affectedUser->id)->where('type', NoticeModel::TYPE_PRICE)->count(), - '受影响门店用户应收到价格变更通知' + NoticeModel::where('user_id', $userA->id)->where('type', NoticeModel::TYPE_PRICE)->count(), + '成本价变更影响所有等级售价,门店用户应收到价格变更通知' ); $this->assertSame( - 0, - NoticeModel::where('user_id', $unaffectedUser->id)->count(), - '无关门店用户不应收到通知' + 1, + NoticeModel::where('user_id', $userB->id)->where('type', NoticeModel::TYPE_PRICE)->count(), + '成本价变更影响所有等级售价,门店用户应收到价格变更通知' ); } - /** 价格矩阵:行=商品 × 列=启用等级 */ + /** 价格矩阵:行=商品 × 列=启用等级,等级格为按上浮比例换算的售价 */ public function test_price_matrix_structure(): void { $this->actingAsSysUser(); - $level = CustomerLevelModel::factory()->create(); - $product = ProductModel::factory()->create(); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 6.00]); + $level = CustomerLevelModel::factory()->create(['percent' => 20]); + $product = ProductModel::factory()->create(['cost_price' => 10]); $response = $this->getJson('/product/goods/priceMatrix'); $response->assertOk()->assertJsonPath('success', true); - $this->assertNotEmpty($response->json('data.levels')); + $levels = $response->json('data.levels'); + $this->assertNotEmpty($levels); + $levelRow = collect($levels)->firstWhere('id', $level->id); + $this->assertSame(20.0, (float) $levelRow['percent'], '矩阵等级列应含上浮比例'); + $row = collect($response->json('data.rows'))->firstWhere('id', $product->id); $this->assertNotNull($row); - $this->assertSame(6.0, (float) $row['price_' . $level->id]); + $this->assertSame(12.0, (float) $row['price_' . $level->id], '矩阵等级格应为换算价:10 元上浮 20% = 12.00'); + } + + /** 等级上浮比例变更:通知该等级下正常门店用户(不影响其他等级门店) */ + public function test_level_percent_update_notifies_level_stores(): void + { + $this->actingAsSysUser(); + + $level = CustomerLevelModel::factory()->create(['percent' => 10]); + $otherLevel = CustomerLevelModel::factory()->create(['percent' => 20]); + $user = UserModel::factory()->forStore( + StoreModel::factory()->create(['level_id' => $level->id])->id + )->create(); + $otherUser = UserModel::factory()->forStore( + StoreModel::factory()->create(['level_id' => $otherLevel->id])->id + )->create(); + + $this->putJson('/customer/level/' . $level->id, [ + 'name' => $level->name, + 'percent' => 30, + ])->assertOk()->assertJsonPath('success', true); + + $this->assertSame('30.00', (string) $level->fresh()->percent, '上浮比例应已更新'); + $this->assertSame( + 1, + NoticeModel::where('user_id', $user->id)->where('type', NoticeModel::TYPE_PRICE)->count(), + '该等级门店用户应收到价格变更通知' + ); + $this->assertSame( + 0, + NoticeModel::where('user_id', $otherUser->id)->count(), + '其他等级门店用户不应收到通知' + ); + } + + /** 上浮比例未变化时不重复通知 */ + public function test_level_update_without_percent_change_sends_no_notice(): void + { + $this->actingAsSysUser(); + + $level = CustomerLevelModel::factory()->create(['percent' => 10]); + $user = UserModel::factory()->forStore( + StoreModel::factory()->create(['level_id' => $level->id])->id + )->create(); + + $this->putJson('/customer/level/' . $level->id, [ + 'name' => $level->name . '(改)', + 'percent' => 10, + ])->assertOk()->assertJsonPath('success', true); + + $this->assertSame(0, NoticeModel::where('user_id', $user->id)->count(), '上浮比例未变不应发通知'); + } + + /** 等级表单:上浮比例校验(负数/超限拒绝) */ + public function test_level_percent_validation(): void + { + $this->actingAsSysUser(); + + $this->postJson('/customer/level', ['name' => '负比例等级', 'percent' => -1]) + ->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '价格上浮比例不能小于 0'); + $this->postJson('/customer/level', ['name' => '超比例等级', 'percent' => 1000]) + ->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '价格上浮比例不能超过 999.99'); + + $this->assertSame(0, CustomerLevelModel::count(), '校验失败不应落库'); } } diff --git a/tests/Feature/PurchaseEditTest.php b/tests/Feature/PurchaseEditTest.php index 7857186..d43fda2 100644 --- a/tests/Feature/PurchaseEditTest.php +++ b/tests/Feature/PurchaseEditTest.php @@ -4,7 +4,6 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; use App\Models\StoreOrderItemModel; @@ -19,7 +18,7 @@ use App\Models\UserModel; class PurchaseEditTest extends ProcurementTestCase { /** - * 造采购链路:门店A 2 件 + 门店B 3 件(同一商品,等级价 10.00,成本 20,包规 10斤/箱 → 单价 2.00), + * 造采购链路:门店A 2 件 + 门店B 3 件(同一商品,等级上浮 0% → 售价=成本价 10.00,包规 10斤/箱), * 接单后生成采购单 * * @return array{0: PurchaseOrderModel, 1: ProductModel, 2: array} @@ -31,11 +30,10 @@ class PurchaseEditTest extends ProcurementTestCase $storeB = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, - 'cost_price' => 20, + 'cost_price' => 10, 'spec' => '10斤/箱', 'unit' => '斤', ]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]); foreach ([[$storeA, 2], [$storeB, 3]] as [$store, $qty]) { $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); @@ -70,7 +68,7 @@ class PurchaseEditTest extends ProcurementTestCase $this->assertSame($product->id, $row['product_id']); $this->assertSame('10斤/箱', $row['product_spec']); $this->assertSame('斤', $row['unit']); - $this->assertSame('20.00', (string) $row['cost_price']); + $this->assertSame('10.00', (string) $row['cost_price']); $this->assertSame(5, $row['quantity'], '2+3'); $cells = $row['cells']; @@ -101,7 +99,7 @@ class PurchaseEditTest extends ProcurementTestCase $purchase = $purchase->fresh(); $this->assertSame('8.00', (string) $purchase->total_quantity, '5+3'); $this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30'); - $this->assertSame('160.00', (string) $purchase->actual_amount, '8 包 × 每包成本 20.00'); + $this->assertSame('80.00', (string) $purchase->actual_amount, '8 包 × 每包成本 10.00'); } /** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×每包成本 重算 */ @@ -279,13 +277,14 @@ class PurchaseEditTest extends ProcurementTestCase $purchase = $purchase->fresh(); $this->assertSame('4.500', (string) $purchase->total_weight); $this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30 订货金额'); - $this->assertSame('160.00', (string) $purchase->actual_amount, '8 包 × 每包成本 20.00(称重仅参考,不参与金额)'); + $this->assertSame('80.00', (string) $purchase->actual_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)'); } - /** 单元格一键同步:按商品ID同步档案 + 等级价重算,订货单与采购单汇总级联 */ + /** 单元格一键同步:按商品ID同步档案 + 等级上浮价重算,订货单与采购单汇总级联 */ public function test_cell_sync_refreshes_snapshot_and_cascades(): void { - $level = CustomerLevelModel::factory()->create(); + // 等级上浮 30%:下单时成本 10.00 → 售价 13.00 + $level = CustomerLevelModel::factory()->create(['percent' => 30]); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, @@ -293,12 +292,6 @@ class PurchaseEditTest extends ProcurementTestCase 'spec' => '1斤/袋', 'unit' => '斤', ]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => 30, - ]); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 2]]]) @@ -371,11 +364,10 @@ class PurchaseEditTest extends ProcurementTestCase $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, - 'cost_price' => 20, + 'cost_price' => 10, 'spec' => '10斤/箱', 'unit' => '斤', ]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); foreach ([2, 3] as $qty) { @@ -433,7 +425,7 @@ class PurchaseEditTest extends ProcurementTestCase ->assertJsonPath('success', false); $this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变'); - $this->assertSame('20.00', (string) $item->fresh()->cost_price); + $this->assertSame('10.00', (string) $item->fresh()->cost_price); // 下钻明细同步标记不可编辑 $this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[0]->id}") diff --git a/tests/Feature/PurchaseGenerateTest.php b/tests/Feature/PurchaseGenerateTest.php index 1e468c1..d7f0a90 100644 --- a/tests/Feature/PurchaseGenerateTest.php +++ b/tests/Feature/PurchaseGenerateTest.php @@ -4,7 +4,6 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; use App\Models\StoreOrderItemModel; @@ -19,17 +18,14 @@ class PurchaseGenerateTest extends ProcurementTestCase { /** * 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单 - * 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价),每包成本 6.00 + * 门店等级上浮 0%(售价=成本价 6.00),预估成本按成本价合计 */ private function seedAcceptedOrders(): ProductModel { $level = CustomerLevelModel::factory()->create(); - $levelLow = CustomerLevelModel::factory()->create(); $storeA = StoreModel::factory()->create(['level_id' => $level->id]); $storeB = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '6.00']); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelLow->id, 'price' => 4.00]); foreach ([[$storeA, 3], [$storeA, 3], [$storeB, 4]] as [$store, $qty]) { $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); @@ -91,8 +87,7 @@ class PurchaseGenerateTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]) diff --git a/tests/Feature/StoreOrderItemTest.php b/tests/Feature/StoreOrderItemTest.php index 0c764f6..0ee521b 100644 --- a/tests/Feature/StoreOrderItemTest.php +++ b/tests/Feature/StoreOrderItemTest.php @@ -4,7 +4,6 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\StoreModel; use App\Models\StoreOrderModel; use App\Models\SupplierModel; @@ -17,23 +16,20 @@ use App\Models\UserModel; class StoreOrderItemTest extends ProcurementTestCase { /** - * 造门店 + 上架商品(指定成本价)+ 固定等级价 + 门店用户 + * 造门店 + 上架商品(指定成本价)+ 门店用户; + * 等级上浮比例按 price/costPrice 反算(售价 = 成本价 × (100 + percent) / 100) * * @return array{0: StoreModel, 1: ProductModel, 2: UserModel} */ private function makeStoreWithProduct(string $price = '5.00', string $costPrice = '4.00'): array { - $level = CustomerLevelModel::factory()->create(); + $percent = round(((float) $price / (float) $costPrice - 1) * 100, 2); + $level = CustomerLevelModel::factory()->create(['percent' => $percent]); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => $costPrice, ]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => $price, - ]); return [$store, $product, UserModel::factory()->forStore($store->id)->create()]; } @@ -58,7 +54,8 @@ class StoreOrderItemTest extends ProcurementTestCase public function test_place_order_snapshots_full_product_info(): void { $supplier = SupplierModel::factory()->create(); - $level = CustomerLevelModel::factory()->create(); + // 上浮 37.5%:成本 4.00 → 售价 5.50 + $level = CustomerLevelModel::factory()->create(['percent' => 37.5]); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, @@ -70,11 +67,6 @@ class StoreOrderItemTest extends ProcurementTestCase 'shelf_life' => 30, 'cost_price' => '4.00', ]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => '5.50', - ]); $user = UserModel::factory()->forStore($store->id)->create(); $this->actingAsMiniUser($user); @@ -244,18 +236,13 @@ class StoreOrderItemTest extends ProcurementTestCase /** 一键同步:百分比计价等级按最新成本价重算单价与订单总价 */ public function test_sync_item_recalculates_percent_price_with_latest_cost(): void { - $level = CustomerLevelModel::factory()->create(); + // 等级上浮 30%:下单时成本 10.00 → 售价 13.00 + $level = CustomerLevelModel::factory()->create(['percent' => 30]); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => '10.00', ]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => 30, - ]); $user = UserModel::factory()->forStore($store->id)->create(); $order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED); @@ -303,16 +290,8 @@ class StoreOrderItemTest extends ProcurementTestCase $store = StoreModel::factory()->create(['level_id' => $level->id]); $user = UserModel::factory()->forStore($store->id)->create(); - $cabbage = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '大白菜A']); - $potato = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '土豆B']); - foreach ([$cabbage, $potato] as $p) { - ProductPriceModel::factory()->create([ - 'product_id' => $p->id, - 'level_id' => $level->id, - 'price' => '5.00', - ]); - } - + $cabbage = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '大白菜A', 'cost_price' => '5.00']); + $potato = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'name' => '土豆B', 'cost_price' => '5.00']); $this->placeOrder($cabbage, $user); $this->placeOrder($potato, $user); diff --git a/tests/Feature/StoreOrderTest.php b/tests/Feature/StoreOrderTest.php index 7c946b1..0700a39 100644 --- a/tests/Feature/StoreOrderTest.php +++ b/tests/Feature/StoreOrderTest.php @@ -4,14 +4,13 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\ProductModel; -use App\Models\ProductPriceModel; use App\Models\StoreModel; use App\Models\StoreOrderModel; use App\Models\UserModel; use Modules\SystemTool\Models\SysFileModel; /** - * 小程序下单:等级价快照、服务端重算总价、取消限制、门店数据隔离 + * 小程序下单:等级上浮价快照、服务端重算总价、取消限制、门店数据隔离 */ class StoreOrderTest extends ProcurementTestCase { @@ -22,11 +21,9 @@ class StoreOrderTest extends ProcurementTestCase { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => $price, + $product = ProductModel::factory()->create([ + 'status' => ProductModel::STATUS_ON, + 'cost_price' => $price, ]); return [$store, $product, UserModel::factory()->forStore($store->id)->create()]; @@ -73,11 +70,11 @@ class StoreOrderTest extends ProcurementTestCase $this->assertSame('10.00', (string) $order->total_amount, '应按等级价 5.00×2 计算,忽略前端金额'); } - /** 商品未设置门店等级价格时拒绝下单 */ - public function test_product_without_level_price_rejected(): void + /** 门店绑定的客户等级被删除时拒绝下单 */ + public function test_store_level_missing_rejected(): void { - [, $product, $user] = $this->makeStoreWithProduct('5.00'); - ProductPriceModel::where('product_id', $product->id)->delete(); + [$store, $product, $user] = $this->makeStoreWithProduct('5.00'); + CustomerLevelModel::whereKey($store->level_id)->delete(); $this->actingAsMiniUser($user); $this->postJson('/mini/order', [ @@ -140,8 +137,7 @@ class StoreOrderTest extends ProcurementTestCase $items = []; foreach (['白菜', '土豆', '番茄', '黄瓜'] as $name) { - $product = ProductModel::factory()->create(['name' => $name, 'status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]); + $product = ProductModel::factory()->create(['name' => $name, 'status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); $items[] = ['product_id' => $product->id, 'quantity' => 2]; } $this->postJson('/mini/order', ['items' => $items])->assertJsonPath('success', true); @@ -345,11 +341,7 @@ class StoreOrderTest extends ProcurementTestCase 'spec' => '30斤/筐', 'unit' => '筐', 'image_ids' => (string) $file->id, - ]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price' => '5.00', + 'cost_price' => '5.00', ]); $user = UserModel::factory()->forStore($store->id)->create(); diff --git a/web/api/product/goods.ts b/web/api/product/goods.ts index 303c00c..28a4801 100644 --- a/web/api/product/goods.ts +++ b/web/api/product/goods.ts @@ -10,8 +10,8 @@ export interface PriceMatrixParams { } /** - * A2 价格矩阵:行=商品,列=启用等级,值=实际销售价(缺失 null); - * 行内含 cost_price 与 price_type_{level_id} / percent_{level_id} + * A2 价格矩阵:行=商品,列=启用等级,值=按等级上浮比例换算的售价(成本价未设置 null); + * 仅成本价可编辑 */ export async function getPriceMatrix(params?: PriceMatrixParams) { return createAxios({ @@ -22,7 +22,7 @@ export async function getPriceMatrix(params?: PriceMatrixParams) { } /** - * A2 批量调价:支持成本价 / 固定价 / 成本百分比三类更新行(提交后给受影响门店生成价格变更通知) + * A2 批量调价:批量调整成本价(等级售价随上浮比例联动;提交后给受影响门店生成价格变更通知) */ export async function batchPrice(updates: IBatchPriceUpdate[]) { return createAxios({ diff --git a/web/domain/iCustomerLevel.ts b/web/domain/iCustomerLevel.ts index df7460d..2abb76e 100644 --- a/web/domain/iCustomerLevel.ts +++ b/web/domain/iCustomerLevel.ts @@ -5,6 +5,8 @@ export default interface ICustomerLevel { id?: number; /** 等级名称 */ name?: string; + /** 价格上浮比例(%,售价 = 成本价 × (100 + percent) / 100) */ + percent?: string | number; sort?: number; status?: number; icon_id?: number; diff --git a/web/domain/iProduct.ts b/web/domain/iProduct.ts index 1c1b82d..e303aa5 100644 --- a/web/domain/iProduct.ts +++ b/web/domain/iProduct.ts @@ -2,19 +2,14 @@ import type IProductCategory from '@/domain/iProductCategory.ts'; import type ISupplier from '@/domain/iSupplier.ts'; import type {ISysFileInfo} from "@/domain/iSysFile.ts"; -/** 商品等级价格行(null = 该等级未设定价格,编辑时移除该行) */ +/** 商品等级展示价(后端按等级上浮比例换算:售价 = 成本价 × (100 + percent) / 100) */ export interface IProductPrice { - id?: number; - product_id?: number; + /** 客户等级ID */ level_id?: number; - /** 单价:固定价=实际单价;成本百分比=等价固定价 */ + /** 该等级的价格上浮比例(%) */ + percent?: string | number; + /** 换算后的售价(两位小数字符串) */ price?: string | number | null; - /** 计价类型:0 固定价(默认) 1 成本百分比 */ - price_type?: number; - /** 成本上浮百分点(price_type=1 时生效,如 30 = 上浮 30%) */ - percent?: string | number | null; - /** 实际销售价(模型换算:百分比 = 成本价 × (100 + percent) / 100) */ - actual_price?: string | number; level?: { id: number; name: string }; } @@ -45,7 +40,7 @@ export default interface IProduct { stock?: number; /** 状态 */ status?: number; - /** 成本价(元,成本百分比计价的基数) */ + /** 成本价(元,等级上浮计价的基数) */ cost_price?: string | number; /** 描述 */ remark?: string; @@ -53,7 +48,7 @@ export default interface IProduct { category?: IProductCategory; /** 供应商关联数据 */ supplier?: ISupplier; - /** 多等级价格 */ + /** 各启用等级的换算展示价 */ prices?: IProductPrice[]; /** 创建时间 */ created_at?: string; @@ -65,7 +60,7 @@ export const PRODUCT_STATUS_MAP: Record }; /** - * 价格矩阵行:固定列 + price_{level_id}(实际价)/ price_type_{level_id} / percent_{level_id} 动态列 + * 价格矩阵行:固定列 + price_{level_id}(按等级上浮比例换算的售价)动态列 */ export type IPriceMatrixRow = { id: number; @@ -77,27 +72,17 @@ export type IPriceMatrixRow = { } & Record; export interface IPriceMatrix { - levels: { id: number; name: string }[]; + levels: { id: number; name: string; percent: string | number }[]; rows: IPriceMatrixRow[]; total: number; } /** - * 批量调价更新行(三类,可混合同一行): - * - 成本行 { product_id, cost_price } - * - 固定价行 { product_id, level_id, price_type: 0, price } - * - 百分比行 { product_id, level_id, price_type: 1, percent }(price 可选,等价固定价) + * 批量调价更新行:{ product_id, cost_price } + * (等级售价 = 成本价 × (100 + 等级上浮比例) / 100,调价即调成本价) */ export interface IBatchPriceUpdate { product_id: number; - /** 成本价(成本行) */ + /** 成本价 */ cost_price?: number; - /** 客户等级ID(等级价格行) */ - level_id?: number; - /** 计价类型:0 固定价(默认) 1 成本百分比 */ - price_type?: number; - /** 固定价 / 百分比行的等价固定价 */ - price?: number; - /** 成本上浮百分点(price_type=1 时必填) */ - percent?: number; } diff --git a/web/pages/customer/level.tsx b/web/pages/customer/level.tsx index fdfc22e..1f0f36e 100644 --- a/web/pages/customer/level.tsx +++ b/web/pages/customer/level.tsx @@ -28,6 +28,17 @@ const CustomerLevelPage: React.FC = () => { align: 'center', rules: [{ required: true, message: '请输入等级名称' }], }, + { + title: '价格上浮比例', + dataIndex: 'percent', + valueType: 'digit', + hideInSearch: true, + initialValue: 0, + tooltip: '该等级售价 = 成本价 × (100 + 上浮比例) / 100', + fieldProps: { min: 0, max: 999.99, precision: 2, suffix: '%', placeholder: '如 30 表示成本价上浮 30%' }, + align: 'center', + render: (_, record) => {Number(record.percent ?? 0)}%, + }, { title: '排序', dataIndex: 'sort', @@ -111,7 +122,7 @@ const CustomerLevelPage: React.FC = () => { <>
客户等级 - 同一商品按客户等级显示不同单价,门店绑定等级后小程序端按对应价格展示。 + 按等级配置价格上浮比例:售价 = 成本价 × (100 + 上浮比例) / 100,门店绑定等级后小程序端按对应价格展示。
{...tableProps} /> diff --git a/web/pages/dashboard/analysis.tsx b/web/pages/dashboard/analysis.tsx index a5948a1..08428d7 100644 --- a/web/pages/dashboard/analysis.tsx +++ b/web/pages/dashboard/analysis.tsx @@ -454,7 +454,7 @@ const Index: React.FC = () => { title: t("dashboard.analysis.quantity"), key: "quantity", width: 100, - render: (_, record) => `${record.quantity} ${record.unit}`, + render: (_, record) => `${record.quantity}`, }, { title: t("dashboard.analysis.amount"), diff --git a/web/pages/product/goods.tsx b/web/pages/product/goods.tsx index 0e334aa..b21d807 100644 --- a/web/pages/product/goods.tsx +++ b/web/pages/product/goods.tsx @@ -2,11 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button, Card, Drawer, - Form, Image, + Image, Input, InputNumber, message, - Select, Space, Table, Tag, Tree, @@ -14,15 +13,14 @@ import { Typography, } from 'antd'; import { TableOutlined } from '@ant-design/icons'; -import type { FormInstance, TableProps } from 'antd'; +import type { TableProps } from 'antd'; import XinTable from '@/components/XinTable'; import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts'; import type IProduct from '@/domain/iProduct.ts'; -import type { IBatchPriceUpdate, IPriceMatrixRow, IProductPrice } from '@/domain/iProduct.ts'; +import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts'; import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts'; import type ICustomerLevel from '@/domain/iCustomerLevel.ts'; import type {IProductCategoryTree} from '@/domain/iProductCategory.ts'; -import { getLevelOptions } from '@/api/customer/level.ts'; import { getSupplierOptions } from '@/api/customer/supplier.ts'; import type ISupplier from '@/domain/iSupplier.ts'; import { getCategoryTree } from '@/api/product/category.ts'; @@ -30,14 +28,6 @@ import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts'; const { Title, Text } = Typography; -/** 计价类型:0 固定价 / 1 成本百分比(与后端 ProductPriceModel::PRICE_TYPE_* 一致) */ -const PRICE_TYPE_FIXED = 0; -const PRICE_TYPE_PERCENT = 1; -const PRICE_TYPE_OPTIONS = [ - { value: PRICE_TYPE_FIXED, label: '固定价' }, - { value: PRICE_TYPE_PERCENT, label: '成本百分比' }, -]; - /** 四舍五入保留两位 */ const round2 = (v: number) => Math.round(v * 100) / 100; @@ -57,122 +47,13 @@ const markParentUnselectable = (nodes: IProductCategoryTree[]): IProductCategory children: node.children?.length ? markParentUnselectable(node.children) : node.children, })); -/** - * 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框; - * 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100)) - */ -const LevelPriceFields: React.FC<{ - form: FormInstance; - levels: ICustomerLevel[]; -}> = ({ form, levels }) => { - const prices = Form.useWatch('prices', form) ?? []; - const costPrice = Number(Form.useWatch('cost_price', form) ?? 0); - - if (levels.length === 0) { - return ( - - 暂无可配置的客户等级,请先到「客户等级」中新增 - - ); - } - - const updateRow = (levelId: number, patch: Partial) => { - const next = prices.filter((p) => p.level_id !== levelId); - next.push({ ...patch, level_id: levelId }); - form.setFieldValue('prices', next); - }; - - const removeRow = (levelId: number) => { - form.setFieldValue('prices', prices.filter((p) => p.level_id !== levelId)); - }; - - /** 切换计价类型:按成本价自动换算(成本未设置时百分比计价不可用) */ - const onTypeChange = (levelId: number, row: IProductPrice | undefined, type: number) => { - if (type === PRICE_TYPE_PERCENT) { - if (!(costPrice > 0)) { - message.warning('请先在商品信息中设置成本价,才能按成本百分比计价'); - return; // Select 为受控组件,未更新表单值即回弹 - } - const price = Number(row?.price ?? 0); - updateRow(levelId, { - price_type: type, - percent: price > 0 ? round2((price / costPrice - 1) * 100) : null, - }); - } else { - const percent = Number(row?.percent ?? 0); - updateRow(levelId, { - price_type: type, - price: costPrice > 0 && percent > 0 ? round2(costPrice * (1 + percent / 100)) : (row?.price ?? null), - }); - } - }; - - return ( - - {levels.map((level) => { - if (level.id == null) return null; - const row = prices.find((p) => p.level_id === level.id); - const type = row?.price_type ?? PRICE_TYPE_FIXED; - return ( - - {level.name} -