From 3dbb9205a40e3a970338c810126fa364a03f8f2a Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Mon, 17 Aug 2026 18:13:33 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=87=E8=B4=AD=E5=8D=95=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .phpunit.result.cache | 2 +- app/Exports/PurchaseOrderExport.php | 289 ++++++---- app/Exports/PurchaseStoreExport.php | 65 +++ app/Exports/PurchaseStoreSheet.php | 123 +++++ app/Exports/PurchaseSupplierExport.php | 66 +++ app/Exports/PurchaseSupplierSheet.php | 123 +++++ app/Exports/SheetName.php | 26 + .../Purchase/PurchaseOrderController.php | 388 +++++++++---- .../Purchase/PurchaseStoreItemRequest.php | 45 ++ app/Services/PurchaseItemService.php | 187 +++++++ tests/Feature/ExportTest.php | 260 +++++++++ tests/Feature/PurchaseEditTest.php | 283 ++++++---- tests/Feature/StoreOrderItemTest.php | 194 +++---- web/api/purchase/order.ts | 57 +- web/domain/iPurchaseOrder.ts | 33 ++ web/pages/purchase/order.tsx | 511 +++++++++++++++++- 16 files changed, 2215 insertions(+), 437 deletions(-) create mode 100644 app/Exports/PurchaseStoreExport.php create mode 100644 app/Exports/PurchaseStoreSheet.php create mode 100644 app/Exports/PurchaseSupplierExport.php create mode 100644 app/Exports/PurchaseSupplierSheet.php create mode 100644 app/Exports/SheetName.php create mode 100644 app/Http/Requests/Purchase/PurchaseStoreItemRequest.php create mode 100644 app/Services/PurchaseItemService.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 354cfb9..90c26c5 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},"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.01,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.01,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.007,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.009,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"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.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.005,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.003,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.037,"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.016,"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.007,"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.006,"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.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.009,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.006,"Tests\\Feature\\CartTest::test_delete_item":0.008,"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.005,"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.005,"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.01,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.01,"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.005,"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.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.012,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.022,"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.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.016,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.021,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.041,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.012,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.023,"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.019,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.019,"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.02,"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.019,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.019,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.031,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.02,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.018,"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.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.008,"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.007,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_parent_category_rejected":0.008,"Tests\\Feature\\ProductCategoryTest::test_create_product_with_leaf_category_ok":0.008,"Tests\\Feature\\ProductCategoryTest::test_mini_categories_returns_all_enabled_categories":0.008,"Tests\\Feature\\MiniBillTest::test_bill_list_derives_pay_state_and_settlement_date":0.007,"Tests\\Feature\\MiniBillTest::test_bill_list_summary_aggregates_unpaid":0.007,"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.007,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.008,"Tests\\Feature\\StoreOrderTest::test_order_list_outputs_status_name_and_item_preview":0.012,"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.064,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.012,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.027,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.008,"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.013,"Tests\\Feature\\BillExportTest::test_mini_export_requires_store_binding":0.005,"Tests\\Feature\\MiniNoticeTest::test_notice_list_includes_personal_and_broadcast":0.006,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.009,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.006,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.006,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.02,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.026,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.02,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.022,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.027,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.012,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.01,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.004,"Tests\\Feature\\MiniProductTest::test_product_detail_store_user_sees_level_price":0.005,"Tests\\Feature\\MiniProductTest::test_product_detail_percent_price_calculated_from_cost":0.006,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.006,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.006,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.005,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.03,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.014,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.01,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.023,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.018,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.018,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.019,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.008,"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.01,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.007,"Tests\\Feature\\ContainerReturnTest::test_export_invalid_params_rejected":0.009,"Tests\\Feature\\ContainerReturnTest::test_export_empty_range_rejected":0.007,"Tests\\Feature\\ContainerReturnTest::test_export_requires_export_permission":0.007,"Tests\\Feature\\DashboardTest::test_guest_cannot_access_dashboard":0.002,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":0.006,"Tests\\Feature\\DashboardTest::test_empty_database_returns_zero_structure":0.009,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.011,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.013,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.01,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.012,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.012,"Tests\\Feature\\DashboardTest::test_top_stores_ranked_by_amount":0.011,"Tests\\Feature\\DashboardTest::test_purchase_amount_prefers_actual_over_estimate":0.013,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":0.011,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.012,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.011,"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.009,"Tests\\Feature\\ProductPriceTest::test_level_percent_update_notifies_level_stores":0.009,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.007,"Tests\\Feature\\ProductPriceTest::test_level_percent_validation":0.007,"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.01,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_level_price":0.008,"Tests\\Feature\\CartTest::test_store_level_missing_rejected":0.006,"Tests\\Feature\\StoreOrderTest::test_store_level_missing_rejected":0.006}} \ 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},"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.015,"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.01,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.007,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.007,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.015,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.01,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.015,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.02,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.033,"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.039,"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.016,"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.007,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.01,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.006,"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.005,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.011,"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.007,"Tests\\Feature\\CartTest::test_delete_item":0.007,"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.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.017,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.008,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.007,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.009,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.008,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.007,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.007,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.01,"Tests\\Feature\\PurchaseGenerateTest::test_generate_uses_min_actual_price_across_types":0.014,"Tests\\Feature\\ProductPriceTest::test_store_without_level_sees_products_without_price":0.007,"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.012,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.019,"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.005,"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.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.015,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.028,"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.017,"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.017,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.023,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.071,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.021,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.031,"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.027,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.032,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.021,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.026,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.03,"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.064,"Tests\\Feature\\PurchaseEditTest::test_cell_detail_rejects_missing_params":0.029,"Tests\\Feature\\PurchaseEditTest::test_update_cell_weight_cascades_totals":0.025,"Tests\\Feature\\PurchaseEditTest::test_cell_sync_refreshes_snapshot_and_cascades":0.012,"Tests\\Feature\\PurchaseEditTest::test_edits_rejected_when_purchase_completed":0.031,"Tests\\Feature\\PurchaseEditTest::test_update_row_does_not_touch_manual_weight":0.027,"Tests\\Feature\\PurchaseEditTest::test_store_summary_returns_aggregated_items":0.022,"Tests\\Feature\\PurchaseEditTest::test_store_summary_aggregates_multiple_orders_with_weighted_price":0.025,"Tests\\Feature\\PurchaseEditTest::test_store_summary_rejects_missing_or_invalid_params":0.024,"Tests\\Feature\\ExportTest::test_export_invalid_type_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_missing_purchase_rejected":0.006,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_container_summary":0.022,"Tests\\Feature\\PurchaseEditTest::test_update_container_syncs_order_amounts":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejected_when_purchase_completed":0.018,"Tests\\Feature\\PurchaseEditTest::test_update_container_rejects_incomplete_or_foreign_orders":0.026,"Tests\\Feature\\PurchaseEditTest::test_update_container_validates_negative_and_duplicate_orders":0.018,"Tests\\Feature\\ProductCategoryTest::test_create_child_category_under_top_level":0.007,"Tests\\Feature\\ProductCategoryTest::test_create_category_under_second_level_rejected":0.009,"Tests\\Feature\\ProductCategoryTest::test_update_second_level_move_to_another_top":0.007,"Tests\\Feature\\ProductCategoryTest::test_update_parent_with_children_cannot_move_under_category":0.007,"Tests\\Feature\\ProductCategoryTest::test_update_with_missing_parent_rejected":0.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.009,"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.008,"Tests\\Feature\\MiniBillTest::test_bill_detail_outputs_bill_items_and_orders":0.008,"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,"Tests\\Feature\\BillExportTest::test_export_merges_items_across_bills":0.057,"Tests\\Feature\\BillExportTest::test_export_filters_by_root_category":0.011,"Tests\\Feature\\BillExportTest::test_export_empty_category_rejected":0.028,"Tests\\Feature\\BillExportTest::test_export_requires_export_permission":0.01,"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.012,"Tests\\Feature\\BillExportTest::test_mini_export_requires_store_binding":0.005,"Tests\\Feature\\MiniNoticeTest::test_notice_list_includes_personal_and_broadcast":0.005,"Tests\\Feature\\MiniNoticeTest::test_broadcast_read_creates_personal_copy":0.008,"Tests\\Feature\\MiniNoticeTest::test_personal_notice_read_marks_directly":0.006,"Tests\\Feature\\MiniNoticeTest::test_other_users_notice_not_found":0.006,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_only_accept_and_cancel":0.022,"Tests\\Feature\\StoreOrderTest::test_manual_status_flow_rejects_delivery_and_completion":0.031,"Tests\\Feature\\StoreOrderTest::test_batch_status_only_accept_and_cancel":0.026,"Tests\\Feature\\BillPaymentTest::test_order_status_progresses_via_business_chain":0.037,"Tests\\Feature\\BillPaymentTest::test_manual_pay_accumulates_store_total_purchase_amount":0.026,"Tests\\Feature\\BillPaymentTest::test_payment_audit_pass_accumulates_store_total_purchase_amount":0.012,"Tests\\Feature\\BillPaymentTest::test_payment_audit_reject_does_not_accumulate":0.009,"Tests\\Feature\\MiniProductTest::test_product_detail_guest_sees_null_price":0.005,"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.006,"Tests\\Feature\\MiniProductTest::test_product_detail_off_shelf_or_missing_rejected":0.007,"Tests\\Feature\\MiniProductTest::test_product_list_store_user_sees_level_price":0.006,"Tests\\Feature\\MiniProductTest::test_product_list_guest_sees_null_price":0.007,"Tests\\Feature\\BillPaymentTest::test_admin_order_list_and_detail_show_bill_info":0.03,"Tests\\Feature\\BillPaymentTest::test_payment_detail_returns_voucher_urls":0.013,"Tests\\Feature\\StoreOrderTest::test_order_list_preview_includes_image_spec_and_unit":0.013,"Tests\\Feature\\ContainerReturnTest::test_press_containers_adds_amount_and_writes_record":0.021,"Tests\\Feature\\ContainerReturnTest::test_return_containers_deducts_amount":0.018,"Tests\\Feature\\ContainerReturnTest::test_mixed_sign_containers":0.019,"Tests\\Feature\\ContainerReturnTest::test_zero_containers_writes_no_record":0.018,"Tests\\Feature\\ContainerReturnTest::test_non_integer_containers_rejected":0.007,"Tests\\Feature\\ContainerReturnTest::test_manual_create_and_delete_routes_removed":0.01,"Tests\\Feature\\ContainerReturnTest::test_export_matrix_merges_same_day_and_zero_fills":0.009,"Tests\\Feature\\ContainerReturnTest::test_export_filters_by_selected_stores":0.008,"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.006,"Tests\\Feature\\DashboardTest::test_guest_cannot_access_dashboard":0.002,"Tests\\Feature\\DashboardTest::test_requires_dashboard_analysis_ability":0.007,"Tests\\Feature\\DashboardTest::test_empty_database_returns_zero_structure":0.009,"Tests\\Feature\\DashboardTest::test_overview_excludes_cancelled_orders":0.011,"Tests\\Feature\\DashboardTest::test_week_growth_rate":0.014,"Tests\\Feature\\DashboardTest::test_trend_covers_30_days_with_zero_filling":0.01,"Tests\\Feature\\DashboardTest::test_category_sales_grouped_by_category":0.012,"Tests\\Feature\\DashboardTest::test_top_products_aggregated_across_orders":0.012,"Tests\\Feature\\DashboardTest::test_top_stores_ranked_by_amount":0.011,"Tests\\Feature\\DashboardTest::test_purchase_amount_prefers_actual_over_estimate":0.009,"Tests\\Feature\\DashboardTest::test_recon_summary_and_receivable":0.011,"Tests\\Feature\\DashboardTest::test_latest_orders_limited_and_ordered":0.012,"Tests\\Feature\\DashboardTest::test_archives_count_active_records_only":0.011,"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.015,"Tests\\Feature\\ProductPriceTest::test_level_percent_update_notifies_level_stores":0.012,"Tests\\Feature\\ProductPriceTest::test_level_update_without_percent_change_sends_no_notice":0.007,"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.009,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_price":0.01,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_row_update":0.019,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_level_price_columns":0.008,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_level_price":0.01,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_level_price":0.027,"Tests\\Feature\\CartTest::test_store_level_missing_rejected":0.005,"Tests\\Feature\\StoreOrderTest::test_store_level_missing_rejected":0.011,"Tests\\Feature\\PurchaseEditTest::test_update_row_skips_catalog_sync_when_product_deleted":0.021,"Tests\\Feature\\PurchaseEditTest::test_store_item_add_creates_item_and_recalculates":0.027,"Tests\\Feature\\PurchaseEditTest::test_store_item_update_merges_multi_order_rows":0.04,"Tests\\Feature\\PurchaseEditTest::test_store_item_remove_deletes_and_recalculates":0.024,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_recalculates_totals":0.018,"Tests\\Feature\\StoreOrderItemTest::test_store_item_edits_rejected_when_purchase_completed":0.03,"Tests\\Feature\\StoreOrderItemTest::test_update_store_item_validates_negative_values":0.023,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_uses_level_percent_price":0.055,"Tests\\Feature\\StoreOrderItemTest::test_add_store_item_rejected_when_product_deleted":0.017,"Tests\\Feature\\ExportTest::test_export_lists_all_catalog_products_with_totals_row":0.02,"Tests\\Feature\\ExportTest::test_export_supplier_filter":0.021,"Tests\\Feature\\ExportTest::test_export_stores_multi_sheet":0.023,"Tests\\Feature\\ExportTest::test_export_stores_single_store":0.022,"Tests\\Feature\\ExportTest::test_export_stores_without_items_rejected":0.02,"Tests\\Feature\\ExportTest::test_export_suppliers_multi_sheet":0.022,"Tests\\Feature\\ExportTest::test_export_suppliers_single":0.02,"Tests\\Feature\\ExportTest::test_export_suppliers_without_items_rejected":0.021}} \ No newline at end of file diff --git a/app/Exports/PurchaseOrderExport.php b/app/Exports/PurchaseOrderExport.php index db9cdd6..c1615c0 100644 --- a/app/Exports/PurchaseOrderExport.php +++ b/app/Exports/PurchaseOrderExport.php @@ -2,6 +2,7 @@ namespace App\Exports; +use App\Exceptions\RepositoryException; use App\Models\ProductCategoryModel; use App\Models\ProductModel; use App\Models\PurchaseOrderModel; @@ -10,44 +11,54 @@ use App\Models\StoreOrderItemModel; use App\Models\SupplierModel; use Illuminate\Support\Collection; use Maatwebsite\Excel\Concerns\FromCollection; -use Maatwebsite\Excel\Concerns\WithHeadings; -use Maatwebsite\Excel\Concerns\WithMapping; +use Maatwebsite\Excel\Concerns\WithStrictNullComparison; use Maatwebsite\Excel\Concerns\WithStyles; +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; /** - * 采购单导出 + * 采购单商品明细导出:系统全部商品行(含本采购单无订货的商品,数量 0), + * 支持按供应商筛选;行尾合计 + 门店列合计;门店列整列填充突出颜色 */ -class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles +class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles { - /** @var array 商品ID => 顶级分类名 */ - private array $categoryNames = []; + /** 门店列填充色(浅橙,突出显示) */ + private const string STORE_FILL = 'FFFFF7E6'; - /** @var array 供应商ID => 名称 */ - private array $supplierNames = []; + private ?Collection $rows = null; + + /** @var array 特殊行索引(1 起)=> 类型(title/scope/header/summary) */ + private array $specialRows = []; + + /** 列头所在行索引 */ + private int $headerRow = 1; + + /** 数据末行索引 */ + private int $lastRow = 1; /** @var array 门店ID => 名称(导出列) */ private array $storeNames = []; - private ?Collection $items = null; - /** * @param PurchaseOrderModel $purchase 采购单 + * @param int $supplierId 供应商筛选(0=全部) */ public function __construct( private readonly PurchaseOrderModel $purchase, + private readonly int $supplierId = 0, ) { } /** - * 导出行(商品聚合行,按 分类sort → 商品sort 排序) + * 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings) */ public function collection(): Collection { - if ($this->items !== null) { - return $this->items; + if ($this->rows !== null) { + return $this->rows; } + // 本采购单订货明细(关联订单过滤软删) $orderItems = StoreOrderItemModel::query() ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') ->where('store_order_item.purchase_id', $this->purchase->id) @@ -61,135 +72,210 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, ->pluck('name', 'id') ->toArray(); - $products = ProductModel::withTrashed() - ->with('category:id,sort') - ->whereIn('id', $orderItems->pluck('product_id')->unique()) - ->get() - ->keyBy('id'); - - $rows = []; + // 明细按商品聚合(快照字段取首条;有订货的商品以快照供应商为准) + $itemGroups = []; foreach ($orderItems->groupBy('product_id') as $productId => $group) { $first = $group->first(); - $product = $products->get((int) $productId); - $unitCost = $first->cost_price ?? '0'; - $quantity = '0'; $weight = '0'; $amount = '0'; - $storeQuantities = array_fill_keys(array_keys($this->storeNames), '0'); + $storeQuantities = array_fill_keys(array_keys($this->storeNames), 0); foreach ($group as $item) { $quantity = bcadd($quantity, (string) $item->quantity, 2); $weight = bcadd($weight, (string) $item->weight, 3); // 金额 = 数量 × 每包成本价(单价/包规不参与金额计算) $amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2); - $storeQuantities[(int) $item->store_id] = bcadd( - $storeQuantities[(int) $item->store_id] ?? '0', - (string) $item->quantity, - 2 - ); + $storeQuantities[(int) $item->store_id] = ($storeQuantities[(int) $item->store_id] ?? 0) + (int) $item->quantity; + } + $itemGroups[(int) $productId] = [ + 'snapshot' => $first, + 'quantity' => $quantity, + 'weight' => $weight, + 'amount' => $amount, + 'store_quantities' => $storeQuantities, + ]; + } + + // 导出范围:系统全部上架商品 ∪ 本采购单有订货的商品(含已删/下架) + $products = ProductModel::withTrashed() + ->with('category:id,sort') + ->where('status', ProductModel::STATUS_ON) + ->orWhereIn('id', array_keys($itemGroups)) + ->get() + ->keyBy('id'); + + $categoryNames = $this->rootCategoryNames($products->pluck('category_id', 'id')->all()); + $supplierNames = SupplierModel::withTrashed()->pluck('name', 'id')->toArray(); + + // 组装商品行(供应商筛选:有订货按快照 supplier_id,无订货按档案 supplier_id) + $items = []; + foreach ($products as $productId => $product) { + $group = $itemGroups[(int) $productId] ?? null; + $snapshot = $group['snapshot'] ?? null; + $rowSupplierId = (int) ($snapshot->supplier_id ?? $product->supplier_id); + if ($this->supplierId > 0 && $rowSupplierId !== $this->supplierId) { + continue; } - $rows[] = [ + $quantity = $group['quantity'] ?? '0'; + $amount = $group['amount'] ?? '0'; + $spec = (string) ($snapshot->product_spec ?? $product->spec); + + $items[] = [ 'product_id' => (int) $productId, - 'supplier_id' => (int) $first->supplier_id, - 'product_name' => $first->product_name, - 'product_spec' => $first->product_spec, - 'unit' => $first->unit, - 'cost_price' => (float) ($first->cost_price ?? 0), - 'unit_cost' => (float) $unitCost, + 'category' => $categoryNames[(int) $productId] ?? '', + 'product_name' => (string) ($snapshot->product_name ?? $product->name), + 'supplier' => $supplierNames[$rowSupplierId] ?? '', + 'product_spec' => $spec, + 'unit' => (string) ($snapshot->unit ?? $product->unit), + 'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price), + // 参考零售价 = 加权平均售价 ÷ 包规数值(无订货行无售价数据,留空) + 'retail_price' => $group !== null && (float) $quantity > 0 + ? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec) + : null, 'quantity' => (float) $quantity, - 'weight' => (float) $weight, + 'weight' => (float) ($group['weight'] ?? '0'), 'amount' => (float) $amount, - 'store_quantities' => array_map('floatval', $storeQuantities), + 'store_quantities' => $group['store_quantities'] + ?? array_fill_keys(array_keys($this->storeNames), 0), 'category_sort' => (int) ($product->category->sort ?? 9999), 'product_sort' => (int) ($product->sort ?? 9999), ]; } - usort($rows, static fn (array $a, array $b): int => + usort($items, static fn (array $a, array $b): int => [$a['category_sort'], $a['product_sort'], $a['product_id']] <=> [$b['category_sort'], $b['product_sort'], $b['product_id']]); - $items = collect($rows); - $this->loadLookups($items); + if ($items === []) { + throw new RepositoryException('该供应商在系统中无商品行,无法导出'); + } - $sort = 1; - return $this->items = $items->map(static function (array $row) use (&$sort): array { - $row['sort'] = $sort++; - return $row; - })->values(); - } + // ===== 手工建行 ===== + $rows = []; + $rowIndex = 0; - public function headings(): array - { - $this->collection(); + // 标题行 + $rows[] = ['采购单 ' . $this->purchase->purchase_no . '(采购日期 ' . $this->purchase->purchase_date->toDateString() . ')']; + $this->specialRows[++$rowIndex] = 'title'; - return array_merge( - ['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'], + // 范围行:供应商筛选 / 导出时间 + $scopeSupplier = '全部'; + if ($this->supplierId > 0) { + $scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId); + } + $rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now()->format('Y-m-d H:i:s')]; + $this->specialRows[++$rowIndex] = 'scope'; + + // 空行 + $rows[] = ['']; + $rowIndex++; + + // 列头 + $rows[] = array_merge( + ['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '参考零售价', '数量', '实际称重', '金额'], array_values($this->storeNames), ); - } + $this->specialRows[++$rowIndex] = 'header'; + $this->headerRow = $rowIndex; - public function map($row): array - { - return array_merge([ - $row['sort'], - $this->categoryNames[$row['product_id']] ?? '', - $row['product_name'], - $this->supplierNames[$row['supplier_id']] ?? '', - $row['product_spec'], - $row['unit'], - $row['cost_price'], - $row['unit_cost'], - $row['quantity'], - $row['weight'], - $row['amount'], - ], array_values($row['store_quantities'])); + // 明细行 + $totalQuantity = '0'; + $totalWeight = '0'; + $totalAmount = '0'; + $storeTotals = array_fill_keys(array_keys($this->storeNames), 0); + foreach (array_values($items) as $sort => $item) { + $rows[] = array_merge([ + $sort + 1, + $item['category'], + $item['product_name'], + $item['supplier'], + $item['product_spec'], + $item['unit'], + $item['cost_price'], + $item['retail_price'] !== null ? $item['retail_price'] : '', + $item['quantity'], + $item['weight'], + $item['amount'], + ], array_values($item['store_quantities'])); + $rowIndex++; + $totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2); + $totalWeight = bcadd($totalWeight, (string) $item['weight'], 3); + $totalAmount = bcadd($totalAmount, (string) $item['amount'], 2); + foreach ($item['store_quantities'] as $storeId => $qty) { + $storeTotals[$storeId] += $qty; + } + } + + // 合计行(行合计 + 门店列合计) + $rows[] = array_merge( + ['', '', '合计', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount], + array_values($storeTotals), + ); + $this->specialRows[++$rowIndex] = 'summary'; + $this->lastRow = $rowIndex; + + return $this->rows = collect($rows); } /** - * 表头加粗 + 冻结首行 + * 标题/列头/合计加粗,门店列整列填充突出颜色,冻结列头 */ public function styles(Worksheet $sheet): array { - $sheet->freezePane('A2'); + $this->collection(); - return [ - 1 => ['font' => ['bold' => true]], - ]; + $sheet->freezePane('A' . ($this->headerRow + 1)); + $widths = [6, 10, 20, 12, 12, 8, 10, 12, 10, 12, 12]; + foreach ($widths as $index => $width) { + $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); + } + + // 门店列整列(列头 → 合计行)填充突出颜色 + $storeCount = count($this->storeNames); + for ($i = 0; $i < $storeCount; $i++) { + $column = Coordinate::stringFromColumnIndex(12 + $i); + $sheet->getColumnDimension($column)->setWidth(12); + $sheet->getStyle($column . $this->headerRow . ':' . $column . $this->lastRow) + ->getFill() + ->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID) + ->getStartColor() + ->setARGB(self::STORE_FILL); + } + + $styles = []; + foreach ($this->specialRows as $row => $type) { + $style = match ($type) { + 'title' => ['font' => ['bold' => true, 'size' => 14]], + 'header', 'summary' => ['font' => ['bold' => true]], + default => [], + }; + if ($style !== []) { + $styles[$row] = $style; + } + } + + return $styles; } /** - * PDF 模板视图数据 + * 每单位参考价 = 整单价 ÷ 包规数值(包规解析不出正数时按 1 处理;仅展示参考) + */ + private function unitRefPrice(float $price, string $spec): float + { + $pack = (float) preg_replace('/[^0-9.].*$/', '', $spec); + return $pack > 0 ? round($price / $pack, 2) : round($price, 2); + } + + /** + * 商品ID => 顶级分类名(沿 parent_id 上溯取根分类) * - * @return array{purchase: PurchaseOrderModel, items: Collection, storeNames: array, categoryNames: array, supplierNames: array} + * @param array $productCategoryIds 商品ID => 分类ID + * @return array */ - public function viewData(): array + private function rootCategoryNames(array $productCategoryIds): array { - return [ - 'purchase' => $this->purchase, - 'items' => $this->collection(), - 'storeNames' => $this->storeNames, - 'categoryNames' => $this->categoryNames, - 'supplierNames' => $this->supplierNames, - ]; - } - - /** - * 预加载供应商名与商品顶级分类名(商品/供应商均含软删除,保证历史单据可导出) - */ - private function loadLookups(Collection $items): void - { - $this->supplierNames = SupplierModel::withTrashed() - ->whereIn('id', $items->pluck('supplier_id')->unique()) - ->pluck('name', 'id') - ->toArray(); - - $productCategoryIds = ProductModel::withTrashed() - ->whereIn('id', $items->pluck('product_id')->unique()) - ->pluck('category_id', 'id'); - $categories = ProductCategoryModel::all()->keyBy('id'); - $this->categoryNames = []; + $names = []; foreach ($productCategoryIds as $productId => $categoryId) { $rootName = ''; $cursor = (int) $categoryId; @@ -202,7 +288,8 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, $rootName = $category->name; $cursor = (int) $category->parent_id; } - $this->categoryNames[(int) $productId] = $rootName; + $names[(int) $productId] = $rootName; } + return $names; } } diff --git a/app/Exports/PurchaseStoreExport.php b/app/Exports/PurchaseStoreExport.php new file mode 100644 index 0000000..66a7416 --- /dev/null +++ b/app/Exports/PurchaseStoreExport.php @@ -0,0 +1,65 @@ + + */ + public function sheets(): array + { + // 本采购单内有明细的门店(含软删除,保证历史单据可导出) + $query = StoreOrderItemModel::query() + ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') + ->where('store_order_item.purchase_id', $this->purchase->id) + ->whereNull('store_order.deleted_at'); + if ($this->storeId > 0) { + $query->where('store_order_item.store_id', $this->storeId); + } + $storeIds = $query->distinct()->pluck('store_order_item.store_id'); + + if ($storeIds->isEmpty()) { + throw new RepositoryException( + $this->storeId > 0 ? '该门店在此采购单中无采购商品' : '该采购单无门店采购明细,无法导出' + ); + } + + $stores = StoreModel::withTrashed() + ->whereIn('id', $storeIds) + ->orderBy('id') + ->get(['id', 'name']); + + $usedNames = []; + $sheets = []; + foreach ($stores as $store) { + $sheets[] = new PurchaseStoreSheet( + $this->purchase, + $store, + SheetName::make((string) $store->name, (int) $store->id, $usedNames), + ); + } + + return $sheets; + } +} diff --git a/app/Exports/PurchaseStoreSheet.php b/app/Exports/PurchaseStoreSheet.php new file mode 100644 index 0000000..867282e --- /dev/null +++ b/app/Exports/PurchaseStoreSheet.php @@ -0,0 +1,123 @@ + 特殊行索引(1 起)=> 类型(title/header/summary) */ + private array $specialRows = []; + + /** 列头所在行索引 */ + private int $headerRow = 1; + + public function __construct( + private readonly PurchaseOrderModel $purchase, + private readonly StoreModel $store, + private readonly string $sheetTitle, + ) { + } + + /** + * 导出行:标题/空行/列头/明细/合计 + */ + public function collection(): Collection + { + if ($this->rows !== null) { + return $this->rows; + } + + $items = app(PurchaseItemService::class)->storeSummaryRows($this->purchase->id, $this->store->id); + + $rows = []; + $rowIndex = 0; + + // 标题行 + $rows[] = [$this->store->name . ' · 采购单 ' . $this->purchase->purchase_no]; + $this->specialRows[++$rowIndex] = 'title'; + + // 空行 + $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, (string) $item['weight'], 3); + $totalAmount = bcadd($totalAmount, (string) $item['amount'], 2); + } + + // 合计行 + $rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount]; + $this->specialRows[++$rowIndex] = 'summary'; + + return $this->rows = collect($rows); + } + + public function title(): string + { + return $this->sheetTitle; + } + + /** + * 标题/列头/合计加粗,冻结列头 + */ + public function styles(Worksheet $sheet): array + { + $this->collection(); + + $sheet->freezePane('A' . ($this->headerRow + 1)); + $widths = [6, 24, 14, 8, 10, 10, 12, 12]; + foreach ($widths as $index => $width) { + $sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($index + 1)) + ->setWidth($width); + } + + $styles = []; + foreach ($this->specialRows as $row => $type) { + $style = match ($type) { + 'title' => ['font' => ['bold' => true, 'size' => 14]], + 'header', 'summary' => ['font' => ['bold' => true]], + default => [], + }; + if ($style !== []) { + $styles[$row] = $style; + } + } + + return $styles; + } +} diff --git a/app/Exports/PurchaseSupplierExport.php b/app/Exports/PurchaseSupplierExport.php new file mode 100644 index 0000000..b7a4149 --- /dev/null +++ b/app/Exports/PurchaseSupplierExport.php @@ -0,0 +1,66 @@ + + */ + public function sheets(): array + { + // 本采购单内有明细的供应商(按订货明细快照 supplier_id 归集) + $query = StoreOrderItemModel::query() + ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') + ->where('store_order_item.purchase_id', $this->purchase->id) + ->whereNull('store_order.deleted_at') + ->where('store_order_item.supplier_id', '>', 0); + if ($this->supplierId > 0) { + $query->where('store_order_item.supplier_id', $this->supplierId); + } + $supplierIds = $query->distinct()->pluck('store_order_item.supplier_id'); + + if ($supplierIds->isEmpty()) { + throw new RepositoryException( + $this->supplierId > 0 ? '该供应商在此采购单中无采购明细' : '该采购单无供应商采购明细,无法导出' + ); + } + + $suppliers = SupplierModel::withTrashed() + ->whereIn('id', $supplierIds) + ->orderBy('id') + ->get(['id', 'name']); + + $usedNames = []; + $sheets = []; + foreach ($suppliers as $supplier) { + $sheets[] = new PurchaseSupplierSheet( + $this->purchase, + $supplier, + SheetName::make((string) $supplier->name, (int) $supplier->id, $usedNames), + ); + } + + return $sheets; + } +} diff --git a/app/Exports/PurchaseSupplierSheet.php b/app/Exports/PurchaseSupplierSheet.php new file mode 100644 index 0000000..61bfe04 --- /dev/null +++ b/app/Exports/PurchaseSupplierSheet.php @@ -0,0 +1,123 @@ + 特殊行索引(1 起)=> 类型(title/header/summary) */ + private array $specialRows = []; + + /** 列头所在行索引 */ + private int $headerRow = 1; + + public function __construct( + private readonly PurchaseOrderModel $purchase, + private readonly SupplierModel $supplier, + private readonly string $sheetTitle, + ) { + } + + /** + * 导出行:标题/空行/列头/明细/合计 + */ + public function collection(): Collection + { + if ($this->rows !== null) { + return $this->rows; + } + + $items = app(PurchaseItemService::class)->supplierRows($this->purchase->id, $this->supplier->id); + + $rows = []; + $rowIndex = 0; + + // 标题行 + $rows[] = [$this->supplier->name . ' · 采购单 ' . $this->purchase->purchase_no]; + $this->specialRows[++$rowIndex] = 'title'; + + // 空行 + $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['cost_price'], + (int) $item['quantity'], + (float) $item['weight'], + (float) $item['amount'], + ]; + $rowIndex++; + $totalQuantity += (int) $item['quantity']; + $totalWeight = bcadd($totalWeight, (string) $item['weight'], 3); + $totalAmount = bcadd($totalAmount, (string) $item['amount'], 2); + } + + // 合计行 + $rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount]; + $this->specialRows[++$rowIndex] = 'summary'; + + return $this->rows = collect($rows); + } + + public function title(): string + { + return $this->sheetTitle; + } + + /** + * 标题/列头/合计加粗,冻结列头 + */ + public function styles(Worksheet $sheet): array + { + $this->collection(); + + $sheet->freezePane('A' . ($this->headerRow + 1)); + $widths = [6, 24, 14, 8, 10, 10, 12, 12]; + foreach ($widths as $index => $width) { + $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); + } + + $styles = []; + foreach ($this->specialRows as $row => $type) { + $style = match ($type) { + 'title' => ['font' => ['bold' => true, 'size' => 14]], + 'header', 'summary' => ['font' => ['bold' => true]], + default => [], + }; + if ($style !== []) { + $styles[$row] = $style; + } + } + + return $styles; + } +} diff --git a/app/Exports/SheetName.php b/app/Exports/SheetName.php new file mode 100644 index 0000000..4d235e0 --- /dev/null +++ b/app/Exports/SheetName.php @@ -0,0 +1,26 @@ + $used 已用名列表(引用传入,调用方维护) + */ + public static function make(string $name, int $id, array &$used): string + { + $base = str_replace(['[', ']', ':', '*', '?', '/', '\\'], '', $name); + $base = mb_substr($base === '' ? '未命名' : $base, 0, 28); + $title = $base; + if (in_array($title, $used, true)) { + $title = mb_substr($base, 0, 25) . '#' . $id; + } + $used[] = $title; + return $title; + } +} diff --git a/app/Http/Controllers/Purchase/PurchaseOrderController.php b/app/Http/Controllers/Purchase/PurchaseOrderController.php index 8453f26..e30867a 100644 --- a/app/Http/Controllers/Purchase/PurchaseOrderController.php +++ b/app/Http/Controllers/Purchase/PurchaseOrderController.php @@ -4,22 +4,29 @@ namespace App\Http\Controllers\Purchase; use App\Exceptions\RepositoryException; use App\Exports\PurchaseOrderExport; +use App\Exports\PurchaseStoreExport; +use App\Exports\PurchaseSupplierExport; use App\Http\Requests\Purchase\PurchaseBillGenerateRequest; use App\Http\Requests\Purchase\PurchaseCellUpdateRequest; use App\Http\Requests\Purchase\PurchaseRowUpdateRequest; +use App\Http\Requests\Purchase\PurchaseStoreItemRequest; use App\Models\BillModel; +use App\Models\CustomerLevelModel; use App\Models\ProductModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; use App\Models\StoreOrderItemModel; use App\Models\StoreOrderModel; +use App\Models\SupplierModel; use App\Services\BillGenerateService; use App\Services\ItemImageResolver; use App\Services\PurchaseGenerateService; +use App\Services\PurchaseItemService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; 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; use Modules\AnnoRoute\Attribute\PutRoute; @@ -98,6 +105,8 @@ class PurchaseOrderController extends BaseController $quantity = 0; // 采购总重量 $weight = '0'; + // 采购总金额(Σ明细 amount,参考零售价按 金额÷数量 加权) + $amount = '0'; // 门店明细 $cellsMap = []; @@ -106,6 +115,7 @@ class PurchaseOrderController extends BaseController // 累加总量 $quantity += (int) $item->quantity; $weight = bcadd($weight, (string) $item->weight, 3); + $amount = bcadd($amount, (string) $item->amount, 2); // 按门店合并 if (!isset($cellsMap[$storeId])) { @@ -127,6 +137,7 @@ class PurchaseOrderController extends BaseController 'cost_price' => $first->cost_price, // 成本价 'quantity' => $quantity, 'weight' => (float) $weight, + 'amount' => $amount, 'cells' => $cellsMap, 'category_sort' => (int) ($product->category->sort ?? 9999), 'product_sort' => (int) ($product->sort ?? 9999), @@ -247,22 +258,68 @@ class PurchaseOrderController extends BaseController } /** - * 导出采购单 Excel 表格 + * 导出采购单商品明细 Excel 表格(系统全部商品行,支持按供应商筛选) * * @throws */ #[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])] - public function export(int $id): Response + public function export(int $id, Request $request): Response { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } + $supplierId = (int) $request->query('supplier_id', 0); - return Excel::download( - new PurchaseOrderExport($purchase), - $purchase->purchase_no . '_采购单.xlsx', - ); + $filename = $purchase->purchase_no . '_采购单.xlsx'; + if ($supplierId > 0) { + $supplier = SupplierModel::withTrashed()->find($supplierId); + $filename = $purchase->purchase_no . '_采购单_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx'; + } + + return Excel::download(new PurchaseOrderExport($purchase, $supplierId), $filename); + } + + /** + * 导出门店购买详情(多工作表:每门店一个工作表;?store_id= 单门店导出) + */ + #[GetRoute(route: '/{id}/exportStores', authorize: 'export', where: ['id' => '[0-9]+'])] + public function exportStores(int $id, Request $request): Response + { + $purchase = PurchaseOrderModel::find($id); + if (empty($purchase)) { + throw new RepositoryException('采购单不存在'); + } + $storeId = (int) $request->query('store_id', 0); + + $filename = $purchase->purchase_no . '_门店购买详情.xlsx'; + if ($storeId > 0) { + $store = StoreModel::withTrashed()->find($storeId); + $filename = $purchase->purchase_no . '_门店购买详情_' . ($store->name ?? ('门店' . $storeId)) . '.xlsx'; + } + + return Excel::download(new PurchaseStoreExport($purchase, $storeId), $filename); + } + + /** + * 导出供应商采购明细(多工作表:每供应商一个工作表;?supplier_id= 单供应商导出) + */ + #[GetRoute(route: '/{id}/exportSuppliers', authorize: 'export', where: ['id' => '[0-9]+'])] + public function exportSuppliers(int $id, Request $request): Response + { + $purchase = PurchaseOrderModel::find($id); + if (empty($purchase)) { + throw new RepositoryException('采购单不存在'); + } + $supplierId = (int) $request->query('supplier_id', 0); + + $filename = $purchase->purchase_no . '_供应商采购明细.xlsx'; + if ($supplierId > 0) { + $supplier = SupplierModel::withTrashed()->find($supplierId); + $filename = $purchase->purchase_no . '_供应商采购明细_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx'; + } + + return Excel::download(new PurchaseSupplierExport($purchase, $supplierId), $filename); } /** @@ -348,68 +405,191 @@ class PurchaseOrderController extends BaseController throw new RepositoryException('采购单不存在'); } - $items = StoreOrderItemModel::query() - ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') - ->where('store_order_item.purchase_id', $purchase->id) - ->where('store_order_item.store_id', $storeId) - ->whereNull('store_order.deleted_at') - ->select('store_order_item.*') - ->orderBy('store_order_item.id') - ->get(); - - // 排序键:分类 sort → 商品 sort(与明细矩阵同序) - $products = ProductModel::withTrashed() - ->with('category:id,sort') - ->whereIn('id', $items->pluck('product_id')->unique()) - ->get() - ->keyBy('id'); - - $rows = []; - foreach ($items->groupBy('product_id') as $productId => $group) { - $product = $products->get((int) $productId); - $first = $group->first(); - // 采购总数量(包数) - $quantity = 0; - // 预计金额 = Σ 明细 amount - $amount = '0'; - // 总重量 - $weight = '0'; - foreach ($group as $item) { - $weight = bcadd($weight, (string) $item->weight, 3); - $quantity += (int) $item->quantity; - $amount = bcadd($amount, (string) $item->amount, 2); - } - - $rows[] = [ - 'product_id' => (int) $productId, - 'product_name' => $first->product_name, - 'product_spec' => $first->product_spec, // 包规 - 'unit' => $first->unit, // 单位 - 'price' => $quantity > 0 // 加权平均单价 - ? bcdiv($amount, (string) $quantity, 2) - : (string) $first->price, - 'quantity' => $quantity, - 'amount' => $amount, - 'weight' => $weight, - 'category_sort' => (int) ($product->category->sort ?? 9999), - 'product_sort' => (int) ($product->sort ?? 9999), - ]; - } - usort($rows, static fn (array $a, array $b): int => - [$a['category_sort'], $a['product_sort'], $a['product_id']] - <=> [$b['category_sort'], $b['product_sort'], $b['product_id']]); - $store = StoreModel::withTrashed()->find($storeId); return $this->success([ 'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null, - 'items' => array_map(static function (array $row): array { - unset($row['category_sort'], $row['product_sort']); - return $row; - }, $rows), + 'items' => app(PurchaseItemService::class)->storeSummaryRows($purchase->id, $storeId), ]); } + /** + * 门店购买详情:新增单品(挂靠该门店在采购单中的最新一笔订单, + * 单价按门店等级上浮比例换算,无等级按成本价兜底) + * + * @throws Throwable + */ + #[PostRoute(route: '/{id}/store/{storeId}/item', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])] + public function storeItemStore(int $id, int $storeId, PurchaseStoreItemRequest $request): JsonResponse + { + $purchase = PurchaseOrderModel::find($id); + if (empty($purchase)) { + throw new RepositoryException('采购单不存在'); + } + $this->assertPurchaseEditable($purchase); + + $validated = $request->validated(); + $productId = (int) $validated['product_id']; + $product = ProductModel::find($productId); + if ($product === null) { + throw new RepositoryException('商品不存在或已被删除,无法添加'); + } + + return DB::transaction(function () use ($purchase, $storeId, $product, $validated) { + // 该门店在采购单中的最新一笔订单 + $order = StoreOrderModel::query() + ->where('purchase_id', $purchase->id) + ->where('store_id', $storeId) + ->orderByDesc('id') + ->lockForUpdate() + ->first(); + if ($order === null) { + throw new RepositoryException('该门店不在此采购单中,无法添加单品'); + } + + $duplicated = StoreOrderItemModel::query() + ->where('purchase_id', $purchase->id) + ->where('store_id', $storeId) + ->where('product_id', $product->id) + ->exists(); + if ($duplicated) { + throw new RepositoryException('该商品已在此门店采购明细中,请直接修改数量'); + } + + // 单价:门店等级上浮换算价(无等级按成本价兜底) + $store = StoreModel::withTrashed()->find($storeId); + $level = ($store !== null && $store->level_id > 0) ? $store->level : null; + $price = $level !== null + ? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent) + : bcadd((string) $product->cost_price, '0', 2); + $quantity = (int) $validated['quantity']; + + $item = StoreOrderItemModel::create([ + 'order_id' => $order->id, + 'purchase_id' => $purchase->id, + 'bill_id' => 0, + 'store_id' => $storeId, + 'product_id' => $product->id, + 'category_id' => (int) $product->category_id, + 'supplier_id' => (int) $product->supplier_id, + 'product_name' => $product->name, + 'product_spec' => $product->spec, + 'unit' => (string) $product->unit, + 'price' => $price, + 'image_ids' => implode(',', (array) $product->image_ids), + 'content' => (string) $product->content, + 'shelf_life' => (int) $product->shelf_life, + 'quantity' => $quantity, + 'weight' => bcadd((string) ($validated['weight'] ?? 0), '0', 3), + 'amount' => bcmul($price, (string) $quantity, 2), + 'cost_price' => (string) $product->cost_price, + 'remark' => '', + ]); + + $service = app(PurchaseItemService::class); + $service->recalcOrder($order->id); + $service->recalcPurchase($purchase->id); + + return $this->success(['id' => $item->id], '已添加单品'); + }); + } + + /** + * 门店购买详情:修改单品(数量/称重/单价);同门店同商品多笔订单明细时合并到最早一条 + * + * @throws Throwable + */ + #[PutRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])] + public function storeItemUpdate(int $id, int $storeId, int $productId, PurchaseStoreItemRequest $request): JsonResponse + { + $purchase = PurchaseOrderModel::find($id); + if (empty($purchase)) { + throw new RepositoryException('采购单不存在'); + } + $this->assertPurchaseEditable($purchase); + + $validated = $request->validated(); + + return DB::transaction(function () use ($purchase, $storeId, $productId, $validated) { + $items = StoreOrderItemModel::query() + ->where('purchase_id', $purchase->id) + ->where('store_id', $storeId) + ->where('product_id', $productId) + ->orderBy('id') + ->lockForUpdate() + ->get(); + if ($items->isEmpty()) { + throw new RepositoryException('该门店在此采购单中无此商品明细'); + } + + $affectedOrderIds = $items->pluck('order_id')->unique()->all(); + + // 多笔订单明细合并到最早一条 + $survivor = $items->first(); + foreach ($items->skip(1) as $extra) { + $extra->delete(); + } + + $survivor->quantity = (int) $validated['quantity']; + if (isset($validated['price'])) { + $survivor->price = bcadd((string) $validated['price'], '0', 2); + } + if (isset($validated['weight'])) { + $survivor->weight = bcadd((string) $validated['weight'], '0', 3); + } + $survivor->amount = bcmul((string) $survivor->quantity, (string) $survivor->price, 2); + $survivor->save(); + + $service = app(PurchaseItemService::class); + foreach ($affectedOrderIds as $orderId) { + $service->recalcOrder((int) $orderId); + } + $service->recalcPurchase($purchase->id); + + return $this->success([], '单品已更新'); + }); + } + + /** + * 门店购买详情:移除单品(该门店此商品的全部订货明细一并删除) + * + * @throws Throwable + */ + #[DeleteRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])] + public function storeItemDelete(int $id, int $storeId, int $productId): JsonResponse + { + $purchase = PurchaseOrderModel::find($id); + if (empty($purchase)) { + throw new RepositoryException('采购单不存在'); + } + $this->assertPurchaseEditable($purchase); + + DB::transaction(function () use ($purchase, $storeId, $productId) { + $items = StoreOrderItemModel::query() + ->where('purchase_id', $purchase->id) + ->where('store_id', $storeId) + ->where('product_id', $productId) + ->lockForUpdate() + ->get(); + if ($items->isEmpty()) { + throw new RepositoryException('该门店在此采购单中无此商品明细'); + } + + $affectedOrderIds = $items->pluck('order_id')->unique()->all(); + foreach ($items as $item) { + $item->delete(); + } + + $service = app(PurchaseItemService::class); + foreach ($affectedOrderIds as $orderId) { + $service->recalcOrder((int) $orderId); + } + $service->recalcPurchase($purchase->id); + }); + + return $this->success([], '已移除单品'); + } + /** * 账单生成预览 */ @@ -489,7 +669,7 @@ class PurchaseOrderController extends BaseController } /** - * 商品行修改:品名/供应商/包规/单位/成本 + * 商品行修改:品名/供应商/包规/单位/成本(同步更新商品档案;商品已删除则跳过档案同步) * @throws Throwable */ #[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])] @@ -499,31 +679,45 @@ class PurchaseOrderController extends BaseController if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } - if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) { - throw new RepositoryException('采购单已完成,不允许修改明细'); - } + $this->assertPurchaseEditable($purchase); $validated = $request->validated(); - $items = StoreOrderItemModel::query() - ->where('purchase_id', $purchase->id) - ->where('product_id', $productId) - ->lockForUpdate() - ->get(); - if ($items->isEmpty()) { - throw new RepositoryException('该采购单下无此商品的订货明细'); - } - foreach ($items as $item) { - $item->fill($validated)->save(); - } + return DB::transaction(function () use ($purchase, $productId, $validated) { + $items = StoreOrderItemModel::query() + ->where('purchase_id', $purchase->id) + ->where('product_id', $productId) + ->lockForUpdate() + ->get(); + if ($items->isEmpty()) { + throw new RepositoryException('该采购单下无此商品的订货明细'); + } + foreach ($items as $item) { + $item->fill($validated)->save(); + } - // 重算采购单成本 - $query = StoreOrderItemModel::query()->where('purchase_id', $purchase->id); - $purchase->total_quantity = $query->sum('quantity'); - $purchase->estimate_amount = $query->sum(DB::raw('quantity * cost_price')); - $purchase->save(); + // 同步保存到商品档案(软删除商品跳过,明细照常更新) + $product = ProductModel::find($productId); + $productSynced = $product !== null; + if ($productSynced) { + $product->update([ + 'name' => $validated['product_name'], + 'supplier_id' => $validated['supplier_id'], + 'spec' => $validated['product_spec'], + 'unit' => $validated['unit'], + 'cost_price' => $validated['cost_price'], + ]); + } - return $this->success(['count' => $items->count()], '已同步 ' . $items->count() . ' 条订货明细'); + app(PurchaseItemService::class)->recalcPurchase($purchase->id); + + return $this->success( + ['count' => $items->count()], + $productSynced + ? '已同步 ' . $items->count() . ' 条订货明细与商品档案' + : '已同步 ' . $items->count() . ' 条订货明细;商品已删除,档案未同步' + ); + }); } /** @@ -544,31 +738,31 @@ class PurchaseOrderController extends BaseController if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } - if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) { - throw new RepositoryException('采购单已完成,不允许修改明细'); - } + $this->assertPurchaseEditable($purchase); $item->quantity = $validated['quantity']; $item->price = $validated['price']; if (array_key_exists('weight', $validated)) { $item->weight = bcadd((string) $validated['weight'], '0', 3); } - $item->amount = bcmul($validated['quantity'], $validated['price'], 3); + $item->amount = bcmul((string) $validated['quantity'], (string) $validated['price'], 2); $item->save(); - // 重算订单金额 - $order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id); - $order->total_weight = $order->items()->sum('weight'); - $order->total_amount = $order->items()->sum('amount'); - $order->save(); - - // 重算采购单重量 - $purchase->total_weight = StoreOrderItemModel::query() - ->where('purchase_id', $purchase->id) - ->sum('weight'); - $purchase->save(); + $service = app(PurchaseItemService::class); + $service->recalcOrder((int) $item->order_id); + $service->recalcPurchase($purchase->id); return $this->success(); }); } + + /** + * 采购单编辑闸:仅进行中(待采购)允许修改明细 + */ + private function assertPurchaseEditable(PurchaseOrderModel $purchase): void + { + if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) { + throw new RepositoryException('采购单已完成,不允许修改明细'); + } + } } diff --git a/app/Http/Requests/Purchase/PurchaseStoreItemRequest.php b/app/Http/Requests/Purchase/PurchaseStoreItemRequest.php new file mode 100644 index 0000000..2a08b54 --- /dev/null +++ b/app/Http/Requests/Purchase/PurchaseStoreItemRequest.php @@ -0,0 +1,45 @@ +isUpdate()) { + return [ + 'quantity' => 'required|integer|min:0', + 'price' => 'nullable|numeric|min:0', + 'weight' => 'nullable|numeric|min:0', + ]; + } + + return [ + 'product_id' => 'required|integer|exists:product,id', + 'quantity' => 'required|integer|min:1', + 'weight' => 'nullable|numeric|min:0', + ]; + } + + public function messages(): array + { + return [ + 'product_id.required' => '请选择商品', + 'product_id.exists' => '商品不存在或已删除', + 'quantity.required' => '采购数量不能为空', + 'quantity.integer' => '采购数量必须为整数', + 'quantity.min' => '采购数量不能小于 0', + 'price.numeric' => '单价必须为数字', + 'price.min' => '单价不能小于 0', + 'weight.numeric' => '称重必须为数字', + 'weight.min' => '称重不能小于 0', + ]; + } +} diff --git a/app/Services/PurchaseItemService.php b/app/Services/PurchaseItemService.php new file mode 100644 index 0000000..e79195b --- /dev/null +++ b/app/Services/PurchaseItemService.php @@ -0,0 +1,187 @@ +find($orderId); + if ($order === null) { + return; + } + $order->total_quantity = (int) $order->items()->sum('quantity'); + $order->total_weight = $order->items()->sum('weight'); + $order->total_amount = $order->items()->sum('amount'); + $order->save(); + } + + /** + * 重算采购单汇总(明细增删改后调用) + */ + public function recalcPurchase(int $purchaseId): void + { + $purchase = PurchaseOrderModel::query()->find($purchaseId); + if ($purchase === null) { + return; + } + $purchase->total_quantity = (int) StoreOrderItemModel::query() + ->where('purchase_id', $purchaseId)->sum('quantity'); + $purchase->estimate_amount = StoreOrderItemModel::query() + ->where('purchase_id', $purchaseId)->sum(DB::raw('quantity * cost_price')); + $purchase->total_weight = StoreOrderItemModel::query() + ->where('purchase_id', $purchaseId)->sum('weight'); + $purchase->save(); + } + + /** + * 采购单内指定门店的采购汇总行(按商品聚合,单价=加权平均 Σ金额÷Σ数量, + * 排序:分类 sort → 商品 sort,与明细矩阵同序) + * + * @return array> + */ + public function storeSummaryRows(int $purchaseId, int $storeId): array + { + $items = $this->purchaseItems($purchaseId) + ->where('store_id', $storeId) + ->sortBy('id'); + + $rows = []; + foreach ($items->groupBy('product_id') as $productId => $group) { + $first = $group->first(); + $quantity = 0; + $amount = '0'; + $weight = '0'; + foreach ($group as $item) { + $weight = bcadd($weight, (string) $item->weight, 3); + $quantity += (int) $item->quantity; + $amount = bcadd($amount, (string) $item->amount, 2); + } + + $rows[] = [ + 'product_id' => (int) $productId, + 'product_name' => $first->product_name, + 'product_spec' => $first->product_spec, + 'unit' => $first->unit, + // 加权平均单价(保证 单价×数量=预计金额) + 'price' => $quantity > 0 + ? bcdiv($amount, (string) $quantity, 2) + : (string) $first->price, + 'quantity' => $quantity, + 'amount' => $amount, + 'weight' => $weight, + 'category_sort' => (int) data_get($first, 'category_sort', 9999), + 'product_sort' => (int) data_get($first, 'product_sort', 9999), + ]; + } + + return $this->sortRows($rows); + } + + /** + * 采购单内指定供应商的商品聚合行(成本口径:金额=Σ数量×成本价, + * 排序:分类 sort → 商品 sort) + * + * @return array> + */ + public function supplierRows(int $purchaseId, int $supplierId): array + { + $items = $this->purchaseItems($purchaseId) + ->where('supplier_id', $supplierId) + ->sortBy('id'); + + $rows = []; + foreach ($items->groupBy('product_id') as $productId => $group) { + $first = $group->first(); + $quantity = 0; + $weight = '0'; + $amount = '0'; + foreach ($group as $item) { + $quantity += (int) $item->quantity; + $weight = bcadd($weight, (string) $item->weight, 3); + $amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2); + } + + $rows[] = [ + 'product_id' => (int) $productId, + 'product_name' => $first->product_name, + 'product_spec' => $first->product_spec, + 'unit' => $first->unit, + 'cost_price' => (string) $first->cost_price, + 'quantity' => $quantity, + 'weight' => $weight, + 'amount' => $amount, + 'category_sort' => (int) data_get($first, 'category_sort', 9999), + 'product_sort' => (int) data_get($first, 'product_sort', 9999), + ]; + } + + return $this->sortRows($rows); + } + + /** + * 采购单订货明细(关联订单过滤软删、成本价可见、附分类/商品排序键) + * + * @return Collection + */ + private function purchaseItems(int $purchaseId): Collection + { + $items = StoreOrderItemModel::query() + ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') + ->where('store_order_item.purchase_id', $purchaseId) + ->whereNull('store_order.deleted_at') + ->select('store_order_item.*') + ->get() + ->makeVisible('cost_price'); + + // 排序键:商品分类 sort → 商品 sort(商品含软删除,保证历史单据可导出) + $products = ProductModel::withTrashed() + ->with('category:id,sort') + ->whereIn('id', $items->pluck('product_id')->unique()) + ->get(['id', 'category_id', 'sort']) + ->keyBy('id'); + + foreach ($items as $item) { + $product = $products->get((int) $item->product_id); + $item->setAttribute('category_sort', (int) ($product?->category?->sort ?? 9999)); + $item->setAttribute('product_sort', (int) ($product?->sort ?? 9999)); + } + + return $items; + } + + /** + * 排序并剥离排序键 + * + * @param array> $rows + * @return array> + */ + private function sortRows(array $rows): array + { + usort($rows, static fn (array $a, array $b): int => + [$a['category_sort'], $a['product_sort'], $a['product_id']] + <=> [$b['category_sort'], $b['product_sort'], $b['product_id']]); + + return array_map(static function (array $row): array { + unset($row['category_sort'], $row['product_sort']); + return $row; + }, $rows); + } +} diff --git a/tests/Feature/ExportTest.php b/tests/Feature/ExportTest.php index 5114543..d54d214 100644 --- a/tests/Feature/ExportTest.php +++ b/tests/Feature/ExportTest.php @@ -2,13 +2,18 @@ namespace Tests\Feature; +use App\Exports\PurchaseOrderExport; +use App\Exports\PurchaseStoreExport; +use App\Exports\PurchaseSupplierExport; use App\Models\CustomerLevelModel; use App\Models\ProductCategoryModel; use App\Models\ProductModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; use App\Models\StoreOrderModel; +use App\Models\SupplierModel; use App\Models\UserModel; +use Maatwebsite\Excel\Facades\Excel; /** * 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type / @@ -85,4 +90,259 @@ class ExportTest extends ProcurementTestCase $response = $this->get("/purchase/order/{$purchase->id}/export"); $this->assertFalse($response->json('success'), '缺少权限点应被拦截'); } + + /** + * 造含双门店/双供应商的采购单: + * 门店A 订 蔬菜2件(供应商甲)+ 肉1件(供应商乙);门店B 订 蔬菜3件(供应商甲) + * + * @return array{0: PurchaseOrderModel, 1: ProductModel, 2: ProductModel, 3: StoreModel, 4: StoreModel, 5: SupplierModel, 6: SupplierModel} + */ + private function buildPurchaseWithSuppliers(): array + { + $supplierA = SupplierModel::factory()->create(); + $supplierB = SupplierModel::factory()->create(); + $level = CustomerLevelModel::factory()->create(); + $storeA = StoreModel::factory()->create(['level_id' => $level->id]); + $storeB = StoreModel::factory()->create(['level_id' => $level->id]); + $veg = ProductModel::factory()->create([ + 'status' => ProductModel::STATUS_ON, + 'cost_price' => '5.00', + 'spec' => '10斤/箱', + 'supplier_id' => $supplierA->id, + ]); + $meat = ProductModel::factory()->create([ + 'status' => ProductModel::STATUS_ON, + 'cost_price' => '20.00', + 'spec' => '20斤/箱', + 'supplier_id' => $supplierB->id, + ]); + + $this->actingAsMiniUser(UserModel::factory()->forStore($storeA->id)->create()); + $this->postJson('/mini/order', ['items' => [ + ['product_id' => $veg->id, 'quantity' => 2], + ['product_id' => $meat->id, 'quantity' => 1], + ]])->assertJsonPath('success', true); + $this->actingAsMiniUser(UserModel::factory()->forStore($storeB->id)->create()); + $this->postJson('/mini/order', ['items' => [ + ['product_id' => $veg->id, 'quantity' => 3], + ]])->assertJsonPath('success', true); + + StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); + + $this->actingAsSysUser(); + $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) + ->assertJsonPath('success', true); + + return [PurchaseOrderModel::first(), $veg, $meat, $storeA, $storeB, $supplierA, $supplierB]; + } + + /** 商品明细导出:含系统全部商品行(无订货数量 0)+ 行列合计 + 参考零售价列 */ + public function test_export_lists_all_catalog_products_with_totals_row(): void + { + [$purchase, $veg, $meat, $storeA, $storeB] = $this->buildPurchaseWithSuppliers(); + // 无订货的上架商品也应出现在导出中 + $extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '7.00']); + + Excel::fake(); + $this->actingAsSysUser(); + $this->get("/purchase/order/{$purchase->id}/export")->assertOk(); + + Excel::assertDownloaded( + $purchase->purchase_no . '_采购单.xlsx', + static function (PurchaseOrderExport $export) use ($veg, $meat, $extra, $storeA, $storeB): bool { + $rows = $export->collection()->values(); + // 标题1 + 范围1 + 空行1 + 列头1 + 商品3 + 合计1 = 8 行 + if ($rows->count() !== 8) { + return false; + } + // 列头含参考零售价与门店列 + $header = $rows[3]; + if ($header[7] !== '参考零售价' || $header[11] !== $storeA->name || $header[12] !== $storeB->name) { + return false; + } + // 蔬菜行:数量 2+3=5、金额 25、参考零售价 5÷10=0.50、门店列 2/3 + $vegRow = $rows->firstWhere(2, $veg->name); + if ($vegRow === null + || (float) $vegRow[8] !== 5.0 || (float) $vegRow[10] !== 25.0 + || (float) $vegRow[7] !== 0.5 + || (float) $vegRow[11] !== 2.0 || (float) $vegRow[12] !== 3.0) { + return false; + } + // 无订货商品行:数量 0、参考零售价留空、门店列 0 + $extraRow = $rows->firstWhere(2, $extra->name); + if ($extraRow === null + || (float) $extraRow[8] !== 0.0 || $extraRow[7] !== '' + || (float) $extraRow[11] !== 0.0) { + return false; + } + // 合计行:总数量 6、总金额 45、门店列合计 3/3 + $total = $rows->last(); + if ($total[2] !== '合计') { + return false; + } + return (float) $total[8] === 6.0 && (float) $total[10] === 45.0 + && (float) $total[11] === 3.0 && (float) $total[12] === 3.0; + } + ); + } + + /** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */ + public function test_export_supplier_filter(): void + { + [$purchase, $veg, $meat, , , $supplierA] = $this->buildPurchaseWithSuppliers(); + + Excel::fake(); + $this->actingAsSysUser(); + $this->get("/purchase/order/{$purchase->id}/export?supplier_id={$supplierA->id}")->assertOk(); + + Excel::assertDownloaded( + $purchase->purchase_no . '_采购单_' . $supplierA->name . '.xlsx', + static function (PurchaseOrderExport $export) use ($veg, $meat, $supplierA): bool { + $rows = $export->collection()->values(); + // 范围行标注供应商 + if (! str_contains((string) $rows[1][0], $supplierA->name)) { + return false; + } + // 数据行(去头尾)只含供应商甲的蔬菜,不含供应商乙的肉 + $names = $rows->slice(4, -1)->map(static fn ($row) => $row[2])->all(); + return in_array($veg->name, $names, true) && ! in_array($meat->name, $names, true); + } + ); + } + + /** 门店购买详情导出:多门店合并一个 XLSX(工作表名=门店名),含合计行 */ + public function test_export_stores_multi_sheet(): void + { + [$purchase, , , $storeA, $storeB] = $this->buildPurchaseWithSuppliers(); + + Excel::fake(); + $this->actingAsSysUser(); + $this->get("/purchase/order/{$purchase->id}/exportStores")->assertOk(); + + Excel::assertDownloaded( + $purchase->purchase_no . '_门店购买详情.xlsx', + static function (PurchaseStoreExport $export) use ($storeA, $storeB): bool { + $sheets = $export->sheets(); + if (count($sheets) !== 2) { + return false; + } + $titles = array_map(static fn ($sheet) => $sheet->title(), $sheets); + if ($titles !== [$storeA->name, $storeB->name]) { + return false; + } + // 门店A 工作表:标题/空行/列头/明细2/合计 = 6 行 + $rows = $sheets[0]->collection()->values(); + if ($rows->count() !== 6) { + return false; + } + if ($rows[2] !== ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额']) { + return false; + } + // 合计:数量 2+1=3,金额 10+20=30 + $total = $rows->last(); + return $total[1] === '合计' && (int) $total[5] === 3 && (float) $total[7] === 30.0; + } + ); + } + + /** 门店购买详情导出:单门店导出 + 无明细门店拒绝 */ + public function test_export_stores_single_store(): void + { + [$purchase, , , , $storeB] = $this->buildPurchaseWithSuppliers(); + + Excel::fake(); + $this->actingAsSysUser(); + $this->get("/purchase/order/{$purchase->id}/exportStores?store_id={$storeB->id}")->assertOk(); + + Excel::assertDownloaded( + $purchase->purchase_no . '_门店购买详情_' . $storeB->name . '.xlsx', + static function (PurchaseStoreExport $export) use ($storeB): bool { + $sheets = $export->sheets(); + return count($sheets) === 1 && $sheets[0]->title() === $storeB->name; + } + ); + } + + /** 门店购买详情导出:门店无明细 → 拒绝(真实执行 sheets() 才触发空校验,不走 fake) */ + public function test_export_stores_without_items_rejected(): void + { + [$purchase] = $this->buildPurchaseWithSuppliers(); + $otherStore = StoreModel::factory()->create(); + + $this->actingAsSysUser(); + $this->get("/purchase/order/{$purchase->id}/exportStores?store_id={$otherStore->id}") + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '该门店在此采购单中无采购商品'); + } + + /** 供应商采购明细导出:多供应商合并一个 XLSX(工作表名=供应商名),按供应商聚合 */ + public function test_export_suppliers_multi_sheet(): void + { + [$purchase, $veg, $meat, , , $supplierA, $supplierB] = $this->buildPurchaseWithSuppliers(); + + Excel::fake(); + $this->actingAsSysUser(); + $this->get("/purchase/order/{$purchase->id}/exportSuppliers")->assertOk(); + + Excel::assertDownloaded( + $purchase->purchase_no . '_供应商采购明细.xlsx', + static function (PurchaseSupplierExport $export) use ($supplierA, $supplierB, $veg, $meat): bool { + $sheets = $export->sheets(); + if (count($sheets) !== 2) { + return false; + } + $titles = array_map(static fn ($sheet) => $sheet->title(), $sheets); + if ($titles !== [$supplierA->name, $supplierB->name]) { + return false; + } + // 供应商甲:蔬菜 2+3=5 件、金额 5×5=25 + $rowsA = $sheets[0]->collection()->values(); + if ($rowsA[2] !== ['序号', '品名', '包规', '单位', '成本价', '数量', '重量(斤)', '金额']) { + return false; + } + $vegRow = $rowsA->firstWhere(1, $veg->name); + if ($vegRow === null || (int) $vegRow[5] !== 5 || (float) $vegRow[7] !== 25.0) { + return false; + } + $totalA = $rowsA->last(); + if ($totalA[1] !== '合计' || (int) $totalA[5] !== 5 || (float) $totalA[7] !== 25.0) { + return false; + } + // 供应商乙:肉 1 件、金额 20 + $rowsB = $sheets[1]->collection()->values(); + $meatRow = $rowsB->firstWhere(1, $meat->name); + return $meatRow !== null && (int) $meatRow[5] === 1 && (float) $meatRow[7] === 20.0; + } + ); + } + + /** 供应商采购明细导出:单供应商导出 + 无明细供应商拒绝 */ + public function test_export_suppliers_single(): void + { + [$purchase, , , , , , $supplierB] = $this->buildPurchaseWithSuppliers(); + + Excel::fake(); + $this->actingAsSysUser(); + $this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$supplierB->id}")->assertOk(); + + Excel::assertDownloaded( + $purchase->purchase_no . '_供应商采购明细_' . $supplierB->name . '.xlsx', + static function (PurchaseSupplierExport $export) use ($supplierB): bool { + $sheets = $export->sheets(); + return count($sheets) === 1 && $sheets[0]->title() === $supplierB->name; + } + ); + } + + /** 供应商采购明细导出:供应商无明细 → 拒绝(真实执行 sheets() 才触发空校验,不走 fake) */ + public function test_export_suppliers_without_items_rejected(): void + { + [$purchase] = $this->buildPurchaseWithSuppliers(); + $otherSupplier = SupplierModel::factory()->create(); + + $this->actingAsSysUser(); + $this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$otherSupplier->id}") + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '该供应商在此采购单中无采购明细'); + } } diff --git a/tests/Feature/PurchaseEditTest.php b/tests/Feature/PurchaseEditTest.php index d43fda2..8ee3cb5 100644 --- a/tests/Feature/PurchaseEditTest.php +++ b/tests/Feature/PurchaseEditTest.php @@ -12,8 +12,9 @@ use App\Models\SupplierModel; use App\Models\UserModel; /** - * C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本, - * 修改同步订货明细并重算订单/采购单汇总(金额口径:数量(包) × 每包价格,单价/包规不参与金额计算) + * C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑、行级修改同步商品档案、 + * 门店购买详情单品增删改,修改后重算订单/采购单汇总 + * (金额口径:订单=数量×单价;采购单预估成本=Σ数量×每包成本价) */ class PurchaseEditTest extends ProcurementTestCase { @@ -69,6 +70,7 @@ class PurchaseEditTest extends ProcurementTestCase $this->assertSame('10斤/箱', $row['product_spec']); $this->assertSame('斤', $row['unit']); $this->assertSame('10.00', (string) $row['cost_price']); + $this->assertSame('50.00', (string) $row['amount'], '订货金额 = Σ明细 amount(5 × 10.00)'); $this->assertSame(5, $row['quantity'], '2+3'); $cells = $row['cells']; @@ -76,16 +78,15 @@ class PurchaseEditTest extends ProcurementTestCase $this->assertSame(3, $cells[$stores[1]->id]); } - /** 门店单元格数量修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */ + /** 门店单元格数量/单价修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */ public function test_update_cell_syncs_order_item_and_totals(): void { [$purchase, , $stores] = $this->buildPurchase(); $this->actingAsSysUser(); $item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first(); - $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5]) - ->assertJsonPath('success', true) - ->assertJsonPath('data.amount', 50); + $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'price' => 10]) + ->assertJsonPath('success', true); $item = $item->fresh(); $this->assertSame(5, $item->quantity); @@ -93,45 +94,59 @@ class PurchaseEditTest extends ProcurementTestCase $order = StoreOrderModel::find($item->order_id); $this->assertSame(5, $order->total_quantity); - $this->assertSame('50.00', (string) $order->product_amount); $this->assertSame('50.00', (string) $order->total_amount); $purchase = $purchase->fresh(); $this->assertSame('8.00', (string) $purchase->total_quantity, '5+3'); - $this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30'); - $this->assertSame('80.00', (string) $purchase->actual_amount, '8 包 × 每包成本 10.00'); + $this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00'); } - /** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×每包成本 重算 */ + /** 行级成本修改 → 同步该商品全部订货明细与商品档案,采购单预估成本重算 */ public function test_update_row_cost_syncs_all_order_items(): void { [$purchase, $product] = $this->buildPurchase(); $this->actingAsSysUser(); - $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30]) - ->assertJsonPath('success', true) + $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [ + 'product_name' => $product->name, + 'supplier_id' => SupplierModel::factory()->create()->id, + 'product_spec' => $product->spec, + 'unit' => $product->unit, + 'cost_price' => 30, + ])->assertJsonPath('success', true) ->assertJsonPath('data.count', 2); $this->assertSame(2, StoreOrderItemModel::where('cost_price', '30.00')->count()); + $this->assertSame('30.00', (string) $product->fresh()->cost_price, '商品档案成本价应同步更新'); $purchase = $purchase->fresh(); - $this->assertSame('150.00', (string) $purchase->actual_amount, '5 包 × 每包成本 30.00'); - $this->assertSame('50.00', (string) $purchase->estimate_amount, '订货金额不受成本修改影响'); + $this->assertSame('150.00', (string) $purchase->estimate_amount, '5 包 × 每包成本 30.00'); } - /** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细(重量为手动录入,不受行修改影响) */ + /** 行级属性修改不影响称重(重量为手动录入,仅作参考) */ public function test_update_row_does_not_touch_manual_weight(): void { - [$purchase, $product] = $this->buildPurchase(); + [$purchase, $product, $stores] = $this->buildPurchase(); + $supplier = SupplierModel::factory()->create(); $this->actingAsSysUser(); - $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30]) + // 先录入一笔手动称重 + $item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first(); + $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 2, 'price' => 10, 'weight' => 4.5]) ->assertJsonPath('success', true); - $this->assertSame(0, StoreOrderItemModel::where('weight', '<>', 0)->count(), '重量保持手动录入值,不自动计算'); + $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [ + 'product_name' => $product->name, + 'supplier_id' => $supplier->id, + 'product_spec' => $product->spec, + 'unit' => $product->unit, + 'cost_price' => 30, + ])->assertJsonPath('success', true); + + $this->assertSame('4.500', (string) $item->fresh()->weight, '重量保持手动录入值,不受行修改影响'); } - /** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细 */ + /** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细与商品档案 */ public function test_update_row_syncs_product_attributes(): void { [$purchase, $product] = $this->buildPurchase(); @@ -157,29 +172,7 @@ class PurchaseEditTest extends ProcurementTestCase $this->assertSame('25.00', (string) $item->cost_price); } - // 未传 sync_product:商品档案保持原值 - $product = $product->fresh(); - $this->assertNotSame('优选土豆', $product->name); - - $this->assertSame('125.00', (string) $purchase->fresh()->actual_amount, '5 包 × 每包成本 25.00'); - } - - /** sync_product=true:订货明细与商品档案同步更新;软删除商品拒绝 */ - public function test_update_row_syncs_to_product_catalog(): void - { - [$purchase, $product] = $this->buildPurchase(); - $supplier = SupplierModel::factory()->create(); - $this->actingAsSysUser(); - - $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [ - 'product_name' => '优选土豆', - 'supplier_id' => $supplier->id, - 'product_spec' => '5斤/袋', - 'unit' => '袋', - 'cost_price' => 25, - 'sync_product' => true, - ])->assertJsonPath('success', true); - + // 商品档案同步保存(商品列表可见) $product = $product->fresh(); $this->assertSame('优选土豆', $product->name); $this->assertSame($supplier->id, $product->supplier_id); @@ -187,16 +180,33 @@ class PurchaseEditTest extends ProcurementTestCase $this->assertSame('袋', $product->unit); $this->assertSame('25.00', (string) $product->cost_price); - // 软删除商品 → 拒绝并回滚(订货明细不同步) - $product->delete(); - $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [ - 'product_name' => '再次改名', - 'sync_product' => true, - ])->assertJsonPath('success', false); - $this->assertSame('优选土豆', StoreOrderItemModel::first()->product_name, '回滚后明细保持原值'); + $this->assertSame('125.00', (string) $purchase->fresh()->estimate_amount, '5 包 × 每包成本 25.00'); } - /** 行级修改:属性与称重至少一项 */ + /** 行级修改:商品已软删除时明细照常更新、跳过档案同步 */ + public function test_update_row_skips_catalog_sync_when_product_deleted(): void + { + [$purchase, $product] = $this->buildPurchase(); + $supplier = SupplierModel::factory()->create(); + $this->actingAsSysUser(); + + $product->delete(); // 软删除 + + $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [ + 'product_name' => '已删商品新名', + 'supplier_id' => $supplier->id, + 'product_spec' => '5斤/袋', + 'unit' => '袋', + 'cost_price' => 25, + ])->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('msg', '已同步 2 条订货明细;商品已删除,档案未同步'); + + $this->assertSame('已删商品新名', StoreOrderItemModel::first()->product_name, '订货明细照常更新'); + $this->assertNotSame('已删商品新名', $product->fresh()->name, '商品档案不同步'); + } + + /** 行级修改:必填字段校验(空 payload 拒绝) */ public function test_update_row_requires_at_least_one_field(): void { [$purchase, $product] = $this->buildPurchase(); @@ -213,11 +223,11 @@ class PurchaseEditTest extends ProcurementTestCase $this->actingAsSysUser(); $item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first(); - $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => -1]) + $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => -1, 'price' => 10]) ->assertJsonPath('success', false); } - /** 单元格下钻:返回该门店该商品的全部订货明细(订单号/状态/快照/可编辑标记),其他门店无明细 */ + /** 单元格下钻:返回该门店该商品的全部订货明细(订单号/状态/快照),其他门店无明细 */ public function test_cell_detail_returns_items_with_order_info(): void { [$purchase, $product, $stores] = $this->buildPurchase(); @@ -237,7 +247,6 @@ class PurchaseEditTest extends ProcurementTestCase $this->assertSame(2, $item['quantity']); $this->assertSame('20.00', (string) $item['amount']); $this->assertSame(StoreOrderModel::STATUS_DELIVERING, $item['order_status'], '生成采购单后源订单为采购中'); - $this->assertTrue($item['editable']); // 其他门店 → 另一条明细 $this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[1]->id}") @@ -262,9 +271,8 @@ class PurchaseEditTest extends ProcurementTestCase $this->actingAsSysUser(); $item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first(); - $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'weight' => 4.5]) - ->assertJsonPath('success', true) - ->assertJsonPath('data.amount', 50); + $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'price' => 10, 'weight' => 4.5]) + ->assertJsonPath('success', true); $item = $item->fresh(); $this->assertSame('4.500', (string) $item->weight); @@ -272,60 +280,120 @@ class PurchaseEditTest extends ProcurementTestCase $order = StoreOrderModel::find($item->order_id); $this->assertSame('4.500', (string) $order->total_weight); - $this->assertSame('50.00', (string) $order->product_amount); + $this->assertSame('50.00', (string) $order->total_amount); $purchase = $purchase->fresh(); $this->assertSame('4.500', (string) $purchase->total_weight); - $this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30 订货金额'); - $this->assertSame('80.00', (string) $purchase->actual_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)'); + $this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)'); } - /** 单元格一键同步:按商品ID同步档案 + 等级上浮价重算,订货单与采购单汇总级联 */ - public function test_cell_sync_refreshes_snapshot_and_cascades(): void + /** 门店购买详情-新增单品:挂靠最新一笔订单,汇总级联重算;重复添加拒绝 */ + public function test_store_item_add_creates_item_and_recalculates(): void { - // 等级上浮 30%:下单时成本 10.00 → 售价 13.00 - $level = CustomerLevelModel::factory()->create(['percent' => 30]); - $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create([ - 'status' => ProductModel::STATUS_ON, - 'cost_price' => '10.00', - 'spec' => '1斤/袋', - 'unit' => '斤', - ]); + [$purchase, , $stores] = $this->buildPurchase(); + $extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => 8]); + $this->actingAsSysUser(); - $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); - $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 2]]]) - ->assertJsonPath('success', true); + $this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [ + 'product_id' => $extra->id, + 'quantity' => 4, + 'weight' => 1.5, + ])->assertOk()->assertJsonPath('success', true); + + $item = StoreOrderItemModel::where('product_id', $extra->id)->first(); + $this->assertNotNull($item); + $this->assertSame($stores[0]->id, $item->store_id); + $this->assertSame($purchase->id, $item->purchase_id); + $this->assertSame('8.00', (string) $item->price, '等级上浮 0% → 单价=成本价'); + $this->assertSame('32.00', (string) $item->amount, '8.00 × 4'); + $this->assertSame('1.500', (string) $item->weight); + + $order = StoreOrderModel::find($item->order_id); + $this->assertSame(6, $order->total_quantity, '2+4'); + $this->assertSame('52.00', (string) $order->total_amount, '20+32'); + + $purchase = $purchase->fresh(); + $this->assertSame('9.00', (string) $purchase->total_quantity, '2+3+4'); + $this->assertSame('82.00', (string) $purchase->estimate_amount, '50+32(4 包 × 成本 8.00)'); + $this->assertSame('1.500', (string) $purchase->total_weight); + + // 重复添加同一商品 → 拒绝 + $this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [ + 'product_id' => $extra->id, + 'quantity' => 1, + ])->assertJsonPath('success', false) + ->assertJsonPath('msg', '该商品已在此门店采购明细中,请直接修改数量'); + } + + /** 门店购买详情-修改单品:同商品多笔订单明细合并到最早一条,涉及订单逐一重算 */ + public function test_store_item_update_merges_multi_order_rows(): void + { + $level = CustomerLevelModel::factory()->create(); + $store = StoreModel::factory()->create(['level_id' => $level->id]); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => 10]); + + // 同一门店两笔订单各订 2 件(同商品)→ 同一采购单 + $user = UserModel::factory()->forStore($store->id)->create(); + $this->actingAsMiniUser($user); + foreach ([2, 2] as $qty) { + $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]]) + ->assertJsonPath('success', true); + } StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); $this->actingAsSysUser(); $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) ->assertJsonPath('success', true); - $purchase = PurchaseOrderModel::first(); + + $this->assertSame(2, StoreOrderItemModel::count(), '两笔订单各一条明细'); + + $this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [ + 'quantity' => 10, + 'price' => 12, + 'weight' => 5, + ])->assertJsonPath('success', true); + + $this->assertSame(1, StoreOrderItemModel::count(), '多笔明细合并为一条'); $item = StoreOrderItemModel::first(); - $this->assertSame('13.00', (string) $item->price, '下单时 10 元上浮 30%'); - $this->assertSame('26.00', (string) $purchase->estimate_amount); + $this->assertSame(10, $item->quantity); + $this->assertSame('12.00', (string) $item->price); + $this->assertSame('120.00', (string) $item->amount); + $this->assertSame('5.000', (string) $item->weight); - // 成本上调后同步:单价按最新成本重算,金额与两级汇总级联 - $product->update(['cost_price' => '20.00']); - - $this->putJson("/purchase/order/cell/{$item->id}/sync") - ->assertJsonPath('success', true) - ->assertJsonPath('data.amount', 52); - - $item->refresh(); - $this->assertSame('20.00', (string) $item->cost_price); - $this->assertSame('26.00', (string) $item->price, '20 元上浮 30% = 26.00'); - $this->assertSame('52.00', (string) $item->amount, '26.00 × 2'); - - $order = StoreOrderModel::first(); - $this->assertSame('52.00', (string) $order->product_amount); - $this->assertSame('52.00', (string) $order->total_amount); + // 保留行所在订单重算;被合并订单清零 + $survivedOrder = StoreOrderModel::find($item->order_id); + $this->assertSame(10, $survivedOrder->total_quantity); + $this->assertSame('120.00', (string) $survivedOrder->total_amount); + $mergedOrder = StoreOrderModel::where('id', '<>', $item->order_id)->first(); + $this->assertSame(0, $mergedOrder->total_quantity); + $this->assertSame('0.00', (string) $mergedOrder->total_amount); $purchase = $purchase->fresh(); - $this->assertSame('52.00', (string) $purchase->estimate_amount); - $this->assertSame('40.00', (string) $purchase->actual_amount, '2 包 × 每包成本 20.00'); + $this->assertSame('10.00', (string) $purchase->total_quantity); + $this->assertSame('100.00', (string) $purchase->estimate_amount, '10 包 × 成本 10.00'); + $this->assertSame('5.000', (string) $purchase->total_weight); + } + + /** 门店购买详情-移除单品:明细删除,订单与采购单汇总级联重算 */ + public function test_store_item_remove_deletes_and_recalculates(): void + { + [$purchase, $product, $stores] = $this->buildPurchase(); + $this->actingAsSysUser(); + + $this->deleteJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item/{$product->id}") + ->assertOk()->assertJsonPath('success', true); + + $this->assertSame(0, StoreOrderItemModel::where('store_id', $stores[0]->id)->count()); + $this->assertSame(1, StoreOrderItemModel::count(), '门店B 明细不受影响'); + + $order = StoreOrderModel::where('store_id', $stores[0]->id)->first(); + $this->assertSame(0, $order->total_quantity); + $this->assertSame('0.00', (string) $order->total_amount); + + $purchase = $purchase->fresh(); + $this->assertSame('3.00', (string) $purchase->total_quantity, '仅剩门店B 3 件'); + $this->assertSame('30.00', (string) $purchase->estimate_amount); } /** 门店购买详情:按商品聚合该门店采购汇总(数量/包规/单位/单价/预计金额),仅含该门店明细 */ @@ -408,28 +476,37 @@ class PurchaseEditTest extends ProcurementTestCase ->assertJsonPath('msg', '采购单不存在'); } - /** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */ + /** 采购单已完成:单元格编辑、行修改、门店单品增删改均拒绝,明细不变 */ public function test_edits_rejected_when_purchase_completed(): void { [$purchase, $product, $stores] = $this->buildPurchase(); $item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first(); + $supplier = SupplierModel::factory()->create(); $purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]); $this->actingAsSysUser(); - $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5]) + $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'price' => 10]) ->assertJsonPath('success', false) ->assertJsonPath('msg', '采购单已完成,不允许修改明细'); - $this->putJson("/purchase/order/cell/{$item->id}/sync") - ->assertJsonPath('success', false); - $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30]) + $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [ + 'product_name' => '改名', + 'supplier_id' => $supplier->id, + 'product_spec' => '5斤/袋', + 'unit' => '袋', + 'cost_price' => 30, + ])->assertJsonPath('success', false) + ->assertJsonPath('msg', '采购单已完成,不允许修改明细'); + $this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [ + 'product_id' => $product->id, + 'quantity' => 1, + ])->assertJsonPath('success', false); + $this->putJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item/{$product->id}", [ + 'quantity' => 5, + ])->assertJsonPath('success', false); + $this->deleteJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item/{$product->id}") ->assertJsonPath('success', false); $this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变'); $this->assertSame('10.00', (string) $item->fresh()->cost_price); - - // 下钻明细同步标记不可编辑 - $this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[0]->id}") - ->assertOk() - ->assertJsonPath('data.items.0.editable', false); } } diff --git a/tests/Feature/StoreOrderItemTest.php b/tests/Feature/StoreOrderItemTest.php index 0ee521b..f0dbb10 100644 --- a/tests/Feature/StoreOrderItemTest.php +++ b/tests/Feature/StoreOrderItemTest.php @@ -4,14 +4,16 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\ProductModel; +use App\Models\PurchaseOrderModel; use App\Models\StoreModel; +use App\Models\StoreOrderItemModel; use App\Models\StoreOrderModel; use App\Models\SupplierModel; use App\Models\UserModel; /** * 门店订单明细(商品快照): - * 下单快照完整商品档案、成本价防泄漏、后台明细修改重算总价、一键同步商品档案、按商品名称搜索订单 + * 下单快照完整商品档案、成本价防泄漏、采购单内门店单品增删改重算汇总、按商品名称搜索订单 */ class StoreOrderItemTest extends ProcurementTestCase { @@ -50,6 +52,17 @@ class StoreOrderItemTest extends ProcurementTestCase return $order; } + /** 下单 → 接单 → 生成采购单,返回采购单 */ + private function generatePurchase(): PurchaseOrderModel + { + StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); + $this->actingAsSysUser(); + $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) + ->assertJsonPath('success', true); + + return PurchaseOrderModel::first(); + } + /** 下单时明细快照完整商品档案(分类/供应商/单位/图片/图文/保质期/成本价) */ public function test_place_order_snapshots_full_product_info(): void { @@ -114,33 +127,23 @@ class StoreOrderItemTest extends ProcurementTestCase ->assertJsonPath('data.items.0.supplier.name', $supplier->name); } - /** 修改明细:重算单品金额与订单总量/总额(总额 = 商品金额,附加金额已迁入账单) */ - public function test_update_item_recalculates_order_totals(): void + /** 采购单内修改门店单品:重算单品金额与订单/采购单汇总 */ + public function test_update_store_item_recalculates_totals(): void { [$store, $product, $user] = $this->makeStoreWithProduct('5.00'); $order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED); $item = $order->items->first(); - $supplier = SupplierModel::factory()->create(); + $purchase = $this->generatePurchase(); $this->actingAsSysUser(); - $this->putJson("/order/store/item/{$item->id}", [ - 'supplier_id' => $supplier->id, - 'product_name' => '改名后的商品', - 'product_spec' => '新规格', - 'unit' => '箱', + $this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [ 'price' => '6.00', - 'cost_price' => '3.50', 'quantity' => 5, 'weight' => '2.5', ])->assertOk()->assertJsonPath('success', true); $item->refresh(); - $this->assertSame($supplier->id, $item->supplier_id); - $this->assertSame('改名后的商品', $item->product_name); - $this->assertSame('新规格', $item->product_spec); - $this->assertSame('箱', $item->unit); $this->assertSame('6.00', (string) $item->price); - $this->assertSame('3.50', (string) $item->cost_price); $this->assertSame(5, $item->quantity); $this->assertSame('2.500', (string) $item->weight); $this->assertSame('30.00', (string) $item->amount, '6.00 × 5'); @@ -148,139 +151,106 @@ class StoreOrderItemTest extends ProcurementTestCase $order->refresh(); $this->assertSame(5, $order->total_quantity, '订货总量 = 明细合计'); $this->assertSame('2.500', (string) $order->total_weight, '总重量 = 明细合计'); - $this->assertSame('30.00', (string) $order->product_amount, '商品总金额 = 明细金额合计'); - $this->assertSame('30.00', (string) $order->total_amount, '订单总金额 = 商品金额'); + $this->assertSame('30.00', (string) $order->total_amount, '订单总金额 = 明细金额合计'); + + $purchase->refresh(); + $this->assertSame('5.00', (string) $purchase->total_quantity); + $this->assertSame('2.500', (string) $purchase->total_weight); + $this->assertSame('20.00', (string) $purchase->estimate_amount, '5 包 × 成本 4.00'); } - /** 已完成/已取消订单不允许修改明细 */ - public function test_update_item_rejected_when_completed_or_cancelled(): void + /** 采购单已完成:门店单品增删改均拒绝,明细不变 */ + public function test_store_item_edits_rejected_when_purchase_completed(): void { [, $product, $user] = $this->makeStoreWithProduct('5.00'); - $order = $this->placeOrder($product, $user, 1); - $item = $order->items->first(); - $payload = [ - 'supplier_id' => 0, - 'product_name' => '不应生效', - 'product_spec' => '', - 'unit' => '斤', - 'price' => '1.00', - 'cost_price' => '1.00', - 'quantity' => 1, - 'weight' => 0, - ]; + $order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED); + $purchase = $this->generatePurchase(); + $store = StoreModel::find($order->store_id); + $purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]); $this->actingAsSysUser(); - foreach ([StoreOrderModel::STATUS_COMPLETED, StoreOrderModel::STATUS_CANCELLED] as $status) { - $order->update(['status' => $status]); - $this->putJson("/order/store/item/{$item->id}", $payload) - ->assertOk()->assertJsonPath('success', false); - } - $this->assertNotSame('不应生效', $item->fresh()->product_name, '被拒绝后明细不变'); + $this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [ + 'quantity' => 9, + ])->assertOk()->assertJsonPath('success', false) + ->assertJsonPath('msg', '采购单已完成,不允许修改明细'); + $this->postJson("/purchase/order/{$purchase->id}/store/{$store->id}/item", [ + 'product_id' => $product->id, + 'quantity' => 1, + ])->assertJsonPath('success', false); + $this->deleteJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}") + ->assertJsonPath('success', false); + + $item = $order->items->first(); + $this->assertSame(1, $item->fresh()->quantity, '被拒绝后明细不变'); $this->assertSame('5.00', (string) $order->fresh()->total_amount, '被拒绝后总金额不变'); } - /** 修改明细参数校验:负单价被拦截 */ - public function test_update_item_validates_negative_price(): void + /** 修改门店单品参数校验:负单价/负数量被拦截 */ + public function test_update_store_item_validates_negative_values(): void { - [, $product, $user] = $this->makeStoreWithProduct('5.00'); - $order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED); - $item = $order->items->first(); + [$store, $product, $user] = $this->makeStoreWithProduct('5.00'); + $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED); + $purchase = $this->generatePurchase(); $this->actingAsSysUser(); - $this->putJson("/order/store/item/{$item->id}", [ - 'supplier_id' => 0, - 'product_name' => $item->product_name, - 'unit' => '斤', - 'price' => -1, - 'cost_price' => 0, + $this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [ 'quantity' => 1, - 'weight' => 0, + 'price' => -1, ])->assertOk() ->assertJsonPath('success', false) ->assertJsonPath('msg', '单价不能小于 0'); + + $this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [ + 'quantity' => -1, + ])->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '采购数量不能小于 0'); } - /** 一键同步:按商品ID同步最新档案(固定价等级保留原单价) */ - public function test_sync_item_updates_snapshot_from_product(): void + /** 新增单品:单价按门店等级上浮比例换算(成本 20 上浮 30% = 26.00) */ + public function test_add_store_item_uses_level_percent_price(): void { - [$store, $product, $user] = $this->makeStoreWithProduct('5.50', '4.00'); - $order = $this->placeOrder($product, $user, 3, StoreOrderModel::STATUS_SUMMARIZED); - $item = $order->items->first(); - $this->assertSame('16.50', (string) $item->amount); - - // 下单后商品档案变更:改名/改规格/改单位/换供应商/调成本价 - $supplier = SupplierModel::factory()->create(); - $product->update([ - 'name' => '同步后的品名', - 'spec' => '同步后的规格', - 'unit' => '箱', - 'supplier_id' => $supplier->id, - 'cost_price' => '9.99', - ]); - - $this->actingAsSysUser(); - $this->putJson("/order/store/item/{$item->id}/sync") - ->assertOk()->assertJsonPath('success', true); - - $item->refresh(); - $this->assertSame('同步后的品名', $item->product_name); - $this->assertSame('同步后的规格', $item->product_spec); - $this->assertSame('箱', $item->unit); - $this->assertSame($supplier->id, $item->supplier_id); - $this->assertSame('9.99', (string) $item->cost_price); - $this->assertSame('5.50', (string) $item->price, '固定价等级单价不随档案变化'); - $this->assertSame('16.50', (string) $item->amount, '单价未变,金额不变'); - } - - /** 一键同步:百分比计价等级按最新成本价重算单价与订单总价 */ - public function test_sync_item_recalculates_percent_price_with_latest_cost(): void - { - // 等级上浮 30%:下单时成本 10.00 → 售价 13.00 $level = CustomerLevelModel::factory()->create(['percent' => 30]); $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create([ - 'status' => ProductModel::STATUS_ON, - 'cost_price' => '10.00', - ]); + $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '10.00']); + $extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '20.00']); $user = UserModel::factory()->forStore($store->id)->create(); - $order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED); - $item = $order->items->first(); - $this->assertSame('13.00', (string) $item->price, '下单时 10 元上浮 30%'); - $this->assertSame('26.00', (string) $order->total_amount); - - // 成本价上调后同步:单价应按最新成本重算 - $product->update(['cost_price' => '20.00']); + $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED); + $purchase = $this->generatePurchase(); $this->actingAsSysUser(); - $this->putJson("/order/store/item/{$item->id}/sync") - ->assertOk()->assertJsonPath('success', true); + $this->postJson("/purchase/order/{$purchase->id}/store/{$store->id}/item", [ + 'product_id' => $extra->id, + 'quantity' => 2, + ])->assertOk()->assertJsonPath('success', true); - $item->refresh(); - $this->assertSame('20.00', (string) $item->cost_price); + $item = StoreOrderItemModel::where('product_id', $extra->id)->first(); + $this->assertNotNull($item); $this->assertSame('26.00', (string) $item->price, '20 元上浮 30% = 26.00'); $this->assertSame('52.00', (string) $item->amount, '26.00 × 2'); - - $order->refresh(); - $this->assertSame('52.00', (string) $order->product_amount); - $this->assertSame('52.00', (string) $order->total_amount); + $this->assertSame('20.00', (string) $item->cost_price, '快照成本价'); + $this->assertSame($extra->name, $item->product_name, '快照品名取自商品档案'); } - /** 一键同步:商品已删除时拒绝同步 */ - public function test_sync_item_rejected_when_product_deleted(): void + /** 新增单品:商品已删除时拒绝 */ + public function test_add_store_item_rejected_when_product_deleted(): void { - [, $product, $user] = $this->makeStoreWithProduct('5.00'); - $order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED); - $item = $order->items->first(); + [$store, $product, $user] = $this->makeStoreWithProduct('5.00'); + $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED); + $purchase = $this->generatePurchase(); - $product->delete(); // 软删除 + $extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '9.00']); + $extra->delete(); // 软删除 $this->actingAsSysUser(); - $this->putJson("/order/store/item/{$item->id}/sync") - ->assertOk() + $this->postJson("/purchase/order/{$purchase->id}/store/{$store->id}/item", [ + 'product_id' => $extra->id, + 'quantity' => 1, + ])->assertOk() ->assertJsonPath('success', false) - ->assertJsonPath('msg', '商品不存在或已被删除,无法同步'); + ->assertJsonPath('msg', '商品不存在或已被删除,无法添加'); } /** 订单列表按包含的商品名称搜索 */ diff --git a/web/api/purchase/order.ts b/web/api/purchase/order.ts index 38a9b25..7f1e6cf 100644 --- a/web/api/purchase/order.ts +++ b/web/api/purchase/order.ts @@ -5,6 +5,8 @@ import type { IPurchaseCell, IPurchaseDetail, IPurchaseStoreSummary, + PurchaseStoreItemAddParams, + PurchaseStoreItemUpdateParams, } from '@/domain/iPurchaseOrder.ts'; /** 订单商品参数修改 */ @@ -102,11 +104,60 @@ export async function generateBill(purchaseId: number, stores: BillGenerateStore }); } -/** 导出采购单(Excel 表格,全品类) */ -export async function exportPurchase(id: number) { +/** 门店购买详情:新增单品(挂靠该门店在采购单中的最新一笔订单) */ +export async function addPurchaseStoreItem(purchaseId: number, storeId: number, data: PurchaseStoreItemAddParams) { + return createAxios<{ id: number }>({ + url: `/purchase/order/${purchaseId}/store/${storeId}/item`, + method: 'post', + data, + }); +} + +/** 门店购买详情:修改单品(数量/称重/单价;同商品多笔订单明细时合并到最早一条) */ +export async function updatePurchaseStoreItem( + purchaseId: number, + storeId: number, + productId: number, + data: PurchaseStoreItemUpdateParams, +) { + return createAxios({ + url: `/purchase/order/${purchaseId}/store/${storeId}/item/${productId}`, + method: 'put', + data, + }); +} + +/** 门店购买详情:移除单品 */ +export async function removePurchaseStoreItem(purchaseId: number, storeId: number, productId: number) { + return createAxios({ + url: `/purchase/order/${purchaseId}/store/${storeId}/item/${productId}`, + method: 'delete', + }); +} + +/** 导出采购单商品明细(系统全部商品行,支持按供应商筛选) */ +export async function exportPurchase(id: number, supplierId?: number) { return downloadBlob( `/purchase/order/${id}/export`, - {}, + supplierId ? { supplier_id: supplierId } : {}, `采购单_${id}.xlsx`, ); } + +/** 导出门店购买详情(多工作表;storeId 单门店导出) */ +export async function exportPurchaseStores(id: number, storeId?: number) { + return downloadBlob( + `/purchase/order/${id}/exportStores`, + storeId ? { store_id: storeId } : {}, + `门店购买详情_${id}.xlsx`, + ); +} + +/** 导出供应商采购明细(多工作表;supplierId 单供应商导出) */ +export async function exportPurchaseSuppliers(id: number, supplierId?: number) { + return downloadBlob( + `/purchase/order/${id}/exportSuppliers`, + supplierId ? { supplier_id: supplierId } : {}, + `供应商采购明细_${id}.xlsx`, + ); +} diff --git a/web/domain/iPurchaseOrder.ts b/web/domain/iPurchaseOrder.ts index bfd3a98..715f98e 100644 --- a/web/domain/iPurchaseOrder.ts +++ b/web/domain/iPurchaseOrder.ts @@ -23,9 +23,42 @@ export interface IPurchaseDetailRow { quantity: number; /** 合计实际称重 */ weight: number; + /** 合计订货金额(Σ明细 amount;参考零售价 = amount÷quantity÷包规数值) */ + amount: string; cells: Record; } +/** 供应商采购明细行(供应商维度聚合,成本口径) */ +export interface IPurchaseSupplierItem { + product_id: number; + product_name: string; + /** 规格/包规 */ + product_spec: string; + unit: string; + /** 成本价 */ + cost_price: string; + /** 数量(包数) */ + quantity: number; + /** 重量(斤) */ + weight: string; + /** 金额 = Σ 数量×成本价 */ + amount: string; +} + +/** 门店购买详情:新增单品参数 */ +export interface PurchaseStoreItemAddParams { + product_id: number; + quantity: number; + weight?: number; +} + +/** 门店购买详情:修改单品参数(同商品多笔订单明细时合并到最早一条) */ +export interface PurchaseStoreItemUpdateParams { + quantity: number; + price?: number; + weight?: number; +} + /** 采购单 */ export default interface IPurchaseOrder { id?: number; diff --git a/web/pages/purchase/order.tsx b/web/pages/purchase/order.tsx index 4444b1f..e57cc6e 100644 --- a/web/pages/purchase/order.tsx +++ b/web/pages/purchase/order.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button, Descriptions, @@ -11,6 +11,7 @@ import { message, Modal, Popconfirm, + Radio, Select, Space, Spin, @@ -19,7 +20,7 @@ import { Tag, Typography, } from 'antd'; -import {AccountBookOutlined, DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons'; +import {AccountBookOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons'; import type { TableProps } from 'antd'; import XinTable from '@/components/XinTable'; import type { @@ -37,25 +38,35 @@ import type { IPurchaseDetailRow, IPurchaseStoreItem, IPurchaseStoreSummary, + IPurchaseSupplierItem, + PurchaseStoreItemAddParams, + PurchaseStoreItemUpdateParams, } from '@/domain/iPurchaseOrder.ts'; import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts'; import { + addPurchaseStoreItem, exportPurchase, + exportPurchaseStores, + exportPurchaseSuppliers, generateBill, getBillPrepare, getPurchaseCell, getPurchaseDetail, getPurchaseStoreSummary, + removePurchaseStoreItem, type BillGenerateStoreParams, type PurchaseCellUpdateParams, type PurchaseRowUpdateParams, updatePurchaseCellItem, updatePurchaseRow, + updatePurchaseStoreItem, } from '@/api/purchase/order.ts'; import { Update } from '@/api/common/table.ts'; import { getSupplierOptions } from '@/api/customer/supplier.ts'; +import { getProductOptions } from '@/api/product/goods.ts'; import type ISupplier from '@/domain/iSupplier.ts'; +import type IProduct from '@/domain/iProduct.ts'; import AuthButton from '@/components/AuthButton'; const { Title, Text } = Typography; @@ -103,6 +114,56 @@ const PurchaseOrderPage: React.FC = () => { const [storeSummary, setStoreSummary] = useState(null); const [storeLoading, setStoreLoading] = useState(false); + // 门店购买详情:新增单品弹窗 + const [addItemOpen, setAddItemOpen] = useState(false); + const [addItemSaving, setAddItemSaving] = useState(false); + const [addItemForm] = Form.useForm(); + const [productOptions, setProductOptions] = useState([]); + + // 门店购买详情:单品编辑弹窗(数量/称重/单价) + const [storeItemTarget, setStoreItemTarget] = useState(null); + const [storeItemSaving, setStoreItemSaving] = useState(false); + const [storeItemForm] = Form.useForm(); + + // 供应商采购明细页签 + const [supplierId, setSupplierId] = useState(0); + + // 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细 + const [itemExportOpen, setItemExportOpen] = useState(false); + const [itemExportSupplier, setItemExportSupplier] = useState(0); + const [storeExportOpen, setStoreExportOpen] = useState(false); + const [storeExportScope, setStoreExportScope] = useState<'current' | 'all'>('current'); + const [supplierExportOpen, setSupplierExportOpen] = useState(false); + const [supplierExportScope, setSupplierExportScope] = useState<'current' | 'all'>('current'); + + /** 采购单内出现的供应商(矩阵/供应商页签筛选与导出选项共用) */ + const purchaseSuppliers = useMemo(() => { + const map = new Map(); + (detail?.items ?? []).forEach((row) => { + if (row.supplier_id > 0 && row.supplier?.name) { + map.set(row.supplier_id, row.supplier.name); + } + }); + return Array.from(map, ([id, name]) => ({ id, name })); + }, [detail?.items]); + + /** 供应商采购明细行:按供应商过滤矩阵行,金额=数量×成本价(成本口径) */ + const supplierItems = useMemo(() => { + if (!detail || supplierId <= 0) return []; + return detail.items + .filter((row) => row.supplier_id === supplierId) + .map((row) => ({ + product_id: row.product_id, + product_name: row.product_name, + product_spec: row.product_spec, + unit: row.unit, + cost_price: String(row.cost_price), + quantity: row.quantity, + weight: String(row.weight), + amount: (row.quantity * Number(row.cost_price)).toFixed(2), + })); + }, [detail, supplierId]); + // 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读) const [billOpen, setBillOpen] = useState(false); const [billPrepare, setBillPrepare] = useState(null); @@ -144,6 +205,15 @@ const PurchaseOrderPage: React.FC = () => { } }, [detail]); + // 详情加载后默认选中第一个供应商(当前选中供应商仍在采购单内则保留) + useEffect(() => { + if (purchaseSuppliers.length > 0) { + setSupplierId((prev) => (purchaseSuppliers.some((s) => s.id === prev) ? prev : purchaseSuppliers[0].id)); + } else { + setSupplierId(0); + } + }, [purchaseSuppliers]); + // 切到「门店购买详情」页签或切换门店时加载汇总 useEffect(() => { if (detailOpen && detailTab === 'stores' && detail && storeId > 0) { @@ -182,6 +252,76 @@ const PurchaseOrderPage: React.FC = () => { } }; + /** 门店单品增删改后:刷新门店汇总、采购单详情与列表 */ + const refreshAfterStoreItemChange = async () => { + await loadStoreSummary(); + if (detail) { + await loadDetail(detail.purchase.id!); + } + await tableRef.current?.reload(); + }; + + /** 打开新增单品弹窗(懒加载商品选项) */ + const openAddItem = () => { + addItemForm.resetFields(); + setAddItemOpen(true); + if (productOptions.length === 0) { + getProductOptions().then((res) => setProductOptions(res.data.data ?? [])); + } + }; + + /** 提交新增单品 */ + const handleAddItemSave = async (values: PurchaseStoreItemAddParams) => { + if (!detail || storeId <= 0) { + return; + } + setAddItemSaving(true); + try { + await addPurchaseStoreItem(detail.purchase.id!, storeId, values); + message.success('已添加单品,订货单与采购单汇总已重算'); + setAddItemOpen(false); + await refreshAfterStoreItemChange(); + } finally { + setAddItemSaving(false); + } + }; + + /** 打开单品编辑弹窗(回显加权平均单价/数量/称重) */ + const openStoreItemEdit = (row: IPurchaseStoreItem) => { + setStoreItemTarget(row); + storeItemForm.setFieldsValue({ + quantity: row.quantity, + price: Number(row.price ?? 0), + weight: Number(row.weight ?? 0), + }); + }; + + /** 提交单品修改:同商品多笔订单明细合并到最早一条,级联重算 */ + const handleStoreItemSave = async (values: PurchaseStoreItemUpdateParams) => { + if (!detail || !storeItemTarget || storeId <= 0) { + return; + } + setStoreItemSaving(true); + try { + await updatePurchaseStoreItem(detail.purchase.id!, storeId, storeItemTarget.product_id, values); + message.success('单品已更新,订货单与采购单汇总已重算'); + setStoreItemTarget(null); + await refreshAfterStoreItemChange(); + } finally { + setStoreItemSaving(false); + } + }; + + /** 移除单品 */ + const handleStoreItemRemove = async (row: IPurchaseStoreItem) => { + if (!detail || storeId <= 0) { + return; + } + await removePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id); + message.success('已移除单品,订货单与采购单汇总已重算'); + await refreshAfterStoreItemChange(); + }; + /** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */ const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => { setCellQuery({ productId: row.product_id, storeId: store.id }); @@ -323,7 +463,7 @@ const PurchaseOrderPage: React.FC = () => { } }; - /** 明细矩阵列:品名/供应商/单价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */ + /** 明细矩阵列:品名/供应商/参考零售价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */ const buildItemColumns = (): TableProps['columns'] => { const fixed: NonNullable['columns']> = [ { title: '品名', dataIndex: 'product_name', width: 120, align: 'center' }, @@ -335,11 +475,17 @@ const PurchaseOrderPage: React.FC = () => { render: (_, row) => row.supplier?.name ?? '-', }, { - title: '参考成本单价', - key: 'unit_cost', + title: '参考零售价', + key: 'retail_price', width: 100, align: 'center', - render: (_, row) => `¥${calcUnitRefPrice(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`, + render: (_, row) => { + // 加权平均售价 ÷ 包规数值(无订货数量时无参考价) + const quantity = Number(row.quantity ?? 0); + if (quantity <= 0) return -; + const weightedPrice = Number(row.amount ?? 0) / quantity; + return `¥${calcUnitRefPrice(weightedPrice, row.product_spec ?? '').toFixed(2)}`; + }, }, { title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' }, { title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' }, @@ -430,7 +576,7 @@ const PurchaseOrderPage: React.FC = () => { ); }; - /** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 */ + /** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 + 操作 */ const storeColumns: TableProps['columns'] = [ { title: '商品', dataIndex: 'product_name', width: 160, align: 'center' }, { @@ -464,6 +610,40 @@ const PurchaseOrderPage: React.FC = () => { align: 'center', render: (v) => ¥{Number(v).toFixed(2)}, }, + { + title: '操作', + key: 'action', + width: 130, + align: 'center', + render: (_, row) => + detail?.purchase.status === 0 ? ( + + + + + + handleStoreItemRemove(row)} + > + + + + + ) : ( + - + ), + }, ]; /** 门店购买详情合计行:总数量 + 总预计金额 */ @@ -486,6 +666,61 @@ const PurchaseOrderPage: React.FC = () => { ¥{totalAmount.toFixed(2)} + + - + + + ); + }; + + /** 供应商采购明细列:品名/包规/单位/成本价/数量/重量/金额(成本口径) */ + const supplierColumns: TableProps['columns'] = [ + { title: '品名', dataIndex: 'product_name', width: 180, align: 'center' }, + { title: '包规', dataIndex: 'product_spec', width: 110, align: 'center', render: (v) => v || '-' }, + { title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' }, + { + title: '成本价', + dataIndex: 'cost_price', + width: 110, + align: 'center', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '数量', + dataIndex: 'quantity', + width: 100, + align: 'center', + render: (v) => {v}, + }, + { title: '重量', dataIndex: 'weight', width: 110, align: 'center', render: (v) => `${Number(v).toFixed(3)}斤` }, + { + title: '金额', + dataIndex: 'amount', + width: 120, + align: 'center', + render: (v) => ¥{Number(v).toFixed(2)}, + }, + ]; + + /** 供应商采购明细合计行 */ + const renderSupplierTotal = () => { + const totalQuantity = supplierItems.reduce((sum, row) => sum + Number(row.quantity), 0); + const totalWeight = supplierItems.reduce((sum, row) => sum + Number(row.weight), 0); + const totalAmount = supplierItems.reduce((sum, row) => sum + Number(row.amount), 0); + return ( + + + 合计 + + + {totalQuantity} + + + {totalWeight.toFixed(3)}斤 + + + ¥{totalAmount.toFixed(2)} + ); }; @@ -770,23 +1005,48 @@ const PurchaseOrderPage: React.FC = () => { items={[ { key: 'items', label: '商品明细' }, { key: 'stores', label: '门店购买详情' }, + { key: 'suppliers', label: '供应商采购明细' }, { key: 'bills', label: '门店账单' }, ]} /> { detailTab === 'stores' ? ( <> -
- 选择门店: - setStoreId(value)} + placeholder="选择门店" + className="w-60!" + showSearch + optionFilterProp="label" + options={detail.stores.map((s) => ({ value: s.id, label: s.name }))} + /> +
+ + {detail.purchase.status === 0 && ( + + + + )} + + + + {storeSummary && storeSummary.items.length > 0 ? ( @@ -810,6 +1070,53 @@ const PurchaseOrderPage: React.FC = () => { )} + ) : detailTab === 'suppliers' ? ( + <> +
+
+ 选择供应商: + ({ + value: p.id!, + label: `${p.name}${p.spec ? `(${p.spec})` : ''}`, + }))} + /> + + + + + + + + + + + {/* 单品编辑:数量/称重/单价(同商品多笔订单明细合并到最早一条) */} + setStoreItemTarget(null)} + onOk={() => storeItemForm.submit()} + confirmLoading={storeItemSaving} + okText="保存" + destroyOnHidden + > +
+ 修改保存后,系统将自动重算明细金额、订货单与采购单汇总。 +
+
+ + + + + + + + + +
+
+ + {/* 商品明细导出:供应商筛选(全部/单个) */} + setItemExportOpen(false)} + onOk={() => { + if (detail) { + void exportPurchase(detail.purchase.id!, itemExportSupplier > 0 ? itemExportSupplier : undefined); + } + setItemExportOpen(false); + }} + okText="导出" + destroyOnHidden + > +
+ 导出系统全部商品行(无订货的商品数量为 0),门店列附数量合计与突出颜色。 +
+
+ 供应商: +