diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 2cf0127..5da5262 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":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,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_uses_level_percent_price":8,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_skips_catalog_sync_when_product_deleted":7,"Tests\\Feature\\ProductImportExportTest::test_export_filters_by_category":8,"Tests\\Feature\\ProductImportExportTest::test_import_creates_products_and_auto_supplier":7,"Tests\\Feature\\ProductImportExportTest::test_import_matches_existing_supplier":7,"Tests\\Feature\\ProductImportExportTest::test_import_errors_abort_all_rows":7,"Tests\\Feature\\ProductImportExportTest::test_import_category_path_resolution":7,"Tests\\Feature\\MiniReportTest::test_report_accumulates_same_product_across_orders":7,"Tests\\Feature\\ExportTest::test_export_suppliers_split_by_market":7,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_syncs_gateway_status":7,"Tests\\Feature\\MiniSpecialTest::test_cart_quantity_attached_for_store":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.094,"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.012,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.057,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.057,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.053,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.054,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.11,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.11,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.107,"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.053,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.146,"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.056,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.003,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.046,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.048,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.046,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.008,"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.085,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.051,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.05,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.045,"Tests\\Feature\\CartTest::test_update_quantity":0.053,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.048,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.046,"Tests\\Feature\\CartTest::test_delete_item":0.05,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.094,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.011,"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.077,"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.051,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.009,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.01,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.009,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.002,"Tests\\Feature\\MiniAuthTest::test_register_rejects_duplicate_openid":0.003,"Tests\\Feature\\MiniStoreTest::test_info_returns_bound_store":0.005,"Tests\\Feature\\MiniStoreTest::test_update_info":0.048,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.044,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.046,"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.052,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.103,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.056,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.071,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.052,"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.067,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.075,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.308,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.073,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.141,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.109,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.177,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.115,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.117,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.14,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.117,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.143,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.116,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.121,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.135,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.132,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.136,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.065,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.111,"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.006,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.008,"Tests\\Feature\\ProductCategoryTest::test_update_second_level_move_to_another_top":0.009,"Tests\\Feature\\ProductCategoryTest::test_update_parent_with_children_cannot_move_under_category":0.007,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.006,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.011,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.009,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.058,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.05,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.052,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.061,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.081,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.12,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.062,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.072,"Tests\\Feature\\StoreOrderTest::test_order_list_filters_by_order_date_range":0.078,"Tests\\Feature\\StoreOrderTest::test_order_list_rejects_invalid_filters":0.061,"Tests\\Feature\\BillExportTest::test_export_merges_items_across_bills":0.115,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.052,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.086,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.048,"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.014,"Tests\\Feature\\BillExportTest::test_mini_export_requires_store_binding":0.005,"Tests\\Feature\\MiniNoticeTest::test_notice_list_includes_personal_and_broadcast":0.059,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.053,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.056,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.007,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.112,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.178,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.137,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.06,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.082,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.052,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.049,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.074,"Tests\\Feature\\MiniProductTest::test_product_detail_store_user_sees_level_price":0.006,"Tests\\Feature\\MiniProductTest::test_product_detail_percent_price_calculated_from_cost":0.051,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.051,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.06,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.072,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.052,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.057,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.09,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.062,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.06,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.077,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.056,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.009,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.106,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.156,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.024,"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.055,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.055,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.075,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.053,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.086,"Tests\\Feature\\DashboardTest::test_top_stores_ranked_by_amount":0.118,"Tests\\Feature\\DashboardTest::test_purchase_amount_prefers_actual_over_estimate":0.009,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":0.057,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.062,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.16,"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.105,"Tests\\Feature\\ProductPriceTest::test_level_percent_update_notifies_level_stores":0.125,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.05,"Tests\\Feature\\ProductPriceTest::test_level_percent_validation":0.009,"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.013,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.067,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.052,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_level_price":0.054,"Tests\\Feature\\CartTest::test_store_level_missing_rejected":0.006,"Tests\\Feature\\StoreOrderTest::test_store_level_missing_rejected":0.059,"Tests\\Feature\\PurchaseEditTest::test_update_row_skips_catalog_sync_when_product_deleted":0.107,"Tests\\Feature\\PurchaseEditTest::test_store_item_add_creates_item_and_recalculates":0.12,"Tests\\Feature\\PurchaseEditTest::test_store_item_update_merges_multi_order_rows":0.08,"Tests\\Feature\\PurchaseEditTest::test_store_item_remove_deletes_and_recalculates":0.143,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_recalculates_totals":0.076,"Tests\\Feature\\StoreOrderItemTest::test_store_item_edits_rejected_when_purchase_completed":0.087,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_validates_negative_values":0.065,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_uses_level_percent_price":0.093,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_rejected_when_product_deleted":0.071,"Tests\\Feature\\ExportTest::test_export_lists_all_catalog_products_with_totals_row":0.123,"Tests\\Feature\\ExportTest::test_export_supplier_filter":0.144,"Tests\\Feature\\ExportTest::test_export_stores_multi_sheet":0.138,"Tests\\Feature\\ExportTest::test_export_stores_single_store":0.119,"Tests\\Feature\\ExportTest::test_export_stores_without_items_rejected":0.174,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":0.174,"Tests\\Feature\\ExportTest::test_export_suppliers_single":0.138,"Tests\\Feature\\ExportTest::test_export_suppliers_without_items_rejected":0.117,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_change_reprices_items_by_store_level":0.133,"Tests\\Feature\\PurchaseEditTest::test_update_row_without_cost_change_keeps_item_price":0.121,"Tests\\Feature\\CartTest::test_update_other_stores_item_rejected":0.09,"Tests\\Feature\\CartTest::test_delete_other_stores_item_rejected":0.092,"Tests\\Feature\\CartTest::test_cart_isolated_between_stores":0.089,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_valid_credentials":0.145,"Tests\\Feature\\MiniAuthTest::test_login_rejects_wrong_password":0.146,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unknown_username":0.002,"Tests\\Feature\\MiniAuthTest::test_disabled_store_cannot_login":0.178,"Tests\\Feature\\MiniAuthTest::test_login_validates_required_params":0.004,"Tests\\Feature\\MiniAuthTest::test_login_token_accesses_protected_routes":0.146,"Tests\\Feature\\MiniAuthTest::test_set_password_succeeds":0.255,"Tests\\Feature\\MiniAuthTest::test_set_password_rejects_wrong_old_password":0.142,"Tests\\Feature\\MiniAuthTest::test_set_password_validates_confirmation":0.077,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_own_store":0.093,"Tests\\Feature\\MiniNoticeTest::test_other_stores_notice_not_found":0.12,"Tests\\Feature\\MiniProductTest::test_product_detail_store_sees_level_price":0.049,"Tests\\Feature\\MiniProductTest::test_product_list_store_sees_level_price":0.063,"Tests\\Feature\\MiniStoreTest::test_info_returns_current_store":0.044,"Tests\\Feature\\MiniStoreTest::test_disabled_store_rejected":0.047,"Tests\\Feature\\ProductImportExportTest::test_export_downloads_all_products":0.009,"Tests\\Feature\\ProductImportExportTest::test_export_filters_by_category":0.008,"Tests\\Feature\\ProductImportExportTest::test_export_template_mode":0.006,"Tests\\Feature\\ProductImportExportTest::test_export_requires_permission":0.007,"Tests\\Feature\\ProductImportExportTest::test_import_creates_products_and_auto_supplier":0.056,"Tests\\Feature\\ProductImportExportTest::test_import_matches_existing_supplier":0.03,"Tests\\Feature\\ProductImportExportTest::test_import_errors_abort_all_rows":0.019,"Tests\\Feature\\ProductImportExportTest::test_import_category_path_resolution":0.034,"Tests\\Feature\\ProductImportExportTest::test_import_rejects_invalid_header":0.019,"Tests\\Feature\\ProductImportExportTest::test_import_requires_permission":0.02,"Tests\\Feature\\ProductImportExportTest::test_list_filters_by_market":0.017,"Tests\\Feature\\MiniReportTest::test_report_aggregates_total_and_item_percent":0.054,"Tests\\Feature\\MiniReportTest::test_report_accumulates_same_product_across_orders":0.052,"Tests\\Feature\\MiniReportTest::test_report_filters_by_custom_date_range":0.05,"Tests\\Feature\\MiniReportTest::test_report_excludes_cancelled_and_deleted_orders":0.063,"Tests\\Feature\\MiniReportTest::test_report_preset_month_and_last_month":0.065,"Tests\\Feature\\MiniReportTest::test_report_preset_week_and_last_week":0.072,"Tests\\Feature\\MiniReportTest::test_report_defaults_to_current_month":0.075,"Tests\\Feature\\MiniReportTest::test_report_data_isolated_between_stores":0.099,"Tests\\Feature\\MiniReportTest::test_report_empty_period_returns_zero":0.063,"Tests\\Feature\\MiniReportTest::test_report_validates_params":0.059,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_soft_deletes_selected_products":0.008,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_requires_non_empty_ids":0.011,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_forbidden_without_permission":0.01,"Tests\\Feature\\CartTest::test_place_order_clears_ordered_items_from_cart":0.058,"Tests\\Feature\\StoreOrderItemTest::test_admin_order_list_filters_by_order_date_range":0.069,"Tests\\Feature\\StoreOrderItemTest::test_admin_order_list_search_by_purchase_no":0.08,"Tests\\Feature\\ExportTest::test_export_suppliers_split_by_market":0.059,"Tests\\Feature\\BillPaymentTest::test_generate_bill_saves_after_sale_and_remark":0.117,"Tests\\Feature\\ProductCostPriceTest::test_product_price_unit_default_and_editable":0.045,"Tests\\Feature\\StoreOrderTest::test_place_order_blocked_by_overdue_unpaid_bill":0.062,"Tests\\Feature\\StoreOrderTest::test_place_order_allowed_within_payment_cycle":0.072,"Tests\\Feature\\CartTest::test_cart_summary_endpoint":0.053,"Tests\\Feature\\CartTest::test_cart_summary_requires_login":0.002,"Tests\\Feature\\MiniProductTest::test_product_list_appends_cart_quantity_and_summary":0.057,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_zero_cart_fields":0.052,"Tests\\Feature\\MiniProductTest::test_product_detail_appends_cart_quantity":0.049,"Tests\\Feature\\MiniProductTest::test_home_appends_cart_summary":0.071,"Tests\\Feature\\MiniOnlinePaymentTest::test_sign_matches_document_example":0.002,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_online_payment_success":0.097,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_online_payment_validation":0.099,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_fails_when_code2session_fails":0.048,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_gateway_failure_releases_bills":0.071,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_settles_payment":0.062,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_rejects_invalid_messages":0.064,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_syncs_gateway_status":0.071,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_rejects_other_store_payment":0.09,"Tests\\Feature\\MiniOnlinePaymentTest::test_offline_payment_flow_unaffected":0.074,"Tests\\Feature\\BillPaymentTest::test_generate_bill_positive_after_sale_added_to_total":0.113,"Tests\\Feature\\BillPaymentTest::test_generate_bill_rejects_non_numeric_after_sale":0.048,"Tests\\Feature\\ClientSpecialTest::test_batch_add_creates_specials":0.008,"Tests\\Feature\\ClientSpecialTest::test_batch_add_skips_existing_products":0.007,"Tests\\Feature\\ClientSpecialTest::test_batch_add_requires_non_empty_product_ids":0.007,"Tests\\Feature\\ClientSpecialTest::test_batch_add_forbidden_without_permission":0.008,"Tests\\Feature\\ClientSpecialTest::test_query_filters_by_product_name_keyword":0.009,"Tests\\Feature\\ClientSpecialTest::test_update_changes_sort_and_status":0.007,"Tests\\Feature\\ClientSpecialTest::test_delete_and_batch_delete":0.009,"Tests\\Feature\\ClientSpecialTest::test_batch_delete_requires_non_empty_ids":0.009,"Tests\\Feature\\MiniSpecialTest::test_guest_sees_specials_with_null_price":0.049,"Tests\\Feature\\MiniSpecialTest::test_store_sees_level_price":0.053,"Tests\\Feature\\MiniSpecialTest::test_disabled_special_and_off_shelf_product_excluded":0.052,"Tests\\Feature\\MiniSpecialTest::test_specials_sorted_by_sort_asc":0.097,"Tests\\Feature\\MiniSpecialTest::test_cart_quantity_attached_for_store":0.046}} \ 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,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_uses_level_percent_price":8,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_skips_catalog_sync_when_product_deleted":7,"Tests\\Feature\\ProductImportExportTest::test_export_filters_by_category":8,"Tests\\Feature\\ProductImportExportTest::test_import_creates_products_and_auto_supplier":7,"Tests\\Feature\\ProductImportExportTest::test_import_matches_existing_supplier":7,"Tests\\Feature\\ProductImportExportTest::test_import_errors_abort_all_rows":7,"Tests\\Feature\\ProductImportExportTest::test_import_category_path_resolution":7,"Tests\\Feature\\MiniReportTest::test_report_accumulates_same_product_across_orders":7,"Tests\\Feature\\ExportTest::test_export_suppliers_split_by_market":7,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_syncs_gateway_status":7,"Tests\\Feature\\MiniSpecialTest::test_cart_quantity_attached_for_store":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.093,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.01,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.007,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.05,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.049,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.051,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.05,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.093,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.102,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.107,"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.044,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.093,"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.056,"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.047,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.051,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.048,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.008,"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.088,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.05,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.051,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.046,"Tests\\Feature\\CartTest::test_update_quantity":0.052,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.05,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.048,"Tests\\Feature\\CartTest::test_delete_item":0.049,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.097,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.003,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.011,"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.049,"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.046,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.009,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.01,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.009,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.002,"Tests\\Feature\\MiniAuthTest::test_register_rejects_duplicate_openid":0.003,"Tests\\Feature\\MiniStoreTest::test_info_returns_bound_store":0.005,"Tests\\Feature\\MiniStoreTest::test_update_info":0.046,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.048,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.048,"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.053,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.122,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.05,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.054,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.054,"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.058,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.061,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.247,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.054,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.107,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.103,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.106,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.102,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.105,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.101,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.105,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.105,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.1,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.103,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.114,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.11,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.105,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.065,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.104,"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.007,"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.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.008,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.007,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.044,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.054,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.048,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.047,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.054,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.089,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.048,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.054,"Tests\\Feature\\StoreOrderTest::test_order_list_filters_by_order_date_range":0.06,"Tests\\Feature\\StoreOrderTest::test_order_list_rejects_invalid_filters":0.049,"Tests\\Feature\\BillExportTest::test_export_merges_items_across_bills":0.1,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.052,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.07,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.049,"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.014,"Tests\\Feature\\BillExportTest::test_mini_export_requires_store_binding":0.005,"Tests\\Feature\\MiniNoticeTest::test_notice_list_includes_personal_and_broadcast":0.047,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.05,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.045,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.007,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.102,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.15,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.103,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.063,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.067,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.052,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.048,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.047,"Tests\\Feature\\MiniProductTest::test_product_detail_store_user_sees_level_price":0.006,"Tests\\Feature\\MiniProductTest::test_product_detail_percent_price_calculated_from_cost":0.047,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.049,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.045,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.072,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.053,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.051,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.061,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.06,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.061,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.059,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.05,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.007,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.09,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.129,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.014,"Tests\\Feature\\ContainerReturnTest::test_export_empty_range_rejected":0.007,"Tests\\Feature\\ContainerReturnTest::test_export_requires_export_permission":0.011,"Tests\\Feature\\DashboardTest::test_guest_cannot_access_dashboard":0.002,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":0.007,"Tests\\Feature\\DashboardTest::test_empty_database_returns_zero_structure":0.009,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.051,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.055,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.052,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.052,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.051,"Tests\\Feature\\DashboardTest::test_top_stores_ranked_by_amount":0.092,"Tests\\Feature\\DashboardTest::test_purchase_amount_prefers_actual_over_estimate":0.01,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":0.051,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.056,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.133,"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.09,"Tests\\Feature\\ProductPriceTest::test_level_percent_update_notifies_level_stores":0.091,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.049,"Tests\\Feature\\ProductPriceTest::test_level_percent_validation":0.009,"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.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.052,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.054,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_level_price":0.048,"Tests\\Feature\\CartTest::test_store_level_missing_rejected":0.006,"Tests\\Feature\\StoreOrderTest::test_store_level_missing_rejected":0.047,"Tests\\Feature\\PurchaseEditTest::test_update_row_skips_catalog_sync_when_product_deleted":0.107,"Tests\\Feature\\PurchaseEditTest::test_store_item_add_creates_item_and_recalculates":0.108,"Tests\\Feature\\PurchaseEditTest::test_store_item_update_merges_multi_order_rows":0.061,"Tests\\Feature\\PurchaseEditTest::test_store_item_remove_deletes_and_recalculates":0.103,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_recalculates_totals":0.061,"Tests\\Feature\\StoreOrderItemTest::test_store_item_edits_rejected_when_purchase_completed":0.066,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_validates_negative_values":0.059,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_uses_level_percent_price":0.06,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_rejected_when_product_deleted":0.058,"Tests\\Feature\\ExportTest::test_export_lists_all_catalog_products_with_totals_row":0.105,"Tests\\Feature\\ExportTest::test_export_supplier_filter":0.103,"Tests\\Feature\\ExportTest::test_export_stores_multi_sheet":0.103,"Tests\\Feature\\ExportTest::test_export_stores_single_store":0.1,"Tests\\Feature\\ExportTest::test_export_stores_without_items_rejected":0.147,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":0.107,"Tests\\Feature\\ExportTest::test_export_suppliers_single":0.102,"Tests\\Feature\\ExportTest::test_export_suppliers_without_items_rejected":0.105,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_change_reprices_items_by_store_level":0.106,"Tests\\Feature\\PurchaseEditTest::test_update_row_without_cost_change_keeps_item_price":0.105,"Tests\\Feature\\CartTest::test_update_other_stores_item_rejected":0.093,"Tests\\Feature\\CartTest::test_delete_other_stores_item_rejected":0.093,"Tests\\Feature\\CartTest::test_cart_isolated_between_stores":0.09,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_valid_credentials":0.127,"Tests\\Feature\\MiniAuthTest::test_login_rejects_wrong_password":0.123,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unknown_username":0.002,"Tests\\Feature\\MiniAuthTest::test_disabled_store_cannot_login":0.126,"Tests\\Feature\\MiniAuthTest::test_login_validates_required_params":0.002,"Tests\\Feature\\MiniAuthTest::test_login_token_accesses_protected_routes":0.127,"Tests\\Feature\\MiniAuthTest::test_set_password_succeeds":0.209,"Tests\\Feature\\MiniAuthTest::test_set_password_rejects_wrong_old_password":0.126,"Tests\\Feature\\MiniAuthTest::test_set_password_validates_confirmation":0.045,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_own_store":0.093,"Tests\\Feature\\MiniNoticeTest::test_other_stores_notice_not_found":0.086,"Tests\\Feature\\MiniProductTest::test_product_detail_store_sees_level_price":0.045,"Tests\\Feature\\MiniProductTest::test_product_list_store_sees_level_price":0.046,"Tests\\Feature\\MiniStoreTest::test_info_returns_current_store":0.045,"Tests\\Feature\\MiniStoreTest::test_disabled_store_rejected":0.049,"Tests\\Feature\\ProductImportExportTest::test_export_downloads_all_products":0.008,"Tests\\Feature\\ProductImportExportTest::test_export_filters_by_category":0.008,"Tests\\Feature\\ProductImportExportTest::test_export_template_mode":0.006,"Tests\\Feature\\ProductImportExportTest::test_export_requires_permission":0.007,"Tests\\Feature\\ProductImportExportTest::test_import_creates_products_and_auto_supplier":0.032,"Tests\\Feature\\ProductImportExportTest::test_import_matches_existing_supplier":0.022,"Tests\\Feature\\ProductImportExportTest::test_import_errors_abort_all_rows":0.02,"Tests\\Feature\\ProductImportExportTest::test_import_category_path_resolution":0.032,"Tests\\Feature\\ProductImportExportTest::test_import_rejects_invalid_header":0.015,"Tests\\Feature\\ProductImportExportTest::test_import_requires_permission":0.01,"Tests\\Feature\\ProductImportExportTest::test_list_filters_by_market":0.01,"Tests\\Feature\\MiniReportTest::test_report_aggregates_total_and_item_percent":0.056,"Tests\\Feature\\MiniReportTest::test_report_accumulates_same_product_across_orders":0.053,"Tests\\Feature\\MiniReportTest::test_report_filters_by_custom_date_range":0.057,"Tests\\Feature\\MiniReportTest::test_report_excludes_cancelled_and_deleted_orders":0.049,"Tests\\Feature\\MiniReportTest::test_report_preset_month_and_last_month":0.05,"Tests\\Feature\\MiniReportTest::test_report_preset_week_and_last_week":0.05,"Tests\\Feature\\MiniReportTest::test_report_defaults_to_current_month":0.047,"Tests\\Feature\\MiniReportTest::test_report_data_isolated_between_stores":0.087,"Tests\\Feature\\MiniReportTest::test_report_empty_period_returns_zero":0.046,"Tests\\Feature\\MiniReportTest::test_report_validates_params":0.051,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_soft_deletes_selected_products":0.008,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_requires_non_empty_ids":0.007,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_forbidden_without_permission":0.007,"Tests\\Feature\\CartTest::test_place_order_clears_ordered_items_from_cart":0.059,"Tests\\Feature\\StoreOrderItemTest::test_admin_order_list_filters_by_order_date_range":0.064,"Tests\\Feature\\StoreOrderItemTest::test_admin_order_list_search_by_purchase_no":0.067,"Tests\\Feature\\ExportTest::test_export_suppliers_split_by_market":0.058,"Tests\\Feature\\BillPaymentTest::test_generate_bill_saves_after_sale_and_remark":0.119,"Tests\\Feature\\ProductCostPriceTest::test_product_price_unit_default_and_editable":0.013,"Tests\\Feature\\StoreOrderTest::test_place_order_blocked_by_overdue_unpaid_bill":0.054,"Tests\\Feature\\StoreOrderTest::test_place_order_allowed_within_payment_cycle":0.057,"Tests\\Feature\\CartTest::test_cart_summary_endpoint":0.065,"Tests\\Feature\\CartTest::test_cart_summary_requires_login":0.003,"Tests\\Feature\\MiniProductTest::test_product_list_appends_cart_quantity_and_summary":0.054,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_zero_cart_fields":0.046,"Tests\\Feature\\MiniProductTest::test_product_detail_appends_cart_quantity":0.063,"Tests\\Feature\\MiniProductTest::test_home_appends_cart_summary":0.069,"Tests\\Feature\\MiniOnlinePaymentTest::test_sign_matches_document_example":0.002,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_online_payment_success":0.076,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_online_payment_validation":0.097,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_fails_when_code2session_fails":0.048,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_gateway_failure_releases_bills":0.051,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_settles_payment":0.054,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_rejects_invalid_messages":0.06,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_syncs_gateway_status":0.062,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_rejects_other_store_payment":0.087,"Tests\\Feature\\MiniOnlinePaymentTest::test_offline_payment_flow_unaffected":0.047,"Tests\\Feature\\BillPaymentTest::test_generate_bill_positive_after_sale_added_to_total":0.117,"Tests\\Feature\\BillPaymentTest::test_generate_bill_rejects_non_numeric_after_sale":0.048,"Tests\\Feature\\ClientSpecialTest::test_batch_add_creates_specials":0.009,"Tests\\Feature\\ClientSpecialTest::test_batch_add_skips_existing_products":0.007,"Tests\\Feature\\ClientSpecialTest::test_batch_add_requires_non_empty_product_ids":0.007,"Tests\\Feature\\ClientSpecialTest::test_batch_add_forbidden_without_permission":0.008,"Tests\\Feature\\ClientSpecialTest::test_query_filters_by_product_name_keyword":0.008,"Tests\\Feature\\ClientSpecialTest::test_update_changes_sort_and_status":0.007,"Tests\\Feature\\ClientSpecialTest::test_delete_and_batch_delete":0.008,"Tests\\Feature\\ClientSpecialTest::test_batch_delete_requires_non_empty_ids":0.006,"Tests\\Feature\\MiniSpecialTest::test_guest_sees_specials_with_null_price":0.045,"Tests\\Feature\\MiniSpecialTest::test_store_sees_level_price":0.047,"Tests\\Feature\\MiniSpecialTest::test_disabled_special_and_off_shelf_product_excluded":0.05,"Tests\\Feature\\MiniSpecialTest::test_specials_sorted_by_sort_asc":0.086,"Tests\\Feature\\MiniSpecialTest::test_cart_quantity_attached_for_store":0.046,"Tests\\Feature\\MiniOnlinePaymentTest::test_aes_encryption_matches_demo_golden":0.002,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_envelope_round_trip":0.005,"Tests\\Feature\\MiniOnlinePaymentTest::test_swapped_key_config_fails_with_clear_message":0.004}} \ No newline at end of file diff --git a/app/Http/Controllers/Mini/OnlinePaymentController.php b/app/Http/Controllers/Mini/OnlinePaymentController.php index a0f269b..fa8adb3 100644 --- a/app/Http/Controllers/Mini/OnlinePaymentController.php +++ b/app/Http/Controllers/Mini/OnlinePaymentController.php @@ -97,19 +97,20 @@ class OnlinePaymentController extends BaseMiniController } /** - * 旺铺支付结果后台通知(公开路由,验签后幂等结账) + * 旺铺支付结果后台通知(公开路由,解密信封后幂等结账) * - * 网关约定:应答 {"code":"00"} 视为通知成功,否则按 2^n 分钟重试 7 次; + * 通知报文与网关应答同为加密信封(data + signature,商户私钥可解), + * 解密成功即视为合法通知;应答 {"code":"00"} 视为通知成功,否则网关按 2^n 分钟重试 7 次; * 重复通知必须幂等(settle 内部行锁 + 状态判断)。 */ #[PostRoute('/payment/notify', authorize: false)] public function notify(Request $request): JsonResponse { - $params = $request->all(); - - if (! $this->wangpu->verifyNotifySign($params)) { - Log::warning('旺铺支付通知验签失败', ['params' => $params]); - return $this->notifyAck('01', '验签失败'); + try { + $params = $this->wangpu->decryptNotify($request->all()); + } catch (Throwable $e) { + Log::warning('旺铺支付通知解密失败', ['error' => $e->getMessage()]); + return $this->notifyAck('01', '报文解密失败'); } try { diff --git a/app/Services/WangpuPayService.php b/app/Services/WangpuPayService.php index c3e2cc7..657704e 100644 --- a/app/Services/WangpuPayService.php +++ b/app/Services/WangpuPayService.php @@ -5,13 +5,16 @@ namespace App\Services; use App\Exceptions\RepositoryException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; /** - * 旺铺支付网关服务(统一下单B-JSAPI / 交易查询 / 后台通知验签) + * 旺铺支付网关服务(行业版接口:统一下单 / 交易查询 / 后台通知解密) * - * 加签规则(请求与回调一致): - * 除 sign 外所有非空参数按参数名 ASCII 码升序排列,以 key=value 形式用 & 拼接成 Str, - * 在 Str 后拼接 &key={SignKey} 得到 SignStr,对其做 MD5(utf-8)并转大写即为签名值。 + * 通讯协议(与官方示例 demo/IndexController.php 一致): + * - 业务参数 JSON 序列化后,用随机 16 位密钥做 AES-128-ECB 加密(base64)放入 data; + * - AES 密钥用旺铺平台公钥 RSA 加密(base64)放入 signature; + * - 整体以 JSON 信封 POST:{serialNo, version, timestamp, data, signature, extras, organizNo}; + * - 应答/通知反向解密:商户私钥解 signature 得 AES 密钥,再用其解 data 得业务报文。 * * 配置优先级:后台「系统设置 → 支付配置」(site_config pay.wangpu_*) > config/services.php(env) */ @@ -27,18 +30,20 @@ class WangpuPayService public const int ORDER_STATUS_PAID = 1; /** - * 统一下单B-JSAPI(微信公众号/小程序、支付宝服务窗/生活号、银联二维码) + * 统一下单(小程序 JSAPI) * * @param array $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等) - * @return array 网关 data 节点(含 order_id/trade_no/调起支付参数) + * @return array 网关 data 解密后的业务报文(含 order_id/调起支付参数) * @throws RepositoryException 网关应答失败 */ public function createOrder(array $params): array { return $this->request('/industrial/payment/order', array_merge([ + 'mer_no' => $this->config('mer_no'), 'mer_code' => $this->config('mer_code'), 'term_code' => $this->config('term_code'), 'payway_code' => $this->config('payway_code'), + 'organiz_no' => $this->config('organiz_no'), ], $params)); } @@ -47,12 +52,15 @@ class WangpuPayService * * @param string $merOrderId 商户唯一订单号(本系统 payment_no) * @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准) - * @return array 网关 data 节点 + * @return array 网关 data 解密后的业务报文 * @throws RepositoryException 网关应答失败 */ public function queryOrder(string $merOrderId, string $orderId = ''): array { - $params = ['mer_order_id' => $merOrderId]; + $params = [ + 'organiz_no' => $this->config('organiz_no'), + 'mer_order_id' => $merOrderId, + ]; if ($orderId !== '') { $params['order_id'] = $orderId; } @@ -60,44 +68,18 @@ class WangpuPayService } /** - * 校验后台通知签名(支付成功/退款成功通知通用) + * 解密支付结果后台通知(报文 RSA+AES 双重加密,解密成功即视为合法通知) * - * @param array $params 通知报文全量参数(含 sign) + * @param array $envelope 通知信封(含 data/signature) + * @return array 解密后的业务报文(mer_order_id/order_status/order_amt/trade_no 等) + * @throws RepositoryException 报文解密失败 */ - public function verifyNotifySign(array $params): bool + public function decryptNotify(array $envelope): array { - $sign = (string) ($params['sign'] ?? ''); - if ($sign === '') { - return false; - } - return hash_equals($this->sign($params), strtoupper($sign)); - } - - /** - * MD5 加签:非空参数(sign 除外)按参数名 ASCII 升序 key=value 以 & 拼接, - * 末尾拼接 &key={SignKey} 后 MD5 转大写 - * - * @param array $params - */ - public function sign(array $params): string - { - unset($params['sign']); - - $pairs = []; - foreach ($params as $key => $value) { - if ($value === null || $value === '') { - continue; - } - if (is_array($value) || is_object($value)) { - $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - } - $pairs[(string) $key] = (string) $key . '=' . (string) $value; - } - ksort($pairs, SORT_STRING); - - $signStr = implode('&', $pairs) . '&key=' . $this->config('sign_key'); - - return strtoupper(md5($signStr)); + Log::driver('pay')->info('旺铺支付通知接收', ['envelope' => $envelope]); + $params = $this->decryptEnvelope($envelope); + Log::driver('pay')->info('旺铺支付通知解密', ['params' => $params]); + return $params; } /** @@ -109,11 +91,11 @@ class WangpuPayService } /** - * 网关 POST 请求(表单方式):公共参数 + 加签 → 发送 → 校验应答码 + * 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答 * * @param array $params 接口业务参数 - * @return array 应答 data 节点 - * @throws RepositoryException 通讯失败或应答码非成功 + * @return array 应答 data 解密后的业务报文 + * @throws RepositoryException 通讯失败 / 应答码非成功 / 应答解密失败 */ protected function request(string $path, array $params): array { @@ -122,24 +104,18 @@ class WangpuPayService throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员'); } - $body = array_merge([ - 'organiz_no' => $this->config('organiz_no'), - 'mer_no' => $this->config('mer_no'), - ], $params); - // 空值参数不下送(与网关签名口径一致) - $body = array_filter($body, static fn ($value): bool => $value !== null && $value !== ''); - $body['sign'] = $this->sign($body); + $envelope = $this->encryptEnvelope($params); + Log::driver('pay')->info('旺铺网关请求', ['path' => $path, 'params' => $params, 'envelope' => $envelope]); + + $response = Http::timeout(15)->post($baseUrl . $path, $envelope); + Log::driver('pay')->info('旺铺网关响应', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]); - $response = Http::asForm()->withOptions([ - 'verify' => false, // 禁用 SSL 证书验证 - ])->timeout(15)->post($baseUrl . $path, $body); $result = $response->json(); if (! is_array($result)) { Log::driver('pay')->error('旺铺网关应答异常', [ 'path' => $path, 'status' => $response->status(), 'body' => $response->body(), - 'request' => $body, ]); throw new RepositoryException('支付网关通讯异常,请稍后重试'); } @@ -147,13 +123,155 @@ class WangpuPayService Log::driver('pay')->warning('旺铺网关应答失败', [ 'path' => $path, 'response' => $result, - 'request' => $body, ]); throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误')); } - $data = $result['data'] ?? []; - return is_array($data) ? $data : []; + try { + $data = $this->decryptEnvelope($result); + } catch (RepositoryException $e) { + Log::driver('pay')->error('旺铺网关应答解密失败', ['path' => $path, 'error' => $e->getMessage()]); + throw new RepositoryException('支付网关应答解密失败,请联系管理员'); + } + Log::driver('pay')->info('旺铺网关应答解密', ['path' => $path, 'data' => $data]); + return $data; + } + + /** + * 业务报文加密装信封:AES-128-ECB 加密报文 + 平台公钥加密 AES 密钥 + * + * @param array $params 业务参数 + * @return array 请求信封 + * @throws RepositoryException 加密失败 + */ + protected function encryptEnvelope(array $params): array + { + $key = Str::random(16); // AES 密钥(16 字节) + return [ + 'serialNo' => Str::random(32), // 请求流水号 + 'version' => '1.0', + 'timestamp' => now()->format('YmdHis'), + 'data' => urlencode($this->aesEncrypt($params, $key)), + 'signature' => urlencode($this->rsaPublicEncrypt($key)), + 'extras' => '', + 'organizNo' => $this->config('organiz_no'), + ]; + } + + /** + * 解密网关信封:商户私钥解 signature 得 AES 密钥 → AES 解 data → JSON + * + * @param array $envelope + * @return array + * @throws RepositoryException 解密失败 + */ + protected function decryptEnvelope(array $envelope): array + { + $data = (string) ($envelope['data'] ?? ''); + $signature = (string) ($envelope['signature'] ?? ''); + if ($data === '' || $signature === '') { + throw new RepositoryException('报文缺少 data/signature 字段'); + } + + $plain = $this->aesDecrypt($data, $this->rsaPrivateDecrypt($signature)); + $decoded = json_decode($plain, true); + if (! is_array($decoded)) { + throw new RepositoryException('报文 JSON 解析失败'); + } + return $decoded; + } + + /** + * AES-128-ECB 加密(base64 输出,与官方示例算法一致) + * + * @param array $data + * @throws RepositoryException 加密失败 + */ + protected function aesEncrypt(array $data, string $key): string + { + $cipher = openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA); + if ($cipher === false) { + throw new RepositoryException('报文 AES 加密失败'); + } + return base64_encode($cipher); + } + + /** + * AES-128-ECB 解密(base64 输入,与官方示例算法一致) + * + * @throws RepositoryException 解密失败 + */ + protected function aesDecrypt(string $cipherBase64, string $key): string + { + $plain = openssl_decrypt((string) base64_decode($cipherBase64), 'AES-128-ECB', $key, OPENSSL_RAW_DATA); + if (! is_string($plain)) { + throw new RepositoryException('报文 AES 解密失败'); + } + return $plain; + } + + /** + * RSA 平台公钥加密(base64 输出,用于加密 AES 密钥) + * + * @throws RepositoryException 加密失败 + */ + protected function rsaPublicEncrypt(string $data): string + { + if (! openssl_public_encrypt($data, $encrypted, $this->pemKey('public_key', 'PUBLIC'))) { + throw new RepositoryException('旺铺平台公钥加密失败,请检查密钥配置'); + } + return base64_encode($encrypted); + } + + /** + * RSA 商户私钥解密(base64 输入,用于解出 AES 密钥) + * + * @throws RepositoryException 解密失败 + */ + protected function rsaPrivateDecrypt(string $cipherBase64): string + { + $ok = openssl_private_decrypt((string) base64_decode($cipherBase64), $decrypted, $this->pemKey('private_key', 'PRIVATE')); + if (! $ok || ! is_string($decrypted)) { + throw new RepositoryException('商户私钥解密失败,请检查密钥配置'); + } + return $decrypted; + } + + /** + * 读取密钥并包装为 PEM 格式(配置存 base64 单行或完整 PEM 均可,自动剔除头尾与空白字符) + * + * 预检密钥有效性(避免 openssl 抛警告式错误),并识别公/私钥填反的常见配置错误 + * + * @throws RepositoryException 未配置或密钥无法解析 + */ + protected function pemKey(string $configKey, string $kind): string + { + $name = $kind === 'PUBLIC' ? '旺铺平台公钥' : '商户RSA私钥'; + $body = (string) preg_replace('/-----[A-Z ]*KEY-----|\s+/', '', $this->config($configKey)); + if ($body === '') { + throw new RepositoryException('旺铺支付未配置' . $name . ',请联系管理员'); + } + + $wrap = static fn (string $header): string => "-----BEGIN {$header} KEY-----\n" + . wordwrap($body, 64, "\n", true) . "\n-----END {$header} KEY-----"; + + if ($kind === 'PUBLIC') { + // 公钥兼容 SPKI(PUBLIC KEY)与 PKCS#1(RSA PUBLIC KEY)两种格式 + foreach (['PUBLIC', 'RSA PUBLIC'] as $header) { + if (openssl_pkey_get_public($wrap($header)) !== false) { + return $wrap($header); + } + } + $isPrivate = openssl_pkey_get_private($wrap('PRIVATE')) !== false; + throw new RepositoryException($name . '配置错误' . ($isPrivate ? '(当前内容是私钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置'); + } + + $pem = $wrap('PRIVATE'); + if (openssl_pkey_get_private($pem) === false) { + $isPublic = openssl_pkey_get_public($wrap('PUBLIC')) !== false; + throw new RepositoryException($name . '配置错误' . ($isPublic ? '(当前内容是公钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置'); + } + return $pem; } /** diff --git a/composer.json b/composer.json index 6c502c2..e9aeb23 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "laravel/tinker": "^3.0", "maatwebsite/excel": "^3.1", "predis/predis": "2.0", - "w7corp/easywechat": "^6.19" + "w7corp/easywechat": "^6.19", + "ext-openssl": "*" }, "require-dev": { "laravel/boost": "^2.0", diff --git a/config/services.php b/config/services.php index d869f61..ea91523 100644 --- a/config/services.php +++ b/config/services.php @@ -63,7 +63,8 @@ return [ 'mer_no' => env('WANGPU_MER_NO', ''), // 旺铺内部商户号 'mer_code' => env('WANGPU_MER_CODE', ''), // 商户号(进件入网后返回) 'term_code' => env('WANGPU_TERM_CODE', ''), // 终端号(进件入网后返回) - 'sign_key' => env('WANGPU_SIGN_KEY', ''), // 加签专用 Key(MD5 加签) + 'public_key' => env('WANGPU_PUBLIC_KEY', ''), // 旺铺平台公钥(base64 单行,加密请求报文 AES 密钥) + 'private_key' => env('WANGPU_PRIVATE_KEY', ''), // 商户 RSA 私钥(base64 单行,解密网关应答/通知报文) 'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid) 'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填) ], diff --git a/database/migrations/2025_01_01_000005_create_site_config_table.php b/database/migrations/2025_01_01_000005_create_site_config_table.php index 24c087b..d900b69 100644 --- a/database/migrations/2025_01_01_000005_create_site_config_table.php +++ b/database/migrations/2025_01_01_000005_create_site_config_table.php @@ -17,7 +17,7 @@ return new class extends Migration $table->string('key', 50)->comment('设置项标示'); $table->string('title', 50)->comment('设置标题'); $table->string('describe', 500)->nullable()->default('')->comment('设置项描述'); - $table->string('values', 255)->nullable()->default('')->comment('设置值'); + $table->string('values', 2000)->nullable()->default('')->comment('设置值'); $table->string('type', 50)->comment('设置类型'); $table->string('options', 500)->nullable()->comment('options配置'); $table->string('props', 500)->nullable()->comment('props配置'); diff --git a/database/seeders/SysDataSeeder.php b/database/seeders/SysDataSeeder.php index f56ab40..fe74da6 100644 --- a/database/seeders/SysDataSeeder.php +++ b/database/seeders/SysDataSeeder.php @@ -34,9 +34,10 @@ class SysDataSeeder extends Seeder ['id' => 14, 'group_id' => 4, 'key' => 'wangpu_mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date], ['id' => 15, 'group_id' => 4, 'key' => 'wangpu_mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date], ['id' => 16, 'group_id' => 4, 'key' => 'wangpu_term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date], - ['id' => 17, 'group_id' => 4, 'key' => 'wangpu_sign_key', 'title' => '旺铺加签Key', 'describe' => '旺铺报文加签专用 Key(SignKey,MD5 加签),请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date], + ['id' => 17, 'group_id' => 4, 'key' => 'wangpu_public_key', 'title' => '旺铺平台公钥', 'describe' => '旺铺平台 RSA 公钥(base64 单行,不含 PEM 头尾),用于加密请求报文 AES 密钥,旺铺提供', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date], ['id' => 18, 'group_id' => 4, 'key' => 'wangpu_sub_appid', 'title' => '旺铺下单子appid', 'describe' => '下单微信子 appid(sub_appid),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 9, 'created_at' => $date, 'updated_at' => $date], ['id' => 19, 'group_id' => 4, 'key' => 'wangpu_payway_code', 'title' => '旺铺支付方式代码', 'describe' => '支付方式代码 payway_code(小程序主扫必填,如 WECHAT_MINI),见旺铺数据词典', 'values' => 'WECHAT_MINI', 'type' => 'Input','options' => "", 'sort' => 10, 'created_at' => $date, 'updated_at' => $date], + ['id' => 20, 'group_id' => 4, 'key' => 'wangpu_private_key', 'title' => '商户RSA私钥', 'describe' => '商户 RSA 私钥(base64 单行,不含 PEM 头尾),用于解密网关应答/支付通知报文,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 11, 'created_at' => $date, 'updated_at' => $date], ]); // 字典类型初始数据 DB::table('sys_dict')->insert([ diff --git a/docs/api/mini-online-payment.md b/docs/api/mini-online-payment.md deleted file mode 100644 index 286a84d..0000000 --- a/docs/api/mini-online-payment.md +++ /dev/null @@ -1,235 +0,0 @@ -# 小程序接口文档:在线支付(旺铺网关 JSAPI) - -> 门店端在线支付:选择本店未支付账单合并付款,后端经旺铺支付网关(统一下单B-JSAPI)下单, -> 小程序调起 `wx.requestPayment` 完成支付;支付结果由**网关后台通知**自动结账, -> 小程序端也可**主动查询**同步结果(回调延迟/丢失时的兜底)。 -> -> 结账效果与线下凭证审核一致:关联账单批量置「已支付」、累加门店总采购金额、门店收到支付成功通知。 - -## 整体流程 - -``` -小程序 后端 旺铺网关 微信 - │ wx.login → code │ │ │ - │ POST /mini/payment/online (bill_ids, code) │ │ - │────────────────────▶│ code2session 换 openid │──────────────────▶│ - │ │ 创建支付单+锁定账单 │ │ - │ │ 统一下单(mer_order_id=支付单号) ──────────▶│ - │ 返回 pay_params │◀──────── order_id + 调起参数 ────────────│ - │◀────────────────────│ │ │ - │ wx.requestPayment(pay_params) ─────────────────────────────────▶│ - │ │ POST /mini/payment/notify(支付成功通知) │ - │ │◀───────────────────────│ │ - │ │ 验签 → 幂等结账 → 应答 {"code":"00"} │ - │ GET .../query 主动同步(兜底) │ │ - │────────────────────▶│ 交易查询 → 已支付则结账 │ │ -``` - -## 通用约定 - -| 项 | 值 | -|---|---| -| 鉴权 | 门店 token:`Authorization: Bearer `(登录见 `/mini/auth/login`) | -| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` | -| 金额单位 | 元,字符串/数字两位小数(如 `150.50`) | - ---- - -## 1. 发起在线支付(合并账单下单) - -| 项 | 值 | -|---|---| -| 请求方式 | `POST` | -| 路径 | `/mini/payment/online` | -| 鉴权 | 门店 token | - -### 请求参数 - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `bill_ids` | int[] | 是 | 要合并付款的账单 ID 数组(`GET /mini/bill?payable=1` 返回的 `id`),至少 1 个 | -| `code` | string | 是 | 小程序 `wx.login()` 返回的登录凭证(后端用它换付款人 openid) | -| `remark` | string | 否 | 付款备注,最长 255 字 | - -### 校验规则(失败均返回 `success:false`) - -- 账单必须全部属于当前门店,且为「未支付」且未被其他支付单锁定; -- 任一账单已支付 / 正在支付中(含凭证审核中)→ 拒绝并提示对应账单号; -- 合计金额必须大于 0; -- 微信 `code` 无效(code2session 失败)→ 报错,不产生支付单; -- 网关下单失败 → 支付单自动作废、账单释放,可重新发起。 - -### 响应示例 - -```json -{ - "success": true, - "msg": "下单成功,请调起支付", - "data": { - "id": 12, - "payment_no": "ZF202608270001", - "amount": "150.50", - "pay_params": { - "order_id": "202608271201444525348059", - "tradeNo": "2021082722001407831438768160", - "user_openid": "oXxx123" - } - } -} -``` - -### 字段说明 - -| 字段 | 说明 | -|---|---| -| `payment_no` | 本系统支付单号(即上送网关的商户订单号 `mer_order_id`),后续查询/对账用 | -| `amount` | 应付金额(= 所选账单总额合计,元) | -| `pay_params` | 旺铺网关 `data` 节点原样透传。**调起支付所需参数以网关返回为准**(服务商配置后通常包含 `timeStamp`/`nonceStr`/`package`/`signType`/`paySign` 等),直接透传给 `wx.requestPayment` 即可 | - -### 小程序端调用示例 - -```js -// 1. 获取登录凭证 -const { code } = await wx.login(); - -// 2. 后端下单 -const res = await request.post('/mini/payment/online', { bill_ids: [101, 102], code }); -const { payment_no, pay_params } = res.data; - -// 3. 调起微信支付(pay_params 以旺铺网关实际返回的调起参数为准) -await wx.requestPayment({ - timeStamp: pay_params.timeStamp, - nonceStr: pay_params.nonceStr, - package: pay_params.package, - signType: pay_params.signType || 'RSA', - paySign: pay_params.paySign, -}); - -// 4. 支付完成后主动同步结果(回调兜底) -const q = await request.get(`/mini/payment/online/${payment_no}/query`); -// q.data.status === 1 → 支付成功,刷新账单列表 -``` - ---- - -## 2. 查询支付结果(主动同步) - -| 项 | 值 | -|---|---| -| 请求方式 | `GET` | -| 路径 | `/mini/payment/online/{payment_no}/query` | -| 鉴权 | 门店 token(仅可查询本店支付单) | - -### 路径参数 - -| 参数 | 说明 | -|---|---| -| `payment_no` | 下单返回的支付单号(如 `ZF202608270001`) | - -### 行为说明 - -- 本地已是「支付成功」→ 直接返回,不再请求网关; -- 否则向旺铺网关发起「交易查询」,网关返回已支付(`order_status=1`)则**立即结账**(与后台通知同一幂等逻辑); -- 建议在 `wx.requestPayment` 成功回调后、以及付款页 `onShow` 时各调一次; -- 用户中途取消支付时本接口返回 `status: 0`,账单仍处于锁定中,可稍后重试或联系客服释放。 - -### 响应示例 - -```json -{ - "success": true, - "msg": "支付成功", - "data": { - "payment_no": "ZF202608270001", - "status": 1, - "status_name": "支付成功", - "paid_at": "2026-08-27 10:00:00", - "trade_no": "2021082722001407831438768160" - } -} -``` - -### `status` 取值 - -| 值 | 含义 | -|---|---| -| `0` | 待支付(网关未确认) | -| `1` | 支付成功(账单已置已支付) | -| `2` | 支付失败(网关下单失败作废,账单已释放,可重新下单) | - ---- - -## 3. 支付结果后台通知(服务端对接,无需小程序调用) - -| 项 | 值 | -|---|---| -| 请求方式 | `POST`(表单) | -| 路径 | `/mini/payment/notify` | -| 鉴权 | 无(公开路由,以报文 `sign` 验签) | -| 来源 | 旺铺网关(下单时上送的 `notifyurl`,由后端自动生成:`{APP_URL}/mini/payment/notify`) | - -### 处理逻辑 - -1. 按旺铺加签规则验签(参数 ASCII 升序 `key=value` 以 `&` 拼接 + `&key=SignKey`,MD5 大写); -2. 验签失败 → 应答 `{"code":"01"}`; -3. 按 `mer_order_id` 定位支付单,校验 `order_status=1` 且 `order_amt` 与支付单金额一致(防篡改); -4. **幂等结账**:支付单置成功(记录 `trade_no`/`order_id`/支付时间)→ 关联账单批量置已支付 → 累加门店总采购金额(只统计商品金额)→ 站内通知门店; -5. 重复通知直接应答成功,不重复结账。 - -### 应答报文(网关约定格式) - -```json -{ "code": "00", "msg": "成功", "timestamp": "20260827100000123" } -``` - -网关收到 `code=00` 才认为通知成功,否则按 2ⁿ 分钟重试 7 次。 - ---- - -## 后台配置(上线前必配) - -配置优先级:后台「系统设置 → 支付配置」> `.env`。 - -### 后台支付配置(site_config `pay` 组,已内置配置项) - -| 配置项 | 说明 | -|---|---| -| `wangpu_base_url` | 旺铺网关域名(接口地址前缀) | -| `wangpu_organiz_no` | 合作机构渠道号 organiz_no | -| `wangpu_mer_no` | 旺铺内部商户号 mer_no(进件入网后返回) | -| `wangpu_mer_code` | 商户号 mer_code | -| `wangpu_term_code` | 终端号 term_code | -| `wangpu_sign_key` | 加签专用 Key(SignKey) | -| `wangpu_sub_appid` | 下单微信子 appid,留空取小程序自身 appid | -| `wangpu_payway_code` | 支付方式代码(默认 `WECHAT_MINI`,以旺铺数据词典为准) | - -### .env 备选配置 - -```dotenv -WECHAT_MINI_APPID= # 小程序 AppID(code2session 必需) -WECHAT_MINI_SECRET= # 小程序 Secret(code2session 必需) -WANGPU_BASE_URL= -WANGPU_ORGANIZ_NO= -WANGPU_MER_NO= -WANGPU_MER_CODE= -WANGPU_TERM_CODE= -WANGPU_SIGN_KEY= -WANGPU_SUB_APPID= -WANGPU_PAYWAY_CODE=WECHAT_MINI -``` - -> 注意:`APP_URL` 必须是网关可访问的公网 HTTPS 地址,否则支付成功通知无法送达(此时依赖小程序主动查询兜底结账)。 - -## 与线下凭证支付的关系 - -两种付款方式并存,同一张账单同一时刻只能处于一条支付链路: - -| | 在线支付(本档) | 线下凭证支付 | -|---|---|---| -| 入口 | `POST /mini/payment/online` | `POST /mini/payment` | -| 支付单 `pay_type` | `2` | `1` | -| 支付单 `pay_method` | `4` 旺铺支付 | `1` 微信 / `2` 支付宝 / `3` 对公汇款 | -| 结账方式 | 网关通知/主动查询自动结账 | 后台审核通过 | -| 失败/拒绝 | 账单自动释放可重新付款 | 审核拒绝后释放 | - -账单被任一支付单锁定期间(`payment_id ≠ 0`),两种入口均会拒绝重复提交。 diff --git a/docs/api/mini-report-purchase.md b/docs/api/mini-report-purchase.md deleted file mode 100644 index b604a5b..0000000 --- a/docs/api/mini-report-purchase.md +++ /dev/null @@ -1,125 +0,0 @@ -# 小程序接口文档:采购运营报表 - -> 门店端运营报表:拉取「一周 / 一月 / 任意选定时间段」内的采购总金额,以及每个单品的累计采购金额与金额占比。 -> -> 示例场景:上个月我店一共采购了 10 万元,其中土豆 8764 元(占比 8.8%)、洋葱 3664 元(占比 3.7%)。 - -## 接口信息 - -| 项 | 值 | -|---|---| -| 请求方式 | `GET` | -| 路径 | `/mini/report/purchase` | -| 鉴权 | 门店 token(`Authorization: Bearer `),与其他小程序接口一致 | -| 数据范围 | 仅当前登录门店自身的订货数据 | - -## 请求参数 - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `preset` | string | 否 | 周期预设:`week` 本周 / `last_week` 上周 / `month` 本月 / `last_month` 上月。**缺省为 `month`** | -| `start_date` | string | 否 | 自定义开始日期,格式 `Y-m-d`(如 `2026-07-01`)。需与 `end_date` 成对出现 | -| `end_date` | string | 否 | 自定义结束日期,格式 `Y-m-d`,不能早于 `start_date` | - -规则: - -- 同时传 `start_date` + `end_date` 时为**自定义区间,优先于 `preset`**(响应中 `preset` 返回 `custom`); -- 只传其中一个日期、日期格式错误、结束日期早于开始日期、preset 非枚举值,均返回参数错误; -- 进行中的周期(本周/本月)结束日期自动**封顶为今天**;周以**周一**为起点。 - -## 响应示例 - -`GET /mini/report/purchase?preset=last_month` - -```json -{ - "success": true, - "msg": "ok", - "data": { - "preset": "last_month", - "start_date": "2026-07-01", - "end_date": "2026-07-31", - "total_amount": "100000.00", - "total_quantity": 1700, - "order_count": 20, - "item_count": 2, - "items": [ - { - "product_id": 11, - "product_name": "土豆", - "product_spec": "50斤/袋", - "unit": "斤", - "quantity": 500, - "weight": "0.000", - "amount": "8764.00", - "percent": 8.8 - }, - { - "product_id": 12, - "product_name": "洋葱", - "product_spec": "20斤/袋", - "unit": "斤", - "quantity": 200, - "weight": "0.000", - "amount": "3664.00", - "percent": 3.7 - } - ] - } -} -``` - -## 响应字段 - -### 顶层(`data`) - -| 字段 | 类型 | 说明 | -|---|---|---| -| `preset` | string | 实际生效的周期预设(`week`/`last_week`/`month`/`last_month`/`custom`) | -| `start_date` | string | 实际统计开始日期 `Y-m-d` | -| `end_date` | string | 实际统计结束日期 `Y-m-d` | -| `total_amount` | string | 周期内采购总金额(元,2 位小数;金额一律以字符串返回防精度丢失) | -| `total_quantity` | number | 周期内订货总量(各单品数量之和) | -| `order_count` | number | 周期内有效订货单数 | -| `item_count` | number | 单品个数(= `items` 长度) | -| `items` | array | 单品累计列表,**按金额降序** | - -### 单品行(`data.items[]`) - -| 字段 | 类型 | 说明 | -|---|---|---| -| `product_id` | number | 商品 ID | -| `product_name` | string | 品名(下单时快照,商品档案改名/删除不影响历史统计) | -| `product_spec` | string | 规格/包规(快照) | -| `unit` | string | 计价单位(快照) | -| `quantity` | number | 周期内累计订货量 | -| `weight` | string | 周期内累计重量(3 位小数,未称重为 `0.000`) | -| `amount` | string | 周期内累计采购金额(元,2 位小数) | -| `percent` | number | 金额占比(%,1 位小数,四舍五入;如 `8.8` 表示 8.8%。周期内无数据时为 `0`) | - -## 统计口径 - -- 数据源为**门店订货单明细**(下单时快照的等级单价 × 数量),即门店视角的应付采购金额; -- **已取消**订单与后台**已删除**订单不计入统计,其余状态(待接单/已接单/采购中/配送中/已完成)全部计入; -- 单品按 `product_id` 聚合,同一商品在周期内多次下单会累计为一行; -- 占比 = 单品金额 ÷ 总金额 × 100,保留 1 位小数(四舍五入),各行占比之和可能因舍入存在 ±0.1 的误差,属正常现象。 - -## 错误响应 - -与小程序其他接口一致:`success: false` + `msg` 描述原因。 - -| 场景 | msg 示例 | -|---|---| -| 未登录 / token 失效 | `Unauthenticated`(按全局鉴权约定) | -| 账号被停用 | `账号不存在或已被停用` | -| preset 非法 | `preset 参数只能是 week/last_week/month/last_month` | -| 日期格式错误 | `开始日期格式为 Y-m-d` | -| 结束早于开始 | `结束日期不能早于开始日期` | -| 自定义区间只传一侧 | `自定义区间需同时提供开始与结束日期` | - -## 前端对接建议 - -- 顶部放周期切换 Tab(本周 / 上周 / 本月 / 上月 / 自定义),自定义时弹出日期选择器,选中后传 `start_date` + `end_date`; -- 头部卡片展示 `total_amount`(总金额)与 `order_count`(单数); -- 列表直接渲染 `items`(已按金额降序),每行展示品名、规格、累计金额与 `percent`%;如需饼图可直接用 `items[].percent`; -- 区间无数据时 `items` 为空数组、`total_amount` 为 `"0.00"`,前端展示空状态即可。 diff --git a/docs/api/mini-special.md b/docs/api/mini-special.md deleted file mode 100644 index d792ab0..0000000 --- a/docs/api/mini-special.md +++ /dev/null @@ -1,146 +0,0 @@ -# 小程序接口文档:特价推荐 - -> 小程序首页「特价推荐」商品区:后台在「客户端配置 → 特价推荐」批量选择商品标记为推荐, -> 小程序端通过本接口分页获取推荐商品列表。 -> -> 推荐仅标记商品、不设特价字段:**价格按登录门店的客户等级价展示**(售价 = 成本价 × (100 + 等级上浮比例) / 100), -> 与 `/mini/product/list` 价格口径一致。 - -## 通用约定 - -| 项 | 值 | -|---|---| -| 鉴权 | 免登录;携带门店 token(`Authorization: Bearer `,登录见 `/mini/auth/login`)时返回等级价与购物车数量 | -| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` | -| 金额单位 | 元,字符串两位小数(如 `26.00`) | - ---- - -## 1. 特价推荐商品列表 - -| 项 | 值 | -|---|---| -| 请求方式 | `GET` | -| 路径 | `/mini/special/list` | -| 鉴权 | 无(可带门店 token) | - -### 请求参数 - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `page` | int | 否 | 页码,默认 1 | -| `pageSize` | int | 否 | 每页条数,默认 10 | - -### 行为说明 - -- 仅返回「状态正常」且商品「已上架」的推荐,按推荐排序 `sort` 升序(越小越靠前); -- 行结构与 `/mini/product/list` 完全一致:商品基础字段 + `price` + `cart_id` / `cart_quantity`; -- 未登录 / 门店未设客户等级时 `price = null`,前端应引导登录后查看价格; -- `cost_price`(成本价)属于商业敏感字段,任何情况下都不会输出; -- `data.cart` 为购物车悬浮球汇总,未登录返回零值结构。 - -### 响应示例 - -```json -{ - "success": true, - "msg": "ok", - "data": { - "current_page": 1, - "per_page": 10, - "total": 2, - "data": [ - { - "id": 15, - "category_id": 2, - "name": "西红柿", - "spec": "约5斤/份", - "unit": "斤", - "price_unit": "元/斤", - "market": "新发地", - "image_ids": "321,322", - "images_arr": [ - { "id": 321, "preview_url": "https://example.com/storage/xxx.jpg" } - ], - "status": 1, - "sort": 0, - "price": "3.50", - "cart_id": 88, - "cart_quantity": "2.00" - }, - { - "id": 23, - "category_id": 3, - "name": "麒麟西瓜", - "spec": "约8斤/个", - "unit": "个", - "price_unit": "元/个", - "market": "岳各庄", - "image_ids": "340", - "images_arr": [ - { "id": 340, "preview_url": "https://example.com/storage/yyy.jpg" } - ], - "status": 1, - "sort": 1, - "price": "26.00", - "cart_id": 0, - "cart_quantity": "0.00" - } - ], - "cart": { - "count": 2, - "quantity": "2.00", - "amount": "7.00" - } - } -} -``` - -### 字段说明 - -| 字段 | 说明 | -|---|---| -| `id` | 商品 ID,点击跳转商品详情 `/mini/product/{id}` | -| `price` | 当前登录门店的等级售价;未登录 / 未设等级为 `null` | -| `cart_id` | 该商品在当前门店购物车中的行 ID(0 = 不在购物车),列表直接加减购物车用 | -| `cart_quantity` | 购物车中该商品数量(不在购物车为 `"0.00"`) | -| `cart` | 购物车悬浮球汇总:`count` 行数 / `quantity` 总数量 / `amount` 总金额(元) | - -### 小程序端调用示例 - -```js -// 首页特价推荐区(带上拉加载更多) -Page({ - data: { specials: [], page: 1, hasMore: true }, - - async loadSpecials() { - const res = await request.get('/mini/special/list', { - page: this.data.page, - pageSize: 10, - }); - const { data: rows, total } = res.data; - this.setData({ - specials: this.data.page === 1 ? rows : [...this.data.specials, ...rows], - hasMore: this.data.specials.length + rows.length < total, - }); - }, - - onReachBottom() { - if (!this.data.hasMore) return; - this.setData({ page: this.data.page + 1 }); - this.loadSpecials(); - }, -}); -``` - ---- - -## 后台配置入口 - -PC 后台「客户端配置 → 特价推荐」: - -| 操作 | 说明 | -|---|---| -| 添加商品 | 按商品名称搜索(仅上架商品可选),支持一次多选批量添加;已在推荐中的商品自动跳过 | -| 批量删除 | 勾选后批量移除推荐(不影响商品档案本身) | -| 编辑 | 仅可调整排序与状态(正常/停用) | diff --git a/tests/Feature/MiniOnlinePaymentTest.php b/tests/Feature/MiniOnlinePaymentTest.php index addebd3..791f8c8 100644 --- a/tests/Feature/MiniOnlinePaymentTest.php +++ b/tests/Feature/MiniOnlinePaymentTest.php @@ -7,18 +7,27 @@ use App\Models\NoticeModel; use App\Models\PaymentModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; +use App\Exceptions\RepositoryException; use App\Services\WangpuPayService; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; +use ReflectionMethod; /** - * 小程序在线支付(旺铺网关 JSAPI):下单 → 调起支付 → 后台通知/主动查询结账 + * 小程序在线支付(旺铺网关行业版):下单 → 调起支付 → 后台通知/主动查询结账 + * + * 通讯协议与官方示例 demo/IndexController.php 一致: + * 业务报文 AES-128-ECB 加密(随机 16 位密钥)+ RSA 公钥加密 AES 密钥,JSON 信封传输。 * * - Http::fake 模拟微信 code2session 与旺铺网关,不触网 * - 结账幂等:重复通知/查询不重复累加门店总采购金额 */ class MiniOnlinePaymentTest extends ProcurementTestCase { - private const string SIGN_KEY = 'test-wangpu-sign-key'; + /** 测试专用 RSA 密钥对(仅测试使用,与生产密钥无关;公私钥同源便于加解密回环) */ + private const string TEST_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqZ4z7UatOnbBolcpFhW2q585II7atHK9w/opQ/k8QUqjwkwIVNYzC9qf/SFDoOnMBufZwS2tM8UUPcgNoy0hbmR5QqBsp9ugBaSL1ZHb5jStgpikzWEjFkyVR5WLtxl4nxKv0R5pV/mfq0RXsIpbutZqugnXRvHPYaqbKomXzf0FKlxmbpvPOXsH9L9rYrnOSKQ1t16vAa6UrqUdkgdQBH7+hI4kiDwY0MIZVOhDhB4r0ODrpWMd0U3yrbhwpHZdtF2B3dtn3JSS92F16Yo7Hec8+TUzNKX0Mu1rn07F6yGZeUf+5/egXsBc2Mx1TgZswFlvkmHS96kbH9gqJyGOVwIDAQAB'; + + private const string TEST_PRIVATE_KEY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpnjPtRq06dsGiVykWFbarnzkgjtq0cr3D+ilD+TxBSqPCTAhU1jML2p/9IUOg6cwG59nBLa0zxRQ9yA2jLSFuZHlCoGyn26AFpIvVkdvmNK2CmKTNYSMWTJVHlYu3GXifEq/RHmlX+Z+rRFewilu61mq6CddG8c9hqpsqiZfN/QUqXGZum885ewf0v2tiuc5IpDW3Xq8BrpSupR2SB1AEfv6EjiSIPBjQwhlU6EOEHivQ4OulYx3RTfKtuHCkdl20XYHd22fclJL3YXXpijsd5zz5NTM0pfQy7WufTsXrIZl5R/7n96BewFzYzHVOBmzAWW+SYdL3qRsf2ConIY5XAgMBAAECggEAANg+h97uaNFYF9c5XjNeLYU0ULqkeD6SKAApIpgTY+6w6FkBoWIuT1y+q0jNYqKpwN+Ds2+vfizT7GX5Bz1iLoN8etHawBeLCmIus83A75VeAr20gOyy6pWv3Qlty1yvMsczEydqsL3vA0+gdQoB2RW+ib4uKmxl66WNR+xQE0DaGp3GGe117l4PrhqZWsMMugQ2sLJy6ZZ/L6+ILnTPYwz+6NdOor8owoSEYHpKJ03wTcDGdMtRkzanEtfBNklHolymof+aDwXeugw5teGm+/AtaTf8dINeUIGp6AFrUa+g78wyO/piUf+inLNa+e6vgGqck+ErsbRPb6VFQFBsQQKBgQDqkgfXGETdBtE5g9QiV2aY+DDHzcmQZBLgRGDoHFhn2SgRdG2E66Xq6uGEnwIpqLuKsCYe4t2JvNjSVFZ14Ul8DOow6NQfWLPxU6YT3fShAh/gTMgccKb7/Pe3fQqwl+6aEV3oMOKNFErKSGrkSptLh9Nf/ii9khpMFhUARErgjQKBgQC5HRwRb45CIkEeS2Q1Gz2xS4ezXACw9P0h4Qp5bMQmqgn/fjN+t4HJRlHCuDfLTnHZ48Vpihwu7BU1RmK1ozXJswDzQXCYE4ejEXTGVoUNxCHht4/VlAbBVbcnOsIREgvtZRIOHvPaMqUQqeSX9e3HcEAHfIaJRkVAhalQnKMrcwKBgDUtH7viU5Irvnikaw3R9H9PHHffLgeeuCzBM5rK+juong2+8CkG5tknoDJZfbsF9mYNYsbztTdJaXndBrC4ftkxcFHgJl5o1Hor9WVhlth9S86keWUBIMnVYi7lmOvJtZyVvU0q7+D9rarH2fug8i2gQAnt6zx2h6GiC+bAlJztAoGBAI/YxgnqhUJw+ec/sKPwAjW2usGu2b6o8deU153Z3mcpNVG70OpEUW+F3F0S6BBtad1muO41a4cu36AhjO0W4eJV3oQpMwSKEJmwI+1IKGa1JZsQGI5gVAuCvyuV5l57hpc4NhqRBO9m8YwMaV2ItviHCsqGgslDuObVtue0gLtvAoGAWSPjf+POrVy2VJ0yY9zJ6Ks1oGLYzejUTx6Ue/DmjZcOcn6IBGqvgj10D5Gu9vX847GiC8D632A3US4FMLJZki0IxEYWME3d3pg0vhqS24YxZwIo97JvxnrHOqMvd486K+Cyvlft8xgxlZdL79Ez5eec4Lvw7OoQaaoxcdDvf08='; protected function setUp(): void { @@ -32,21 +41,74 @@ class MiniOnlinePaymentTest extends ProcurementTestCase 'services.wangpu.mer_no' => 'mer001', 'services.wangpu.mer_code' => 'code001', 'services.wangpu.term_code' => 'term001', - 'services.wangpu.sign_key' => self::SIGN_KEY, + 'services.wangpu.public_key' => self::TEST_PUBLIC_KEY, + 'services.wangpu.private_key' => self::TEST_PRIVATE_KEY, 'services.wangpu.payway_code' => 'WECHAT_MINI', ]); } + /** 密钥 PEM 包装(与服务内实现一致) */ + private function pem(string $body, string $kind): string + { + return "-----BEGIN {$kind} KEY-----\n" . wordwrap($body, 64, "\n", true) . "\n-----END {$kind} KEY-----"; + } + + /** + * 模拟网关加密信封(demo 协议:AES-128-ECB 加密报文 + RSA 公钥加密 AES 密钥) + * + * @param array $data 业务报文 + * @return array + */ + private function gatewayEnvelope(array $data): array + { + $key = Str::random(16); + openssl_public_encrypt($key, $encryptedKey, $this->pem(self::TEST_PUBLIC_KEY, 'PUBLIC')); + return [ + 'serialNo' => Str::random(32), + 'version' => '1.0', + 'timestamp' => now()->format('YmdHis'), + 'data' => base64_encode((string) openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA)), + 'signature' => base64_encode($encryptedKey), + 'extras' => '', + 'organizNo' => 'org001', + ]; + } + + /** + * 解密我方发往网关的请求信封(断言上送报文用;服务侧 urlencode 需先解码) + * + * @param array $body 请求信封 + * @return array + */ + private function decryptRequest(array $body): array + { + openssl_private_decrypt( + (string) base64_decode(urldecode((string) $body['signature'])), + $key, + $this->pem(self::TEST_PRIVATE_KEY, 'PRIVATE') + ); + $plain = openssl_decrypt( + (string) base64_decode(urldecode((string) $body['data'])), + 'AES-128-ECB', + (string) $key, + OPENSSL_RAW_DATA + ); + return (array) json_decode((string) $plain, true); + } + /** 模拟微信 code2session + 旺铺统一下单均成功 */ private function fakeGatewaySuccess(string $openid = 'oOpenidTest001'): void { Http::fake([ 'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200), - 'https://wangpu.test/industrial/payment/order' => Http::response([ - 'code' => '0000', - 'msg' => '调用成功', - 'data' => ['order_id' => 'WP202608270001', 'tradeNo' => 'T20260827001', 'user_openid' => $openid], - ], 200), + 'https://wangpu.test/industrial/payment/order' => Http::response( + ['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope([ + 'order_id' => 'WP202608270001', + 'tradeNo' => 'T20260827001', + 'user_openid' => $openid, + ]), + 200 + ), ]); } @@ -89,10 +151,10 @@ class MiniOnlinePaymentTest extends ProcurementTestCase return $payment; } - /** 构造已加签的支付成功通知报文 */ - private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array + /** 构造加密的支付成功通知信封 */ + private function encryptedNotifyParams(PaymentModel $payment, array $overrides = []): array { - $params = array_merge([ + return $this->gatewayEnvelope(array_merge([ 'mer_order_id' => $payment->payment_no, 'order_status' => '1', 'order_amt' => (string) $payment->amount, @@ -104,36 +166,53 @@ class MiniOnlinePaymentTest extends ProcurementTestCase 'mer_no' => 'mer001', 'device_no' => 'dev001', 'order_title' => '账单合并付款', - ], $overrides); - $params['sign'] = app(WangpuPayService::class)->sign($params); - return $params; + ], $overrides)); } - /** 旺铺加签算法:按接口文档示例 golden test(MD5 升序拼接 + key) */ - public function test_sign_matches_document_example(): void + /** AES-128-ECB 加密 golden test:与官方示例 demo/functions.php encryption 算法输出一致 */ + public function test_aes_encryption_matches_demo_golden(): void { - config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']); + $key = 'AbCdEfGh12345678'; // 16 字节密钥 + $data = ['mer_order_id' => 'ZF202608270001', 'order_amt' => '0.01', 'organiz_no' => '100005']; - $sign = app(WangpuPayService::class)->sign([ - 'mer_order_id' => 'CBC92E5GTL000083202004121010143', - 'trade_no' => '11420200410120144102483', - 'mer_no' => '2001071119360E5Riu', - 'order_amt' => '0.01', - 'payway_code' => 'QR_WECHAT_BARPAY', - 'order_id' => '202004101201444525348059', - 'order_status' => '1', - 'order_title' => '住宿酒店', - 'mer_code' => 'W00000000001381', - 'device_no' => 'CBC92E5GTL000083', - 'order_time' => '2020-04-10 12:01:44', - 'trade_time' => '2020-04-10 12:01:47', - 'gateway_mer_order_id' => '2020041012014445269', - ]); + $service = app(WangpuPayService::class); + $method = new ReflectionMethod($service, 'aesEncrypt'); + $cipher = $method->invoke($service, $data, $key); - $this->assertSame('A31998F2E0549E0A80B2A4B3A0473784', $sign); + // demo 算法直算的固定密文(算法/填充/编码任何偏差都会改变该值) + $this->assertSame( + 'jl7iWrSNXWQ4D5CCOCENDYG/3JBxiTYWB2XJSMCFGZjfDr5ned8ZLvfXMC+05bVvbBu5aYc6/245g+wuHU0Mo7zUvmMTrPOvgEfHzsnxHa0=', + $cipher + ); } - /** 发起在线支付:锁定账单、创建支付单、上送网关参数正确、openid 绑定到门店 */ + /** 信封加解密回环:网关侧加密(demo 算法)→ 服务解密还原业务报文 */ + public function test_notify_envelope_round_trip(): void + { + $params = [ + 'mer_order_id' => 'ZF202608270001', + 'order_status' => '1', + 'order_amt' => '150.50', + 'order_title' => '账单合并付款', // 中文报文 + ]; + + $decrypted = app(WangpuPayService::class)->decryptNotify($this->gatewayEnvelope($params)); + + $this->assertSame($params, $decrypted); + } + + /** 密钥配置错误:公钥栏误填私钥时给出明确中文报错(而非 openssl 警告) */ + public function test_swapped_key_config_fails_with_clear_message(): void + { + config(['services.wangpu.public_key' => self::TEST_PRIVATE_KEY]); // 公钥栏误填私钥 + + $this->expectException(RepositoryException::class); + $this->expectExceptionMessage('旺铺平台公钥配置错误(当前内容是私钥,请检查是否填反)'); + + app(WangpuPayService::class)->createOrder(['mer_order_id' => 'ZF202609010001', 'order_amt' => '0.01']); + } + + /** 发起在线支付:锁定账单、创建支付单、上送网关报文正确、openid 绑定到门店 */ public function test_create_online_payment_success(): void { $this->fakeGatewaySuccess(); @@ -163,16 +242,30 @@ class MiniOnlinePaymentTest extends ProcurementTestCase $this->assertSame($payment->id, $bill2->fresh()->payment_id); $this->assertSame('oOpenidTest001', $store->fresh()->openid); - // 上送网关的报文:商户订单号=支付单号、金额、openid、带签名 - Http::assertSent(static function ($request) use ($paymentNo) { + // 上送网关的加密信封:解出业务报文校验商户订单号/金额/openid/商户信息 + Http::assertSent(function ($request) use ($paymentNo) { + if (! str_contains($request->url(), '/industrial/payment/order')) { + return false; + } $body = $request->data(); - return str_contains($request->url(), '/industrial/payment/order') - && ($body['mer_order_id'] ?? '') === $paymentNo - && ($body['order_amt'] ?? '') === '150.50' - && ($body['open_id'] ?? '') === 'oOpenidTest001' - && ($body['sub_appid'] ?? '') === 'wx-mini-test' - && ! empty($body['sign']) - && ! empty($body['notifyurl']); + if (($body['organizNo'] ?? '') !== 'org001' + || empty($body['serialNo']) + || ($body['version'] ?? '') !== '1.0' + || empty($body['timestamp']) + ) { + return false; + } + $plain = $this->decryptRequest($body); + return ($plain['mer_order_id'] ?? '') === $paymentNo + && ($plain['order_amt'] ?? '') === '150.50' + && ($plain['open_id'] ?? '') === 'oOpenidTest001' + && ($plain['sub_appid'] ?? '') === 'wx-mini-test' + && ($plain['mer_no'] ?? '') === 'mer001' + && ($plain['mer_code'] ?? '') === 'code001' + && ($plain['term_code'] ?? '') === 'term001' + && ($plain['payway_code'] ?? '') === 'WECHAT_MINI' + && ($plain['organiz_no'] ?? '') === 'org001' + && ! empty($plain['notifyurl']); }); } @@ -243,7 +336,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase $this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款'); } - /** 支付成功通知:验签通过 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */ + /** 支付成功通知:解密信封 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */ public function test_notify_settles_payment(): void { $store = StoreModel::factory()->create(); @@ -251,7 +344,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase $bill2 = $this->makeBill($store, '50.00'); $payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2); - $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment)) + $this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment)) ->assertJsonPath('code', '00'); $payment->refresh(); @@ -273,34 +366,34 @@ class MiniOnlinePaymentTest extends ProcurementTestCase ); // 重复通知幂等:仍应答成功,金额不重复累加 - $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment)) + $this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment)) ->assertJsonPath('code', '00'); $this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount); $this->assertSame(1, NoticeModel::where('store_id', $store->id)->count()); } - /** 通知验签失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */ + /** 通知解密失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */ public function test_notify_rejects_invalid_messages(): void { $store = StoreModel::factory()->create(); $bill = $this->makeBill($store, '100.00'); $payment = $this->makeOnlinePayment($store, '100.00', $bill); - // 验签失败 - $badSign = $this->signedNotifyParams($payment); - $badSign['sign'] = 'INVALIDSIGN'; - $this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01'); + // 信封 signature 非法 → 解密失败 + $badEnvelope = $this->encryptedNotifyParams($payment); + $badEnvelope['signature'] = 'INVALIDSIGN'; + $this->postJson('/mini/payment/notify', $badEnvelope)->assertJsonPath('code', '01'); // 金额不一致(防篡改) - $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99'])) + $this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_amt' => '99.99'])) ->assertJsonPath('code', '01'); // 订单号不存在 - $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000'])) + $this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000'])) ->assertJsonPath('code', '01'); // 非支付成功状态 - $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0'])) + $this->postJson('/mini/payment/notify', $this->encryptedNotifyParams($payment, ['order_status' => '0'])) ->assertJsonPath('code', '01'); // 均未结账 @@ -331,7 +424,7 @@ class MiniOnlinePaymentTest extends ProcurementTestCase 'order_id' => 'WP202608270002', 'trade_time' => '2026-08-27 11:00:00', ]; - return Http::response(['code' => '0000', 'msg' => '调用成功', 'data' => $data], 200); + return Http::response(['code' => '0000', 'msg' => '调用成功'] + $this->gatewayEnvelope($data), 200); }, ]);