From 13709267806ad2f3372a51f55d2437edd767b03f Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Thu, 3 Sep 2026 20:36:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .phpunit.result.cache | 2 +- .../Mini/OnlinePaymentController.php | 43 +- app/Services/OnlinePaymentService.php | 145 +++++- app/Services/WechatPayService.php | 255 +++++++++++ config/services.php | 23 + database/seeders/SysDataSeeder.php | 8 + tests/Feature/MiniWechatPaymentTest.php | 419 ++++++++++++++++++ 7 files changed, 874 insertions(+), 21 deletions(-) create mode 100644 app/Services/WechatPayService.php create mode 100644 tests/Feature/MiniWechatPaymentTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 5da5262..5c46ce7 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.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 +{"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,"Tests\\Feature\\MiniWechatPaymentTest::test_create_wechat_payment_success":7},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.004,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.088,"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.048,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.048,"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.051,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.092,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.101,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.1,"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.047,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.122,"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.06,"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.048,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.049,"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.086,"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.05,"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.048,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.093,"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.046,"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.007,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.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.045,"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.045,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.045,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.045,"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.054,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.103,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.049,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.05,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.053,"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.062,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.061,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.248,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.054,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.106,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.102,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.105,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.101,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.1,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.1,"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.103,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.101,"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.11,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.109,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.103,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.06,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.102,"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.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.006,"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.045,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.048,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.047,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.047,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.068,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.088,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.049,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.053,"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.106,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.052,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.089,"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.051,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.048,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.046,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.007,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.103,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.151,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.102,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.062,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.068,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.053,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.048,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.045,"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.046,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.046,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.044,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.073,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.061,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.05,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.06,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.061,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.06,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.061,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.048,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.008,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.089,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.13,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.01,"Tests\\Feature\\ContainerReturnTest::test_export_empty_range_rejected":0.006,"Tests\\Feature\\ContainerReturnTest::test_export_requires_export_permission":0.006,"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.01,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.05,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.055,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.051,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.053,"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.009,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":0.051,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.052,"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.089,"Tests\\Feature\\ProductPriceTest::test_level_percent_update_notifies_level_stores":0.09,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.048,"Tests\\Feature\\ProductPriceTest::test_level_percent_validation":0.008,"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.01,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_price":0.007,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.051,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.048,"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.108,"Tests\\Feature\\PurchaseEditTest::test_store_item_add_creates_item_and_recalculates":0.106,"Tests\\Feature\\PurchaseEditTest::test_store_item_update_merges_multi_order_rows":0.06,"Tests\\Feature\\PurchaseEditTest::test_store_item_remove_deletes_and_recalculates":0.1,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_recalculates_totals":0.06,"Tests\\Feature\\StoreOrderItemTest::test_store_item_edits_rejected_when_purchase_completed":0.062,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_validates_negative_values":0.058,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_uses_level_percent_price":0.059,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_rejected_when_product_deleted":0.06,"Tests\\Feature\\ExportTest::test_export_lists_all_catalog_products_with_totals_row":0.104,"Tests\\Feature\\ExportTest::test_export_supplier_filter":0.104,"Tests\\Feature\\ExportTest::test_export_stores_multi_sheet":0.106,"Tests\\Feature\\ExportTest::test_export_stores_single_store":0.102,"Tests\\Feature\\ExportTest::test_export_stores_without_items_rejected":0.144,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":0.104,"Tests\\Feature\\ExportTest::test_export_suppliers_single":0.101,"Tests\\Feature\\ExportTest::test_export_suppliers_without_items_rejected":0.101,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_change_reprices_items_by_store_level":0.102,"Tests\\Feature\\PurchaseEditTest::test_update_row_without_cost_change_keeps_item_price":0.105,"Tests\\Feature\\CartTest::test_update_other_stores_item_rejected":0.09,"Tests\\Feature\\CartTest::test_delete_other_stores_item_rejected":0.089,"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.123,"Tests\\Feature\\MiniAuthTest::test_login_validates_required_params":0.002,"Tests\\Feature\\MiniAuthTest::test_login_token_accesses_protected_routes":0.126,"Tests\\Feature\\MiniAuthTest::test_set_password_succeeds":0.207,"Tests\\Feature\\MiniAuthTest::test_set_password_rejects_wrong_old_password":0.124,"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.047,"Tests\\Feature\\MiniProductTest::test_product_list_store_sees_level_price":0.045,"Tests\\Feature\\MiniStoreTest::test_info_returns_current_store":0.045,"Tests\\Feature\\MiniStoreTest::test_disabled_store_rejected":0.047,"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.005,"Tests\\Feature\\ProductImportExportTest::test_export_requires_permission":0.007,"Tests\\Feature\\ProductImportExportTest::test_import_creates_products_and_auto_supplier":0.043,"Tests\\Feature\\ProductImportExportTest::test_import_matches_existing_supplier":0.019,"Tests\\Feature\\ProductImportExportTest::test_import_errors_abort_all_rows":0.017,"Tests\\Feature\\ProductImportExportTest::test_import_category_path_resolution":0.031,"Tests\\Feature\\ProductImportExportTest::test_import_rejects_invalid_header":0.014,"Tests\\Feature\\ProductImportExportTest::test_import_requires_permission":0.01,"Tests\\Feature\\ProductImportExportTest::test_list_filters_by_market":0.008,"Tests\\Feature\\MiniReportTest::test_report_aggregates_total_and_item_percent":0.047,"Tests\\Feature\\MiniReportTest::test_report_accumulates_same_product_across_orders":0.048,"Tests\\Feature\\MiniReportTest::test_report_filters_by_custom_date_range":0.047,"Tests\\Feature\\MiniReportTest::test_report_excludes_cancelled_and_deleted_orders":0.047,"Tests\\Feature\\MiniReportTest::test_report_preset_month_and_last_month":0.049,"Tests\\Feature\\MiniReportTest::test_report_preset_week_and_last_week":0.051,"Tests\\Feature\\MiniReportTest::test_report_defaults_to_current_month":0.047,"Tests\\Feature\\MiniReportTest::test_report_data_isolated_between_stores":0.086,"Tests\\Feature\\MiniReportTest::test_report_empty_period_returns_zero":0.046,"Tests\\Feature\\MiniReportTest::test_report_validates_params":0.049,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_soft_deletes_selected_products":0.009,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_requires_non_empty_ids":0.006,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_forbidden_without_permission":0.007,"Tests\\Feature\\CartTest::test_place_order_clears_ordered_items_from_cart":0.072,"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.064,"Tests\\Feature\\ExportTest::test_export_suppliers_split_by_market":0.057,"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.057,"Tests\\Feature\\StoreOrderTest::test_place_order_allowed_within_payment_cycle":0.056,"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.053,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_zero_cart_fields":0.045,"Tests\\Feature\\MiniProductTest::test_product_detail_appends_cart_quantity":0.049,"Tests\\Feature\\MiniProductTest::test_home_appends_cart_summary":0.065,"Tests\\Feature\\MiniOnlinePaymentTest::test_sign_matches_document_example":0.002,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_online_payment_success":0.068,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_online_payment_validation":0.097,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_fails_when_code2session_fails":0.047,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_gateway_failure_releases_bills":0.052,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_settles_payment":0.053,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_rejects_invalid_messages":0.058,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_syncs_gateway_status":0.061,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_rejects_other_store_payment":0.088,"Tests\\Feature\\MiniOnlinePaymentTest::test_offline_payment_flow_unaffected":0.049,"Tests\\Feature\\BillPaymentTest::test_generate_bill_positive_after_sale_added_to_total":0.114,"Tests\\Feature\\BillPaymentTest::test_generate_bill_rejects_non_numeric_after_sale":0.048,"Tests\\Feature\\ClientSpecialTest::test_batch_add_creates_specials":0.01,"Tests\\Feature\\ClientSpecialTest::test_batch_add_skips_existing_products":0.007,"Tests\\Feature\\ClientSpecialTest::test_batch_add_requires_non_empty_product_ids":0.006,"Tests\\Feature\\ClientSpecialTest::test_batch_add_forbidden_without_permission":0.007,"Tests\\Feature\\ClientSpecialTest::test_query_filters_by_product_name_keyword":0.007,"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.007,"Tests\\Feature\\MiniSpecialTest::test_guest_sees_specials_with_null_price":0.045,"Tests\\Feature\\MiniSpecialTest::test_store_sees_level_price":0.048,"Tests\\Feature\\MiniSpecialTest::test_disabled_special_and_off_shelf_product_excluded":0.045,"Tests\\Feature\\MiniSpecialTest::test_specials_sorted_by_sort_asc":0.087,"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,"Tests\\Feature\\MiniWechatPaymentTest::test_create_wechat_payment_success":0.068,"Tests\\Feature\\MiniWechatPaymentTest::test_create_wechat_payment_gateway_failure_releases_bills":0.053,"Tests\\Feature\\MiniWechatPaymentTest::test_wechat_notify_settles_payment":0.056,"Tests\\Feature\\MiniWechatPaymentTest::test_wechat_notify_rejects_invalid_messages":0.053,"Tests\\Feature\\MiniWechatPaymentTest::test_wechat_notify_with_platform_cert_validates_signature":0.052,"Tests\\Feature\\MiniWechatPaymentTest::test_wechat_query_syncs_status":0.057,"Tests\\Feature\\MiniWechatPaymentTest::test_wangpu_channel_remains_default":0.049}} \ No newline at end of file diff --git a/app/Http/Controllers/Mini/OnlinePaymentController.php b/app/Http/Controllers/Mini/OnlinePaymentController.php index fa8adb3..b50f9bf 100644 --- a/app/Http/Controllers/Mini/OnlinePaymentController.php +++ b/app/Http/Controllers/Mini/OnlinePaymentController.php @@ -6,6 +6,7 @@ use App\Exceptions\RepositoryException; use App\Models\PaymentModel; use App\Services\OnlinePaymentService; use App\Services\WangpuPayService; +use App\Services\WechatPayService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -15,11 +16,11 @@ use Modules\AnnoRoute\Attribute\RequestAttribute; use Throwable; /** - * 小程序在线支付(旺铺网关 JSAPI) + * 小程序在线支付(渠道二选一:旺铺网关 JSAPI / 微信官方支付 APIv3) * * 链路:POST /mini/payment/online 下单(返回调起支付参数) * → 小程序 wx.requestPayment 完成支付 - * → 网关 POST /mini/payment/notify 后台通知(验签 + 幂等结账) + * → 渠道后台通知(旺铺 /mini/payment/notify;微信 /mini/payment/wechat-notify)验签 + 幂等结账 * → 小程序 GET /mini/payment/online/{paymentNo}/query 主动同步支付结果(回调兜底) */ #[RequestAttribute('/mini', 'mini', authGuard: 'users')] @@ -28,6 +29,7 @@ class OnlinePaymentController extends BaseMiniController public function __construct( protected OnlinePaymentService $onlinePayment, protected WangpuPayService $wangpu, + protected WechatPayService $wxpay, ) {} /** @@ -123,6 +125,43 @@ class OnlinePaymentController extends BaseMiniController return $this->notifyAck(WangpuPayService::NOTIFY_ACK_OK, '成功'); } + /** + * 微信支付结果后台通知(公开路由,验签 + AES-GCM 解密后幂等结账) + * + * 应答 {"code":"SUCCESS","message":"成功"} 视为通知成功,否则微信按阶梯间隔重推; + * 重复通知必须幂等(settle 内部行锁 + 状态判断)。 + */ + #[PostRoute('/payment/wechat-notify', authorize: false)] + public function wechatNotify(Request $request): JsonResponse + { + try { + $params = $this->wxpay->decryptNotify($request); + } catch (Throwable $e) { + Log::warning('微信支付通知验签/解密失败', ['error' => $e->getMessage()]); + return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_FAIL, '报文验签解密失败'); + } + + try { + $this->onlinePayment->settleByWechatNotify($params); + } catch (Throwable $e) { + Log::error('微信支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]); + return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_FAIL, $e->getMessage()); + } + + return $this->wechatNotifyAck(WechatPayService::NOTIFY_ACK_OK, '成功'); + } + + /** + * 微信通知应答报文(微信约定格式) + */ + protected function wechatNotifyAck(string $code, string $msg): JsonResponse + { + return response()->json([ + 'code' => $code, + 'message' => mb_substr($msg, 0, 64), + ], $code === WechatPayService::NOTIFY_ACK_OK ? 200 : 500); + } + /** * 通知应答报文(网关约定格式,timestamp:yyyyMMddHHmmssSSS) */ diff --git a/app/Services/OnlinePaymentService.php b/app/Services/OnlinePaymentService.php index 02d8e0d..7b8d42a 100644 --- a/app/Services/OnlinePaymentService.php +++ b/app/Services/OnlinePaymentService.php @@ -13,34 +13,57 @@ use Illuminate\Support\Facades\Log; use Throwable; /** - * 在线支付编排服务(旺铺网关) + * 在线支付编排服务(渠道二选一:旺铺网关 / 微信官方支付) * * 链路:门店小程序选择账单合并付款 * → code2session 换 openid → 校验并锁定账单 → 创建支付单(pay_type=2) - * → 旺铺统一下单 → 返回调起支付参数 → 小程序 wx.requestPayment + * → 按当前渠道(site_config pay.online_channel)走旺铺统一下单或微信 JSAPI 下单 + * → 返回调起支付参数 → 小程序 wx.requestPayment * → 网关后台通知 / 小程序主动查询 → settle() 幂等结账: * 支付单置成功 + 关联账单批量置已支付 + 累加门店总采购金额(只统计商品金额)+ 通知门店 * + * 已创建的支付单按自身 pay_method 固定在原渠道查询/结账(渠道切换不影响进行中的支付单)。 * 结账口径与后台「支付审核通过 / 线下收款登记」保持一致。 */ class OnlinePaymentService { + /** 在线支付渠道:旺铺支付网关 */ + public const string CHANNEL_WANGPU = 'wangpu'; + /** 在线支付渠道:微信官方支付 */ + public const string CHANNEL_WECHAT = 'wechat'; + public function __construct( protected BillNumberService $billNumber, protected WangpuPayService $wangpu, + protected WechatPayService $wxpay, protected WechatMiniService $wechat, ) {} + /** + * 当前在线支付渠道(site_config pay.online_channel 优先,回退 env PAY_ONLINE_CHANNEL,默认旺铺) + */ + public function channel(): string + { + $channel = (string) site_config('pay.online_channel', ''); + if ($channel === '') { + $channel = (string) config('services.pay.online_channel', self::CHANNEL_WANGPU); + } + return $channel === self::CHANNEL_WECHAT ? self::CHANNEL_WECHAT : self::CHANNEL_WANGPU; + } + /** * 发起在线支付 * * @param array $billIds 合并付款的账单ID * @param string $code 小程序 wx.login() 返回的登录凭证 - * @return array{0: PaymentModel, 1: array} 支付单与旺铺返回的调起支付参数 + * @return array{0: PaymentModel, 1: array} 支付单与渠道返回的调起支付参数 * @throws Throwable */ public function create(StoreModel $store, array $billIds, string $code, string $remark = ''): array { + $channel = $this->channel(); + $payMethod = $channel === self::CHANNEL_WECHAT ? PaymentModel::METHOD_WECHAT : PaymentModel::METHOD_WANGPU; + // 换取付款人 openid 并绑定到门店(下次可直接复用) $openid = $this->wechat->code2session($code); if ((string) $store->openid !== $openid) { @@ -48,7 +71,7 @@ class OnlinePaymentService $store->save(); } - [$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark) { + [$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark, $payMethod) { $bills = BillModel::query() ->where('store_id', $store->id) ->whereIn('id', $billIds) @@ -79,7 +102,7 @@ class OnlinePaymentService 'store_id' => $store->id, 'amount' => $amount, 'pay_type' => PaymentModel::TYPE_ONLINE, - 'pay_method' => PaymentModel::METHOD_WANGPU, + 'pay_method' => $payMethod, 'voucher_ids' => '', 'status' => PaymentModel::STATUS_PENDING, 'openid' => $openid, @@ -93,21 +116,30 @@ class OnlinePaymentService }); try { - $gatewayData = $this->wangpu->createOrder([ - 'mer_order_id' => $payment->payment_no, - 'order_amt' => (string) $payment->amount, - 'open_id' => $openid, - 'sub_appid' => $this->subAppid(), - 'order_title' => '账单合并付款-' . $payment->payment_no, - 'notifyurl' => $this->wangpu->notifyUrl(), - ]); + $gatewayData = $channel === self::CHANNEL_WECHAT + ? $this->wxpay->createOrder( + $payment->payment_no, + (string) $payment->amount, + $openid, + '账单合并付款-' . $payment->payment_no, + ) + : $this->wangpu->createOrder([ + 'mer_order_id' => $payment->payment_no, + 'order_amt' => (string) $payment->amount, + 'open_id' => $openid, + 'sub_appid' => $this->subAppid(), + 'order_title' => '账单合并付款-' . $payment->payment_no, + 'notifyurl' => $this->wangpu->notifyUrl(), + ]); } catch (Throwable $e) { // 网关下单失败:整笔作废并释放账单,门店可重新发起 $this->discard($payment, $e->getMessage()); throw $e; } - $payment->order_id = (string) ($gatewayData['order_id'] ?? ''); + if ($channel === self::CHANNEL_WANGPU) { + $payment->order_id = (string) ($gatewayData['order_id'] ?? ''); + } $payment->pay_params = $gatewayData; $payment->save(); @@ -115,7 +147,7 @@ class OnlinePaymentService } /** - * 支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账 + * 旺铺支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账 * * @param array $params 通知报文(已验签) * @return bool 本次是否执行了结账(false = 重复通知) @@ -145,9 +177,46 @@ class OnlinePaymentService } /** - * 主动查询网关订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用) + * 微信支付成功后台通知处理:验签/解密由 WechatPayService 完成,此处按 out_trade_no 定位支付单并幂等结账 * - * @return array{payment: PaymentModel, order_status: int} 最新支付单与网关订单状态 + * @param array $params 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount) + * @return bool 本次是否执行了结账(false = 重复通知) + * @throws Throwable + */ + public function settleByWechatNotify(array $params): bool + { + $outTradeNo = (string) ($params['out_trade_no'] ?? ''); + $payment = PaymentModel::query() + ->where('payment_no', $outTradeNo) + ->where('pay_type', PaymentModel::TYPE_ONLINE) + ->first(); + if ($payment === null) { + throw new RepositoryException('支付记录不存在:' . $outTradeNo); + } + if (($params['trade_state'] ?? '') !== WechatPayService::TRADE_STATE_SUCCESS) { + throw new RepositoryException('订单未支付成功(trade_state=' . ($params['trade_state'] ?? '空') . ')'); + } + // 商户号一致性校验,防串号 + $mchId = (string) ($params['mchid'] ?? ''); + if ($mchId !== '' && $mchId !== $this->wxpayMchId()) { + throw new RepositoryException('通知商户号与配置不一致'); + } + + return $this->settle( + $payment, + (string) ($params['transaction_id'] ?? ''), + '', + (string) ($params['success_time'] ?? ''), + bcdiv((string) (int) ($params['amount']['total'] ?? 0), '100', 2), + ); + } + + /** + * 主动查询渠道订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用) + * + * 按支付单的 pay_method 固定在原渠道查询,渠道切换不影响进行中的支付单 + * + * @return array{payment: PaymentModel, order_status: int} 最新支付单与渠道订单状态(1=已支付) * @throws Throwable */ public function queryAndSettle(PaymentModel $payment): array @@ -156,6 +225,10 @@ class OnlinePaymentService return ['payment' => $payment, 'order_status' => WangpuPayService::ORDER_STATUS_PAID]; } + if ($payment->pay_method === PaymentModel::METHOD_WECHAT) { + return $this->queryWechatAndSettle($payment); + } + $data = $this->wangpu->queryOrder($payment->payment_no, (string) $payment->order_id); $orderStatus = (int) ($data['order_status'] ?? -1); @@ -172,6 +245,30 @@ class OnlinePaymentService return ['payment' => $payment->fresh(), 'order_status' => $orderStatus]; } + /** + * 微信渠道主动查询:trade_state=SUCCESS 则结账 + * + * @return array{payment: PaymentModel, order_status: int} + * @throws Throwable + */ + protected function queryWechatAndSettle(PaymentModel $payment): array + { + $data = $this->wxpay->queryOrder($payment->payment_no); + $paid = ($data['trade_state'] ?? '') === WechatPayService::TRADE_STATE_SUCCESS; + + if ($paid) { + $this->settle( + $payment, + (string) ($data['transaction_id'] ?? ''), + '', + (string) ($data['success_time'] ?? ''), + bcdiv((string) (int) ($data['amount']['total'] ?? 0), '100', 2), + ); + } + + return ['payment' => $payment->fresh(), 'order_status' => $paid ? 1 : 0]; + } + /** * 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重) * @@ -208,7 +305,7 @@ class OnlinePaymentService 'status' => BillModel::STATUS_PAID, 'paid_at' => $paidAt, 'paid_operator_id' => 0, - 'pay_remark' => PaymentModel::METHOD_NAMES[PaymentModel::METHOD_WANGPU] . '(支付单号 ' . $payment->payment_no . ')', + 'pay_remark' => (PaymentModel::METHOD_NAMES[$payment->pay_method] ?? '在线支付') . '(支付单号 ' . $payment->payment_no . ')', ]); // 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额) @@ -307,4 +404,16 @@ class OnlinePaymentService } return trim($subAppid); } + + /** + * 微信渠道商户号(用于通知商户号一致性校验) + */ + protected function wxpayMchId(): string + { + $mchId = (string) site_config('pay.wxpay_mch_id', ''); + if ($mchId === '') { + $mchId = (string) config('services.wxpay.mch_id', ''); + } + return trim($mchId); + } } diff --git a/app/Services/WechatPayService.php b/app/Services/WechatPayService.php new file mode 100644 index 0000000..0ff8d6c --- /dev/null +++ b/app/Services/WechatPayService.php @@ -0,0 +1,255 @@ + config/services.php(env) + */ +class WechatPayService +{ + /** 通知应答码:成功 / 失败(微信收到 SUCCESS 才停止重推) */ + public const string NOTIFY_ACK_OK = 'SUCCESS'; + public const string NOTIFY_ACK_FAIL = 'FAIL'; + + /** 交易状态:支付成功(交易查询 / 支付通知的 trade_state) */ + public const string TRADE_STATE_SUCCESS = 'SUCCESS'; + + protected ?Application $application = null; + + /** + * 统一下单(小程序 JSAPI) + * + * @param string $paymentNo 商户订单号(本系统支付单号) + * @param string $amountYuan 金额(元,两位小数) + * @param string $openid 付款人 openid + * @param string $description 订单标题 + * @return array wx.requestPayment 调起参数(timeStamp/nonceStr/package/signType/paySign) + * @throws RepositoryException 未配置或下单失败 + */ + public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description): array + { + $result = $this->call('POST', '/v3/pay/transactions/jsapi', [ + 'appid' => $this->appid(), + 'mchid' => $this->config('mch_id'), + 'description' => mb_substr($description, 0, 127), + 'out_trade_no' => $paymentNo, + 'notify_url' => $this->notifyUrl(), + 'amount' => [ + 'total' => (int) bcmul($amountYuan, '100'), // APIv3 金额单位:分 + 'currency' => 'CNY', + ], + 'payer' => ['openid' => $openid], + ], '下单'); + + $prepayId = (string) ($result['prepay_id'] ?? ''); + if ($prepayId === '') { + Log::driver('pay')->error('微信支付下单应答缺少 prepay_id', ['response' => $result]); + throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id'); + } + + $params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $this->appid()); + Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId]); + + return $params; + } + + /** + * 交易查询(按商户订单号) + * + * @param string $paymentNo 商户订单号(本系统支付单号) + * @return array 微信交易报文(trade_state/transaction_id/success_time/amount.total 等) + * @throws RepositoryException 查询失败 + */ + public function queryOrder(string $paymentNo): array + { + return $this->call('GET', '/v3/pay/transactions/out-trade-no/' . $paymentNo, [ + 'mchid' => $this->config('mch_id'), + ], '查询'); + } + + /** + * 解密支付结果后台通知(配置平台证书/公钥时先验签,再以 APIv3 密钥 AES-256-GCM 解密) + * + * @return array 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount 等) + * @throws RepositoryException 验签或解密失败 + */ + public function decryptNotify(Request $request): array + { + Log::driver('pay')->info('微信支付通知接收', ['body' => $request->getContent()]); + + $app = $this->app(); + $server = $app->getServer(); + $server->setRequestFromSymfonyRequest($request); + + // 配置平台证书/公钥时校验 Wechatpay-Signature;未配置时依赖 AES-GCM 认证解密(APIv3 密钥仅微信与商户持有) + if ($this->config('platform_cert') !== '' && $this->config('platform_serial') !== '') { + try { + $app->getValidator()->validate($server->getRequest()); + } catch (Throwable $e) { + throw new RepositoryException('微信支付通知验签失败:' . $e->getMessage()); + } + } + + try { + $message = $server->getRequestMessage(); + } catch (Throwable $e) { + throw new RepositoryException('微信支付通知解密失败:' . $e->getMessage()); + } + + $params = $message->toArray(); + Log::driver('pay')->info('微信支付通知解密', ['params' => $params]); + return $params; + } + + /** + * 支付结果后台通知地址(统一下单时上送的 notify_url) + */ + public function notifyUrl(): string + { + return rtrim((string) config('app.url'), '/') . '/mini/payment/wechat-notify'; + } + + /** + * 注入 EasyWeChat Application(测试 mock 用) + */ + public function setApplication(Application $application): static + { + $this->application = $application; + return $this; + } + + /** + * EasyWeChat Pay Application(按当前配置懒加载构建) + * + * @throws RepositoryException 必填配置缺失或密钥无法解析 + */ + public function app(): Application + { + if ($this->application !== null) { + return $this->application; + } + + $mchId = $this->config('mch_id'); + if ($mchId === '') { + throw new RepositoryException('微信支付未配置商户号,请联系管理员'); + } + + $platformCerts = []; + $platformCert = $this->config('platform_cert'); + $platformSerial = $this->config('platform_serial'); + if ($platformCert !== '' && $platformSerial !== '') { + $platformCerts[$platformSerial] = $this->pemContent($platformCert, 'CERTIFICATE'); + } + + try { + $this->application = new Application([ + 'mch_id' => $mchId, + 'private_key' => $this->pemContent($this->config('private_key'), 'PRIVATE'), + 'certificate' => $this->pemContent($this->config('certificate'), 'CERTIFICATE'), + 'secret_key' => $this->config('secret_key'), + 'platform_certs' => $platformCerts, + ]); + } catch (RepositoryException $e) { + throw $e; + } catch (Throwable $e) { + throw new RepositoryException('微信支付配置错误:' . $e->getMessage()); + } + + return $this->application; + } + + /** + * 调起微信 v3 接口并处理应答(通讯/业务失败统一抛 RepositoryException) + * + * @param array $payload POST 时为 JSON 报文,GET 时为 query 参数 + * @return array 应答报文 + * @throws RepositoryException + */ + protected function call(string $method, string $uri, array $payload, string $action): array + { + try { + $response = $method === 'GET' + ? $this->app()->getClient()->get($uri, ['query' => $payload]) + : $this->app()->getClient()->postJson($uri, $payload); + } catch (RepositoryException $e) { + throw $e; + } catch (Throwable $e) { + Log::driver('pay')->error('微信支付' . $action . '通讯异常', ['uri' => $uri, 'error' => $e->getMessage()]); + throw new RepositoryException('微信支付' . $action . '通讯异常,请稍后重试'); + } + + $body = $response->getContent(false); + Log::driver('pay')->info('微信支付' . $action . '应答', [ + 'uri' => $uri, + 'status' => $response->getStatusCode(), + 'body' => $body, + ]); + + $result = json_decode($body, true); + if ($response->isFailed()) { + $message = is_array($result) ? (string) ($result['message'] ?? $result['code'] ?? '') : ''; + throw new RepositoryException('微信支付' . $action . '失败:' . ($message !== '' ? $message : '未知错误')); + } + + return is_array($result) ? $result : []; + } + + /** + * 下单小程序 appid(默认取小程序自身 appid) + */ + protected function appid(): string + { + $appid = $this->config('appid'); + if ($appid === '') { + $appid = (string) config('services.wechat.mini.appid', ''); + } + return $appid; + } + + /** + * 密钥/证书内容归一化为 PEM:支持完整 PEM、base64 单行、file:// 路径或本地文件路径 + * + * @throws RepositoryException 未配置 + */ + protected function pemContent(string $value, string $kind): string + { + $value = trim($value); + if ($value === '') { + $name = $kind === 'PRIVATE' ? '商户API私钥' : '证书'; + throw new RepositoryException('微信支付未配置' . $name . ',请联系管理员'); + } + if (str_starts_with($value, 'file://') || str_contains($value, '-----BEGIN')) { + return $value; + } + if (is_file($value)) { + return 'file://' . $value; + } + // base64 单行内容包装为 PEM + $header = $kind === 'PRIVATE' ? 'PRIVATE' : 'CERTIFICATE'; + return "-----BEGIN {$header}-----\n" . wordwrap($value, 64, "\n", true) . "\n-----END {$header}-----"; + } + + /** + * 读取支付配置:后台站点配置优先,为空回退 config/services.php(env) + */ + protected function config(string $key): string + { + $value = site_config('pay.wxpay_' . $key, ''); + if ($value === null || $value === '') { + $value = config('services.wxpay.' . $key, ''); + } + return trim((string) $value); + } +} diff --git a/config/services.php b/config/services.php index ea91523..3335b56 100644 --- a/config/services.php +++ b/config/services.php @@ -53,6 +53,14 @@ return [ ], ], + /* + * 在线支付渠道开关(二选一):wangpu=旺铺支付网关 / wechat=微信官方支付 + * 优先读取后台「系统设置 → 支付配置」(site_config pay.online_channel),为空时回退到这里的 env 配置 + */ + 'pay' => [ + 'online_channel' => env('PAY_ONLINE_CHANNEL', 'wangpu'), + ], + /* * 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知) * 优先读取后台「系统设置 → 支付配置」(site_config pay.wangpu_*),为空时回退到这里的 env 配置 @@ -68,4 +76,19 @@ return [ 'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid) 'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填) ], + + /* + * 微信官方支付(APIv3 小程序 JSAPI,基于 EasyWeChat Pay) + * 优先读取后台「系统设置 → 支付配置」(site_config pay.wxpay_*),为空时回退到这里的 env 配置 + * 密钥/证书支持:完整 PEM 文本、base64 单行、或以 file:// 开头的文件路径 + */ + 'wxpay' => [ + 'mch_id' => env('WXPAY_MCH_ID', ''), // 微信支付商户号 + 'appid' => env('WXPAY_APPID', ''), // 下单小程序 appid(留空取小程序自身 appid) + 'private_key' => env('WXPAY_PRIVATE_KEY', ''), // 商户 API 私钥(apiclient_key.pem) + 'certificate' => env('WXPAY_CERTIFICATE', ''), // 商户 API 证书(apiclient_cert.pem,请求签名序列号取自证书) + 'secret_key' => env('WXPAY_SECRET_KEY', ''), // APIv3 密钥(回调报文 AES-256-GCM 解密) + 'platform_cert' => env('WXPAY_PLATFORM_CERT', ''), // 微信支付平台证书/微信支付公钥(回调验签,可空) + 'platform_serial' => env('WXPAY_PLATFORM_SERIAL', ''), // 平台证书序列号/微信支付公钥ID(配合 platform_cert) + ], ]; diff --git a/database/seeders/SysDataSeeder.php b/database/seeders/SysDataSeeder.php index fe74da6..085d515 100644 --- a/database/seeders/SysDataSeeder.php +++ b/database/seeders/SysDataSeeder.php @@ -38,6 +38,14 @@ class SysDataSeeder extends Seeder ['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], + ['id' => 21, 'group_id' => 4, 'key' => 'online_channel', 'title' => '在线支付渠道', 'describe' => '小程序在线支付通道(二选一):旺铺支付网关 或 微信官方支付(APIv3),切换仅影响之后发起的支付单', 'values' => 'wangpu', 'type' => 'Radio','options' => "wangpu=旺铺支付\nwechat=微信官方支付", 'sort' => 12, 'created_at' => $date, 'updated_at' => $date], + ['id' => 22, 'group_id' => 4, 'key' => 'wxpay_mch_id', 'title' => '微信支付商户号', 'describe' => '微信支付商户号 mch_id(微信商户平台分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 13, 'created_at' => $date, 'updated_at' => $date], + ['id' => 23, 'group_id' => 4, 'key' => 'wxpay_appid', 'title' => '微信支付小程序appid', 'describe' => '下单小程序 appid(须与商户号绑定),留空则取小程序自身 appid', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 14, 'created_at' => $date, 'updated_at' => $date], + ['id' => 24, 'group_id' => 4, 'key' => 'wxpay_private_key', 'title' => '微信商户API私钥', 'describe' => '商户 API 私钥 apiclient_key.pem 内容(完整 PEM 或 base64 单行),用于请求签名,请勿泄露', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 15, 'created_at' => $date, 'updated_at' => $date], + ['id' => 25, 'group_id' => 4, 'key' => 'wxpay_certificate', 'title' => '微信商户API证书', 'describe' => '商户 API 证书 apiclient_cert.pem 内容(完整 PEM 或 base64 单行),请求签名序列号取自证书', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 16, 'created_at' => $date, 'updated_at' => $date], + ['id' => 26, 'group_id' => 4, 'key' => 'wxpay_secret_key', 'title' => '微信APIv3密钥', 'describe' => 'APIv3 密钥(32 位,商户平台自行设置),用于解密支付结果通知报文', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 17, 'created_at' => $date, 'updated_at' => $date], + ['id' => 27, 'group_id' => 4, 'key' => 'wxpay_platform_cert', 'title' => '微信平台证书/公钥', 'describe' => '微信支付平台证书或微信支付公钥内容(完整 PEM 或 base64 单行),用于回调验签,可留空(留空则以 APIv3 密钥解密结果为准)', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 18, 'created_at' => $date, 'updated_at' => $date], + ['id' => 28, 'group_id' => 4, 'key' => 'wxpay_platform_serial', 'title' => '微信平台证书序列号', 'describe' => '平台证书序列号或微信支付公钥ID(PUB_KEY_ID_ 开头),配合平台证书/公钥使用', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 19, 'created_at' => $date, 'updated_at' => $date], ]); // 字典类型初始数据 DB::table('sys_dict')->insert([ diff --git a/tests/Feature/MiniWechatPaymentTest.php b/tests/Feature/MiniWechatPaymentTest.php new file mode 100644 index 0000000..5b68016 --- /dev/null +++ b/tests/Feature/MiniWechatPaymentTest.php @@ -0,0 +1,419 @@ + 'wx-mini-test', + 'services.wechat.mini.secret' => 'wx-secret-test', + 'services.pay.online_channel' => 'wechat', + 'services.wxpay.mch_id' => '1630000001', + 'services.wxpay.private_key' => self::TEST_PRIVATE_KEY, + 'services.wxpay.certificate' => self::TEST_CERTIFICATE, + 'services.wxpay.secret_key' => self::TEST_SECRET_KEY, + ]); + } + + /** + * 以 MockHttpClient 替换微信支付接口应答,并注入容器(控制器经由容器取同一实例) + * + * @param callable|array $responses 应答回调或应答队列 + */ + private function fakeWechatApi(callable|array $responses): WechatPayService + { + $service = app(WechatPayService::class); + $app = $service->app(); + $app->setHttpClient(new MockHttpClient($responses, 'https://api.mch.weixin.qq.com')); + $this->app->instance(WechatPayService::class, $service); + + return $service; + } + + /** 模拟微信 code2session 成功 */ + private function fakeCode2session(string $openid = 'oOpenidTest001'): void + { + Http::fake([ + 'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200), + ]); + } + + /** 造一张指定金额的未支付账单(总额=商品金额) */ + private function makeBill(StoreModel $store, string $amount, array $attributes = []): BillModel + { + return BillModel::create(array_merge([ + 'bill_no' => 'ZD' . random_int(100000000000, 999999999999), + 'purchase_id' => PurchaseOrderModel::factory()->create()->id, + 'store_id' => $store->id, + 'bill_date' => '2026-09-01', + 'product_amount' => $amount, + 'delivery_fee' => '0.00', + 'box_num' => 0, + 'tray_num' => 0, + 'box_price' => '0.00', + 'tray_price' => '0.00', + 'added_amount' => '0.00', + 'total_amount' => $amount, + 'status' => BillModel::STATUS_UNPAID, + ], $attributes)); + } + + /** 造一笔待支付的微信渠道在线支付单并锁定账单 */ + private function makeWechatPayment(StoreModel $store, string $amount, BillModel ...$bills): PaymentModel + { + $payment = PaymentModel::create([ + 'payment_no' => 'ZF' . now()->format('Ymd') . random_int(1000, 9999), + 'store_id' => $store->id, + 'amount' => $amount, + 'pay_type' => PaymentModel::TYPE_ONLINE, + 'pay_method' => PaymentModel::METHOD_WECHAT, + 'voucher_ids' => '', + 'status' => PaymentModel::STATUS_PENDING, + 'openid' => 'oOpenidTest001', + ]); + foreach ($bills as $bill) { + $bill->update(['payment_id' => $payment->id]); + } + return $payment; + } + + /** + * 构造微信支付结果通知报文(交易报文以 APIv3 密钥 AES-256-GCM 加密装入 resource) + * + * @param array $resourceOverrides 交易报文覆盖项 + * @return array 通知报文 + */ + private function wechatNotifyBody(PaymentModel $payment, array $resourceOverrides = []): array + { + $resource = array_merge([ + 'mchid' => '1630000001', + 'appid' => 'wx-mini-test', + 'out_trade_no' => $payment->payment_no, + 'transaction_id' => '4200002501202609010001', + 'trade_state' => 'SUCCESS', + 'trade_state_desc' => '支付成功', + 'success_time' => '2026-09-01T14:00:00+08:00', + 'amount' => [ + 'total' => (int) bcmul((string) $payment->amount, '100'), + 'payer_total' => (int) bcmul((string) $payment->amount, '100'), + 'currency' => 'CNY', + ], + ], $resourceOverrides); + + return [ + 'id' => 'EV-202609011400000001', + 'create_time' => '2026-09-01T14:00:05+08:00', + 'resource_type' => 'encrypt-resource', + 'event_type' => 'TRANSACTION.SUCCESS', + 'summary' => '支付成功', + 'resource' => [ + 'original_type' => 'transaction', + 'algorithm' => 'AEAD_AES_256_GCM', + 'ciphertext' => AesGcm::encrypt((string) json_encode($resource), self::TEST_SECRET_KEY, 'nonce1234567', 'transaction'), + 'associated_data' => 'transaction', + 'nonce' => 'nonce1234567', + ], + ]; + } + + /** 以原始报文 + 自定义请求头发送微信通知(验签场景需要精确控制 body 参与签名) */ + private function postWechatNotify(array $body, array $headers = []): \Illuminate\Testing\TestResponse + { + $server = ['CONTENT_TYPE' => 'application/json']; + foreach ($headers as $name => $value) { + $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + return $this->call('POST', '/mini/payment/wechat-notify', [], [], [], $server, (string) json_encode($body)); + } + + /** 对通知报文按微信规则签名(平台证书验签场景):timestamp\nnonce\nbody\n */ + private function signNotify(string $body, int $timestamp, string $nonce): string + { + openssl_sign("{$timestamp}\n{$nonce}\n{$body}\n", $signature, self::TEST_PRIVATE_KEY, 'sha256WithRSAEncryption'); + return base64_encode($signature); + } + + /** 发起微信渠道在线支付:锁定账单、创建支付单、上送报文正确、返回标准 wx.requestPayment 调起参数 */ + public function test_create_wechat_payment_success(): void + { + $this->fakeCode2session(); + $captured = []; + $this->fakeWechatApi(function (string $method, string $url, array $options) use (&$captured) { + $captured = compact('method', 'url', 'options'); + return new MockResponse((string) json_encode(['prepay_id' => 'wx202609011200000001']), ['http_code' => 200]); + }); + + $store = StoreModel::factory()->create(); + $bill1 = $this->makeBill($store, '100.00'); + $bill2 = $this->makeBill($store, '50.50'); + + $this->actingAsMiniStore($store); + $response = $this->postJson('/mini/payment/online', [ + 'bill_ids' => [$bill1->id, $bill2->id], + 'code' => 'wx-login-code', + ])->assertJsonPath('success', true); + + $paymentNo = $response->json('data.payment_no'); + $this->assertSame('150.50', $response->json('data.amount')); + + // 调起参数为标准 wx.requestPayment 五要素,package 含 prepay_id + $payParams = $response->json('data.pay_params'); + $this->assertSame('wx-mini-test', $payParams['appId'] ?? null); + $this->assertNotEmpty($payParams['timeStamp']); + $this->assertNotEmpty($payParams['nonceStr']); + $this->assertSame('prepay_id=wx202609011200000001', $payParams['package']); + $this->assertSame('RSA', $payParams['signType']); + $this->assertNotEmpty($payParams['paySign']); + + $payment = PaymentModel::where('payment_no', $paymentNo)->first(); + $this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type); + $this->assertSame(PaymentModel::METHOD_WECHAT, $payment->pay_method); + $this->assertSame(PaymentModel::STATUS_PENDING, $payment->status); + $this->assertSame('', (string) $payment->order_id, '微信渠道下单成功后才回传 transaction_id'); + + // 账单锁定 + openid 绑定门店 + $this->assertSame($payment->id, $bill1->fresh()->payment_id); + $this->assertSame($payment->id, $bill2->fresh()->payment_id); + $this->assertSame('oOpenidTest001', $store->fresh()->openid); + + // 上送微信的报文:JSAPI 下单,金额转换为分,通知地址为微信回调路由 + //(MockHttpClient 回调收到的是预处理后的 options:json 已编码进 body,header 名小写且值为数组) + $this->assertSame('POST', $captured['method']); + $this->assertStringContainsString('/v3/pay/transactions/jsapi', $captured['url']); + $json = (array) json_decode((string) ($captured['options']['body'] ?? ''), true); + $this->assertSame('wx-mini-test', $json['appid']); + $this->assertSame('1630000001', $json['mchid']); + $this->assertSame($paymentNo, $json['out_trade_no']); + $this->assertSame(15050, $json['amount']['total']); + $this->assertSame('CNY', $json['amount']['currency']); + $this->assertSame('oOpenidTest001', $json['payer']['openid']); + $this->assertStringContainsString('/mini/payment/wechat-notify', $json['notify_url']); + // 请求头携带商户签名(预处理后的 headers 为 "Name: value" 字符串列表) + $headerLines = implode("\n", array_map('strval', (array) ($captured['options']['headers'] ?? []))); + $this->assertStringContainsString('Authorization: WECHATPAY2-SHA256-RSA2048', $headerLines); + } + + /** 微信下单失败(应答 4xx):支付单作废并释放账单,可重新发起 */ + public function test_create_wechat_payment_gateway_failure_releases_bills(): void + { + $this->fakeCode2session(); + $this->fakeWechatApi([ + new MockResponse((string) json_encode(['code' => 'PARAM_ERROR', 'message' => 'appid 与 mchid 不匹配']), ['http_code' => 400]), + ]); + + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '20.00'); + + $this->actingAsMiniStore($store); + $this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'c']) + ->assertJsonPath('success', false); + + $payment = PaymentModel::first(); + $this->assertSame(PaymentModel::STATUS_REJECTED, $payment->status); + $this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款'); + } + + /** 微信支付成功通知:AES-GCM 解密 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */ + public function test_wechat_notify_settles_payment(): void + { + $store = StoreModel::factory()->create(); + $bill1 = $this->makeBill($store, '100.00'); + $bill2 = $this->makeBill($store, '50.00'); + $payment = $this->makeWechatPayment($store, '150.00', $bill1, $bill2); + + $this->postWechatNotify($this->wechatNotifyBody($payment)) + ->assertJsonPath('code', 'SUCCESS'); + + $payment->refresh(); + $this->assertSame(PaymentModel::STATUS_APPROVED, $payment->status); + $this->assertSame('4200002501202609010001', $payment->trade_no); + $this->assertSame('2026-09-01 14:00:00', (string) $payment->paid_at); + + foreach ([$bill1, $bill2] as $bill) { + $bill->refresh(); + $this->assertSame(BillModel::STATUS_PAID, $bill->status); + $this->assertStringContainsString('微信支付', (string) $bill->pay_remark); + $this->assertStringContainsString($payment->payment_no, (string) $bill->pay_remark); + } + $this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount); + $this->assertTrue( + NoticeModel::where('store_id', $store->id)->where('title', '账单支付成功')->exists() + ); + + // 重复通知幂等:仍应答成功,金额不重复累加 + $this->postWechatNotify($this->wechatNotifyBody($payment)) + ->assertJsonPath('code', 'SUCCESS'); + $this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount); + $this->assertSame(1, NoticeModel::where('store_id', $store->id)->count()); + } + + /** 通知解密失败 / 金额不一致 / 订单号不存在 / 非支付成功状态 / 商户号不一致:应答失败且不结账 */ + public function test_wechat_notify_rejects_invalid_messages(): void + { + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '100.00'); + $payment = $this->makeWechatPayment($store, '100.00', $bill); + + // 密文非法(密钥不匹配)→ 解密失败 + $badBody = $this->wechatNotifyBody($payment); + $badBody['resource']['ciphertext'] = AesGcm::encrypt('{"out_trade_no":"x"}', str_repeat('x', 32), 'nonce1234567', 'transaction'); + $this->postWechatNotify($badBody)->assertJsonPath('code', 'FAIL'); + + // 金额不一致(防篡改) + $this->postWechatNotify($this->wechatNotifyBody($payment, ['amount' => ['total' => 9999, 'payer_total' => 9999]])) + ->assertJsonPath('code', 'FAIL'); + + // 订单号不存在 + $this->postWechatNotify($this->wechatNotifyBody($payment, ['out_trade_no' => 'ZF000000000000'])) + ->assertJsonPath('code', 'FAIL'); + + // 非支付成功状态 + $this->postWechatNotify($this->wechatNotifyBody($payment, ['trade_state' => 'NOTPAY'])) + ->assertJsonPath('code', 'FAIL'); + + // 商户号不一致(防串号) + $this->postWechatNotify($this->wechatNotifyBody($payment, ['mchid' => '9999999999'])) + ->assertJsonPath('code', 'FAIL'); + + // 均未结账 + $this->assertSame(PaymentModel::STATUS_PENDING, $payment->fresh()->status); + $this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status); + $this->assertSame('0.00', (string) $store->fresh()->total_purchase_amount); + } + + /** 配置平台证书后走验签链路:签名合法才受理通知 */ + public function test_wechat_notify_with_platform_cert_validates_signature(): void + { + config([ + 'services.wxpay.platform_cert' => self::TEST_CERTIFICATE, + 'services.wxpay.platform_serial' => self::TEST_PLATFORM_SERIAL, + ]); + + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '66.00'); + $payment = $this->makeWechatPayment($store, '66.00', $bill); + + $body = (string) json_encode($this->wechatNotifyBody($payment)); + $timestamp = time(); + $nonce = 'notifyNonce001'; + + // 无签名头 → 验签失败(微信请求必带签名头) + $this->postWechatNotify($this->wechatNotifyBody($payment))->assertJsonPath('code', 'FAIL'); + + // 签名头齐全且合法 → 受理并结账 + $this->postWechatNotify($this->wechatNotifyBody($payment), [ + 'Wechatpay-Timestamp' => (string) $timestamp, + 'Wechatpay-Nonce' => $nonce, + 'Wechatpay-Serial' => self::TEST_PLATFORM_SERIAL, + 'Wechatpay-Signature' => $this->signNotify($body, $timestamp, $nonce), + ])->assertJsonPath('code', 'SUCCESS'); + + $this->assertSame(PaymentModel::STATUS_APPROVED, $payment->fresh()->status); + $this->assertSame(BillModel::STATUS_PAID, $bill->fresh()->status); + } + + /** 主动查询:微信已支付则同步结账;未支付保持待支付;已结账后不再请求微信 */ + public function test_wechat_query_syncs_status(): void + { + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '80.00'); + $payment = $this->makeWechatPayment($store, '80.00', $bill); + + $queryCount = 0; + $this->fakeWechatApi(function (string $method, string $url) use (&$queryCount, $payment) { + $queryCount++; + $this->assertSame('GET', $method); + $this->assertStringContainsString('/v3/pay/transactions/out-trade-no/' . $payment->payment_no, $url); + $data = $queryCount === 1 + ? ['trade_state' => 'NOTPAY', 'out_trade_no' => $payment->payment_no] + : [ + 'trade_state' => 'SUCCESS', + 'out_trade_no' => $payment->payment_no, + 'transaction_id' => '4200002501202609010002', + 'success_time' => '2026-09-01T15:00:00+08:00', + 'amount' => ['total' => 8000, 'payer_total' => 8000], + ]; + return new MockResponse((string) json_encode($data), ['http_code' => 200]); + }); + + // 场景一:微信未支付 + $this->actingAsMiniStore($store); + $this->getJson("/mini/payment/online/{$payment->payment_no}/query") + ->assertJsonPath('success', true) + ->assertJsonPath('data.status', PaymentModel::STATUS_PENDING); + $this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status); + + // 场景二:微信已支付 → 查询即结账 + $this->getJson("/mini/payment/online/{$payment->payment_no}/query") + ->assertJsonPath('success', true) + ->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED) + ->assertJsonPath('data.trade_no', '4200002501202609010002'); + + $this->assertSame(BillModel::STATUS_PAID, $bill->fresh()->status); + $this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount); + + // 已结账后重复查询不再请求微信(本地直接返回) + $this->getJson("/mini/payment/online/{$payment->payment_no}/query") + ->assertJsonPath('success', true) + ->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED); + $this->assertSame(2, $queryCount, '已结账后不再请求微信'); + $this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount); + } + + /** 渠道开关为旺铺(默认)时不受影响:仍走旺铺下单(pay_method=旺铺支付,旺铺通道报错而非微信) */ + public function test_wangpu_channel_remains_default(): void + { + config(['services.pay.online_channel' => 'wangpu']); + Http::fake([ + 'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oOpenidTest001'], 200), + ]); + + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '10.00'); + + $this->actingAsMiniStore($store); + // 未配置旺铺密钥:旺铺通道在本地装信封即报错(证明分发到旺铺而非微信),支付单作废释放账单 + $this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'c']) + ->assertJsonPath('success', false); + + $payment = PaymentModel::first(); + $this->assertSame(PaymentModel::METHOD_WANGPU, $payment->pay_method); + $this->assertSame(PaymentModel::STATUS_REJECTED, $payment->status); + $this->assertSame(0, $bill->fresh()->payment_id); + } +}