diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 50cfe46..b3fe115 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":7,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":8,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":8,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":7,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":7,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":5,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":7,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":7,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":8,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":8,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":7,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":8,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":8,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":8,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":8,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":8,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":8,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":8,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":8,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":7,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":7,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":7,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":7,"Tests\\Feature\\ExportTest::test_export_pdf":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":7,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":7,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":7,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":7,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":7,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":7,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":7,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":8,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":7},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.003,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.008,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.011,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.007,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.01,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.007,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.019,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.01,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.009,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.018,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.02,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.076,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.022,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.028,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.021,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.019,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.022,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.019,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.02,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.036,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.011,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.013,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.016,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.056,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.003,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.005,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.001,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.005,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.003,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.066,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.02,"Tests\\Feature\\ExportTest::test_export_pdf":0.015,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.015,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.003,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.045,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.009,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.006,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.006,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.006,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.01,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.006,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.01,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.011,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.005,"Tests\\Feature\\CartTest::test_update_quantity":0.008,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.008,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.006,"Tests\\Feature\\CartTest::test_delete_item":0.007,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.008,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.013,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.009,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_fixed_and_percent":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_edge_cases":0.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.008,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.006,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.009,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.009,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.011,"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.008,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.008,"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.004,"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.008,"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.002,"Tests\\Feature\\MiniStoreTest::test_info_returns_bound_store":0.005,"Tests\\Feature\\MiniStoreTest::test_update_info":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.006,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.005,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":0.012,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":0.022,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":0.019,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":0.02,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":0.014,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_accepted_orders_fails":0.014,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.021,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.008,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.01,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.014,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":0.012,"Tests\\Feature\\StoreOrderItemTest::test_update_item_rejected_when_completed_or_cancelled":0.011,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":0.01,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":0.011,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":0.012,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":0.01,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":0.016,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.021,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.042,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.013,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.021,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.019,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.02,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.019,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.026,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.021,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.021,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.018,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.02,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.017,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.02,"Tests\\Feature\\ExportTest::test_export_invalid_type_rejected":0.017,"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.044,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.008,"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.006,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.009,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.007,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.059,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.008,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.008,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.012,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.009,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.01,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.014,"Tests\\Feature\\StoreOrderTest::test_order_list_filters_by_order_date_range":0.02,"Tests\\Feature\\StoreOrderTest::test_order_list_rejects_invalid_filters":0.009}} \ 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":7,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":7,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":7,"Tests\\Feature\\ExportTest::test_export_pdf":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":7,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":7,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":7,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":7,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":7,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":7,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":7,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":8,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":7,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":8,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":8,"Tests\\Feature\\DebugPermissionTest::test_debug_permission":8},"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.006,"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.007,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.008,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.011,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.01,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.012,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.02,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.076,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.022,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.028,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.021,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.019,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.022,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.019,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.02,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.036,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.011,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.013,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.016,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.056,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.002,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.005,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.001,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.007,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.006,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.054,"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.017,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.004,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.009,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.008,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.006,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.006,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.006,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.007,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.006,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.011,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.01,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.005,"Tests\\Feature\\CartTest::test_update_quantity":0.008,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.01,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.008,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.006,"Tests\\Feature\\CartTest::test_delete_item":0.01,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.013,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.009,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_fixed_and_percent":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_edge_cases":0.001,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.006,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.01,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.009,"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.008,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.011,"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.007,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.008,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.016,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.023,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.003,"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.006,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.005,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.007,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.006,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":0.012,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":0.022,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":0.019,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":0.02,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":0.014,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_accepted_orders_fails":0.012,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.023,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.008,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.01,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.012,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":0.015,"Tests\\Feature\\StoreOrderItemTest::test_update_item_rejected_when_completed_or_cancelled":0.014,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":0.02,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":0.012,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":0.011,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":0.013,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":0.017,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.019,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.042,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.013,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.023,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.019,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.019,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.022,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.019,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.016,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_product_attributes":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_row_syncs_to_product_catalog":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_returns_items_with_order_info":0.019,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.02,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.02,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.019,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.02,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.022,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.021,"Tests\\Feature\\ExportTest::test_export_invalid_type_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_missing_purchase_rejected":0.007,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_container_summary":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_container_syncs_order_amounts":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejected_when_purchase_completed":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejects_incomplete_or_foreign_orders":0.026,"Tests\\Feature\\PurchaseEditTest::test_update_container_validates_negative_and_duplicate_orders":0.018,"Tests\\Feature\\ProductCategoryTest::test_create_child_category_under_top_level":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_update_second_level_move_to_another_top":0.006,"Tests\\Feature\\ProductCategoryTest::test_update_parent_with_children_cannot_move_under_category":0.007,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.007,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.005,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.047,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_payable_filter":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_filters_by_bill_date_range":0.012,"Tests\\Feature\\MiniBillTest::test_bill_data_isolated_between_stores":0.009,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.01,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.013,"Tests\\Feature\\StoreOrderTest::test_order_list_filters_by_order_date_range":0.019,"Tests\\Feature\\StoreOrderTest::test_order_list_rejects_invalid_filters":0.008,"Tests\\Feature\\BillExportTest::test_export_merges_items_across_bills":0.069,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.012,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.04,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.009,"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.016,"Tests\\Feature\\BillExportTest::test_mini_export_requires_store_binding":0.005}} \ No newline at end of file diff --git a/app/Exports/BillExport.php b/app/Exports/BillExport.php new file mode 100644 index 0000000..8c5701f --- /dev/null +++ b/app/Exports/BillExport.php @@ -0,0 +1,226 @@ + 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */ + private array $specialRows = []; + + /** @var array 标签需左合并 A:G 的合计行索引 */ + private array $mergeRows = []; + + /** 列头所在行索引 */ + private int $headerRow = 1; + + /** + * @param Collection $bills 勾选账单(已按 bill_date/id 排序,with store) + * @param int $categoryId 一级分类ID(0=全部) + */ + public function __construct( + private readonly Collection $bills, + private readonly int $categoryId = 0, + ) { + } + + /** + * 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings) + */ + public function collection(): Collection + { + if ($this->rows !== null) { + return $this->rows; + } + + $items = app(BillDetailService::class)->mergedItemsOfBills( + $this->bills->pluck('id')->map(static fn ($id): int => (int) $id)->all() + ); + + $categoryName = '全部'; + if ($this->categoryId > 0) { + [$items, $categoryName] = $this->filterByRootCategory($items); + } + + $rows = []; + $rowIndex = 0; + + // 标题行 + $rows[] = ['门店账单合并导出(共 ' . $this->bills->count() . ' 张)']; + $this->specialRows[++$rowIndex] = 'title'; + + // 范围行:账单号 / 门店 / 分类 / 导出时间 + $billNos = $this->bills->pluck('bill_no')->all(); + $billNoText = count($billNos) > 8 + ? implode('、', array_slice($billNos, 0, 8)) . ' 等 ' . count($billNos) . ' 张' + : implode('、', $billNos); + $storeNames = $this->bills + ->map(static fn (BillModel $bill): string => $bill->store->name ?? ('门店#' . $bill->store_id)) + ->unique() + ->values() + ->all(); + $rows[] = ['账单号:' . $billNoText]; + $this->specialRows[++$rowIndex] = 'scope'; + $rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now()->format('Y-m-d H:i:s')]; + $this->specialRows[++$rowIndex] = 'scope'; + + // 空行 + $rows[] = ['']; + $rowIndex++; + + // 列头 + $rows[] = ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '金额']; + $this->specialRows[++$rowIndex] = 'header'; + $this->headerRow = $rowIndex; + + // 明细行(跨账单同商品已合并,单价为加权平均) + $totalQuantity = 0; + $totalWeight = '0'; + $totalAmount = '0'; + foreach (array_values($items) as $sort => $item) { + $rows[] = [ + $sort + 1, + $item['product_name'], + $item['product_spec'], + $item['unit'], + (float) $item['price'], + (int) $item['quantity'], + (float) $item['weight'], + (float) $item['amount'], + ]; + $rowIndex++; + $totalQuantity += (int) $item['quantity']; + $totalWeight = bcadd($totalWeight, $item['weight'], 3); + $totalAmount = bcadd($totalAmount, $item['amount'], 2); + } + + // 商品合计行 + $rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount]; + $this->specialRows[++$rowIndex] = 'summary'; + + // 配送费/附加金额/总金额(账单级费用全额汇总,不受分类过滤影响) + $deliveryTotal = '0'; + $addedTotal = '0'; + $boxNum = 0; + $trayNum = 0; + foreach ($this->bills as $bill) { + $deliveryTotal = bcadd($deliveryTotal, (string) $bill->delivery_fee, 2); + $addedTotal = bcadd($addedTotal, (string) $bill->added_amount, 2); + $boxNum += (int) $bill->box_num; + $trayNum += (int) $bill->tray_num; + } + $grandTotal = bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2); + + $rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal]; + $this->specialRows[++$rowIndex] = 'summary'; + $this->mergeRows[] = $rowIndex; + + $rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal]; + $this->specialRows[++$rowIndex] = 'summary'; + $this->mergeRows[] = $rowIndex; + + $rows[] = ['总金额(商品金额+配送费+附加金额)', '', '', '', '', '', '', (float) $grandTotal]; + $this->specialRows[++$rowIndex] = 'grand'; + $this->mergeRows[] = $rowIndex; + + return $this->rows = collect($rows); + } + + /** + * 标题/列头/合计行加粗,合计行标签左合并 A:G,冻结列头 + */ + public function styles(Worksheet $sheet): array + { + $this->collection(); + + $sheet->freezePane('A' . ($this->headerRow + 1)); + $sheet->getColumnDimension('A')->setWidth(6); + $sheet->getColumnDimension('B')->setWidth(24); + $sheet->getColumnDimension('C')->setWidth(14); + $sheet->getColumnDimension('D')->setWidth(8); + $sheet->getColumnDimension('E')->setWidth(10); + $sheet->getColumnDimension('F')->setWidth(10); + $sheet->getColumnDimension('G')->setWidth(12); + $sheet->getColumnDimension('H')->setWidth(12); + + foreach ($this->mergeRows as $row) { + $sheet->mergeCells('A' . $row . ':G' . $row); + } + + $styles = []; + foreach ($this->specialRows as $row => $type) { + $style = match ($type) { + 'title' => ['font' => ['bold' => true, 'size' => 14]], + 'header', 'summary' => ['font' => ['bold' => true]], + 'grand' => ['font' => ['bold' => true, 'size' => 12]], + default => [], + }; + if ($style !== []) { + $styles[$row] = $style; + } + } + + return $styles; + } + + /** + * 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出), + * 仅保留根分类为所选分类的行 + * + * @param array $items 合并后的商品明细 + * @return array{0: array, 1: string} [过滤后明细, 一级分类名] + */ + private function filterByRootCategory(array $items): array + { + $categories = ProductCategoryModel::all()->keyBy('id'); + $categoryName = $categories->get($this->categoryId)->name ?? ('分类#' . $this->categoryId); + + $productCategoryIds = ProductModel::withTrashed() + ->whereIn('id', array_column($items, 'product_id')) + ->pluck('category_id', 'id'); + + // 商品ID => 根分类ID(与采购单导出同口径的上溯解析) + $rootIds = []; + foreach ($productCategoryIds as $productId => $categoryId) { + $rootId = 0; + $cursor = (int) $categoryId; + $guard = 0; + while ($cursor > 0 && $guard++ < 20) { + $category = $categories->get($cursor); + if ($category === null) { + break; + } + $rootId = (int) $category->id; + $cursor = (int) $category->parent_id; + } + $rootIds[(int) $productId] = $rootId; + } + + $categoryId = $this->categoryId; + $filtered = array_values(array_filter( + $items, + static fn (array $row): bool => ($rootIds[$row['product_id']] ?? 0) === $categoryId + )); + + if ($filtered === []) { + throw new RepositoryException('所选账单在「' . $categoryName . '」分类下无商品明细'); + } + + return [$filtered, $categoryName]; + } +} diff --git a/app/Http/Controllers/Mini/BillController.php b/app/Http/Controllers/Mini/BillController.php index 45679d9..4093434 100644 --- a/app/Http/Controllers/Mini/BillController.php +++ b/app/Http/Controllers/Mini/BillController.php @@ -3,12 +3,15 @@ namespace App\Http\Controllers\Mini; use App\Exceptions\RepositoryException; +use App\Exports\BillExport; use App\Models\BillModel; use App\Services\BillDetailService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Maatwebsite\Excel\Facades\Excel; use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; +use Symfony\Component\HttpFoundation\Response; /** * 小程序门店账单(采购单完成后由后台生成,门店端只读) @@ -84,6 +87,46 @@ class BillController extends BaseMiniController return $this->success($data); } + /** + * 合并导出账单(静态路由放在 /bill/{id} 之前声明) + * 仅可导出本店账单(强制 store_id 过滤,张数不匹配说明混入无效账单整批拒绝); + * ?ids=1,2,3 必选(1~100 张);?category_id= 一级分类过滤(0/缺省=全部) + */ + #[GetRoute('/bill/export', authorize: true)] + public function export(Request $request): Response + { + $data = $request->validate([ + 'ids' => 'required|string', + 'category_id' => 'nullable|integer|min:0', + ]); + + $user = $this->currentUser($request); + $store = $this->ensureStoreBound($user); + + $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string) $data['ids']))))); + if ($ids === [] || count($ids) > 100) { + throw new RepositoryException('请选择 1~100 张账单'); + } + + $bills = BillModel::with('store:id,name') + ->where('store_id', $store->id) + ->whereIn('id', $ids) + ->orderBy('bill_date') + ->orderBy('id') + ->get(); + if ($bills->isEmpty()) { + throw new RepositoryException('账单不存在'); + } + if ($bills->count() !== count($ids)) { + throw new RepositoryException('存在无效账单,请刷新后重试'); + } + + return Excel::download( + new BillExport($bills, (int) ($data['category_id'] ?? 0)), + '门店账单_' . now()->format('Ymd_His') . '.xlsx' + ); + } + /** 账单详情(校验归属:仅能查看本店账单;含合并后的商品明细与关联订单) */ #[GetRoute('/bill/{id}', authorize: true, where: ['id' => '[0-9]+'])] public function detail(int $id, Request $request): JsonResponse diff --git a/app/Http/Controllers/Recon/BillController.php b/app/Http/Controllers/Recon/BillController.php index c39a19f..545ec7e 100644 --- a/app/Http/Controllers/Recon/BillController.php +++ b/app/Http/Controllers/Recon/BillController.php @@ -3,14 +3,17 @@ namespace App\Http\Controllers\Recon; use App\Exceptions\RepositoryException; +use App\Exports\BillExport; use App\Models\BillModel; use App\Services\BillDetailService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Maatwebsite\Excel\Facades\Excel; use Modules\AnnoRoute\Attribute\GetRoute; use Modules\AnnoRoute\Attribute\PutRoute; use Modules\AnnoRoute\Attribute\RequestAttribute; use Modules\Common\Http\Controllers\BaseController; +use Symfony\Component\HttpFoundation\Response; /** * 门店账单管理(采购单完成后按门店生成,后台查看 + 线下收款登记) @@ -55,6 +58,38 @@ class BillController extends BaseController return $this->success($data); } + /** + * 合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤), + * 表尾汇总商品金额/配送费/附加金额/总金额 + */ + #[GetRoute(route: '/export', authorize: 'export')] + public function export(Request $request): Response + { + $data = $request->validate([ + 'ids' => 'required|string', + 'category_id' => 'nullable|integer|min:0', + ]); + + $ids = array_values(array_filter(array_map('intval', explode(',', (string) $data['ids'])))); + if ($ids === [] || count($ids) > 100) { + throw new RepositoryException('请选择 1~100 张账单'); + } + + $bills = BillModel::with('store:id,name') + ->whereIn('id', $ids) + ->orderBy('bill_date') + ->orderBy('id') + ->get(); + if ($bills->isEmpty()) { + throw new RepositoryException('账单不存在'); + } + + return Excel::download( + new BillExport($bills, (int) ($data['category_id'] ?? 0)), + '门店账单_' . now()->format('Ymd_His') . '.xlsx' + ); + } + /** * 账单详情:账单信息 + 合并后的商品明细(按商品聚合)+ 关联订单 */ diff --git a/app/Services/BillDetailService.php b/app/Services/BillDetailService.php index f5fea04..f5d844b 100644 --- a/app/Services/BillDetailService.php +++ b/app/Services/BillDetailService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\BillModel; use App\Models\ProductModel; use App\Models\StoreOrderItemModel; +use Illuminate\Support\Collection; /** * 账单详情数据组装(后台门店账单页与小程序账单详情共用) @@ -24,6 +25,35 @@ class BillDetailService ->orderBy('id') ->get(); + return $this->aggregateItems($items); + } + + /** + * 合并后的商品明细(跨账单口径):按商品聚合多张账单的全部订单明细 + * 用于账单合并导出,单价同为加权平均口径 + * + * @param array $billIds 账单ID列表 + * @return array + */ + public function mergedItemsOfBills(array $billIds): array + { + $items = StoreOrderItemModel::query() + ->whereIn('bill_id', $billIds) + ->orderBy('id') + ->get(); + + return $this->aggregateItems($items); + } + + /** + * 按商品聚合订单明细:数量累加、重量/金额 bc 累加、单价加权平均, + * 按「分类sort → 商品sort」排序 + * + * @param Collection $items + * @return array + */ + private function aggregateItems(Collection $items): array + { // 排序键:分类 sort → 商品 sort(与采购单明细矩阵同序) $products = ProductModel::withTrashed() ->with('category:id,sort') diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index d48dbd6..e03d20c 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -213,6 +213,7 @@ class PermissionSeeder extends Seeder 'children' => [ ['type' => 'rule', 'key' => 'recon.bill.query', 'name' => '查询'], ['type' => 'rule', 'key' => 'recon.bill.pay', 'name' => '确认收款'], + ['type' => 'rule', 'key' => 'recon.bill.export', 'name' => '导出'], ], ], [ diff --git a/tests/Feature/BillExportTest.php b/tests/Feature/BillExportTest.php new file mode 100644 index 0000000..f0b04f3 --- /dev/null +++ b/tests/Feature/BillExportTest.php @@ -0,0 +1,269 @@ + 'ZD' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT), + 'purchase_id' => PurchaseOrderModel::factory()->create()->id, + 'store_id' => $store->id, + 'bill_date' => '2026-08-10', + 'product_amount' => '100.00', + 'delivery_fee' => '10.00', + 'box_num' => 2, + 'tray_num' => 1, + 'box_price' => '5.00', + 'tray_price' => '20.00', + 'added_amount' => '30.00', + 'total_amount' => '140.00', + 'status' => BillModel::STATUS_UNPAID, + ], $attributes)); + } + + /** 造一条账单明细(快照品名/包规/单位取自商品档案) */ + private function makeItem(BillModel $bill, ProductModel $product, int $quantity, string $price): StoreOrderItemModel + { + return StoreOrderItemModel::factory()->create([ + 'bill_id' => $bill->id, + 'store_id' => $bill->store_id, + 'product_id' => $product->id, + 'category_id' => $product->category_id, + 'product_name' => $product->name, + 'product_spec' => $product->spec, + 'unit' => $product->unit, + 'price' => $price, + 'quantity' => $quantity, + 'weight' => $quantity * 10, + 'amount' => bcmul($price, (string) $quantity, 2), + ]); + } + + /** 跨账单合并:同商品数量累加、单价加权平均,配送费/附加/总金额全额汇总 */ + public function test_export_merges_items_across_bills(): void + { + $this->freezeTime(); + + $root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); + $store = StoreModel::factory()->create(); + $productA = ProductModel::factory()->create(['category_id' => $root->id]); + $productB = ProductModel::factory()->create(['category_id' => $root->id]); + + $bill1 = $this->makeBill($store); + $bill2 = $this->makeBill($store); + // 同一商品跨账单:10×2.00 + 6×3.00 → 数量 16、金额 38.00、加权单价 2.37 + $this->makeItem($bill1, $productA, 10, '2.00'); + $this->makeItem($bill2, $productA, 6, '3.00'); + $this->makeItem($bill2, $productB, 4, '5.00'); + + $this->actingAsSysUser(); + Excel::fake(); + $this->get("/recon/bill/export?ids={$bill1->id},{$bill2->id}")->assertOk(); + + Excel::assertDownloaded( + '门店账单_' . now()->format('Ymd_His') . '.xlsx', + static function (BillExport $export) use ($productA, $productB): bool { + $rows = $export->collection()->values(); + // 标题1 + 范围2 + 空行1 + 列头1 + 明细2 + 合计4 = 11 行 + if ($rows->count() !== 11) { + return false; + } + + $details = $rows->slice(5, 2)->values(); + $rowA = $details->firstWhere(1, $productA->name); + $rowB = $details->firstWhere(1, $productB->name); + if ($rowA === null || $rowB === null) { + return false; + } + // 跨账单同商品合并:数量 16、加权单价 2.37、金额 38.00 + if ((float) $rowA[4] !== 2.37 || (int) $rowA[5] !== 16 || (float) $rowA[7] !== 38.0) { + return false; + } + if ((int) $rowB[5] !== 4 || (float) $rowB[7] !== 20.0) { + return false; + } + + $summary = $rows->slice(7)->values(); + // 合计:数量 20、商品金额 58.00;配送费 20.00;附加 60.00(筐 4 / 托盘 2);总金额 138.00 + if ((int) $summary[0][5] !== 20 || (float) $summary[0][7] !== 58.0) { + return false; + } + if ($summary[1][0] !== '配送费合计' || (float) $summary[1][7] !== 20.0) { + return false; + } + if (! str_contains((string) $summary[2][0], '筐 4 个 / 周转托盘 2 个') || (float) $summary[2][7] !== 60.0) { + return false; + } + + return (float) $summary[3][7] === 138.0; + } + ); + } + + /** 一级分类过滤:子分类商品归入根分类,其他分类商品忽略;费用行仍全额汇总 */ + public function test_export_filters_by_root_category(): void + { + $this->freezeTime(); + + $vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); + $vegChild = ProductCategoryModel::create(['name' => '叶菜类', 'parent_id' => $vegRoot->id, 'sort' => 0, 'status' => 1]); + $meatRoot = ProductCategoryModel::create(['name' => '肉禽蛋', 'parent_id' => 0, 'sort' => 1, 'status' => 1]); + + $store = StoreModel::factory()->create(); + $veg = ProductModel::factory()->create(['category_id' => $vegChild->id]); + $meat = ProductModel::factory()->create(['category_id' => $meatRoot->id]); + + $bill = $this->makeBill($store); + $this->makeItem($bill, $veg, 10, '2.00'); + $this->makeItem($bill, $meat, 5, '20.00'); + + $this->actingAsSysUser(); + Excel::fake(); + $this->get("/recon/bill/export?ids={$bill->id}&category_id={$vegRoot->id}")->assertOk(); + + Excel::assertDownloaded( + '门店账单_' . now()->format('Ymd_His') . '.xlsx', + static function (BillExport $export) use ($veg): bool { + $rows = $export->collection()->values(); + // 范围行标注所选分类 + if (! str_contains((string) $rows[2][0], '分类:蔬菜')) { + return false; + } + // 明细仅剩蔬菜(子分类归入根分类),肉禽被忽略 + $detail = $rows->slice(5, 1)->values()->first(); + if ($detail === null || $detail[1] !== $veg->name) { + return false; + } + + $summary = $rows->slice(6)->values(); + // 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 全额;总金额 60.00 + if ((float) $summary[0][7] !== 20.0) { + return false; + } + if ((float) $summary[1][7] !== 10.0 || (float) $summary[2][7] !== 30.0) { + return false; + } + + return (float) $summary[3][7] === 60.0; + } + ); + } + + /** 分类过滤后无明细 → 拒绝(真实下载链路,导出类抛业务异常) */ + public function test_export_empty_category_rejected(): void + { + $vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); + $meatRoot = ProductCategoryModel::create(['name' => '肉禽蛋', 'parent_id' => 0, 'sort' => 1, 'status' => 1]); + + $store = StoreModel::factory()->create(); + $meat = ProductModel::factory()->create(['category_id' => $meatRoot->id]); + + $bill = $this->makeBill($store); + $this->makeItem($bill, $meat, 5, '20.00'); + + $this->actingAsSysUser(); + $this->get("/recon/bill/export?ids={$bill->id}&category_id={$vegRoot->id}") + ->assertJsonPath('success', false); + } + + /** 无 recon.bill.export 权限点 → 拦截 */ + public function test_export_requires_export_permission(): void + { + $store = StoreModel::factory()->create(); + $bill = $this->makeBill($store); + + // 先建一个占位用户:每个测试方法内首个系统用户自增 id=1, + // SysAccessToken::can() 对 tokenable_id==1(超管)放行全部权限,会绕过 abilities 校验 + $this->actingAsSysUser(); + // 仅持有查询权限的用户 + $this->actingAsSysUser(['recon.bill.query']); + + $response = $this->get("/recon/bill/export?ids={$bill->id}"); + $this->assertFalse($response->json('success'), '缺少权限点应被拦截'); + } + + /** ids 缺失 / 超 100 张 / 账单不存在 → 拒绝 */ + public function test_export_invalid_ids_rejected(): void + { + $this->actingAsSysUser(); + + $this->get('/recon/bill/export') + ->assertJsonPath('success', false); + + $this->get('/recon/bill/export?ids=' . implode(',', range(1, 101))) + ->assertJsonPath('success', false); + + $this->get('/recon/bill/export?ids=99999') + ->assertJsonPath('success', false); + } + + /** 小程序端导出:强制本店过滤,混入他店账单整批拒绝 */ + public function test_mini_export_scoped_to_bound_store(): void + { + $this->freezeTime(); + + $root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); + $storeA = StoreModel::factory()->create(); + $storeB = StoreModel::factory()->create(); + $product = ProductModel::factory()->create(['category_id' => $root->id]); + + $billA = $this->makeBill($storeA); + $billB = $this->makeBill($storeB); + $this->makeItem($billA, $product, 10, '2.00'); + $this->makeItem($billB, $product, 5, '20.00'); + + $user = UserModel::factory()->forStore($storeA->id)->create(); + $this->actingAsMiniUser($user); + + // 本店账单正常导出 + Excel::fake(); + $this->get("/mini/bill/export?ids={$billA->id}")->assertOk(); + Excel::assertDownloaded( + '门店账单_' . now()->format('Ymd_His') . '.xlsx', + static function (BillExport $export): bool { + $rows = $export->collection()->values(); + // 明细仅本店账单的 1 行(他店账单未混入) + return $rows->count() === 10; + } + ); + + // 混入他店账单 → 张数不匹配整批拒绝 + $this->get("/mini/bill/export?ids={$billA->id},{$billB->id}") + ->assertJsonPath('success', false); + + // 仅他店账单 → 本店口径查无账单 + $this->get("/mini/bill/export?ids={$billB->id}") + ->assertJsonPath('success', false); + } + + /** 小程序端导出:未绑定门店 → 拒绝 */ + public function test_mini_export_requires_store_binding(): void + { + $user = UserModel::factory()->create(); + $this->actingAsMiniUser($user); + + $this->get('/mini/bill/export?ids=1') + ->assertJsonPath('success', false); + } +} diff --git a/web/api/recon/bill.ts b/web/api/recon/bill.ts index 0347054..485f238 100644 --- a/web/api/recon/bill.ts +++ b/web/api/recon/bill.ts @@ -1,4 +1,5 @@ import createAxios from '@/utils/request'; +import { downloadBlob } from '@/api/common/download.ts'; import type { IBillDetail } from '@/domain/iBill.ts'; /** 账单详情(账单信息 + 合并商品明细 + 关联订单) */ @@ -17,3 +18,12 @@ export async function payBill(id: number, data: { paid_at: string; pay_remark?: data, }); } + +/** 合并导出账单:勾选账单跨账单按商品合并明细,可按一级分类过滤 */ +export async function exportBills(ids: number[], categoryId: number) { + return downloadBlob( + '/recon/bill/export', + { ids: ids.join(','), category_id: categoryId }, + '门店账单.xlsx' + ); +} diff --git a/web/pages/recon/bill.tsx b/web/pages/recon/bill.tsx index 9dac626..95e0a2c 100644 --- a/web/pages/recon/bill.tsx +++ b/web/pages/recon/bill.tsx @@ -8,12 +8,13 @@ import { Input, message, Modal, + Select, Table, Tag, Typography, } from 'antd'; import type { TableProps } from 'antd'; -import { UnorderedListOutlined } from '@ant-design/icons'; +import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import XinTable from '@/components/XinTable'; import type { @@ -24,8 +25,9 @@ import type { import type { IBill, IBillDetail, IBillGoodsItem, IBillOrder } from '@/domain/iBill.ts'; import { BILL_STATUS_MAP } from '@/domain/iBill.ts'; import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts'; -import { getBillDetail, payBill } from '@/api/recon/bill.ts'; +import { exportBills, getBillDetail, payBill } from '@/api/recon/bill.ts'; import { getStoreOptions } from '@/api/customer/store.ts'; +import { getCategoryTree } from '@/api/product/category.ts'; import type IStore from '@/domain/iStore.ts'; import AuthButton from '@/components/AuthButton'; @@ -53,8 +55,20 @@ const BillPage: React.FC = () => { const [paySaving, setPaySaving] = useState(false); const [payForm] = Form.useForm(); + // 合并导出(勾选账单跨账单按商品合并明细,可按一级分类过滤) + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [exportOpen, setExportOpen] = useState(false); + const [exporting, setExporting] = useState(false); + const [categoryId, setCategoryId] = useState(0); + const [topCategories, setTopCategories] = useState<{ value: number; label: string }[]>([]); + useEffect(() => { getStoreOptions().then((res) => setStores(res.data.data ?? [])); + getCategoryTree().then((res) => + setTopCategories( + (res.data.data ?? []).map((c) => ({ value: c.id!, label: c.name ?? '' })), + ), + ); }, []); const openDetail = async (id: number) => { @@ -93,6 +107,20 @@ const BillPage: React.FC = () => { } }; + /** 提交合并导出:下载失败时 downloadBlob 内部已提示,保留勾选便于重试 */ + const handleExport = async () => { + if (selectedRowKeys.length === 0) { + return; + } + setExporting(true); + try { + await exportBills(selectedRowKeys, categoryId); + setExportOpen(false); + } finally { + setExporting(false); + } + }; + /** 合并商品明细列:品名/包规/单位/单价(加权平均)/数量/重量/金额 */ const itemColumns: TableProps['columns'] = [ { title: '品名', dataIndex: 'product_name', width: 160, align: 'center' }, @@ -306,6 +334,26 @@ const BillPage: React.FC = () => { operateRender, formProps: false, actionBarRender: (dom) => [dom.search, dom.keywordSearch], + rowSelection: { + selectedRowKeys, + onChange: (keys) => setSelectedRowKeys(keys.map(Number)), + preserveSelectedRowKeys: true, + }, + toolBarRender: (dom) => [ + + + , + dom.columnSetting, + dom.hideBorder, + dom.reload, + dom.columnHeight, + ], }; return ( @@ -313,7 +361,8 @@ const BillPage: React.FC = () => {
门店账单 - 采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘)。 + 采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘); + 勾选多张账单可合并导出为一个 Excel(明细跨账单按商品合并,可按一级分类过滤)。
{...tableProps} /> @@ -415,6 +464,32 @@ const BillPage: React.FC = () => { + + {/* 合并导出:勾选账单跨账单按商品合并明细,可按一级分类过滤 */} + setExportOpen(false)} + onOk={handleExport} + confirmLoading={exporting} + okText="导出" + destroyOnHidden + > +
+ 已选 {selectedRowKeys.length} 张账单,导出为一个 + Excel:商品明细跨账单按商品合并,表尾汇总配送费、附加金额与总金额; + 选择分类后仅导出该一级分类下的商品明细(配送费/附加金额仍全额汇总)。 +
+
+ +