diff --git a/.env.example b/.env.example index 0273b4a..75da312 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,17 @@ MAIL_USERNAME= MAIL_PASSWORD= MAIL_FROM_ADDRESS= MAIL_FROM_NAME= + +# 微信小程序(code2session 换取 openid,在线支付必需) +WECHAT_MINI_APPID= +WECHAT_MINI_SECRET= + +# 旺铺支付网关(也可在后台 系统设置→支付配置 中维护,后台配置优先) +WANGPU_BASE_URL= +WANGPU_ORGANIZ_NO= +WANGPU_MER_NO= +WANGPU_MER_CODE= +WANGPU_TERM_CODE= +WANGPU_SIGN_KEY= +WANGPU_SUB_APPID= +WANGPU_PAYWAY_CODE=WECHAT_MINI diff --git a/.phpunit.result.cache b/.phpunit.result.cache index d8dd8e3..469594f 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},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.007,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.1,"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.011,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.052,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.052,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.057,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.053,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.101,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.124,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.127,"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.034,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.058,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.12,"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.072,"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.049,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.052,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.049,"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.053,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.053,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.046,"Tests\\Feature\\CartTest::test_update_quantity":0.051,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.051,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.05,"Tests\\Feature\\CartTest::test_delete_item":0.05,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.098,"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.05,"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.008,"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.053,"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.05,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.049,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.052,"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.06,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.119,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.053,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.062,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.06,"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.066,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.08,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.296,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.064,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.122,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.116,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.119,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.108,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.111,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.115,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.116,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.116,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.11,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.12,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.129,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.124,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.115,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.076,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.116,"Tests\\Feature\\ExportTest::test_export_invalid_type_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_missing_purchase_rejected":0.009,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_container_summary":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_container_syncs_order_amounts":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejected_when_purchase_completed":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejects_incomplete_or_foreign_orders":0.026,"Tests\\Feature\\PurchaseEditTest::test_update_container_validates_negative_and_duplicate_orders":0.018,"Tests\\Feature\\ProductCategoryTest::test_create_child_category_under_top_level":0.009,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.009,"Tests\\Feature\\ProductCategoryTest::test_update_second_level_move_to_another_top":0.011,"Tests\\Feature\\ProductCategoryTest::test_update_parent_with_children_cannot_move_under_category":0.01,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.009,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.013,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.011,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.052,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.051,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.05,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.053,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.057,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.104,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.075,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.063,"Tests\\Feature\\StoreOrderTest::test_order_list_filters_by_order_date_range":0.067,"Tests\\Feature\\StoreOrderTest::test_order_list_rejects_invalid_filters":0.055,"Tests\\Feature\\BillExportTest::test_export_merges_items_across_bills":0.113,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.055,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.074,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.054,"Tests\\Feature\\BillExportTest::test_export_invalid_ids_rejected":0.012,"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.057,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.054,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.054,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.007,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.127,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.179,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.135,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.062,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.075,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.06,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.05,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.049,"Tests\\Feature\\MiniProductTest::test_product_detail_store_user_sees_level_price":0.006,"Tests\\Feature\\MiniProductTest::test_product_detail_percent_price_calculated_from_cost":0.051,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.051,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.054,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.08,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.056,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.068,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.078,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.07,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.072,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.078,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.055,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.01,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.097,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.138,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.014,"Tests\\Feature\\ContainerReturnTest::test_export_empty_range_rejected":0.008,"Tests\\Feature\\ContainerReturnTest::test_export_requires_export_permission":0.007,"Tests\\Feature\\DashboardTest::test_guest_cannot_access_dashboard":0.002,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":0.007,"Tests\\Feature\\DashboardTest::test_empty_database_returns_zero_structure":0.01,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.066,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.091,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.067,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.057,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.059,"Tests\\Feature\\DashboardTest::test_top_stores_ranked_by_amount":0.103,"Tests\\Feature\\DashboardTest::test_purchase_amount_prefers_actual_over_estimate":0.014,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":0.056,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.06,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.16,"Tests\\Feature\\DashboardDebugTest::test_debug_ability_check":0.044,"Tests\\Feature\\DashboardDebugTest::test_debug":0.045,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_cost_and_notifies_all_stores":0.113,"Tests\\Feature\\ProductPriceTest::test_level_percent_update_notifies_level_stores":0.109,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.057,"Tests\\Feature\\ProductPriceTest::test_level_percent_validation":0.016,"Tests\\Feature\\ProductCostPriceTest::test_calc_level_price":0.002,"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.012,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.055,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.01,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.055,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_level_price":0.06,"Tests\\Feature\\CartTest::test_store_level_missing_rejected":0.006,"Tests\\Feature\\StoreOrderTest::test_store_level_missing_rejected":0.053,"Tests\\Feature\\PurchaseEditTest::test_update_row_skips_catalog_sync_when_product_deleted":0.121,"Tests\\Feature\\PurchaseEditTest::test_store_item_add_creates_item_and_recalculates":0.116,"Tests\\Feature\\PurchaseEditTest::test_store_item_update_merges_multi_order_rows":0.069,"Tests\\Feature\\PurchaseEditTest::test_store_item_remove_deletes_and_recalculates":0.115,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_recalculates_totals":0.077,"Tests\\Feature\\StoreOrderItemTest::test_store_item_edits_rejected_when_purchase_completed":0.076,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_validates_negative_values":0.072,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_uses_level_percent_price":0.066,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_rejected_when_product_deleted":0.076,"Tests\\Feature\\ExportTest::test_export_lists_all_catalog_products_with_totals_row":0.148,"Tests\\Feature\\ExportTest::test_export_supplier_filter":0.122,"Tests\\Feature\\ExportTest::test_export_stores_multi_sheet":0.139,"Tests\\Feature\\ExportTest::test_export_stores_single_store":0.113,"Tests\\Feature\\ExportTest::test_export_stores_without_items_rejected":0.182,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":0.123,"Tests\\Feature\\ExportTest::test_export_suppliers_single":0.113,"Tests\\Feature\\ExportTest::test_export_suppliers_without_items_rejected":0.112,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_change_reprices_items_by_store_level":0.111,"Tests\\Feature\\PurchaseEditTest::test_update_row_without_cost_change_keeps_item_price":0.117,"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.1,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_valid_credentials":0.148,"Tests\\Feature\\MiniAuthTest::test_login_rejects_wrong_password":0.139,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unknown_username":0.004,"Tests\\Feature\\MiniAuthTest::test_disabled_store_cannot_login":0.135,"Tests\\Feature\\MiniAuthTest::test_login_validates_required_params":0.005,"Tests\\Feature\\MiniAuthTest::test_login_token_accesses_protected_routes":0.139,"Tests\\Feature\\MiniAuthTest::test_set_password_succeeds":0.229,"Tests\\Feature\\MiniAuthTest::test_set_password_rejects_wrong_old_password":0.133,"Tests\\Feature\\MiniAuthTest::test_set_password_validates_confirmation":0.055,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_own_store":0.099,"Tests\\Feature\\MiniNoticeTest::test_other_stores_notice_not_found":0.097,"Tests\\Feature\\MiniProductTest::test_product_detail_store_sees_level_price":0.053,"Tests\\Feature\\MiniProductTest::test_product_list_store_sees_level_price":0.052,"Tests\\Feature\\MiniStoreTest::test_info_returns_current_store":0.05,"Tests\\Feature\\MiniStoreTest::test_disabled_store_rejected":0.05,"Tests\\Feature\\ProductImportExportTest::test_export_downloads_all_products":0.014,"Tests\\Feature\\ProductImportExportTest::test_export_filters_by_category":0.01,"Tests\\Feature\\ProductImportExportTest::test_export_template_mode":0.007,"Tests\\Feature\\ProductImportExportTest::test_export_requires_permission":0.009,"Tests\\Feature\\ProductImportExportTest::test_import_creates_products_and_auto_supplier":0.044,"Tests\\Feature\\ProductImportExportTest::test_import_matches_existing_supplier":0.027,"Tests\\Feature\\ProductImportExportTest::test_import_errors_abort_all_rows":0.037,"Tests\\Feature\\ProductImportExportTest::test_import_category_path_resolution":0.053,"Tests\\Feature\\ProductImportExportTest::test_import_rejects_invalid_header":0.027,"Tests\\Feature\\ProductImportExportTest::test_import_requires_permission":0.014,"Tests\\Feature\\ProductImportExportTest::test_list_filters_by_market":0.01,"Tests\\Feature\\MiniReportTest::test_report_aggregates_total_and_item_percent":0.051,"Tests\\Feature\\MiniReportTest::test_report_accumulates_same_product_across_orders":0.054,"Tests\\Feature\\MiniReportTest::test_report_filters_by_custom_date_range":0.049,"Tests\\Feature\\MiniReportTest::test_report_excludes_cancelled_and_deleted_orders":0.052,"Tests\\Feature\\MiniReportTest::test_report_preset_month_and_last_month":0.055,"Tests\\Feature\\MiniReportTest::test_report_preset_week_and_last_week":0.054,"Tests\\Feature\\MiniReportTest::test_report_defaults_to_current_month":0.05,"Tests\\Feature\\MiniReportTest::test_report_data_isolated_between_stores":0.095,"Tests\\Feature\\MiniReportTest::test_report_empty_period_returns_zero":0.053,"Tests\\Feature\\MiniReportTest::test_report_validates_params":0.053,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_soft_deletes_selected_products":0.009,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_requires_non_empty_ids":0.011,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_forbidden_without_permission":0.011,"Tests\\Feature\\CartTest::test_place_order_clears_ordered_items_from_cart":0.063,"Tests\\Feature\\StoreOrderItemTest::test_admin_order_list_filters_by_order_date_range":0.077,"Tests\\Feature\\StoreOrderItemTest::test_admin_order_list_search_by_purchase_no":0.08,"Tests\\Feature\\ExportTest::test_export_suppliers_split_by_market":0.074,"Tests\\Feature\\BillPaymentTest::test_generate_bill_saves_after_sale_and_remark":0.125,"Tests\\Feature\\ProductCostPriceTest::test_product_price_unit_default_and_editable":0.017,"Tests\\Feature\\StoreOrderTest::test_place_order_blocked_by_overdue_unpaid_bill":0.06,"Tests\\Feature\\StoreOrderTest::test_place_order_allowed_within_payment_cycle":0.063,"Tests\\Feature\\CartTest::test_cart_summary_endpoint":0.057,"Tests\\Feature\\CartTest::test_cart_summary_requires_login":0.004,"Tests\\Feature\\MiniProductTest::test_product_list_appends_cart_quantity_and_summary":0.063,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_zero_cart_fields":0.052,"Tests\\Feature\\MiniProductTest::test_product_detail_appends_cart_quantity":0.053,"Tests\\Feature\\MiniProductTest::test_home_appends_cart_summary":0.056}} \ 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},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.017,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.089,"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.048,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.05,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.05,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.095,"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.101,"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.045,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.1,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.017,"Tests\\Feature\\ExportTest::test_export_pdf":0.015,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.056,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.003,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.046,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.049,"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.087,"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.048,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.049,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.048,"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.109,"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.047,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.046,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":0.012,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":0.022,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":0.019,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":0.02,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":0.014,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_accepted_orders_fails":0.052,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.106,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.048,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.052,"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.057,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.061,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.249,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.057,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.103,"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.099,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.1,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.104,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.104,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.102,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.1,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.104,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.113,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.107,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.101,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.072,"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.007,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.009,"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.051,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.089,"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.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.102,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.052,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.086,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.052,"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.045,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.049,"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.101,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.149,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.103,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.059,"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.054,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.049,"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.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.059,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.06,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.065,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.048,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.023,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.091,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.129,"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.008,"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.053,"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.054,"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.052,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.052,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.132,"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.104,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.048,"Tests\\Feature\\ProductPriceTest::test_level_percent_validation":0.01,"Tests\\Feature\\ProductCostPriceTest::test_calc_level_price":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_level_price_edge_cases":0.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_level_prices":0.008,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_price":0.007,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.052,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.049,"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.104,"Tests\\Feature\\PurchaseEditTest::test_store_item_add_creates_item_and_recalculates":0.105,"Tests\\Feature\\PurchaseEditTest::test_store_item_update_merges_multi_order_rows":0.061,"Tests\\Feature\\PurchaseEditTest::test_store_item_remove_deletes_and_recalculates":0.102,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_recalculates_totals":0.059,"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.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.104,"Tests\\Feature\\ExportTest::test_export_supplier_filter":0.102,"Tests\\Feature\\ExportTest::test_export_stores_multi_sheet":0.105,"Tests\\Feature\\ExportTest::test_export_stores_single_store":0.101,"Tests\\Feature\\ExportTest::test_export_stores_without_items_rejected":0.143,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":0.103,"Tests\\Feature\\ExportTest::test_export_suppliers_single":0.103,"Tests\\Feature\\ExportTest::test_export_suppliers_without_items_rejected":0.104,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_change_reprices_items_by_store_level":0.103,"Tests\\Feature\\PurchaseEditTest::test_update_row_without_cost_change_keeps_item_price":0.105,"Tests\\Feature\\CartTest::test_update_other_stores_item_rejected":0.091,"Tests\\Feature\\CartTest::test_delete_other_stores_item_rejected":0.091,"Tests\\Feature\\CartTest::test_cart_isolated_between_stores":0.09,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_valid_credentials":0.126,"Tests\\Feature\\MiniAuthTest::test_login_rejects_wrong_password":0.124,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unknown_username":0.002,"Tests\\Feature\\MiniAuthTest::test_disabled_store_cannot_login":0.124,"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.21,"Tests\\Feature\\MiniAuthTest::test_set_password_rejects_wrong_old_password":0.129,"Tests\\Feature\\MiniAuthTest::test_set_password_validates_confirmation":0.055,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_own_store":0.094,"Tests\\Feature\\MiniNoticeTest::test_other_stores_notice_not_found":0.085,"Tests\\Feature\\MiniProductTest::test_product_detail_store_sees_level_price":0.046,"Tests\\Feature\\MiniProductTest::test_product_list_store_sees_level_price":0.046,"Tests\\Feature\\MiniStoreTest::test_info_returns_current_store":0.044,"Tests\\Feature\\MiniStoreTest::test_disabled_store_rejected":0.047,"Tests\\Feature\\ProductImportExportTest::test_export_downloads_all_products":0.009,"Tests\\Feature\\ProductImportExportTest::test_export_filters_by_category":0.008,"Tests\\Feature\\ProductImportExportTest::test_export_template_mode":0.005,"Tests\\Feature\\ProductImportExportTest::test_export_requires_permission":0.006,"Tests\\Feature\\ProductImportExportTest::test_import_creates_products_and_auto_supplier":0.037,"Tests\\Feature\\ProductImportExportTest::test_import_matches_existing_supplier":0.018,"Tests\\Feature\\ProductImportExportTest::test_import_errors_abort_all_rows":0.017,"Tests\\Feature\\ProductImportExportTest::test_import_category_path_resolution":0.035,"Tests\\Feature\\ProductImportExportTest::test_import_rejects_invalid_header":0.015,"Tests\\Feature\\ProductImportExportTest::test_import_requires_permission":0.012,"Tests\\Feature\\ProductImportExportTest::test_list_filters_by_market":0.011,"Tests\\Feature\\MiniReportTest::test_report_aggregates_total_and_item_percent":0.046,"Tests\\Feature\\MiniReportTest::test_report_accumulates_same_product_across_orders":0.047,"Tests\\Feature\\MiniReportTest::test_report_filters_by_custom_date_range":0.046,"Tests\\Feature\\MiniReportTest::test_report_excludes_cancelled_and_deleted_orders":0.048,"Tests\\Feature\\MiniReportTest::test_report_preset_month_and_last_month":0.051,"Tests\\Feature\\MiniReportTest::test_report_preset_week_and_last_week":0.064,"Tests\\Feature\\MiniReportTest::test_report_defaults_to_current_month":0.047,"Tests\\Feature\\MiniReportTest::test_report_data_isolated_between_stores":0.088,"Tests\\Feature\\MiniReportTest::test_report_empty_period_returns_zero":0.046,"Tests\\Feature\\MiniReportTest::test_report_validates_params":0.065,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_soft_deletes_selected_products":0.022,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_requires_non_empty_ids":0.006,"Tests\\Feature\\ProductBatchDeleteTest::test_batch_delete_forbidden_without_permission":0.008,"Tests\\Feature\\CartTest::test_place_order_clears_ordered_items_from_cart":0.06,"Tests\\Feature\\StoreOrderItemTest::test_admin_order_list_filters_by_order_date_range":0.077,"Tests\\Feature\\StoreOrderItemTest::test_admin_order_list_search_by_purchase_no":0.064,"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.053,"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.054,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_zero_cart_fields":0.046,"Tests\\Feature\\MiniProductTest::test_product_detail_appends_cart_quantity":0.049,"Tests\\Feature\\MiniProductTest::test_home_appends_cart_summary":0.052,"Tests\\Feature\\MiniOnlinePaymentTest::test_sign_matches_document_example":0.002,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_online_payment_success":0.063,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_online_payment_validation":0.099,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_fails_when_code2session_fails":0.048,"Tests\\Feature\\MiniOnlinePaymentTest::test_create_gateway_failure_releases_bills":0.051,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_settles_payment":0.051,"Tests\\Feature\\MiniOnlinePaymentTest::test_notify_rejects_invalid_messages":0.05,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_syncs_gateway_status":0.053,"Tests\\Feature\\MiniOnlinePaymentTest::test_query_rejects_other_store_payment":0.087,"Tests\\Feature\\MiniOnlinePaymentTest::test_offline_payment_flow_unaffected":0.047}} \ No newline at end of file diff --git a/app/Http/Controllers/Mini/OnlinePaymentController.php b/app/Http/Controllers/Mini/OnlinePaymentController.php new file mode 100644 index 0000000..a0f269b --- /dev/null +++ b/app/Http/Controllers/Mini/OnlinePaymentController.php @@ -0,0 +1,136 @@ +validate([ + 'bill_ids' => 'required|array|min:1', + 'bill_ids.*' => 'integer|distinct', + 'code' => 'required|string|max:64', + 'remark' => 'nullable|string|max:255', + ], [ + 'bill_ids.required' => '请选择要付款的账单', + 'bill_ids.min' => '请选择要付款的账单', + 'code.required' => '微信登录凭证缺失,请重新进入小程序', + 'remark.max' => '备注超过最大长度', + ]); + + $store = $this->currentStore($request); + + [$payment, $payParams] = $this->onlinePayment->create( + $store, + array_map('intval', $data['bill_ids']), + (string) $data['code'], + (string) ($data['remark'] ?? ''), + ); + + return $this->success([ + 'id' => $payment->id, + 'payment_no' => $payment->payment_no, + 'amount' => $payment->amount, + 'pay_params' => $payParams, + ], '下单成功,请调起支付'); + } + + /** + * 查询支付结果:网关回调可能延迟/丢失,小程序完成支付后主动调用同步结账 + * @throws Throwable + */ + #[GetRoute('/payment/online/{paymentNo}/query', authorize: true)] + public function query(string $paymentNo, Request $request): JsonResponse + { + $store = $this->currentStore($request); + + $payment = PaymentModel::query() + ->where('store_id', $store->id) + ->where('payment_no', $paymentNo) + ->where('pay_type', PaymentModel::TYPE_ONLINE) + ->first(); + if ($payment === null) { + throw new RepositoryException('支付记录不存在'); + } + + $result = $this->onlinePayment->queryAndSettle($payment); + $payment = $result['payment']; + + return $this->success([ + 'payment_no' => $payment->payment_no, + 'status' => $payment->status, + 'status_name' => PaymentModel::ONLINE_STATUS_NAMES[$payment->status] ?? '待支付', + 'paid_at' => $payment->paid_at, + 'trade_no' => $payment->trade_no, + ], $payment->status === PaymentModel::STATUS_APPROVED ? '支付成功' : '支付结果确认中'); + } + + /** + * 旺铺支付结果后台通知(公开路由,验签后幂等结账) + * + * 网关约定:应答 {"code":"00"} 视为通知成功,否则按 2^n 分钟重试 7 次; + * 重复通知必须幂等(settle 内部行锁 + 状态判断)。 + */ + #[PostRoute('/payment/notify', authorize: false)] + public function notify(Request $request): JsonResponse + { + $params = $request->all(); + + if (! $this->wangpu->verifyNotifySign($params)) { + Log::warning('旺铺支付通知验签失败', ['params' => $params]); + return $this->notifyAck('01', '验签失败'); + } + + try { + $this->onlinePayment->settleByNotify($params); + } catch (Throwable $e) { + Log::error('旺铺支付通知处理失败', ['params' => $params, 'error' => $e->getMessage()]); + return $this->notifyAck('01', $e->getMessage()); + } + + return $this->notifyAck(WangpuPayService::NOTIFY_ACK_OK, '成功'); + } + + /** + * 通知应答报文(网关约定格式,timestamp:yyyyMMddHHmmssSSS) + */ + protected function notifyAck(string $code, string $msg): JsonResponse + { + return response()->json([ + 'code' => $code, + 'msg' => mb_substr($msg, 0, 100), + 'timestamp' => now()->format('YmdHisv'), + ]); + } +} diff --git a/app/Http/Controllers/Recon/PaymentController.php b/app/Http/Controllers/Recon/PaymentController.php index b185c0d..241a7f1 100644 --- a/app/Http/Controllers/Recon/PaymentController.php +++ b/app/Http/Controllers/Recon/PaymentController.php @@ -24,6 +24,7 @@ class PaymentController extends BaseController protected array $searchField = [ 'store_id' => '=', 'status' => '=', + 'pay_type' => '=', 'pay_method' => '=', 'payment_no' => 'like', ]; diff --git a/app/Models/PaymentModel.php b/app/Models/PaymentModel.php index 5b69e54..5c8d172 100644 --- a/app/Models/PaymentModel.php +++ b/app/Models/PaymentModel.php @@ -11,31 +11,49 @@ use Modules\SystemTool\Models\SysFileModel; use Modules\SystemUser\Models\SysUserModel; /** - * 支付记录模型(小程序选择门店账单合并付款,提交汇款凭证;后台审核通过后关联账单批量置已支付) + * 支付记录模型(小程序选择门店账单合并付款) + * + * 支付类型 pay_type: + * - 1 线下凭证支付:门店提交汇款凭证,后台审核通过后关联账单批量置已支付 + * - 2 旺铺在线支付:调起微信/支付宝在线支付,网关回调(或主动查询)确认后自动结账 */ class PaymentModel extends Model { use HasFactory; + /** 支付类型:线下凭证支付 */ + public const int TYPE_OFFLINE = 1; + /** 支付类型:旺铺在线支付 */ + public const int TYPE_ONLINE = 2; + + /** 支付类型中文名 */ + public const array TYPE_NAMES = [ + self::TYPE_OFFLINE => '凭证支付', + self::TYPE_ONLINE => '在线支付', + ]; + /** 支付方式:微信 */ public const int METHOD_WECHAT = 1; /** 支付方式:支付宝 */ public const int METHOD_ALIPAY = 2; /** 支付方式:对公汇款(银行卡) */ public const int METHOD_BANK = 3; + /** 支付方式:旺铺在线支付(微信/支付宝小程序 JSAPI) */ + public const int METHOD_WANGPU = 4; /** 支付方式中文名 */ public const array METHOD_NAMES = [ self::METHOD_WECHAT => '微信支付', self::METHOD_ALIPAY => '支付宝', self::METHOD_BANK => '对公汇款', + self::METHOD_WANGPU => '旺铺支付', ]; - /** 状态:待审核 */ + /** 状态:待审核(线下凭证)/ 待支付(在线支付) */ public const int STATUS_PENDING = 0; - /** 状态:已通过 */ + /** 状态:已通过(线下凭证)/ 支付成功(在线支付) */ public const int STATUS_APPROVED = 1; - /** 状态:已拒绝 */ + /** 状态:已拒绝(线下凭证)/ 支付失败(在线支付) */ public const int STATUS_REJECTED = 2; /** 状态中文名 */ @@ -45,6 +63,13 @@ class PaymentModel extends Model self::STATUS_REJECTED => '已拒绝', ]; + /** 在线支付状态中文名(pay_type=2 时使用) */ + public const array ONLINE_STATUS_NAMES = [ + self::STATUS_PENDING => '待支付', + self::STATUS_APPROVED => '支付成功', + self::STATUS_REJECTED => '支付失败', + ]; + protected $table = 'payment'; protected $primaryKey = 'id'; @@ -52,9 +77,15 @@ class PaymentModel extends Model 'payment_no', 'store_id', 'amount', + 'pay_type', 'pay_method', 'voucher_ids', 'status', + 'order_id', + 'trade_no', + 'openid', + 'paid_at', + 'pay_params', 'remark', 'audited_at', 'auditor_id', @@ -64,13 +95,28 @@ class PaymentModel extends Model protected $casts = [ 'store_id' => 'integer', 'amount' => 'decimal:2', + 'pay_type' => 'integer', 'pay_method' => 'integer', 'status' => 'integer', + 'paid_at' => 'datetime:Y-m-d H:i:s', 'audited_at' => 'datetime:Y-m-d H:i:s', 'auditor_id' => 'integer', 'created_at' => 'datetime:Y-m-d H:i:s', ]; + /** + * 旺铺下单返回的调起支付参数(JSON 字符串 ↔ 数组) + * + * @return Attribute, string> + */ + public function payParams(): Attribute + { + return Attribute::make( + get: fn ($value) => $value === '' || $value === null ? [] : (json_decode((string) $value, true) ?: []), + set: fn ($value) => is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : $value, + ); + } + /** * 汇款凭证图片ID(逗号分隔字符串 ↔ 数组) */ diff --git a/app/Models/StoreModel.php b/app/Models/StoreModel.php index 634850a..7a8e913 100644 --- a/app/Models/StoreModel.php +++ b/app/Models/StoreModel.php @@ -37,6 +37,7 @@ class StoreModel extends Authenticatable 'phone', 'address', 'payment_cycle_days', + 'openid', 'status', 'remark', ]; diff --git a/app/Services/OnlinePaymentService.php b/app/Services/OnlinePaymentService.php new file mode 100644 index 0000000..02d8e0d --- /dev/null +++ b/app/Services/OnlinePaymentService.php @@ -0,0 +1,310 @@ + $billIds 合并付款的账单ID + * @param string $code 小程序 wx.login() 返回的登录凭证 + * @return array{0: PaymentModel, 1: array} 支付单与旺铺返回的调起支付参数 + * @throws Throwable + */ + public function create(StoreModel $store, array $billIds, string $code, string $remark = ''): array + { + // 换取付款人 openid 并绑定到门店(下次可直接复用) + $openid = $this->wechat->code2session($code); + if ((string) $store->openid !== $openid) { + $store->openid = $openid; + $store->save(); + } + + [$payment, $bills] = DB::transaction(function () use ($store, $billIds, $openid, $remark) { + $bills = BillModel::query() + ->where('store_id', $store->id) + ->whereIn('id', $billIds) + ->lockForUpdate() + ->get(); + if ($bills->count() !== count($billIds)) { + throw new RepositoryException('包含不属于本店的账单,请刷新后重试'); + } + foreach ($bills as $bill) { + if ($bill->status === BillModel::STATUS_PAID) { + throw new RepositoryException('账单 ' . $bill->bill_no . ' 已支付,请刷新后重试'); + } + if ((int) $bill->payment_id !== 0) { + throw new RepositoryException('账单 ' . $bill->bill_no . ' 正在支付中,请勿重复提交'); + } + } + + $amount = $bills->reduce( + static fn (string $carry, BillModel $bill): string => bcadd($carry, (string) $bill->total_amount, 2), + '0' + ); + if (bccomp($amount, '0', 2) <= 0) { + throw new RepositoryException('支付金额必须大于 0'); + } + + $payment = PaymentModel::create([ + 'payment_no' => $this->billNumber->make('ZF'), + 'store_id' => $store->id, + 'amount' => $amount, + 'pay_type' => PaymentModel::TYPE_ONLINE, + 'pay_method' => PaymentModel::METHOD_WANGPU, + 'voucher_ids' => '', + 'status' => PaymentModel::STATUS_PENDING, + 'openid' => $openid, + 'remark' => $remark, + ]); + + // 锁定账单到本支付记录(支付失败/取消后释放,可重新付款) + BillModel::query()->whereIn('id', $bills->pluck('id'))->update(['payment_id' => $payment->id]); + + return [$payment, $bills]; + }); + + 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(), + ]); + } catch (Throwable $e) { + // 网关下单失败:整笔作废并释放账单,门店可重新发起 + $this->discard($payment, $e->getMessage()); + throw $e; + } + + $payment->order_id = (string) ($gatewayData['order_id'] ?? ''); + $payment->pay_params = $gatewayData; + $payment->save(); + + return [$payment, $gatewayData]; + } + + /** + * 支付成功后台通知处理:验签由控制器完成,此处按 mer_order_id 定位支付单并幂等结账 + * + * @param array $params 通知报文(已验签) + * @return bool 本次是否执行了结账(false = 重复通知) + * @throws Throwable + */ + public function settleByNotify(array $params): bool + { + $merOrderId = (string) ($params['mer_order_id'] ?? ''); + $payment = PaymentModel::query() + ->where('payment_no', $merOrderId) + ->where('pay_type', PaymentModel::TYPE_ONLINE) + ->first(); + if ($payment === null) { + throw new RepositoryException('支付记录不存在:' . $merOrderId); + } + if ((int) ($params['order_status'] ?? -1) !== WangpuPayService::ORDER_STATUS_PAID) { + throw new RepositoryException('订单未支付成功(order_status=' . ($params['order_status'] ?? '空') . ')'); + } + + return $this->settle( + $payment, + (string) ($params['trade_no'] ?? ''), + (string) ($params['order_id'] ?? ''), + (string) ($params['trade_time'] ?? ''), + (string) ($params['order_amt'] ?? '0'), + ); + } + + /** + * 主动查询网关订单状态:已支付则结账(回调延迟/丢失时的兜底,小程序支付完成后调用) + * + * @return array{payment: PaymentModel, order_status: int} 最新支付单与网关订单状态 + * @throws Throwable + */ + public function queryAndSettle(PaymentModel $payment): array + { + if ($payment->status === PaymentModel::STATUS_APPROVED) { + return ['payment' => $payment, 'order_status' => WangpuPayService::ORDER_STATUS_PAID]; + } + + $data = $this->wangpu->queryOrder($payment->payment_no, (string) $payment->order_id); + $orderStatus = (int) ($data['order_status'] ?? -1); + + if ($orderStatus === WangpuPayService::ORDER_STATUS_PAID) { + $this->settle( + $payment, + (string) ($data['trade_no'] ?? ''), + (string) ($data['order_id'] ?? ''), + (string) ($data['trade_time'] ?? ''), + (string) ($data['order_amt'] ?? '0'), + ); + } + + return ['payment' => $payment->fresh(), 'order_status' => $orderStatus]; + } + + /** + * 幂等结账(网关通知与主动查询共用入口,内部事务 + 行锁防重) + * + * @return bool 本次是否执行了结账(false = 已结账,直接吞掉重复通知) + * @throws Throwable + */ + public function settle(PaymentModel $payment, string $tradeNo, string $orderId, string $tradeTime, string $paidAmount): bool + { + $settled = DB::transaction(function () use ($payment, $tradeNo, $orderId, $tradeTime, $paidAmount) { + $payment = PaymentModel::query()->lockForUpdate()->find($payment->id); + if ($payment === null) { + throw new RepositoryException('支付记录不存在'); + } + if ($payment->status === PaymentModel::STATUS_APPROVED) { + return false; // 幂等:重复通知/查询直接成功 + } + if ($payment->pay_type !== PaymentModel::TYPE_ONLINE || $payment->status !== PaymentModel::STATUS_PENDING) { + throw new RepositoryException('支付记录状态异常,无法结账'); + } + // 金额一致性校验,防通知篡改 + if (bccomp($paidAmount, (string) $payment->amount, 2) !== 0) { + throw new RepositoryException('支付金额与订单金额不一致(通知 ' . $paidAmount . ' / 订单 ' . $payment->amount . ')'); + } + + $bills = $payment->bills()->lockForUpdate()->get(); + // 任一账单已通过其他方式收款(如线下登记)则中止,避免重复收款,需人工核实 + $paid = $bills->where('status', BillModel::STATUS_PAID); + if ($paid->isNotEmpty()) { + throw new RepositoryException('账单 ' . $paid->pluck('bill_no')->implode('、') . ' 已通过其他方式收款,请人工核实'); + } + + $paidAt = $this->parseTradeTime($tradeTime); + BillModel::query()->whereIn('id', $bills->pluck('id'))->update([ + 'status' => BillModel::STATUS_PAID, + 'paid_at' => $paidAt, + 'paid_operator_id' => 0, + 'pay_remark' => PaymentModel::METHOD_NAMES[PaymentModel::METHOD_WANGPU] . '(支付单号 ' . $payment->payment_no . ')', + ]); + + // 按门店累加总采购金额(只统计商品金额,不含配送费/附加金额) + $store = StoreModel::query()->lockForUpdate()->find($payment->store_id); + if ($store !== null) { + $amount = '0'; + foreach ($bills as $bill) { + $amount = bcadd($amount, (string) $bill->product_amount, 2); + } + $store->total_purchase_amount = bcadd((string) $store->total_purchase_amount, $amount, 2); + $store->save(); + } + + $payment->status = PaymentModel::STATUS_APPROVED; + $payment->trade_no = $tradeNo; + if ($orderId !== '') { + $payment->order_id = $orderId; + } + $payment->paid_at = $paidAt; + $payment->save(); + + return true; + }); + + if ($settled) { + $this->notifyStore($payment->fresh()); + } + + return $settled; + } + + /** + * 网关下单失败后作废支付单:置失败并释放账单(保留记录便于排查) + */ + protected function discard(PaymentModel $payment, string $reason): void + { + try { + DB::transaction(function () use ($payment, $reason) { + BillModel::query() + ->where('payment_id', $payment->id) + ->where('status', BillModel::STATUS_UNPAID) + ->update(['payment_id' => 0]); + PaymentModel::query()->where('id', $payment->id)->update([ + 'status' => PaymentModel::STATUS_REJECTED, + 'audit_remark' => mb_substr('网关下单失败:' . $reason, 0, 255), + ]); + }); + } catch (Throwable $e) { + Log::error('在线支付作废失败', ['payment_id' => $payment->id, 'error' => $e->getMessage()]); + } + } + + /** + * 结账成功后通知门店 + */ + protected function notifyStore(PaymentModel $payment): void + { + try { + $billsCount = $payment->bills()->count(); + NoticeModel::create([ + 'store_id' => $payment->store_id, + 'type' => NoticeModel::TYPE_SYSTEM, + 'title' => '账单支付成功', + 'content' => mb_substr("您的 {$billsCount} 张账单已通过在线支付完成付款,金额 {$payment->amount} 元(支付单号 {$payment->payment_no})", 0, 500), + 'data' => ['payment_id' => $payment->id], + 'is_read' => NoticeModel::UNREAD, + ]); + } catch (Throwable $e) { + Log::warning('支付成功通知门店失败', ['payment_id' => $payment->id, 'error' => $e->getMessage()]); + } + } + + /** + * 网关交易时间解析(格式 yyyy-MM-dd HH:mm:ss,异常回退当前时间) + */ + protected function parseTradeTime(string $tradeTime): Carbon + { + try { + return $tradeTime !== '' ? Carbon::parse($tradeTime) : now(); + } catch (Throwable) { + return now(); + } + } + + /** + * 下单微信子 appid(默认取小程序 appid) + */ + protected function subAppid(): string + { + $subAppid = (string) site_config('pay.wangpu_sub_appid', ''); + if ($subAppid === '') { + $subAppid = (string) config('services.wangpu.sub_appid', ''); + } + if ($subAppid === '') { + $subAppid = (string) config('services.wechat.mini.appid', ''); + } + return trim($subAppid); + } +} diff --git a/app/Services/WangpuPayService.php b/app/Services/WangpuPayService.php new file mode 100644 index 0000000..6ec4231 --- /dev/null +++ b/app/Services/WangpuPayService.php @@ -0,0 +1,159 @@ + config/services.php(env) + */ +class WangpuPayService +{ + /** 网关应答成功码 */ + public const string CODE_SUCCESS = '0000'; + + /** 通知应答码:成功(网关收到 {"code":"00"} 才认为通知成功) */ + public const string NOTIFY_ACK_OK = '00'; + + /** 订单状态:支付成功(交易查询 / 后台通知的 order_status) */ + public const int ORDER_STATUS_PAID = 1; + + /** + * 统一下单B-JSAPI(微信公众号/小程序、支付宝服务窗/生活号、银联二维码) + * + * @param array $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等) + * @return array 网关 data 节点(含 order_id/trade_no/调起支付参数) + * @throws RepositoryException 网关应答失败 + */ + public function createOrder(array $params): array + { + return $this->request('/industrial/payment/order', array_merge([ + 'mer_code' => $this->config('mer_code'), + 'term_code' => $this->config('term_code'), + 'payway_code' => $this->config('payway_code'), + ], $params)); + } + + /** + * 交易查询(单笔订单支付状态同步) + * + * @param string $merOrderId 商户唯一订单号(本系统 payment_no) + * @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准) + * @return array 网关 data 节点 + * @throws RepositoryException 网关应答失败 + */ + public function queryOrder(string $merOrderId, string $orderId = ''): array + { + $params = ['mer_order_id' => $merOrderId]; + if ($orderId !== '') { + $params['order_id'] = $orderId; + } + return $this->request('/industrial/query/order', $params); + } + + /** + * 校验后台通知签名(支付成功/退款成功通知通用) + * + * @param array $params 通知报文全量参数(含 sign) + */ + public function verifyNotifySign(array $params): bool + { + $sign = (string) ($params['sign'] ?? ''); + if ($sign === '') { + return false; + } + return hash_equals($this->sign($params), strtoupper($sign)); + } + + /** + * MD5 加签:非空参数(sign 除外)按参数名 ASCII 升序 key=value 以 & 拼接, + * 末尾拼接 &key={SignKey} 后 MD5 转大写 + * + * @param array $params + */ + public function sign(array $params): string + { + unset($params['sign']); + + $pairs = []; + foreach ($params as $key => $value) { + if ($value === null || $value === '') { + continue; + } + if (is_array($value) || is_object($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + $pairs[(string) $key] = (string) $key . '=' . (string) $value; + } + ksort($pairs, SORT_STRING); + + $signStr = implode('&', $pairs) . '&key=' . $this->config('sign_key'); + + return strtoupper(md5($signStr)); + } + + /** + * 支付结果后台通知地址(统一下单时上送的 notifyurl) + */ + public function notifyUrl(): string + { + return rtrim((string) config('app.url'), '/') . '/mini/payment/notify'; + } + + /** + * 网关 POST 请求(表单方式):公共参数 + 加签 → 发送 → 校验应答码 + * + * @param array $params 接口业务参数 + * @return array 应答 data 节点 + * @throws RepositoryException 通讯失败或应答码非成功 + */ + protected function request(string $path, array $params): array + { + $baseUrl = rtrim($this->config('base_url'), '/'); + if ($baseUrl === '') { + throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员'); + } + + $body = array_merge([ + 'organiz_no' => $this->config('organiz_no'), + 'mer_no' => $this->config('mer_no'), + ], $params); + // 空值参数不下送(与网关签名口径一致) + $body = array_filter($body, static fn ($value): bool => $value !== null && $value !== ''); + $body['sign'] = $this->sign($body); + + $response = Http::asForm()->timeout(15)->post($baseUrl . $path, $body); + $result = $response->json(); + if (! is_array($result)) { + Log::error('旺铺网关应答异常', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]); + throw new RepositoryException('支付网关通讯异常,请稍后重试'); + } + if (($result['code'] ?? '') !== self::CODE_SUCCESS) { + Log::warning('旺铺网关应答失败', ['path' => $path, 'response' => $result]); + throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误')); + } + + $data = $result['data'] ?? []; + return is_array($data) ? $data : []; + } + + /** + * 读取网关配置:后台站点配置优先,为空回退 config/services.php(env) + */ + protected function config(string $key): string + { + $value = site_config('pay.wangpu_' . $key, ''); + if ($value === null || $value === '') { + $value = config('services.wangpu.' . $key, ''); + } + return trim((string) $value); + } +} diff --git a/app/Services/WechatMiniService.php b/app/Services/WechatMiniService.php new file mode 100644 index 0000000..569c3a1 --- /dev/null +++ b/app/Services/WechatMiniService.php @@ -0,0 +1,46 @@ +get('https://api.weixin.qq.com/sns/jscode2session', [ + 'appid' => $appid, + 'secret' => $secret, + 'js_code' => $code, + 'grant_type' => 'authorization_code', + ])->json(); + + $openid = is_array($result) ? (string) ($result['openid'] ?? '') : ''; + if ($openid === '') { + Log::warning('微信 code2session 失败', ['response' => $result]); + throw new RepositoryException('微信登录凭证校验失败:' . ($result['errmsg'] ?? '请重新进入小程序')); + } + + return $openid; + } +} diff --git a/config/services.php b/config/services.php index 6809c2e..d869f61 100644 --- a/config/services.php +++ b/config/services.php @@ -52,4 +52,19 @@ return [ 'secret' => env('WECHAT_MINI_SECRET', ''), ], ], + + /* + * 旺铺支付网关(统一下单B-JSAPI / 交易查询 / 后台通知) + * 优先读取后台「系统设置 → 支付配置」(site_config pay.wangpu_*),为空时回退到这里的 env 配置 + */ + 'wangpu' => [ + 'base_url' => env('WANGPU_BASE_URL', ''), // 网关域名,如 https://pay.example.com + 'organiz_no' => env('WANGPU_ORGANIZ_NO', ''), // 合作机构渠道号 + 'mer_no' => env('WANGPU_MER_NO', ''), // 旺铺内部商户号 + 'mer_code' => env('WANGPU_MER_CODE', ''), // 商户号(进件入网后返回) + 'term_code' => env('WANGPU_TERM_CODE', ''), // 终端号(进件入网后返回) + 'sign_key' => env('WANGPU_SIGN_KEY', ''), // 加签专用 Key(MD5 加签) + 'sub_appid' => env('WANGPU_SUB_APPID', ''), // 下单微信子 appid(默认取小程序 appid) + 'payway_code' => env('WANGPU_PAYWAY_CODE', 'WECHAT_MINI'), // 支付方式代码(主扫必填) + ], ]; diff --git a/database/migrations/2026_07_23_030608_create_store_table.php b/database/migrations/2026_07_23_030608_create_store_table.php index c66bbf6..a99a280 100644 --- a/database/migrations/2026_07_23_030608_create_store_table.php +++ b/database/migrations/2026_07_23_030608_create_store_table.php @@ -42,6 +42,7 @@ return new class extends Migration $table->string('phone', 20)->default('')->comment('联系电话'); $table->string('address', 255)->default('')->comment('门店地址'); $table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)'); + $table->string('openid', 64)->default('')->comment('微信小程序 openid(在线支付付款人标识,wx.login 换取后绑定)'); $table->decimal('total_purchase_amount', 12, 2)->default(0)->comment('总采购金额(只统计商品金额,账单支付后累加)'); $table->integer('status')->default(1)->comment('状态(1正常 0停用)'); $table->string('remark', 255)->nullable()->default('')->comment('备注'); diff --git a/database/migrations/2026_08_14_000002_create_payment_table.php b/database/migrations/2026_08_14_000002_create_payment_table.php index bed5ed7..ead5cff 100644 --- a/database/migrations/2026_08_14_000002_create_payment_table.php +++ b/database/migrations/2026_08_14_000002_create_payment_table.php @@ -15,19 +15,26 @@ return new class extends Migration if (! Schema::hasTable('payment')) { Schema::create('payment', function (Blueprint $table) { $table->increments('id')->comment('支付记录ID'); - $table->string('payment_no', 32)->unique()->comment('支付单号'); + $table->string('payment_no', 32)->unique()->comment('支付单号(在线支付时作为商户订单号 mer_order_id 上送网关)'); $table->integer('store_id')->comment('门店ID(提交门店即支付人)'); $table->decimal('amount', 10, 2)->default(0)->comment('支付金额(= 关联账单总金额合计,提交时快照)'); - $table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款)'); - $table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔)'); - $table->integer('status')->default(0)->comment('状态(0待审核 1已通过 2已拒绝)'); + $table->integer('pay_type')->default(1)->comment('支付类型(1线下凭证支付 2旺铺在线支付)'); + $table->integer('pay_method')->comment('支付方式(1微信 2支付宝 3对公汇款 4旺铺在线支付)'); + $table->string('voucher_ids', 255)->default('')->comment('汇款凭证图片ID(逗号分隔,线下凭证支付)'); + $table->integer('status')->default(0)->comment('状态(0待审核/待支付 1已通过/支付成功 2已拒绝/支付失败)'); + $table->string('order_id', 64)->default('')->comment('旺铺平台订单号(网关返回)'); + $table->string('trade_no', 64)->default('')->comment('通道交易流水号(支付成功返回)'); + $table->string('openid', 64)->default('')->comment('付款人微信 openid(小程序在线支付)'); + $table->timestamp('paid_at')->nullable()->comment('支付成功时间(网关交易时间)'); + $table->text('pay_params')->nullable()->comment('旺铺下单返回的调起支付参数(JSON 快照)'); $table->string('remark', 255)->default('')->comment('门店备注'); $table->timestamp('audited_at')->nullable()->comment('审核时间'); $table->integer('auditor_id')->default(0)->comment('审核人(后台系统用户ID)'); $table->string('audit_remark', 255)->default('')->comment('审核备注(拒绝原因)'); $table->timestamps(); $table->index(['store_id', 'status'], 'payment_store_status_index'); - $table->comment('支付记录表(小程序合并付款,后台审核汇款凭证)'); + $table->index(['pay_type', 'status'], 'payment_type_status_index'); + $table->comment('支付记录表(小程序合并付款:线下凭证后台审核 / 旺铺在线支付回调结账)'); }); } } diff --git a/database/seeders/SysDataSeeder.php b/database/seeders/SysDataSeeder.php index 8543035..f56ab40 100644 --- a/database/seeders/SysDataSeeder.php +++ b/database/seeders/SysDataSeeder.php @@ -29,6 +29,14 @@ class SysDataSeeder extends Seeder ['id' => 9, 'group_id' => 4, 'key' => 'wechat_qrcode', 'title' => '微信收款码', 'describe' => '微信收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 0, 'created_at' => $date, 'updated_at' => $date], ['id' => 10, 'group_id' => 4, 'key' => 'alipay_qrcode', 'title' => '支付宝收款码', 'describe' => '支付宝收款码图片地址(在文件管理中上传收款码后复制图片链接,或直接填写文件ID),小程序付款页展示', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 1, 'created_at' => $date, 'updated_at' => $date], ['id' => 11, 'group_id' => 4, 'key' => 'bank_info', 'title' => '对公汇款信息', 'describe' => '对公账户汇款信息(户名、账号、开户行等),小程序付款页展示', 'values' => '', 'type' => 'TextArea','options' => "", 'sort' => 2, 'created_at' => $date, 'updated_at' => $date], + ['id' => 12, 'group_id' => 4, 'key' => 'wangpu_base_url', 'title' => '旺铺网关地址', 'describe' => '旺铺支付网关域名(如 https://pay.example.com),在线支付下单/查询接口前缀', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 3, 'created_at' => $date, 'updated_at' => $date], + ['id' => 13, 'group_id' => 4, 'key' => 'wangpu_organiz_no', 'title' => '旺铺机构渠道号', 'describe' => '合作机构渠道号 organiz_no(旺铺分配)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 4, 'created_at' => $date, 'updated_at' => $date], + ['id' => 14, 'group_id' => 4, 'key' => 'wangpu_mer_no', 'title' => '旺铺内部商户号', 'describe' => '旺铺内部商户号 mer_no(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 5, 'created_at' => $date, 'updated_at' => $date], + ['id' => 15, 'group_id' => 4, 'key' => 'wangpu_mer_code', 'title' => '旺铺商户号', 'describe' => '商户号 mer_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 6, 'created_at' => $date, 'updated_at' => $date], + ['id' => 16, 'group_id' => 4, 'key' => 'wangpu_term_code', 'title' => '旺铺终端号', 'describe' => '终端号 term_code(进件入网后返回)', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 7, 'created_at' => $date, 'updated_at' => $date], + ['id' => 17, 'group_id' => 4, 'key' => 'wangpu_sign_key', 'title' => '旺铺加签Key', 'describe' => '旺铺报文加签专用 Key(SignKey,MD5 加签),请勿泄露', 'values' => '', 'type' => 'Input','options' => "", 'sort' => 8, 'created_at' => $date, 'updated_at' => $date], + ['id' => 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], ]); // 字典类型初始数据 DB::table('sys_dict')->insert([ diff --git a/docs/api/mini-online-payment.md b/docs/api/mini-online-payment.md new file mode 100644 index 0000000..286a84d --- /dev/null +++ b/docs/api/mini-online-payment.md @@ -0,0 +1,235 @@ +# 小程序接口文档:在线支付(旺铺网关 JSAPI) + +> 门店端在线支付:选择本店未支付账单合并付款,后端经旺铺支付网关(统一下单B-JSAPI)下单, +> 小程序调起 `wx.requestPayment` 完成支付;支付结果由**网关后台通知**自动结账, +> 小程序端也可**主动查询**同步结果(回调延迟/丢失时的兜底)。 +> +> 结账效果与线下凭证审核一致:关联账单批量置「已支付」、累加门店总采购金额、门店收到支付成功通知。 + +## 整体流程 + +``` +小程序 后端 旺铺网关 微信 + │ wx.login → code │ │ │ + │ POST /mini/payment/online (bill_ids, code) │ │ + │────────────────────▶│ code2session 换 openid │──────────────────▶│ + │ │ 创建支付单+锁定账单 │ │ + │ │ 统一下单(mer_order_id=支付单号) ──────────▶│ + │ 返回 pay_params │◀──────── order_id + 调起参数 ────────────│ + │◀────────────────────│ │ │ + │ wx.requestPayment(pay_params) ─────────────────────────────────▶│ + │ │ POST /mini/payment/notify(支付成功通知) │ + │ │◀───────────────────────│ │ + │ │ 验签 → 幂等结账 → 应答 {"code":"00"} │ + │ GET .../query 主动同步(兜底) │ │ + │────────────────────▶│ 交易查询 → 已支付则结账 │ │ +``` + +## 通用约定 + +| 项 | 值 | +|---|---| +| 鉴权 | 门店 token:`Authorization: Bearer `(登录见 `/mini/auth/login`) | +| 响应格式 | `{ "success": true|false, "data": {...}, "msg": "..." }` | +| 金额单位 | 元,字符串/数字两位小数(如 `150.50`) | + +--- + +## 1. 发起在线支付(合并账单下单) + +| 项 | 值 | +|---|---| +| 请求方式 | `POST` | +| 路径 | `/mini/payment/online` | +| 鉴权 | 门店 token | + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `bill_ids` | int[] | 是 | 要合并付款的账单 ID 数组(`GET /mini/bill?payable=1` 返回的 `id`),至少 1 个 | +| `code` | string | 是 | 小程序 `wx.login()` 返回的登录凭证(后端用它换付款人 openid) | +| `remark` | string | 否 | 付款备注,最长 255 字 | + +### 校验规则(失败均返回 `success:false`) + +- 账单必须全部属于当前门店,且为「未支付」且未被其他支付单锁定; +- 任一账单已支付 / 正在支付中(含凭证审核中)→ 拒绝并提示对应账单号; +- 合计金额必须大于 0; +- 微信 `code` 无效(code2session 失败)→ 报错,不产生支付单; +- 网关下单失败 → 支付单自动作废、账单释放,可重新发起。 + +### 响应示例 + +```json +{ + "success": true, + "msg": "下单成功,请调起支付", + "data": { + "id": 12, + "payment_no": "ZF202608270001", + "amount": "150.50", + "pay_params": { + "order_id": "202608271201444525348059", + "tradeNo": "2021082722001407831438768160", + "user_openid": "oXxx123" + } + } +} +``` + +### 字段说明 + +| 字段 | 说明 | +|---|---| +| `payment_no` | 本系统支付单号(即上送网关的商户订单号 `mer_order_id`),后续查询/对账用 | +| `amount` | 应付金额(= 所选账单总额合计,元) | +| `pay_params` | 旺铺网关 `data` 节点原样透传。**调起支付所需参数以网关返回为准**(服务商配置后通常包含 `timeStamp`/`nonceStr`/`package`/`signType`/`paySign` 等),直接透传给 `wx.requestPayment` 即可 | + +### 小程序端调用示例 + +```js +// 1. 获取登录凭证 +const { code } = await wx.login(); + +// 2. 后端下单 +const res = await request.post('/mini/payment/online', { bill_ids: [101, 102], code }); +const { payment_no, pay_params } = res.data; + +// 3. 调起微信支付(pay_params 以旺铺网关实际返回的调起参数为准) +await wx.requestPayment({ + timeStamp: pay_params.timeStamp, + nonceStr: pay_params.nonceStr, + package: pay_params.package, + signType: pay_params.signType || 'RSA', + paySign: pay_params.paySign, +}); + +// 4. 支付完成后主动同步结果(回调兜底) +const q = await request.get(`/mini/payment/online/${payment_no}/query`); +// q.data.status === 1 → 支付成功,刷新账单列表 +``` + +--- + +## 2. 查询支付结果(主动同步) + +| 项 | 值 | +|---|---| +| 请求方式 | `GET` | +| 路径 | `/mini/payment/online/{payment_no}/query` | +| 鉴权 | 门店 token(仅可查询本店支付单) | + +### 路径参数 + +| 参数 | 说明 | +|---|---| +| `payment_no` | 下单返回的支付单号(如 `ZF202608270001`) | + +### 行为说明 + +- 本地已是「支付成功」→ 直接返回,不再请求网关; +- 否则向旺铺网关发起「交易查询」,网关返回已支付(`order_status=1`)则**立即结账**(与后台通知同一幂等逻辑); +- 建议在 `wx.requestPayment` 成功回调后、以及付款页 `onShow` 时各调一次; +- 用户中途取消支付时本接口返回 `status: 0`,账单仍处于锁定中,可稍后重试或联系客服释放。 + +### 响应示例 + +```json +{ + "success": true, + "msg": "支付成功", + "data": { + "payment_no": "ZF202608270001", + "status": 1, + "status_name": "支付成功", + "paid_at": "2026-08-27 10:00:00", + "trade_no": "2021082722001407831438768160" + } +} +``` + +### `status` 取值 + +| 值 | 含义 | +|---|---| +| `0` | 待支付(网关未确认) | +| `1` | 支付成功(账单已置已支付) | +| `2` | 支付失败(网关下单失败作废,账单已释放,可重新下单) | + +--- + +## 3. 支付结果后台通知(服务端对接,无需小程序调用) + +| 项 | 值 | +|---|---| +| 请求方式 | `POST`(表单) | +| 路径 | `/mini/payment/notify` | +| 鉴权 | 无(公开路由,以报文 `sign` 验签) | +| 来源 | 旺铺网关(下单时上送的 `notifyurl`,由后端自动生成:`{APP_URL}/mini/payment/notify`) | + +### 处理逻辑 + +1. 按旺铺加签规则验签(参数 ASCII 升序 `key=value` 以 `&` 拼接 + `&key=SignKey`,MD5 大写); +2. 验签失败 → 应答 `{"code":"01"}`; +3. 按 `mer_order_id` 定位支付单,校验 `order_status=1` 且 `order_amt` 与支付单金额一致(防篡改); +4. **幂等结账**:支付单置成功(记录 `trade_no`/`order_id`/支付时间)→ 关联账单批量置已支付 → 累加门店总采购金额(只统计商品金额)→ 站内通知门店; +5. 重复通知直接应答成功,不重复结账。 + +### 应答报文(网关约定格式) + +```json +{ "code": "00", "msg": "成功", "timestamp": "20260827100000123" } +``` + +网关收到 `code=00` 才认为通知成功,否则按 2ⁿ 分钟重试 7 次。 + +--- + +## 后台配置(上线前必配) + +配置优先级:后台「系统设置 → 支付配置」> `.env`。 + +### 后台支付配置(site_config `pay` 组,已内置配置项) + +| 配置项 | 说明 | +|---|---| +| `wangpu_base_url` | 旺铺网关域名(接口地址前缀) | +| `wangpu_organiz_no` | 合作机构渠道号 organiz_no | +| `wangpu_mer_no` | 旺铺内部商户号 mer_no(进件入网后返回) | +| `wangpu_mer_code` | 商户号 mer_code | +| `wangpu_term_code` | 终端号 term_code | +| `wangpu_sign_key` | 加签专用 Key(SignKey) | +| `wangpu_sub_appid` | 下单微信子 appid,留空取小程序自身 appid | +| `wangpu_payway_code` | 支付方式代码(默认 `WECHAT_MINI`,以旺铺数据词典为准) | + +### .env 备选配置 + +```dotenv +WECHAT_MINI_APPID= # 小程序 AppID(code2session 必需) +WECHAT_MINI_SECRET= # 小程序 Secret(code2session 必需) +WANGPU_BASE_URL= +WANGPU_ORGANIZ_NO= +WANGPU_MER_NO= +WANGPU_MER_CODE= +WANGPU_TERM_CODE= +WANGPU_SIGN_KEY= +WANGPU_SUB_APPID= +WANGPU_PAYWAY_CODE=WECHAT_MINI +``` + +> 注意:`APP_URL` 必须是网关可访问的公网 HTTPS 地址,否则支付成功通知无法送达(此时依赖小程序主动查询兜底结账)。 + +## 与线下凭证支付的关系 + +两种付款方式并存,同一张账单同一时刻只能处于一条支付链路: + +| | 在线支付(本档) | 线下凭证支付 | +|---|---|---| +| 入口 | `POST /mini/payment/online` | `POST /mini/payment` | +| 支付单 `pay_type` | `2` | `1` | +| 支付单 `pay_method` | `4` 旺铺支付 | `1` 微信 / `2` 支付宝 / `3` 对公汇款 | +| 结账方式 | 网关通知/主动查询自动结账 | 后台审核通过 | +| 失败/拒绝 | 账单自动释放可重新付款 | 审核拒绝后释放 | + +账单被任一支付单锁定期间(`payment_id ≠ 0`),两种入口均会拒绝重复提交。 diff --git a/tests/Feature/MiniOnlinePaymentTest.php b/tests/Feature/MiniOnlinePaymentTest.php new file mode 100644 index 0000000..addebd3 --- /dev/null +++ b/tests/Feature/MiniOnlinePaymentTest.php @@ -0,0 +1,392 @@ + 'wx-mini-test', + 'services.wechat.mini.secret' => 'wx-secret-test', + 'services.wangpu.base_url' => 'https://wangpu.test', + 'services.wangpu.organiz_no' => 'org001', + 'services.wangpu.mer_no' => 'mer001', + 'services.wangpu.mer_code' => 'code001', + 'services.wangpu.term_code' => 'term001', + 'services.wangpu.sign_key' => self::SIGN_KEY, + 'services.wangpu.payway_code' => 'WECHAT_MINI', + ]); + } + + /** 模拟微信 code2session + 旺铺统一下单均成功 */ + private function fakeGatewaySuccess(string $openid = 'oOpenidTest001'): void + { + Http::fake([ + 'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200), + 'https://wangpu.test/industrial/payment/order' => Http::response([ + 'code' => '0000', + 'msg' => '调用成功', + 'data' => ['order_id' => 'WP202608270001', 'tradeNo' => 'T20260827001', 'user_openid' => $openid], + ], 200), + ]); + } + + /** 造一张指定金额的未支付账单(总额=商品金额) */ + 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-08-27', + '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 makeOnlinePayment(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_WANGPU, + 'voucher_ids' => '', + 'status' => PaymentModel::STATUS_PENDING, + 'openid' => 'oOpenidTest001', + ]); + foreach ($bills as $bill) { + $bill->update(['payment_id' => $payment->id]); + } + return $payment; + } + + /** 构造已加签的支付成功通知报文 */ + private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array + { + $params = array_merge([ + 'mer_order_id' => $payment->payment_no, + 'order_status' => '1', + 'order_amt' => (string) $payment->amount, + 'trade_no' => 'T20260827001', + 'order_id' => 'WP202608270001', + 'order_time' => '2026-08-27 09:59:00', + 'trade_time' => '2026-08-27 10:00:00', + 'payway_code' => 'WECHAT_MINI', + 'mer_no' => 'mer001', + 'device_no' => 'dev001', + 'order_title' => '账单合并付款', + ], $overrides); + $params['sign'] = app(WangpuPayService::class)->sign($params); + return $params; + } + + /** 旺铺加签算法:按接口文档示例 golden test(MD5 升序拼接 + key) */ + public function test_sign_matches_document_example(): void + { + config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']); + + $sign = app(WangpuPayService::class)->sign([ + 'mer_order_id' => 'CBC92E5GTL000083202004121010143', + 'trade_no' => '11420200410120144102483', + 'mer_no' => '2001071119360E5Riu', + 'order_amt' => '0.01', + 'payway_code' => 'QR_WECHAT_BARPAY', + 'order_id' => '202004101201444525348059', + 'order_status' => '1', + 'order_title' => '住宿酒店', + 'mer_code' => 'W00000000001381', + 'device_no' => 'CBC92E5GTL000083', + 'order_time' => '2020-04-10 12:01:44', + 'trade_time' => '2020-04-10 12:01:47', + 'gateway_mer_order_id' => '2020041012014445269', + ]); + + $this->assertSame('A31998F2E0549E0A80B2A4B3A0473784', $sign); + } + + /** 发起在线支付:锁定账单、创建支付单、上送网关参数正确、openid 绑定到门店 */ + public function test_create_online_payment_success(): void + { + $this->fakeGatewaySuccess(); + $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')); + $this->assertSame('WP202608270001', $response->json('data.pay_params.order_id')); + + $payment = PaymentModel::where('payment_no', $paymentNo)->first(); + $this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type); + $this->assertSame(PaymentModel::METHOD_WANGPU, $payment->pay_method); + $this->assertSame(PaymentModel::STATUS_PENDING, $payment->status); + $this->assertSame('oOpenidTest001', $payment->openid); + $this->assertSame('WP202608270001', $payment->order_id); + + // 账单锁定 + openid 绑定门店 + $this->assertSame($payment->id, $bill1->fresh()->payment_id); + $this->assertSame($payment->id, $bill2->fresh()->payment_id); + $this->assertSame('oOpenidTest001', $store->fresh()->openid); + + // 上送网关的报文:商户订单号=支付单号、金额、openid、带签名 + Http::assertSent(static function ($request) use ($paymentNo) { + $body = $request->data(); + return str_contains($request->url(), '/industrial/payment/order') + && ($body['mer_order_id'] ?? '') === $paymentNo + && ($body['order_amt'] ?? '') === '150.50' + && ($body['open_id'] ?? '') === 'oOpenidTest001' + && ($body['sub_appid'] ?? '') === 'wx-mini-test' + && ! empty($body['sign']) + && ! empty($body['notifyurl']); + }); + } + + /** 发起支付校验:缺 code / 非本店账单 / 已支付账单 / 锁定中账单 均拒绝 */ + public function test_create_online_payment_validation(): void + { + $this->fakeGatewaySuccess(); + $store = StoreModel::factory()->create(); + $other = StoreModel::factory()->create(); + + $this->actingAsMiniStore($store); + // 缺 code + $this->postJson('/mini/payment/online', ['bill_ids' => [1]])->assertJsonPath('success', false); + + // 非本店账单 + $otherBill = $this->makeBill($other, '10.00'); + $this->postJson('/mini/payment/online', ['bill_ids' => [$otherBill->id], 'code' => 'c']) + ->assertJsonPath('success', false); + + // 已支付账单 + $paidBill = $this->makeBill($store, '10.00', ['status' => BillModel::STATUS_PAID]); + $this->postJson('/mini/payment/online', ['bill_ids' => [$paidBill->id], 'code' => 'c']) + ->assertJsonPath('success', false); + + // 锁定中账单(已在其他支付单) + $lockedBill = $this->makeBill($store, '10.00'); + $this->makeOnlinePayment($store, '10.00', $lockedBill); + $this->postJson('/mini/payment/online', ['bill_ids' => [$lockedBill->id], 'code' => 'c']) + ->assertJsonPath('success', false); + + // 均未产生新的待支付在线支付单(除锁定用那笔) + $this->assertSame(1, PaymentModel::where('pay_type', PaymentModel::TYPE_ONLINE)->count()); + } + + /** 微信 code2session 失败:报错且不产生支付单 */ + public function test_create_fails_when_code2session_fails(): void + { + Http::fake([ + 'https://api.weixin.qq.com/*' => Http::response(['errcode' => 40029, 'errmsg' => 'invalid code'], 200), + ]); + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '20.00'); + + $this->actingAsMiniStore($store); + $this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'bad-code']) + ->assertJsonPath('success', false); + + $this->assertSame(0, PaymentModel::count()); + $this->assertSame(0, $bill->fresh()->payment_id); + } + + /** 网关下单失败:支付单作废(置失败)并释放账单,可重新发起 */ + public function test_create_gateway_failure_releases_bills(): void + { + Http::fake([ + 'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oOpenidTest001'], 200), + 'https://wangpu.test/*' => Http::response(['code' => '9999', 'msg' => '商户号不存在'], 200), + ]); + $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, '账单释放可重新付款'); + } + + /** 支付成功通知:验签通过 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */ + public function test_notify_settles_payment(): void + { + $store = StoreModel::factory()->create(); + $bill1 = $this->makeBill($store, '100.00'); + $bill2 = $this->makeBill($store, '50.00'); + $payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2); + + $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment)) + ->assertJsonPath('code', '00'); + + $payment->refresh(); + $this->assertSame(PaymentModel::STATUS_APPROVED, $payment->status); + $this->assertSame('T20260827001', $payment->trade_no); + $this->assertSame('WP202608270001', $payment->order_id); + $this->assertSame('2026-08-27 10:00:00', (string) $payment->paid_at); + + foreach ([$bill1, $bill2] as $bill) { + $bill->refresh(); + $this->assertSame(BillModel::STATUS_PAID, $bill->status); + $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->postJson('/mini/payment/notify', $this->signedNotifyParams($payment)) + ->assertJsonPath('code', '00'); + $this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount); + $this->assertSame(1, NoticeModel::where('store_id', $store->id)->count()); + } + + /** 通知验签失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */ + public function test_notify_rejects_invalid_messages(): void + { + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '100.00'); + $payment = $this->makeOnlinePayment($store, '100.00', $bill); + + // 验签失败 + $badSign = $this->signedNotifyParams($payment); + $badSign['sign'] = 'INVALIDSIGN'; + $this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01'); + + // 金额不一致(防篡改) + $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99'])) + ->assertJsonPath('code', '01'); + + // 订单号不存在 + $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000'])) + ->assertJsonPath('code', '01'); + + // 非支付成功状态 + $this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0'])) + ->assertJsonPath('code', '01'); + + // 均未结账 + $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_query_syncs_gateway_status(): void + { + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '80.00'); + $payment = $this->makeOnlinePayment($store, '80.00', $bill); + + // 第 1 次查询网关未支付,第 2 次已支付(fake 回调按调用次数返回,避免重复注册被先注册的 stub 拦截) + $queryCount = 0; + Http::fake([ + 'https://wangpu.test/industrial/query/order' => function () use (&$queryCount, $payment) { + $queryCount++; + $data = $queryCount === 1 + ? ['order_status' => 0, 'mer_order_id' => $payment->payment_no, 'order_amt' => '80.00'] + : [ + 'order_status' => 1, + 'mer_order_id' => $payment->payment_no, + 'order_amt' => '80.00', + 'trade_no' => 'T20260827002', + 'order_id' => 'WP202608270002', + 'trade_time' => '2026-08-27 11:00:00', + ]; + return Http::response(['code' => '0000', 'msg' => '调用成功', 'data' => $data], 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', 'T20260827002'); + + $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); + } + + /** 查询接口隔离:他人支付单不可见 */ + public function test_query_rejects_other_store_payment(): void + { + $store = StoreModel::factory()->create(); + $other = StoreModel::factory()->create(); + $bill = $this->makeBill($other, '10.00'); + $payment = $this->makeOnlinePayment($other, '10.00', $bill); + + $this->actingAsMiniStore($store); + $this->getJson("/mini/payment/online/{$payment->payment_no}/query") + ->assertJsonPath('success', false); + } + + /** 现有线下凭证支付流程不受影响:默认 pay_type=1 */ + public function test_offline_payment_flow_unaffected(): void + { + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store, '30.00'); + + $this->actingAsMiniStore($store); + $this->postJson('/mini/payment', [ + 'bill_ids' => [$bill->id], + 'pay_method' => PaymentModel::METHOD_BANK, + 'voucher_ids' => [1], + ])->assertJsonPath('success', true); + + $payment = PaymentModel::first(); + $this->assertSame(PaymentModel::TYPE_OFFLINE, $payment->pay_type, '线下凭证支付默认 pay_type=1'); + $this->assertSame($payment->id, $bill->fresh()->payment_id); + } +}