diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 9c9c0e7..023878e 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},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.003,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.199,"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.072,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.067,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.078,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.058,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.147,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.13,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.151,"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.006,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.048,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.113,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.017,"Tests\\Feature\\ExportTest::test_export_pdf":0.015,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.06,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.003,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.048,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.049,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.046,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.008,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.006,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.006,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.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.045,"Tests\\Feature\\CartTest::test_update_quantity":0.05,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.055,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.046,"Tests\\Feature\\CartTest::test_delete_item":0.048,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.094,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.011,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_fixed_and_percent":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_edge_cases":0.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.077,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.008,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.009,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.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.081,"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.062,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.069,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.065,"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.132,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.144,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.068,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.093,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.087,"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.097,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.079,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.415,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.11,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.165,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.17,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.15,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.19,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.148,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.114,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.172,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.17,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.139,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.149,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.23,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.145,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.124,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.102,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.136,"Tests\\Feature\\ExportTest::test_export_invalid_type_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_missing_purchase_rejected":0.01,"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.012,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.011,"Tests\\Feature\\ProductCategoryTest::test_update_second_level_move_to_another_top":0.013,"Tests\\Feature\\ProductCategoryTest::test_update_parent_with_children_cannot_move_under_category":0.013,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.025,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.014,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.057,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.086,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.069,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.063,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.072,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.1,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.059,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.06,"Tests\\Feature\\StoreOrderTest::test_order_list_filters_by_order_date_range":0.129,"Tests\\Feature\\StoreOrderTest::test_order_list_rejects_invalid_filters":0.071,"Tests\\Feature\\BillExportTest::test_export_merges_items_across_bills":0.095,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.052,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.077,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.049,"Tests\\Feature\\BillExportTest::test_export_invalid_ids_rejected":0.009,"Tests\\Feature\\DebugPermissionTest::test_debug_permission":0.098,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_bound_store":0.014,"Tests\\Feature\\BillExportTest::test_mini_export_requires_store_binding":0.005,"Tests\\Feature\\MiniNoticeTest::test_notice_list_includes_personal_and_broadcast":0.057,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.079,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.048,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.007,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.149,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.299,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.237,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.061,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.069,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.052,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.05,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.073,"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.062,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.061,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.044,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.073,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.052,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.088,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.06,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.062,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.061,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.059,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.048,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.006,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.09,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.184,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.017,"Tests\\Feature\\ContainerReturnTest::test_export_empty_range_rejected":0.008,"Tests\\Feature\\ContainerReturnTest::test_export_requires_export_permission":0.016,"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.016,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.085,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.069,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.064,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.078,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.088,"Tests\\Feature\\DashboardTest::test_top_stores_ranked_by_amount":0.158,"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.082,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.161,"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.151,"Tests\\Feature\\ProductPriceTest::test_level_percent_update_notifies_level_stores":0.166,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.098,"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.011,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_price":0.009,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.086,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.083,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_level_price":0.073,"Tests\\Feature\\CartTest::test_store_level_missing_rejected":0.006,"Tests\\Feature\\StoreOrderTest::test_store_level_missing_rejected":0.067,"Tests\\Feature\\PurchaseEditTest::test_update_row_skips_catalog_sync_when_product_deleted":0.196,"Tests\\Feature\\PurchaseEditTest::test_store_item_add_creates_item_and_recalculates":0.155,"Tests\\Feature\\PurchaseEditTest::test_store_item_update_merges_multi_order_rows":0.074,"Tests\\Feature\\PurchaseEditTest::test_store_item_remove_deletes_and_recalculates":0.12,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_recalculates_totals":0.09,"Tests\\Feature\\StoreOrderItemTest::test_store_item_edits_rejected_when_purchase_completed":0.072,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_validates_negative_values":0.084,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_uses_level_percent_price":0.078,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_rejected_when_product_deleted":0.072,"Tests\\Feature\\ExportTest::test_export_lists_all_catalog_products_with_totals_row":0.131,"Tests\\Feature\\ExportTest::test_export_supplier_filter":0.117,"Tests\\Feature\\ExportTest::test_export_stores_multi_sheet":0.165,"Tests\\Feature\\ExportTest::test_export_stores_single_store":0.151,"Tests\\Feature\\ExportTest::test_export_stores_without_items_rejected":0.175,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":0.137,"Tests\\Feature\\ExportTest::test_export_suppliers_single":0.11,"Tests\\Feature\\ExportTest::test_export_suppliers_without_items_rejected":0.127,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_change_reprices_items_by_store_level":0.122,"Tests\\Feature\\PurchaseEditTest::test_update_row_without_cost_change_keeps_item_price":0.181,"Tests\\Feature\\CartTest::test_update_other_stores_item_rejected":0.092,"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.186,"Tests\\Feature\\MiniAuthTest::test_login_rejects_wrong_password":0.152,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unknown_username":0.003,"Tests\\Feature\\MiniAuthTest::test_disabled_store_cannot_login":0.187,"Tests\\Feature\\MiniAuthTest::test_login_validates_required_params":0.002,"Tests\\Feature\\MiniAuthTest::test_login_token_accesses_protected_routes":0.207,"Tests\\Feature\\MiniAuthTest::test_set_password_succeeds":0.268,"Tests\\Feature\\MiniAuthTest::test_set_password_rejects_wrong_old_password":0.212,"Tests\\Feature\\MiniAuthTest::test_set_password_validates_confirmation":0.07,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_own_store":0.094,"Tests\\Feature\\MiniNoticeTest::test_other_stores_notice_not_found":0.115,"Tests\\Feature\\MiniProductTest::test_product_detail_store_sees_level_price":0.061,"Tests\\Feature\\MiniProductTest::test_product_list_store_sees_level_price":0.066,"Tests\\Feature\\MiniStoreTest::test_info_returns_current_store":0.045,"Tests\\Feature\\MiniStoreTest::test_disabled_store_rejected":0.089}} \ 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},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.003,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.09,"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.006,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.048,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.048,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.061,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.049,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.092,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.1,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.102,"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.006,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.045,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.092,"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.057,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.003,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.048,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.048,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.047,"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.049,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.051,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.045,"Tests\\Feature\\CartTest::test_update_quantity":0.05,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.048,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.047,"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.094,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.011,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_fixed_and_percent":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_edge_cases":0.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.045,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.008,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.009,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.006,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.01,"Tests\\Feature\\PurchaseGenerateTest::test_generate_uses_min_actual_price_across_types":0.014,"Tests\\Feature\\ProductPriceTest::test_store_without_level_sees_products_without_price":0.046,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.009,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.01,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.009,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.002,"Tests\\Feature\\MiniAuthTest::test_register_rejects_duplicate_openid":0.003,"Tests\\Feature\\MiniStoreTest::test_info_returns_bound_store":0.005,"Tests\\Feature\\MiniStoreTest::test_update_info":0.045,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.046,"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.051,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.101,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.048,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.051,"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.245,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.054,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.105,"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.102,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.1,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.101,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.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.104,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.1,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.102,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.124,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.108,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.103,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.061,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.103,"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.006,"Tests\\Feature\\ProductCategoryTest::test_update_second_level_move_to_another_top":0.007,"Tests\\Feature\\ProductCategoryTest::test_update_parent_with_children_cannot_move_under_category":0.008,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.006,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.007,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.05,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.049,"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.053,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.089,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.051,"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.097,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.053,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.069,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.052,"Tests\\Feature\\BillExportTest::test_export_invalid_ids_rejected":0.011,"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.048,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.049,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.046,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.007,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.101,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.15,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.102,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.063,"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.052,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.049,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.044,"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.045,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.049,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.046,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.073,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.056,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.05,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.061,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.061,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.06,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.06,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.048,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.007,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.092,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.129,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.009,"Tests\\Feature\\ContainerReturnTest::test_export_empty_range_rejected":0.006,"Tests\\Feature\\ContainerReturnTest::test_export_requires_export_permission":0.009,"Tests\\Feature\\DashboardTest::test_guest_cannot_access_dashboard":0.002,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":0.006,"Tests\\Feature\\DashboardTest::test_empty_database_returns_zero_structure":0.009,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.052,"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.054,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.053,"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.053,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.131,"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.092,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.047,"Tests\\Feature\\ProductPriceTest::test_level_percent_validation":0.008,"Tests\\Feature\\ProductCostPriceTest::test_calc_level_price":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_level_price_edge_cases":0.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_level_prices":0.008,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_price":0.007,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.051,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.048,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_level_price":0.049,"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.103,"Tests\\Feature\\PurchaseEditTest::test_store_item_add_creates_item_and_recalculates":0.106,"Tests\\Feature\\PurchaseEditTest::test_store_item_update_merges_multi_order_rows":0.06,"Tests\\Feature\\PurchaseEditTest::test_store_item_remove_deletes_and_recalculates":0.1,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_recalculates_totals":0.059,"Tests\\Feature\\StoreOrderItemTest::test_store_item_edits_rejected_when_purchase_completed":0.061,"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.055,"Tests\\Feature\\ExportTest::test_export_lists_all_catalog_products_with_totals_row":0.101,"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.101,"Tests\\Feature\\ExportTest::test_export_suppliers_without_items_rejected":0.102,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_change_reprices_items_by_store_level":0.101,"Tests\\Feature\\PurchaseEditTest::test_update_row_without_cost_change_keeps_item_price":0.106,"Tests\\Feature\\CartTest::test_update_other_stores_item_rejected":0.091,"Tests\\Feature\\CartTest::test_delete_other_stores_item_rejected":0.09,"Tests\\Feature\\CartTest::test_cart_isolated_between_stores":0.089,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_valid_credentials":0.128,"Tests\\Feature\\MiniAuthTest::test_login_rejects_wrong_password":0.124,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unknown_username":0.003,"Tests\\Feature\\MiniAuthTest::test_disabled_store_cannot_login":0.123,"Tests\\Feature\\MiniAuthTest::test_login_validates_required_params":0.003,"Tests\\Feature\\MiniAuthTest::test_login_token_accesses_protected_routes":0.13,"Tests\\Feature\\MiniAuthTest::test_set_password_succeeds":0.207,"Tests\\Feature\\MiniAuthTest::test_set_password_rejects_wrong_old_password":0.127,"Tests\\Feature\\MiniAuthTest::test_set_password_validates_confirmation":0.046,"Tests\\Feature\\BillExportTest::test_mini_export_scoped_to_own_store":0.093,"Tests\\Feature\\MiniNoticeTest::test_other_stores_notice_not_found":0.086,"Tests\\Feature\\MiniProductTest::test_product_detail_store_sees_level_price":0.046,"Tests\\Feature\\MiniProductTest::test_product_list_store_sees_level_price":0.045,"Tests\\Feature\\MiniStoreTest::test_info_returns_current_store":0.045,"Tests\\Feature\\MiniStoreTest::test_disabled_store_rejected":0.046,"Tests\\Feature\\ProductImportExportTest::test_export_downloads_all_products":0.008,"Tests\\Feature\\ProductImportExportTest::test_export_filters_by_category":0.008,"Tests\\Feature\\ProductImportExportTest::test_export_template_mode":0.005,"Tests\\Feature\\ProductImportExportTest::test_export_requires_permission":0.007,"Tests\\Feature\\ProductImportExportTest::test_import_creates_products_and_auto_supplier":0.043,"Tests\\Feature\\ProductImportExportTest::test_import_matches_existing_supplier":0.018,"Tests\\Feature\\ProductImportExportTest::test_import_errors_abort_all_rows":0.017,"Tests\\Feature\\ProductImportExportTest::test_import_category_path_resolution":0.031,"Tests\\Feature\\ProductImportExportTest::test_import_rejects_invalid_header":0.024,"Tests\\Feature\\ProductImportExportTest::test_import_requires_permission":0.01}} \ No newline at end of file diff --git a/app/Exports/ProductExport.php b/app/Exports/ProductExport.php new file mode 100644 index 0000000..40d5910 --- /dev/null +++ b/app/Exports/ProductExport.php @@ -0,0 +1,126 @@ +rows !== null) { + return $this->rows; + } + + $rows = [self::HEADERS]; + + if ($this->template) { + $rows[] = ['西红柿', '蔬菜/茄果类', '示例供应商', '10斤/箱', '斤', 2.5, 0, 100, 3, '上架', '示例行,导入前请删除']; + return $this->rows = collect($rows); + } + + $products = ProductModel::query() + ->when($this->categoryId > 0, fn ($query) => $query->where('category_id', $this->categoryId)) + ->orderBy('sort') + ->orderBy('id') + ->get() + ->makeVisible('cost_price'); + + $categoryPaths = $this->categoryPaths($products->pluck('category_id')->unique()->all()); + $supplierNames = SupplierModel::withTrashed()->pluck('name', 'id'); + + foreach ($products as $product) { + $rows[] = [ + $product->name, + $categoryPaths[(int) $product->category_id] ?? '', + $supplierNames[(int) $product->supplier_id] ?? '', + $product->spec, + $product->unit, + (float) $product->cost_price, + (int) $product->sort, + (int) $product->stock, + (int) $product->shelf_life, + (int) $product->status === ProductModel::STATUS_ON ? '上架' : '下架', + $product->remark, + ]; + } + + return $this->rows = collect($rows); + } + + /** + * 列头加粗、冻结首行、设置列宽 + */ + public function styles(Worksheet $sheet): array + { + $this->collection(); + + $sheet->freezePane('A2'); + $widths = [20, 16, 14, 14, 8, 10, 8, 8, 8, 8, 24]; + foreach ($widths as $index => $width) { + $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); + } + + return [ + 1 => ['font' => ['bold' => true]], + ]; + } + + /** + * 分类ID => 「父分类/子分类」完整路径(沿 parent_id 上溯拼接) + * + * @param array $categoryIds + * @return array + */ + private function categoryPaths(array $categoryIds): array + { + $categories = ProductCategoryModel::all()->keyBy('id'); + $paths = []; + foreach ($categoryIds as $categoryId) { + $names = []; + $cursor = (int) $categoryId; + $guard = 0; + while ($cursor > 0 && $guard++ < 20) { + $category = $categories->get($cursor); + if ($category === null) { + break; + } + array_unshift($names, $category->name); + $cursor = (int) $category->parent_id; + } + $paths[(int) $categoryId] = implode('/', $names); + } + return $paths; + } +} diff --git a/app/Http/Controllers/Product/ProductController.php b/app/Http/Controllers/Product/ProductController.php index 6f599be..e6d97ce 100644 --- a/app/Http/Controllers/Product/ProductController.php +++ b/app/Http/Controllers/Product/ProductController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers\Product; use App\Exceptions\RepositoryException; +use App\Exports\ProductExport; use App\Http\Requests\Product\BatchPriceRequest; use App\Http\Requests\Product\ProductFormRequest; +use App\Imports\ProductImport; use App\Models\CustomerLevelModel; use App\Models\NoticeModel; use App\Models\ProductModel; @@ -13,6 +15,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Maatwebsite\Excel\Facades\Excel; use Modules\AnnoRoute\Attribute\DeleteRoute; use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\PostRoute; @@ -20,6 +23,7 @@ use Modules\AnnoRoute\Attribute\PutRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\Common\Http\Controllers\BaseController; use Modules\SystemTool\Services\SysFileService; +use Symfony\Component\HttpFoundation\Response; /** * 商品档案管理(售价 = 成本价 × (100 + 客户等级上浮比例) / 100,不再维护等级价格行) @@ -90,6 +94,53 @@ class ProductController extends BaseController return $this->success(['id' => $product->id]); } + /** + * 商品导出(列格式与导入模板一致,导出文件修改后可直接重新导入); + * template=1 时仅输出列头 + 示例行(导入模板) + */ + #[GetRoute('/export', 'export')] + public function export(Request $request): Response + { + $template = $request->boolean('template'); + $categoryId = max(0, (int) $request->query('category_id', 0)); + $filename = $template + ? '商品导入模板.xlsx' + : '商品列表_' . now()->format('Ymd_His') . '.xlsx'; + return Excel::download(new ProductExport($categoryId, $template), $filename); + } + + /** + * Excel 批量导入商品:整表校验,有错全部不导入; + * 分类按名称/路径匹配末级分类,供应商按名称匹配、不存在自动创建;一律新增商品 + */ + #[PostRoute('/import', 'import')] + public function import(Request $request): JsonResponse + { + $data = $request->validate([ + 'file' => 'required|file|mimes:xlsx,xls|max:10240', + ], [ + 'file.required' => '请选择要导入的 Excel 文件', + 'file.mimes' => '仅支持 xlsx/xls 格式的 Excel 文件', + 'file.max' => '文件大小不能超过 10MB', + ]); + + $import = new ProductImport(); + Excel::import($import, $data['file']); + + if ($import->errors !== []) { + return $this->error( + ['errors' => $import->errors], + '共发现 ' . count($import->errors) . ' 处数据错误,请修正后重新导入' + ); + } + + $msg = '成功导入 ' . $import->created . ' 件商品'; + if ($import->suppliersCreated !== []) { + $msg .= ',自动创建供应商:' . implode('、', $import->suppliersCreated); + } + return $this->success(['created' => $import->created], $msg); + } + /** 编辑商品 */ #[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] public function update(int $id, ProductFormRequest $request): JsonResponse diff --git a/app/Imports/ProductImport.php b/app/Imports/ProductImport.php new file mode 100644 index 0000000..059fdbd --- /dev/null +++ b/app/Imports/ProductImport.php @@ -0,0 +1,357 @@ + 校验错误行(Excel 行号,1 起) */ + public array $errors = []; + + /** 成功写入的商品数 */ + public int $created = 0; + + /** @var array 本次自动创建的供应商名称 */ + public array $suppliersCreated = []; + + /** + * maatwebsite 入口:读取首个工作表全部行(第 1 行为列头) + * + * @param Collection> $rows + */ + public function collection(Collection $rows): void + { + $header = $rows->first(); + if (! $this->isValidHeader($header)) { + $this->errors[] = ['row' => 1, 'message' => '列头与导入模板不一致,请下载最新模板后重试']; + return; + } + + $dataRows = $rows->slice(1)->values(); + if ($dataRows->count() > self::MAX_ROWS) { + $this->errors[] = ['row' => 2, 'message' => '单次最多导入 ' . self::MAX_ROWS . ' 行,当前 ' . $dataRows->count() . ' 行,请拆分后导入']; + return; + } + + $categories = ProductCategoryModel::all(); + $leafIds = $this->leafCategoryIds($categories); + + $parsed = []; + foreach ($dataRows as $index => $row) { + $excelRow = $index + 2; // Excel 行号(1 起,含列头行) + $cells = array_map(static fn ($cell) => is_string($cell) ? trim($cell) : $cell, $row->toArray()); + + // 整行空白视为空行跳过 + if (implode('', array_map(static fn ($cell) => (string) $cell, $cells)) === '') { + continue; + } + + $result = $this->parseRow($cells, $categories, $leafIds); + if ($result['errors'] !== []) { + foreach ($result['errors'] as $message) { + $this->errors[] = ['row' => $excelRow, 'message' => $message]; + } + } else { + $parsed[] = $result['data']; + } + } + + if ($this->errors !== []) { + return; // 整表校验:有错一行不写 + } + if ($parsed === []) { + $this->errors[] = ['row' => 2, 'message' => '表格中没有可导入的数据行']; + return; + } + + $this->writeRows($parsed); + } + + /** + * 解析并校验一行数据;全部合法时返回写入字段 + * + * @param array $cells + * @param Collection $categories + * @param array $leafIds + * @return array{data: array, errors: array} + */ + private function parseRow(array $cells, Collection $categories, array $leafIds): array + { + [$name, $categoryName, $supplierName, $spec, $unit, $costPrice, $sort, $stock, $shelfLife, $statusText, $remark] = + array_pad(array_slice($cells, 0, 11), 11, null); + + $errors = []; + + $name = (string) $name; + if ($name === '') { + $errors[] = '品名不能为空'; + } elseif (mb_strlen($name) > 100) { + $errors[] = '品名最长 100 个字符'; + } + + $categoryId = 0; + $categoryName = (string) $categoryName; + if ($categoryName === '') { + $errors[] = '分类不能为空'; + } else { + $categoryId = $this->resolveCategoryId($categoryName, $categories, $leafIds); + if ($categoryId === 0) { + $errors[] = '分类「' . $categoryName . '」不存在或不是末级分类(多个同名末级分类时请使用「父分类/子分类」格式)'; + } + } + + $supplierName = (string) $supplierName; + if (mb_strlen($supplierName) > 100) { + $errors[] = '供应商名称最长 100 个字符'; + } + + $spec = (string) $spec; + if (mb_strlen($spec) > 100) { + $errors[] = '规格/包规最长 100 个字符'; + } + + $unit = (string) $unit === '' ? '斤' : (string) $unit; + if (mb_strlen($unit) > 20) { + $errors[] = '单位最长 20 个字符'; + } + + $cost = $this->parseDecimal($costPrice, 0, 99999999); + if ($cost === null) { + $errors[] = '成本价必须为不小于 0 的数字'; + } + + $sort = $this->parseInteger($sort); + if ($sort === null) { + $errors[] = '排序必须为不小于 0 的整数'; + } + + $stock = $this->parseInteger($stock); + if ($stock === null) { + $errors[] = '库存必须为不小于 0 的整数'; + } + + $shelfLife = $this->parseInteger($shelfLife); + if ($shelfLife === null) { + $errors[] = '保质期必须为不小于 0 的整数'; + } + + $status = $this->parseStatus($statusText); + if ($status === null) { + $errors[] = '状态只能填「上架」或「下架」'; + } + + $remark = (string) $remark; + if (mb_strlen($remark) > 255) { + $errors[] = '备注最长 255 个字符'; + } + + if ($errors !== []) { + return ['data' => [], 'errors' => $errors]; + } + + return [ + 'data' => [ + 'category_id' => $categoryId, + 'supplier_id' => 0, // 写入阶段按 supplier_name 解析(匹配或自动创建) + 'supplier_name' => $supplierName, // 写入阶段解析,不入 product 表 + 'name' => $name, + 'spec' => $spec, + 'unit' => $unit, + 'image_ids' => '', + 'content' => '', + 'sort' => $sort, + 'shelf_life' => $shelfLife, + 'stock' => $stock, + 'status' => $status, + 'cost_price' => $cost, + 'remark' => $remark, + ], + 'errors' => [], + ]; + } + + /** + * 事务写入:自动创建缺失供应商,批量插入商品 + * + * @param array> $parsed + */ + private function writeRows(array $parsed): void + { + DB::transaction(function () use ($parsed) { + // 供应商按名称匹配,不存在自动创建(supplier_id=0 且 supplier_name 非空 = 待创建) + $supplierIds = SupplierModel::pluck('id', 'name'); + foreach (collect($parsed)->pluck('supplier_name')->filter()->unique() as $name) { + if (! isset($supplierIds[$name])) { + $supplier = SupplierModel::create(['name' => $name]); + $supplierIds[$name] = $supplier->id; + $this->suppliersCreated[] = $name; + } + } + + $now = now(); + $payload = array_map(static function (array $row) use ($supplierIds, $now) { + if ($row['supplier_name'] !== '') { + $row['supplier_id'] = (int) $supplierIds[$row['supplier_name']]; + } + unset($row['supplier_name']); + $row['created_at'] = $now; + $row['updated_at'] = $now; + return $row; + }, $parsed); + + ProductModel::insert($payload); + $this->created = count($payload); + }); + } + + /** + * 分类名称/路径 → 末级分类ID(0 = 解析失败) + * 支持「父分类/子分类」路径逐级匹配;单名称时要求末级分类唯一 + * + * @param Collection $categories + * @param array $leafIds + */ + private function resolveCategoryId(string $name, Collection $categories, array $leafIds): int + { + // 需 u 修饰符:多字节分隔符(/)按字节解析会切碎中文 + $segments = array_values(array_filter(array_map( + static fn ($segment) => trim($segment), + preg_split('#[//\\\\]#u', $name) ?: [] + ), static fn ($segment) => $segment !== '')); + + if ($segments === []) { + return 0; + } + + if (count($segments) > 1) { + // 路径逐级匹配 + $parentId = 0; + $matched = null; + foreach ($segments as $segment) { + $matched = $categories->first( + static fn (ProductCategoryModel $category) => $category->name === $segment + && (int) $category->parent_id === $parentId + ); + if ($matched === null) { + return 0; + } + $parentId = (int) $matched->id; + } + return isset($leafIds[(int) $matched->id]) ? (int) $matched->id : 0; + } + + // 单名称:仅在末级分类中唯一时命中 + $leafMatches = $categories->filter( + static fn (ProductCategoryModel $category) => $category->name === $segments[0] + && isset($leafIds[(int) $category->id]) + ); + if ($leafMatches->count() !== 1) { + return 0; + } + return (int) $leafMatches->first()->id; + } + + /** + * 末级分类ID集合(无任何子分类) + * + * @param Collection $categories + * @return array + */ + private function leafCategoryIds(Collection $categories): array + { + $parentIds = []; + foreach ($categories as $category) { + if ((int) $category->parent_id > 0) { + $parentIds[(int) $category->parent_id] = true; + } + } + $leafIds = []; + foreach ($categories as $category) { + if (! isset($parentIds[(int) $category->id])) { + $leafIds[(int) $category->id] = true; + } + } + return $leafIds; + } + + /** + * 列头校验:与导出/模板列头完全一致(允许尾部多余空单元格) + */ + private function isValidHeader(?Collection $header): bool + { + if ($header === null) { + return false; + } + $cells = array_map(static fn ($cell) => trim((string) $cell), $header->toArray()); + foreach (ProductExport::HEADERS as $index => $expected) { + if (($cells[$index] ?? '') !== $expected) { + return false; + } + } + return true; + } + + /** + * 解析十进制数字(空串取默认值;越界/非数字返回 null) + */ + private function parseDecimal(mixed $value, float $min, float $max): ?string + { + if ($value === null || $value === '') { + $value = 0; + } + if (! is_numeric($value)) { + return null; + } + $number = (float) $value; + if ($number < $min || $number > $max) { + return null; + } + return number_format($number, 2, '.', ''); + } + + /** + * 解析非负整数(空串取 0;非整数/负数返回 null) + */ + private function parseInteger(mixed $value): ?int + { + if ($value === null || $value === '') { + return 0; + } + if (! is_numeric($value)) { + return null; + } + $number = (float) $value; + if ($number < 0 || $number !== (float) (int) $number) { + return null; + } + return (int) $number; + } + + /** + * 解析状态(空串默认上架;接受 上架/下架/1/0) + */ + private function parseStatus(mixed $value): ?int + { + $text = trim((string) $value); + return match ($text) { + '', '上架', '1' => ProductModel::STATUS_ON, + '下架', '0' => ProductModel::STATUS_OFF, + default => null, + }; + } +} diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 496af5f..b9ab0c6 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -89,6 +89,8 @@ class PermissionSeeder extends Seeder ['type' => 'rule', 'key' => 'product.goods.update', 'name' => '编辑'], ['type' => 'rule', 'key' => 'product.goods.delete', 'name' => '删除'], ['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'], + ['type' => 'rule', 'key' => 'product.goods.import', 'name' => '导入'], + ['type' => 'rule', 'key' => 'product.goods.export', 'name' => '导出'], ], ], [ diff --git a/docs/小程序端修改文档.md b/docs/小程序端修改文档.md new file mode 100644 index 0000000..c5a2062 --- /dev/null +++ b/docs/小程序端修改文档.md @@ -0,0 +1,172 @@ +# 小程序端修改文档(门店端) + +> 版本:2026-08-17 +> 后端变更:用户表与门店表合并(一个用户就是一个门店),门店登录由「微信静默登录」改为「账号 + 密码登录」。 +> 本文档面向小程序(门店端)开发者,说明需要配合修改的内容。**未列出的接口均无变化**。 + +--- + +## 一、变更总览 + +| 事项 | 旧 | 新 | +|------|-----|-----| +| 登录方式 | `wx.login` 拿 code,调 `/mini/auth/login` 静默登录 | 账号 + 密码登录(账号由商家后台分配) | +| 注册 | `POST /mini/auth/register`(微信 code + 手机号 + 门店编码) | **接口下线**,门店账号由商家在 PC 后台创建 | +| 登录主体 | 微信用户(user),再绑定门店(store) | **门店即用户**:登录成功返回的就是门店本身 | +| 修改密码 | 无 | 新增 `PUT /mini/auth/password` | +| Token 鉴权 | Bearer Token | **不变** | +| 其他业务接口 | 购物车 / 订单 / 账单 / 支付 / 通知 / 商品 / 首页 | **全部不变**(路径、入参、出参) | + +### 需要小程序端配合的改动 + +1. **重做登录页**:去掉 `wx.login` 流程,改为账号 + 密码表单(账号密码由商家线下告知门店)。 +2. **删除注册页/绑定门店页**:注册接口已下线,「尚未绑定门店」的场景已不存在。 +3. **用户信息结构调整**:登录与 `auth/info` 返回的对象字段变化(见下文)。 +4. **新增「修改密码」入口**(建议放在「我的」页面)。 +5. **重新登录**:旧 token 全部失效,需引导用户用账号密码重新登录一次。 + +--- + +## 二、登录接口(变更) + +### `POST /mini/auth/login` + +**请求参数(变更):** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| username | string | 是 | 登录账号(商家后台分配,4~20 位) | +| password | string | 是 | 登录密码 | + +> 旧参数 `code`(wx.login 凭证)已废弃,无需再传,也无需调用 `wx.login`。 + +**成功响应:** + +```json +{ + "success": true, + "msg": "登录成功", + "data": { + "token": "1|xxxxxxxxxxxxxxxx", + "user": { + "id": 3, + "name": "好又多超市", + "code": "S000003", + "username": "hyd001", + "avatar": "", + "level_id": 1, + "level": { "id": 1, "name": "一级客户" }, + "contact": "张三", + "phone": "13800000000", + "address": "xx路 1 号", + "payment_cycle_days": 2, + "status": 1, + "last_login_at": "2026-08-17 10:00:00", + "created_at": "2026-08-01 09:00:00" + } + } +} +``` + +**user 字段变化要点:** + +- `user` 现在**就是门店对象**(扁平结构),不再有 `user.store` 嵌套,也不再有 `openid`、`unionid`、`nickname`、`email`、`store_id` 字段。 +- 门店名称取 `user.name`(原 `user.store.name`);客户等级取 `user.level`。 +- `password` 字段永不返回。 + +**失败响应(`success: false`,`msg` 提示):** + +| 场景 | msg | +|------|-----| +| 账号不存在或密码错误 | 账号或密码错误 | +| 门店已停用 | 账号已被停用,请联系客服处理 | +| 参数缺失 | 请输入登录账号 / 请输入登录密码 | + +**Token 使用(不变):** 后续所有请求携带请求头 `Authorization: Bearer {token}`。 + +--- + +## 三、注册接口(下线) + +### ~~`POST /mini/auth/register`~~ —— 已删除 + +- 门店账号全部由商家在 PC 后台「客户管理 → 门店管理」创建并分配(含登录账号、初始密码)。 +- 小程序端请移除注册页、「输入门店编码绑定」页及相关逻辑。 +- 同步移除 `wx.getPhoneNumber` 手机号授权流程(后端已不再使用)。 + +--- + +## 四、当前用户信息(结构变化) + +### `GET /mini/auth/info`(需登录) + +返回当前登录门店完整信息(含 `level` 等级嵌套),字段与登录接口的 `user` 一致(见上文示例)。原来读取 `info.store.xxx` 的地方改为直接读 `info.xxx`。 + +--- + +## 五、修改密码(新增) + +### `PUT /mini/auth/password`(需登录) + +**请求参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| oldPassword | string | 是 | 原密码 | +| newPassword | string | 是 | 新密码(6~20 位) | +| rePassword | string | 是 | 确认新密码(须与 newPassword 一致) | + +**失败场景:** 原密码不正确 / 两次输入的密码不一致 / 新密码至少 6 位。 + +> 修改密码成功后现有 token 仍然有效,无需强制重新登录。 + +--- + +## 六、门店信息(不变) + +### `GET /mini/store/info`(需登录) + +返回:`{ id, name, code, contact, phone, address, payment_cycle_days }`(门店名称/编码/回款周期只读,由后台维护)。 + +### `PUT /mini/store/info`(需登录) + +可改字段不变:`contact`(联系人)、`phone`(联系电话)、`address`(地址)。 + +--- + +## 七、以下接口完全不变 + +路径、入参、出参均无变化,仅内部归属从「用户」变为「门店」(对小程序透明): + +| 模块 | 接口 | +|------|------| +| 首页 | `GET /mini/home/*`(轮播/导航/促销) | +| 商品 | `GET /mini/product/categories`、`GET /mini/product/list`、`GET /mini/product/{id}`(登录后按本店等级显示价格) | +| 购物车 | `POST /mini/cart`、`GET /mini/cart`、`PUT /mini/cart/{id}`、`DELETE /mini/cart/{id}`、`DELETE /mini/cart` | +| 订单 | `POST /mini/order`、`GET /mini/order`、`GET /mini/order/summary`、`GET /mini/order/{id}`、`PUT /mini/order/{id}/cancel` | +| 账单 | `GET /mini/bill`、`GET /mini/bill/{id}`、`GET /mini/bill/export` | +| 支付 | `GET /mini/payment/config`、`GET /mini/payment`、`POST /mini/payment`、`GET /mini/payment/{id}` | +| 通知 | `GET /mini/notice`、`PUT /mini/notice/{id}/read` | +| 上传 | `POST /mini/upload` | + +> 唯一语义差异:数据隔离现在天然按门店划分(一个门店一个账号),原「同一门店多个微信账号各自购物车」的合并场景不再存在。 + +--- + +## 八、错误语义(不变) + +- 未携带/无效 token:HTTP `401`。 +- 业务错误:HTTP `200` + `{ success: false, msg: "..." }`,直接 toast `msg` 即可。 +- 「门店未设置客户等级,无法加购/下单,请联系客服」等提示文案不变。 +- 「尚未绑定门店」提示已移除(该场景不存在)。 + +--- + +## 九、上线 Checklist(小程序端) + +- [ ] 登录页改为账号 + 密码表单,移除 `wx.login` / `wx.getPhoneNumber` 调用 +- [ ] 移除注册页、门店绑定页及路由 +- [ ] 全局用户信息读取点从 `user.store.*` 调整为 `user.*`(门店名、等级、回款周期等) +- [ ] 「我的」页面新增修改密码入口 +- [ ] 旧版本缓存的 token 失效处理:401 时引导重新登录 +- [ ] 等级价格展示逻辑不变(接口返回的 `price` 字段含义不变) diff --git a/tests/Feature/ProductImportExportTest.php b/tests/Feature/ProductImportExportTest.php new file mode 100644 index 0000000..4e96a5b --- /dev/null +++ b/tests/Feature/ProductImportExportTest.php @@ -0,0 +1,308 @@ +> $rows + */ + private function makeUploadFile(array $rows): UploadedFile + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + foreach ($rows as $index => $row) { + $sheet->fromArray($row, null, 'A' . ($index + 1)); + } + $path = tempnam(sys_get_temp_dir(), 'imp') . '.xlsx'; + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + + return new UploadedFile( + $path, + 'import.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + null, + true + ); + } + + /** 创建 根分类 + 末级子分类,返回末级分类 */ + private function makeLeafCategory(string $rootName = '蔬菜', string $leafName = '叶菜类'): ProductCategoryModel + { + $root = ProductCategoryModel::create(['name' => $rootName, 'parent_id' => 0, 'sort' => 0, 'status' => 1]); + return ProductCategoryModel::create(['name' => $leafName, 'parent_id' => $root->id, 'sort' => 0, 'status' => 1]); + } + + // ==================== 导出 ==================== + + /** 导出全量商品:列头 + 分类路径 + 供应商名 + 状态中文 */ + public function test_export_downloads_all_products(): void + { + $this->freezeTime(); + + $leaf = $this->makeLeafCategory(); + $supplier = SupplierModel::create(['name' => '张三蔬菜批发']); + ProductModel::factory()->create([ + 'category_id' => $leaf->id, + 'supplier_id' => $supplier->id, + 'name' => '上海青', + 'spec' => '10斤/箱', + 'unit' => '斤', + 'cost_price' => '2.50', + 'sort' => 3, + 'stock' => 100, + 'shelf_life' => 2, + 'status' => ProductModel::STATUS_ON, + 'remark' => '新鲜直达', + ]); + ProductModel::factory()->off()->create(['category_id' => $leaf->id, 'name' => '下架商品']); + + $this->actingAsSysUser(); + Excel::fake(); + $this->get('/product/goods/export')->assertOk(); + + Excel::assertDownloaded( + '商品列表_' . now()->format('Ymd_His') . '.xlsx', + static function (ProductExport $export): bool { + $rows = $export->collection()->values(); + // 列头 + 2 件商品 + if ($rows->count() !== 3) { + return false; + } + if ($rows[0] !== ProductExport::HEADERS) { + return false; + } + $row = $rows->firstWhere(0, '上海青'); + if ($row === null) { + return false; + } + // [品名, 分类路径, 供应商, 规格, 单位, 成本价, 排序, 库存, 保质期, 状态, 备注] + if ($row[1] !== '蔬菜/叶菜类' || $row[2] !== '张三蔬菜批发' || $row[9] !== '上架') { + return false; + } + if ((float) $row[5] !== 2.5 || (int) $row[6] !== 3 || (int) $row[7] !== 100 || (int) $row[8] !== 2) { + return false; + } + if ($row[10] !== '新鲜直达') { + return false; + } + + $offRow = $rows->firstWhere(0, '下架商品'); + return $offRow !== null && $offRow[9] === '下架' && $offRow[2] === ''; + } + ); + } + + /** 按末级分类过滤导出 */ + public function test_export_filters_by_category(): void + { + $this->freezeTime(); + + $leafA = $this->makeLeafCategory('蔬菜', '叶菜类'); + $leafB = $this->makeLeafCategory('水果', '浆果类'); + ProductModel::factory()->create(['category_id' => $leafA->id, 'name' => '菠菜']); + ProductModel::factory()->create(['category_id' => $leafB->id, 'name' => '草莓']); + + $this->actingAsSysUser(); + Excel::fake(); + $this->get('/product/goods/export?category_id=' . $leafA->id)->assertOk(); + + Excel::assertDownloaded( + '商品列表_' . now()->format('Ymd_His') . '.xlsx', + static function (ProductExport $export): bool { + $rows = $export->collection()->values(); + return $rows->count() === 2 + && $rows[1][0] === '菠菜' + && $rows[1][1] === '蔬菜/叶菜类'; + } + ); + } + + /** template=1 输出导入模板:列头 + 示例行 */ + public function test_export_template_mode(): void + { + $this->actingAsSysUser(); + Excel::fake(); + $this->get('/product/goods/export?template=1')->assertOk(); + + Excel::assertDownloaded( + '商品导入模板.xlsx', + static function (ProductExport $export): bool { + $rows = $export->collection()->values(); + return $rows->count() === 2 + && $rows[0] === ProductExport::HEADERS + && $rows[1][0] === '西红柿'; + } + ); + } + + /** 无 product.goods.export 权限点 → 拦截 */ + public function test_export_requires_permission(): void + { + // 先建占位用户:每个测试方法内首个系统用户自增 id=1,超管旁路会绕过 abilities 校验 + $this->actingAsSysUser(); + $this->actingAsSysUser(['product.goods.query']); + + $response = $this->get('/product/goods/export'); + $this->assertFalse($response->json('success'), '缺少导出权限点应被拦截'); + } + + // ==================== 导入 ==================== + + /** 正常导入:新增商品 + 供应商自动创建 + 默认值(单位斤/状态上架) */ + public function test_import_creates_products_and_auto_supplier(): void + { + $leaf = $this->makeLeafCategory(); + + $this->actingAsSysUser(); + $file = $this->makeUploadFile([ + ProductExport::HEADERS, + ['上海青', '叶菜类', '新供应商A', '10斤/箱', '', 2.5, 3, 100, 2, '下架', '备注信息'], + ['西红柿', '蔬菜/茄果类', '', '散装', '箱', 1.8, '', '', '', '', ''], + ]); + // 第二行用到了不存在的「茄果类」路径 → 先补建该分类 + ProductCategoryModel::create(['name' => '茄果类', 'parent_id' => $leaf->parent_id, 'sort' => 1, 'status' => 1]); + + $response = $this->post('/product/goods/import', ['file' => $file]); + $response->assertJsonPath('success', true); + $response->assertJsonPath('data.created', 2); + + $productA = ProductModel::where('name', '上海青')->first(); + $this->assertNotNull($productA); + $this->assertSame($leaf->id, $productA->category_id); + $this->assertSame('斤', $productA->unit, '单位留空应默认斤'); + $this->assertSame(ProductModel::STATUS_OFF, $productA->status); + $this->assertSame('2.50', (string) $productA->cost_price); + + $supplier = SupplierModel::where('name', '新供应商A')->first(); + $this->assertNotNull($supplier, '供应商不存在应自动创建'); + $this->assertSame($supplier->id, $productA->supplier_id); + + $productB = ProductModel::where('name', '西红柿')->first(); + $this->assertNotNull($productB); + $this->assertSame(0, $productB->supplier_id, '供应商留空应为 0(不创建)'); + $this->assertSame(ProductModel::STATUS_ON, $productB->status, '状态留空应默认上架'); + $this->assertSame(0, $productB->sort); + } + + /** 供应商名称已存在 → 直接匹配,不重复创建 */ + public function test_import_matches_existing_supplier(): void + { + $leaf = $this->makeLeafCategory(); + $existing = SupplierModel::create(['name' => '老供应商']); + + $this->actingAsSysUser(); + $file = $this->makeUploadFile([ + ProductExport::HEADERS, + ['黄瓜', '叶菜类', '老供应商', '散装', '斤', 1.2, 0, 0, 0, '上架', ''], + ]); + $this->post('/product/goods/import', ['file' => $file])->assertJsonPath('success', true); + + $this->assertSame(1, SupplierModel::where('name', '老供应商')->count(), '已存在供应商不应重复创建'); + $this->assertSame($existing->id, ProductModel::where('name', '黄瓜')->value('supplier_id')); + } + + /** 错误行(缺品名/分类不存在/成本价非数字)→ 全部不导入,供应商也不创建 */ + public function test_import_errors_abort_all_rows(): void + { + $this->makeLeafCategory(); + + $this->actingAsSysUser(); + $file = $this->makeUploadFile([ + ProductExport::HEADERS, + ['正常商品', '叶菜类', '供应商X', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], + ['', '叶菜类', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], + ['坏商品A', '不存在的分类', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], + ['坏商品B', '叶菜类', '', '散装', '斤', 'abc', 0, 0, 0, '上架', ''], + ]); + $response = $this->post('/product/goods/import', ['file' => $file]); + + $response->assertJsonPath('success', false); + $errors = $response->json('data.errors'); + $this->assertCount(3, $errors); + $this->assertSame(3, $errors[0]['row']); + $this->assertStringContainsString('品名', $errors[0]['message']); + $this->assertSame(4, $errors[1]['row']); + $this->assertStringContainsString('分类', $errors[1]['message']); + $this->assertSame(5, $errors[2]['row']); + $this->assertStringContainsString('成本价', $errors[2]['message']); + + $this->assertSame(0, ProductModel::count(), '有错误行时整表不应写入'); + $this->assertSame(0, SupplierModel::where('name', '供应商X')->count(), '有错误行时供应商不应创建'); + } + + /** 分类解析:路径消歧命中;单名称歧义/非末级 → 报错 */ + public function test_import_category_path_resolution(): void + { + $vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); + $fruitRoot = ProductCategoryModel::create(['name' => '水果', 'parent_id' => 0, 'sort' => 1, 'status' => 1]); + $vegLeaf = ProductCategoryModel::create(['name' => '叶菜', 'parent_id' => $vegRoot->id, 'sort' => 0, 'status' => 1]); + ProductCategoryModel::create(['name' => '叶菜', 'parent_id' => $fruitRoot->id, 'sort' => 0, 'status' => 1]); + + $this->actingAsSysUser(); + // 路径写法命中蔬菜/叶菜 + $file = $this->makeUploadFile([ + ProductExport::HEADERS, + ['菠菜', '蔬菜/叶菜', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], + ]); + $this->post('/product/goods/import', ['file' => $file])->assertJsonPath('success', true); + $this->assertSame($vegLeaf->id, ProductModel::where('name', '菠菜')->value('category_id')); + + // 单名称歧义 + 非末级分类 → 报错零写入 + $file = $this->makeUploadFile([ + ProductExport::HEADERS, + ['歧义商品', '叶菜', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], + ['非末级商品', '蔬菜', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], + ]); + $response = $this->post('/product/goods/import', ['file' => $file]); + $response->assertJsonPath('success', false); + $this->assertCount(2, $response->json('data.errors')); + $this->assertSame(1, ProductModel::count(), '本次应零写入(仅剩上一条菠菜)'); + } + + /** 列头与模板不一致 → 拒绝导入 */ + public function test_import_rejects_invalid_header(): void + { + $this->actingAsSysUser(); + $file = $this->makeUploadFile([ + ['名称', '类别', '供应商'], + ['上海青', '叶菜类', ''], + ]); + $response = $this->post('/product/goods/import', ['file' => $file]); + $response->assertJsonPath('success', false); + $this->assertSame(1, $response->json('data.errors.0.row')); + $this->assertSame(0, ProductModel::count()); + } + + /** 无 product.goods.import 权限点 → 拦截 */ + public function test_import_requires_permission(): void + { + $this->actingAsSysUser(); + $this->actingAsSysUser(['product.goods.query']); + + $file = $this->makeUploadFile([ + ProductExport::HEADERS, + ['上海青', '叶菜类', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], + ]); + $response = $this->post('/product/goods/import', ['file' => $file]); + $this->assertFalse($response->json('success'), '缺少导入权限点应被拦截'); + $this->assertSame(0, ProductModel::count()); + } +} diff --git a/web/api/product/goods.ts b/web/api/product/goods.ts index 28a4801..7d62030 100644 --- a/web/api/product/goods.ts +++ b/web/api/product/goods.ts @@ -1,4 +1,5 @@ import createAxios from '@/utils/request'; +import { downloadBlob } from '@/api/common/download.ts'; import type IProduct from '@/domain/iProduct.ts'; import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts'; @@ -9,6 +10,47 @@ export interface PriceMatrixParams { pageSize?: number; } +/** 导入校验错误行(Excel 行号,1 起) */ +export interface IImportError { + row: number; + message: string; +} + +/** 商品下拉选项(仅上架) */ +export async function getProductOptions(keyword?: string) { + return createAxios({ + url: '/product/goods/options', + method: 'get', + params: keyword ? { keyword } : {}, + }); +} + +/** 商品列表导出(列格式与导入模板一致,导出文件修改后可直接重新导入) */ +export async function exportProducts(params: { category_id?: number } = {}) { + return downloadBlob('/product/goods/export', params, '商品列表.xlsx'); +} + +/** 下载商品导入模板(列头 + 示例行) */ +export async function downloadProductTemplate() { + return downloadBlob('/product/goods/export', { template: 1 }, '商品导入模板.xlsx'); +} + +/** + * Excel 批量导入商品(整表校验,有错全部不导入) + * 失败时 promise reject,错误行明细在 err.data.data.errors + */ +export async function importProducts(file: File) { + const formData = new FormData(); + formData.append('file', file); + return createAxios<{ created: number }>({ + url: '/product/goods/import', + method: 'post', + data: formData, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000, + }); +} + /** * A2 价格矩阵:行=商品,列=启用等级,值=按等级上浮比例换算的售价(成本价未设置 null); * 仅成本价可编辑 @@ -31,12 +73,3 @@ export async function batchPrice(updates: IBatchPriceUpdate[]) { data: { updates }, }); } - -/** 商品下拉选项(仅上架) */ -export async function getProductOptions(keyword?: string) { - return createAxios({ - url: '/product/goods/options', - method: 'get', - params: keyword ? { keyword } : {}, - }); -} diff --git a/web/pages/product/goods.tsx b/web/pages/product/goods.tsx index b21d807..2feff58 100644 --- a/web/pages/product/goods.tsx +++ b/web/pages/product/goods.tsx @@ -1,20 +1,24 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { + Alert, Button, Card, Drawer, Image, Input, InputNumber, message, + Modal, Space, Table, Tag, Tree, TreeSelect, Typography, + Upload, } from 'antd'; -import { TableOutlined } from '@ant-design/icons'; +import { DownloadOutlined, InboxOutlined, TableOutlined, UploadOutlined } from '@ant-design/icons'; import type { TableProps } from 'antd'; import XinTable from '@/components/XinTable'; +import AuthButton from '@/components/AuthButton'; import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts'; import type IProduct from '@/domain/iProduct.ts'; import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts'; @@ -25,6 +29,8 @@ import { getSupplierOptions } from '@/api/customer/supplier.ts'; import type ISupplier from '@/domain/iSupplier.ts'; import { getCategoryTree } from '@/api/product/category.ts'; import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts'; +import type { IImportError } from '@/api/product/goods.ts'; +import { downloadProductTemplate, exportProducts, importProducts } from '@/api/product/goods.ts'; const { Title, Text } = Typography; @@ -80,6 +86,45 @@ const ProductGoodsPage: React.FC = () => { /** 跨页未保存的成本价:productId → cost(null 视为未修改,不提交) */ const costDirtyRef = useRef>({}); + // ===== Excel 导入/导出 ===== + const [exportLoading, setExportLoading] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [importFile, setImportFile] = useState(null); + const [importing, setImporting] = useState(false); + const [importErrors, setImportErrors] = useState([]); + + /** 导出商品列表(跟随侧栏分类筛选;列格式与导入模板一致) */ + const handleExport = async () => { + setExportLoading(true); + try { + await exportProducts(activeCategory ? { category_id: activeCategory } : {}); + } finally { + setExportLoading(false); + } + }; + + /** 提交导入:成功刷新列表;校验失败展示错误行明细(错误提示由拦截器统一弹出) */ + const handleImport = async () => { + if (!importFile) { + return; + } + setImporting(true); + setImportErrors([]); + try { + await importProducts(importFile); + setImportOpen(false); + setImportFile(null); + tableRef.current?.reset(); + } catch (err: any) { + const errors = err?.data?.data?.errors; + if (Array.isArray(errors)) { + setImportErrors(errors); + } + } finally { + setImporting(false); + } + }; + useEffect(() => { getSupplierOptions().then((res) => setSuppliers(res.data.data ?? [])); getCategoryTree().then((res) => setCategoryTree(res.data.data ?? [])); @@ -435,6 +480,16 @@ const ProductGoodsPage: React.FC = () => { , + + + , + + + , dom.keywordSearch, ], formProps: { @@ -484,6 +539,63 @@ const ProductGoodsPage: React.FC = () => { + setImportOpen(false)} + onOk={handleImport} + okText="开始导入" + okButtonProps={{ disabled: !importFile }} + confirmLoading={importing} + destroyOnHidden + width={560} + > +
+ + 列格式:品名*、分类*(末级分类,支持「父分类/子分类」路径)、供应商(不存在自动创建)、 + 规格/包规、单位(默认斤)、成本价、排序、库存、保质期、状态(上架/下架)、备注。 + 导入一律新增商品;整表校验,任何一行有错则全部不导入。 + + +
+ { + setImportFile(file); + setImportErrors([]); + return false; + }} + onRemove={() => { + setImportFile(null); + setImportErrors([]); + }} + > +

+ +

+

点击或拖拽 Excel 文件到此区域

+

仅支持 .xlsx / .xls,单次最多 1000 行

+
+ {importErrors.length > 0 && ( + + {importErrors.map((item, index) => ( +
  • 第 {item.row} 行:{item.message}
  • + ))} + + } + /> + )} +
    +