From dadfdc1511bfd982dd772cd65eebc10624210299 Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Wed, 12 Aug 2026 14:38:20 +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 | 122 +++- .../Purchase/PurchaseOrderController.php | 262 ++++---- .../Recon/ReconciliationController.php | 2 +- .../Purchase/PurchaseCellUpdateRequest.php | 32 + .../Purchase/PurchaseItemUpdateRequest.php | 39 -- .../Purchase/PurchaseRowUpdateRequest.php | 31 + app/Models/PurchaseAllocationModel.php | 67 --- app/Models/PurchaseOrderItemModel.php | 87 --- app/Models/PurchaseOrderModel.php | 22 +- app/Models/ReconciliationItemModel.php | 10 - app/Models/StoreOrderItemModel.php | 4 +- app/Models/SupplierModel.php | 8 - app/Services/PurchaseAllocateService.php | 113 ---- app/Services/PurchaseEditService.php | 177 ++++++ app/Services/PurchaseGenerateService.php | 123 +--- app/Services/ReconciliationBuildService.php | 125 ++-- ..._07_23_030610_create_store_order_table.php | 2 + ...026_07_23_030611_create_purchase_table.php | 46 -- ..._23_030612_create_reconciliation_table.php | 1 - database/seeders/PermissionSeeder.php | 2 - resources/views/exports/purchase.blade.php | 36 +- tests/Feature/AllocationTest.php | 150 ----- tests/Feature/PurchaseEditTest.php | 171 ++++++ tests/Feature/PurchaseGenerateTest.php | 67 +-- tests/Feature/ReconciliationTest.php | 84 +-- web/api/purchase/order.ts | 62 +- web/domain/iPurchaseOrder.ts | 87 +-- web/domain/iReconciliation.ts | 3 +- web/pages/purchase/order.tsx | 563 ++++++------------ 30 files changed, 1059 insertions(+), 1441 deletions(-) create mode 100644 app/Http/Requests/Purchase/PurchaseCellUpdateRequest.php delete mode 100644 app/Http/Requests/Purchase/PurchaseItemUpdateRequest.php create mode 100644 app/Http/Requests/Purchase/PurchaseRowUpdateRequest.php delete mode 100644 app/Models/PurchaseAllocationModel.php delete mode 100644 app/Models/PurchaseOrderItemModel.php delete mode 100644 app/Services/PurchaseAllocateService.php create mode 100644 app/Services/PurchaseEditService.php delete mode 100644 tests/Feature/AllocationTest.php create mode 100644 tests/Feature/PurchaseEditTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 9adf4d4..bec3c52 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":7,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":8,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":8,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":7,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":7,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":5,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":7,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":7,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":8,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":8,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":7,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":8,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":8,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":8,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":8,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":8,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":8,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":8,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":8,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":7,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":7},"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.018,"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.013,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.008,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.007,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.01,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.016,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.014,"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.033,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.03,"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.045,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.032,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.035,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.034,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.043,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.016,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.026,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.019,"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.069,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.019,"Tests\\Feature\\ExportTest::test_export_pdf":1.302,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.019,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.019,"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.009,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.009,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.007,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.006,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.007,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.006,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.01,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.014,"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.01,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.007,"Tests\\Feature\\CartTest::test_delete_item":0.009,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.011,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.017,"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.007,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.01,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.01,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.011,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.02,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.013,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.016,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.007,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.01,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.009,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.014,"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.009,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.014,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.022,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.002,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.039,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.005,"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.006,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.007,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":0.016,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":0.018,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":0.019,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":0.014,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":0.012,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_accepted_orders_fails":0.016,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.05,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.008,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.015,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.017,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":0.021,"Tests\\Feature\\StoreOrderItemTest::test_update_item_rejected_when_completed_or_cancelled":0.015,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":0.011,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":0.014,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":0.014,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":0.014,"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.042,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.014}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":7,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":8,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":8,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":7,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":7,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":5,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":7,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":7,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":8,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":8,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":7,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":8,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":8,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":8,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":8,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":8,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":8,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":8,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":8,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":8,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":8,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":7,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":7,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":7,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":7,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":7},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.004,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.018,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.024,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.016,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.015,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.013,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.011,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.028,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.021,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.027,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.028,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.041,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.054,"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.046,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.061,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.043,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.052,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.043,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.026,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.021,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.041,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.056,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.006,"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.01,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.009,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.077,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.023,"Tests\\Feature\\ExportTest::test_export_pdf":1.46,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.021,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.02,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.005,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.063,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.016,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.008,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.008,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.009,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.014,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.011,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.013,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.016,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.006,"Tests\\Feature\\CartTest::test_update_quantity":0.012,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.022,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.018,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.015,"Tests\\Feature\\CartTest::test_delete_item":0.009,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.013,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.021,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.003,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.013,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_fixed_and_percent":0.002,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_edge_cases":0.002,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.018,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.015,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.019,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.021,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.017,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.021,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.015,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.02,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.014,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.012,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.019,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.022,"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.015,"Tests\\Feature\\ProductPriceTest::test_unbound_user_can_browse_products_without_price_but_cannot_cart":0.023,"Tests\\Feature\\MiniAuthTest::test_login_issues_token_for_registered_user":0.017,"Tests\\Feature\\MiniAuthTest::test_login_rejects_unregistered_openid":0.004,"Tests\\Feature\\MiniAuthTest::test_register_binds_store_and_issues_token":0.021,"Tests\\Feature\\MiniAuthTest::test_register_rejects_unknown_store":0.004,"Tests\\Feature\\MiniAuthTest::test_register_rejects_duplicate_openid":0.006,"Tests\\Feature\\MiniStoreTest::test_info_returns_bound_store":0.008,"Tests\\Feature\\MiniStoreTest::test_update_info":0.011,"Tests\\Feature\\MiniStoreTest::test_update_info_ignores_readonly_fields":0.011,"Tests\\Feature\\MiniStoreTest::test_requires_bound_store":0.012,"Tests\\Feature\\MiniStoreTest::test_update_info_validates_length":0.012,"Tests\\Feature\\StoreOrderTest::test_container_update_recalculates_amounts":0.026,"Tests\\Feature\\StoreOrderTest::test_container_update_allowed_in_editable_statuses":0.032,"Tests\\Feature\\StoreOrderTest::test_container_update_rejected_in_other_statuses":0.032,"Tests\\Feature\\StoreOrderTest::test_container_update_heals_legacy_product_amount":0.028,"Tests\\Feature\\StoreOrderTest::test_container_update_validates_negative_numbers":0.021,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_accepted_orders_fails":0.026,"Tests\\Feature\\PurchaseGenerateTest::test_generate_with_selected_order_ids":0.045,"Tests\\Feature\\StoreOrderItemTest::test_place_order_snapshots_full_product_info":0.021,"Tests\\Feature\\StoreOrderItemTest::test_mini_detail_hides_cost_price":0.023,"Tests\\Feature\\StoreOrderItemTest::test_admin_detail_shows_cost_price_and_supplier":0.022,"Tests\\Feature\\StoreOrderItemTest::test_update_item_recalculates_order_totals":0.031,"Tests\\Feature\\StoreOrderItemTest::test_update_item_rejected_when_completed_or_cancelled":0.026,"Tests\\Feature\\StoreOrderItemTest::test_update_item_validates_negative_price":0.019,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_updates_snapshot_from_product":0.02,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_recalculates_percent_price_with_latest_cost":0.033,"Tests\\Feature\\StoreOrderItemTest::test_sync_item_rejected_when_product_deleted":0.025,"Tests\\Feature\\StoreOrderItemTest::test_order_list_search_by_product_name":0.026,"Tests\\Feature\\StoreOrderTest::test_soft_delete_only_cancelled_orders":0.043,"Tests\\Feature\\StoreOrderTest::test_soft_delete_rejected_for_non_cancelled_orders":0.098,"Tests\\Feature\\StoreOrderTest::test_soft_delete_twice_rejected":0.028,"Tests\\Feature\\PurchaseEditTest::test_detail_returns_store_matrix":0.062,"Tests\\Feature\\PurchaseEditTest::test_update_cell_syncs_order_item_and_totals":0.053,"Tests\\Feature\\PurchaseEditTest::test_update_row_cost_syncs_all_order_items":0.052,"Tests\\Feature\\PurchaseEditTest::test_update_row_weight_distributed_by_quantity":0.05,"Tests\\Feature\\PurchaseEditTest::test_update_row_requires_at_least_one_field":0.046,"Tests\\Feature\\PurchaseEditTest::test_update_cell_rejects_negative_quantity":0.04,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_order_items_into_header":0.039,"Tests\\Feature\\ReconciliationTest::test_build_excludes_unfinished_orders":0.042}} \ No newline at end of file diff --git a/app/Exports/PurchaseOrderExport.php b/app/Exports/PurchaseOrderExport.php index 3fd18b1..6eaaac8 100644 --- a/app/Exports/PurchaseOrderExport.php +++ b/app/Exports/PurchaseOrderExport.php @@ -5,7 +5,10 @@ namespace App\Exports; use App\Models\ProductCategoryModel; use App\Models\ProductModel; use App\Models\PurchaseOrderModel; +use App\Models\StoreModel; +use App\Models\StoreOrderItemModel; use App\Models\SupplierModel; +use App\Services\PurchaseEditService; use Illuminate\Support\Collection; use Maatwebsite\Excel\Concerns\FromCollection; use Maatwebsite\Excel\Concerns\WithHeadings; @@ -15,6 +18,7 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; /** * 采购单导出(C2 全品类按分类 sort 排序 / C3 仅蔬果分类) + * 数据源为订货明细按商品聚合(无独立采购明细表),每门店一列显示数量 */ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles { @@ -24,6 +28,9 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, /** @var array 供应商ID => 名称 */ private array $supplierNames = []; + /** @var array 门店ID => 名称(导出列) */ + private array $storeNames = []; + private ?Collection $items = null; /** @@ -37,7 +44,7 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, } /** - * 导出行(按 sort 排序;蔬果分类时按顶级分类名过滤) + * 导出行(商品聚合行,按 分类sort → 商品sort 排序;蔬果分类时按顶级分类名过滤) */ public function collection(): Collection { @@ -45,38 +52,112 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, return $this->items; } - $items = $this->purchase->items()->orderBy('sort')->get(); + $orderItems = 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') + ->select('store_order_item.*') + ->get() + ->makeVisible('cost_price'); + + $this->storeNames = StoreModel::withTrashed() + ->whereIn('id', $orderItems->pluck('store_id')->unique()) + ->pluck('name', 'id') + ->toArray(); + + $products = ProductModel::withTrashed() + ->with('category:id,sort') + ->whereIn('id', $orderItems->pluck('product_id')->unique()) + ->get() + ->keyBy('id'); + + $rows = []; + foreach ($orderItems->groupBy('product_id') as $productId => $group) { + $first = $group->first(); + $product = $products->get((int) $productId); + $unitCost = PurchaseEditService::unitCost((string) ($first->cost_price ?? '0'), (string) $first->product_spec); + + $quantity = '0'; + $weight = '0'; + $amount = '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, PurchaseEditService::costAmount( + (string) $item->quantity, + (string) $item->weight, + $unitCost + ), 2); + $storeQuantities[(int) $item->store_id] = bcadd( + $storeQuantities[(int) $item->store_id] ?? '0', + (string) $item->quantity, + 2 + ); + } + + $rows[] = [ + '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, + 'quantity' => (float) $quantity, + 'weight' => (float) $weight, + 'amount' => (float) $amount, + 'store_quantities' => array_map('floatval', $storeQuantities), + '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']]); + + $items = collect($rows); $this->loadLookups($items); if ($this->type === 'category') { - $items = $items->filter(function ($item): bool { - $rootName = $this->categoryNames[(int) $item->product_id] ?? ''; + $items = $items->filter(function (array $row): bool { + $rootName = $this->categoryNames[$row['product_id']] ?? ''; return str_contains($rootName, '蔬菜') || str_contains($rootName, '水果'); })->values(); } - return $this->items = $items; + $sort = 1; + return $this->items = $items->map(static function (array $row) use (&$sort): array { + $row['sort'] = $sort++; + return $row; + })->values(); } public function headings(): array { - return ['序号', '分类', '品名', '规格/包规', '单价', '数量', '实际称重', '金额', '供应商', '备注']; + $this->collection(); + + return array_merge( + ['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'], + array_map(static fn (string $name): string => $name . '(数量)', array_values($this->storeNames)), + ); } - public function map($item): array + public function map($row): array { - return [ - $item->sort, - $this->categoryNames[(int) $item->product_id] ?? '', - $item->product_name, - $item->product_spec, - (float) $item->price, - (float) $item->quantity, - (float) $item->weight, - (float) $item->amount, - $this->supplierNames[(int) $item->supplier_id] ?? '', - $item->remark, - ]; + 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'])); } /** @@ -94,13 +175,14 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, /** * PDF 模板视图数据 * - * @return array{purchase: PurchaseOrderModel, items: Collection, categoryNames: array, supplierNames: array} + * @return array{purchase: PurchaseOrderModel, items: Collection, storeNames: array, categoryNames: array, supplierNames: array} */ public function viewData(): array { return [ 'purchase' => $this->purchase, 'items' => $this->collection(), + 'storeNames' => $this->storeNames, 'categoryNames' => $this->categoryNames, 'supplierNames' => $this->supplierNames, ]; diff --git a/app/Http/Controllers/Purchase/PurchaseOrderController.php b/app/Http/Controllers/Purchase/PurchaseOrderController.php index 8caa3d1..6ad4eaf 100644 --- a/app/Http/Controllers/Purchase/PurchaseOrderController.php +++ b/app/Http/Controllers/Purchase/PurchaseOrderController.php @@ -3,12 +3,14 @@ namespace App\Http\Controllers\Purchase; use App\Exceptions\RepositoryException; -use App\Http\Requests\Purchase\PurchaseItemUpdateRequest; -use App\Models\PurchaseAllocationModel; -use App\Models\PurchaseOrderItemModel; +use App\Http\Requests\Purchase\PurchaseCellUpdateRequest; +use App\Http\Requests\Purchase\PurchaseRowUpdateRequest; +use App\Models\ProductModel; use App\Models\PurchaseOrderModel; +use App\Models\StoreModel; +use App\Models\StoreOrderItemModel; use App\Services\ExportService; -use App\Services\PurchaseAllocateService; +use App\Services\PurchaseEditService; use App\Services\PurchaseGenerateService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -20,7 +22,8 @@ use Modules\Common\Http\Controllers\BaseController; use Symfony\Component\HttpFoundation\Response; /** - * 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改 / C5-C6 发送供应商 / D3 金额分摊) + * 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改) + * 采购单无独立明细表,明细直接溯源门店订货明细(store_order_item.purchase_id) */ #[RequestAttribute('/purchase/order', 'purchase.order')] class PurchaseOrderController extends BaseController @@ -45,30 +48,110 @@ class PurchaseOrderController extends BaseController return $this->success($data); } - /** 采购单详情:头 + 明细(含供应商)+ 分摊记录 */ + /** + * 采购单详情:头 + 明细矩阵(商品行 × 门店列) + * 行 = 商品(按分类 sort → 商品 sort 排序),列 = 商品信息 + 每个门店一格(数量/金额) + */ #[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])] public function detail(int $id): JsonResponse { - $purchase = PurchaseOrderModel::with([ - 'operator:id,nickname', - 'items.supplier:id,name', - 'items.allocations.store:id,name', - ])->find($id); + $purchase = PurchaseOrderModel::with('operator:id,nickname')->find($id); if (empty($purchase)) { - throw new RepositoryException('采购单不存在'); + return $this->error('采购单不存在'); } - return $this->success($purchase->toArray()); + + // 订货明细 + $orderItems = StoreOrderItemModel::query() + ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') + ->where('store_order_item.purchase_id', $purchase->id) + ->whereNull('store_order.deleted_at') + ->with('supplier:id,name') + ->select('store_order_item.*') + ->get() + ->makeVisible('cost_price'); + + // 门店列(含软删除门店,保证历史单据可见) + $stores = StoreModel::withTrashed() + ->whereIn('id', $orderItems->pluck('store_id')->unique()) + ->get(['id', 'name']) + ->map(static fn (StoreModel $store) => ['id' => $store->id, 'name' => $store->name]) + ->values(); + + // 排序键:分类 sort → 商品 sort + $products = ProductModel::withTrashed() + ->with('category:id,sort') + ->whereIn('id', $orderItems->pluck('product_id')->unique()) + ->get() + ->keyBy('id'); + + $rows = []; + foreach ($orderItems->groupBy('product_id') as $productId => $group) { + $first = $group->first(); + // 商品 + $product = $products->get((int) $productId); + // 采购总数 + $quantity = 0; + // 采购总重量 + $weight = '0'; + // 门店明细 + $cellsMap = []; + + foreach ($group as $item) { + $storeId = $item->store_id; + // 累加总量 + $quantity += (int) $item->quantity; + $weight = bcadd($weight, (string) $item->weight, 3); + + // 按门店合并 + if (!isset($cellsMap[$storeId])) { + $cellsMap[$storeId] = 0; + } + + $cellsMap[$storeId] += (int) $item->quantity; + } + + // 供应商 + $supplier = $first->supplier ? ['id' => $first->supplier->id, 'name' => $first->supplier->name] : null; + $rows[] = [ + 'product_id' => (int) $productId, + 'product_name' => $first->product_name, // 品名 + 'product_spec' => $first->product_spec, // 包规 + 'unit' => $first->unit, // 单位 + 'supplier_id' => (int) $first->supplier_id, // 供应商id + 'supplier' => $supplier, // 供应商 + 'cost_price' => $first->cost_price, // 成本价 + 'quantity' => $quantity, + 'weight' => (float) $weight, + 'cells' => $cellsMap, + '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']]); + + return $this->success([ + 'purchase' => $purchase->toArray(), + 'stores' => $stores->toArray(), + 'items' => array_map(static function (array $row): array { + unset($row['category_sort'], $row['product_sort']); + return $row; + }, $rows), + ]); } - /** C4 修改采购单头信息(采购日期、备注) */ + /** C4 修改采购单头信息(采购日期、状态、备注) */ #[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] public function update(int $id, Request $request): JsonResponse { $data = $request->validate([ 'purchase_date' => 'nullable|date_format:Y-m-d', + 'status' => 'nullable|integer|in:' . PurchaseOrderModel::STATUS_PENDING . ',' . PurchaseOrderModel::STATUS_COMPLETED, 'remark' => 'nullable|string|max:255', ], [ 'purchase_date.date_format' => '采购日期格式为 Y-m-d', + 'status.in' => '采购单状态不正确', ]); $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { @@ -80,7 +163,7 @@ class PurchaseOrderController extends BaseController /** * C1 生成采购单:合并全部「已接单」门店订单(或指定的 order_ids), - * 生成后源订单转为「采购中」并回写 purchase_id + * 生成后源订单转为「采购中」并回写 purchase_id(订单与订货明细同步归集) */ #[PostRoute('/generate', 'generate')] public function generate(Request $request): JsonResponse @@ -130,128 +213,49 @@ class PurchaseOrderController extends BaseController } /** - * C4 采购明细修改:amount 后端重算(weight>0 ? weight×price : quantity×price), - * 同步回写采购单头汇总(Σ total_weight / actual_amount) + * C4 门店单元格修改:数量/称重回写订货明细,金额重算并同步订单/采购单汇总 */ - #[PutRoute(route: '/item/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] - public function updateItem(int $id, PurchaseItemUpdateRequest $request): JsonResponse + #[PutRoute(route: '/cell/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] + public function updateCell(int $id, PurchaseCellUpdateRequest $request): JsonResponse { - $item = PurchaseOrderItemModel::find($id); + $item = StoreOrderItemModel::find($id); if (empty($item)) { - throw new RepositoryException('采购明细不存在'); + throw new RepositoryException('订货明细不存在'); } $validated = $request->validated(); - $price = (string) $validated['price']; - $quantity = (string) $validated['quantity']; - $weight = (string) ($validated['weight'] ?? 0); - $amount = (float) $weight > 0 - ? bcmul($weight, $price, 2) - : bcmul($quantity, $price, 2); - - $item->update([ - 'product_name' => $validated['product_name'] ?? $item->product_name, - 'product_spec' => $validated['product_spec'] ?? $item->product_spec, - 'price' => $price, - 'quantity' => $quantity, - 'weight' => $weight, - 'amount' => $amount, - 'remark' => $validated['remark'] ?? $item->remark, - ]); - - // 回写采购单头汇总 - $sums = PurchaseOrderItemModel::query() - ->where('purchase_id', $item->purchase_id) - ->selectRaw('COALESCE(SUM(weight), 0) as total_weight, COALESCE(SUM(amount), 0) as actual_amount') - ->first(); - PurchaseOrderModel::whereKey($item->purchase_id)->update([ - 'total_weight' => $sums->total_weight, - 'actual_amount' => $sums->actual_amount, - ]); - - return $this->success(['amount' => $amount]); - } - - /** C5/C6 明细发送供应商:is_sent=1 + sent_at;联动采购单状态(全发送→ALL_SENT,否则 PART_SENT) */ - #[PutRoute(route: '/item/{id}/send', authorize: 'send', where: ['id' => '[0-9]+'])] - public function sendItem(int $id): JsonResponse - { - $item = PurchaseOrderItemModel::find($id); - if (empty($item)) { - throw new RepositoryException('采购明细不存在'); - } - if ($item->is_sent === PurchaseOrderItemModel::SENT) { - throw new RepositoryException('该明细已发送,请勿重复操作'); - } - $item->is_sent = PurchaseOrderItemModel::SENT; - $item->sent_at = now(); - $item->save(); - - $purchase = $item->purchase; - $hasUnsent = $purchase->items() - ->where('is_sent', PurchaseOrderItemModel::NOT_SENT) - ->exists(); - $purchase->status = $hasUnsent - ? PurchaseOrderModel::STATUS_PART_SENT - : PurchaseOrderModel::STATUS_ALL_SENT; - $purchase->save(); - - return $this->success(); - } - - /** D3 执行金额分摊(按订货比例摊到门店/单品,尾差修正守恒;可重复执行) */ - #[PostRoute(route: '/{id}/allocate', authorize: 'allocate', where: ['id' => '[0-9]+'])] - public function allocate(int $id): JsonResponse - { - $purchase = PurchaseOrderModel::find($id); - if (empty($purchase)) { - throw new RepositoryException('采购单不存在'); - } - $count = app(PurchaseAllocateService::class)->allocate($purchase); - return $this->success(['count' => $count], '分摊完成'); - } - - /** 分摊结果:按门店、按商品两个聚合维度 */ - #[GetRoute(route: '/{id}/allocation', authorize: 'query', where: ['id' => '[0-9]+'])] - public function allocation(int $id): JsonResponse - { - $purchase = PurchaseOrderModel::find($id); - if (empty($purchase)) { - throw new RepositoryException('采购单不存在'); - } - - $allocations = PurchaseAllocationModel::query() - ->whereIn('purchase_item_id', $purchase->items()->pluck('id')) - ->with(['store:id,name', 'product:id,name,unit']) - ->get(); - - $byStore = $allocations->groupBy('store_id')->map(function ($group) { - $first = $group->first(); - return [ - 'store_id' => $first->store_id, - 'store_name' => $first->store?->name ?? '', - 'quantity' => (float) $group->sum('quantity'), - 'weight' => (float) $group->sum('weight'), - 'amount' => (float) $group->sum('amount'), - ]; - })->values(); - - $byProduct = $allocations->groupBy('product_id')->map(function ($group) { - $first = $group->first(); - return [ - 'product_id' => $first->product_id, - 'product_name' => $first->product?->name ?? '', - 'unit' => $first->product?->unit ?? '', - 'quantity' => (float) $group->sum('quantity'), - 'weight' => (float) $group->sum('weight'), - 'amount' => (float) $group->sum('amount'), - ]; - })->values(); + $item = app(PurchaseEditService::class)->updateCell( + $item, + (int) $validated['quantity'], + isset($validated['weight']) ? (float) $validated['weight'] : null, + ); return $this->success([ - 'by_store' => $byStore->toArray(), - 'by_product' => $byProduct->toArray(), - 'total_amount' => (float) $allocations->sum('amount'), + 'quantity' => (int) $item->quantity, + 'weight' => (float) $item->weight, + 'amount' => (float) $item->amount, ]); } + + /** + * C4 商品行修改:采购成本同步该商品全部订货明细;实际称重按数量比例分摊(尾差修正守恒) + */ + #[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])] + public function updateRow(int $id, int $productId, PurchaseRowUpdateRequest $request): JsonResponse + { + $purchase = PurchaseOrderModel::find($id); + if (empty($purchase)) { + throw new RepositoryException('采购单不存在'); + } + $validated = $request->validated(); + $costPrice = isset($validated['cost_price']) ? (float) $validated['cost_price'] : null; + $weight = isset($validated['weight']) ? (float) $validated['weight'] : null; + if ($costPrice === null && $weight === null) { + throw new RepositoryException('采购成本与实际称重至少填写一项'); + } + + $count = app(PurchaseEditService::class)->updateRow($purchase, $productId, $costPrice, $weight); + + return $this->success(['count' => $count], '已同步 ' . $count . ' 条订货明细'); + } } diff --git a/app/Http/Controllers/Recon/ReconciliationController.php b/app/Http/Controllers/Recon/ReconciliationController.php index be03983..a707a78 100644 --- a/app/Http/Controllers/Recon/ReconciliationController.php +++ b/app/Http/Controllers/Recon/ReconciliationController.php @@ -109,7 +109,7 @@ class ReconciliationController extends BaseController return $this->success(); } - /** 生成对账明细(按周期 + 品类 + 供应商拉取采购分摊数据;可重复生成) */ + /** 生成对账明细(按周期 + 品类 + 供应商拉取已完成订单明细;可重复生成) */ #[PostRoute(route: '/{id}/build', authorize: 'build', where: ['id' => '[0-9]+'])] public function build(int $id): JsonResponse { diff --git a/app/Http/Requests/Purchase/PurchaseCellUpdateRequest.php b/app/Http/Requests/Purchase/PurchaseCellUpdateRequest.php new file mode 100644 index 0000000..a725e37 --- /dev/null +++ b/app/Http/Requests/Purchase/PurchaseCellUpdateRequest.php @@ -0,0 +1,32 @@ + 'required|integer|min:0', + 'weight' => 'nullable|numeric|min:0', + ]; + } + + public function messages(): array + { + return [ + 'quantity.required' => '数量不能为空', + 'quantity.integer' => '数量必须为整数', + 'quantity.min' => '数量不能小于 0', + 'weight.numeric' => '实际称重必须为数字', + 'weight.min' => '实际称重不能小于 0', + ]; + } +} diff --git a/app/Http/Requests/Purchase/PurchaseItemUpdateRequest.php b/app/Http/Requests/Purchase/PurchaseItemUpdateRequest.php deleted file mode 100644 index 2f3bf51..0000000 --- a/app/Http/Requests/Purchase/PurchaseItemUpdateRequest.php +++ /dev/null @@ -1,39 +0,0 @@ -0 ? weight×price : quantity×price 重算) - */ -class PurchaseItemUpdateRequest extends BaseFormRequest -{ - protected $stopOnFirstFailure = true; - - public function rules(): array - { - return [ - 'product_name' => 'nullable|string|max:100', - 'product_spec' => 'nullable|string|max:100', - 'price' => 'required|numeric|min:0', - 'quantity' => 'required|numeric|min:0', - 'weight' => 'nullable|numeric|min:0', - 'remark' => 'nullable|string|max:255', - ]; - } - - public function messages(): array - { - return [ - 'price.required' => '采购单价不能为空', - 'price.numeric' => '采购单价必须为数字', - 'price.min' => '采购单价不能小于 0', - 'quantity.required' => '采购量不能为空', - 'quantity.numeric' => '采购量必须为数字', - 'quantity.min' => '采购量不能小于 0', - 'weight.numeric' => '实际称重必须为数字', - 'weight.min' => '实际称重不能小于 0', - ]; - } -} diff --git a/app/Http/Requests/Purchase/PurchaseRowUpdateRequest.php b/app/Http/Requests/Purchase/PurchaseRowUpdateRequest.php new file mode 100644 index 0000000..9df9396 --- /dev/null +++ b/app/Http/Requests/Purchase/PurchaseRowUpdateRequest.php @@ -0,0 +1,31 @@ + 'nullable|numeric|min:0', + 'weight' => 'nullable|numeric|min:0', + ]; + } + + public function messages(): array + { + return [ + 'cost_price.numeric' => '采购成本必须为数字', + 'cost_price.min' => '采购成本不能小于 0', + 'weight.numeric' => '实际称重必须为数字', + 'weight.min' => '实际称重不能小于 0', + ]; + } +} diff --git a/app/Models/PurchaseAllocationModel.php b/app/Models/PurchaseAllocationModel.php deleted file mode 100644 index 8a62531..0000000 --- a/app/Models/PurchaseAllocationModel.php +++ /dev/null @@ -1,67 +0,0 @@ - 'integer', - 'order_item_id' => 'integer', - 'store_id' => 'integer', - 'product_id' => 'integer', - 'quantity' => 'decimal:2', - 'weight' => 'decimal:3', - 'amount' => 'decimal:2', - ]; - - /** - * 来源采购明细 - */ - public function purchaseItem(): BelongsTo - { - return $this->belongsTo(PurchaseOrderItemModel::class, 'purchase_item_id', 'id'); - } - - /** - * 溯源订货明细 - */ - public function orderItem(): BelongsTo - { - return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id'); - } - - /** - * 分摊门店 - */ - public function store(): BelongsTo - { - return $this->belongsTo(StoreModel::class, 'store_id', 'id'); - } - - /** - * 分摊商品 - */ - public function product(): BelongsTo - { - return $this->belongsTo(ProductModel::class, 'product_id', 'id'); - } -} diff --git a/app/Models/PurchaseOrderItemModel.php b/app/Models/PurchaseOrderItemModel.php deleted file mode 100644 index 469bb3b..0000000 --- a/app/Models/PurchaseOrderItemModel.php +++ /dev/null @@ -1,87 +0,0 @@ - 'integer', - 'product_id' => 'integer', - 'supplier_id' => 'integer', - 'price' => 'decimal:2', - 'quantity' => 'decimal:2', - 'weight' => 'decimal:3', - 'amount' => 'decimal:2', - 'sort' => 'integer', - 'is_sent' => 'integer', - 'sent_at' => 'datetime', - 'supplier_confirmed_at' => 'datetime', - ]; - - /** - * 所属采购单 - */ - public function purchase(): BelongsTo - { - return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id'); - } - - /** - * 采购商品 - */ - public function product(): BelongsTo - { - return $this->belongsTo(ProductModel::class, 'product_id', 'id'); - } - - /** - * 供货供应商 - */ - public function supplier(): BelongsTo - { - return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id'); - } - - /** - * 金额分摊记录(按订货比例摊到门店/单品) - */ - public function allocations(): HasMany - { - return $this->hasMany(PurchaseAllocationModel::class, 'purchase_item_id', 'id'); - } -} diff --git a/app/Models/PurchaseOrderModel.php b/app/Models/PurchaseOrderModel.php index de3a1e2..d00cce2 100644 --- a/app/Models/PurchaseOrderModel.php +++ b/app/Models/PurchaseOrderModel.php @@ -9,18 +9,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Modules\SystemUser\Models\SysUserModel; /** - * 采购单模型(按门店订单汇总生成,按商品+供应商聚合) + * 采购单模型(按门店订单汇总生成;无独立明细表,明细直接溯源订货明细 store_order_item.purchase_id) */ class PurchaseOrderModel extends Model { use HasFactory; - /** 状态:待发送 */ + /** 状态:进行中(已生成,待完成) */ public const STATUS_PENDING = 0; - /** 状态:部分发送 */ - public const STATUS_PART_SENT = 1; - /** 状态:全部发送 */ - public const STATUS_ALL_SENT = 2; /** 状态:已完成 */ public const STATUS_COMPLETED = 3; @@ -58,10 +54,18 @@ class PurchaseOrderModel extends Model } /** - * 采购明细 + * 本采购单归集的门店订货明细 */ - public function items(): HasMany + public function orderItems(): HasMany { - return $this->hasMany(PurchaseOrderItemModel::class, 'purchase_id', 'id')->orderBy('sort'); + return $this->hasMany(StoreOrderItemModel::class, 'purchase_id', 'id'); + } + + /** + * 本采购单合并的门店订单 + */ + public function orders(): HasMany + { + return $this->hasMany(StoreOrderModel::class, 'purchase_id', 'id'); } } diff --git a/app/Models/ReconciliationItemModel.php b/app/Models/ReconciliationItemModel.php index 2052514..d70cbe0 100644 --- a/app/Models/ReconciliationItemModel.php +++ b/app/Models/ReconciliationItemModel.php @@ -21,7 +21,6 @@ class ReconciliationItemModel extends Model protected $fillable = [ 'recon_id', 'store_id', - 'purchase_item_id', 'order_item_id', 'product_id', 'product_name', @@ -38,7 +37,6 @@ class ReconciliationItemModel extends Model protected $casts = [ 'recon_id' => 'integer', 'store_id' => 'integer', - 'purchase_item_id' => 'integer', 'order_item_id' => 'integer', 'product_id' => 'integer', 'quantity' => 'decimal:2', @@ -74,14 +72,6 @@ class ReconciliationItemModel extends Model return $this->belongsTo(ProductModel::class, 'product_id', 'id'); } - /** - * 来源采购明细 - */ - public function purchaseItem(): BelongsTo - { - return $this->belongsTo(PurchaseOrderItemModel::class, 'purchase_item_id', 'id'); - } - /** * 溯源订货明细 */ diff --git a/app/Models/StoreOrderItemModel.php b/app/Models/StoreOrderItemModel.php index 2fac920..4d87b35 100644 --- a/app/Models/StoreOrderItemModel.php +++ b/app/Models/StoreOrderItemModel.php @@ -20,6 +20,7 @@ class StoreOrderItemModel extends Model protected $fillable = [ 'order_id', + 'purchase_id', 'store_id', 'product_id', 'category_id', @@ -40,6 +41,7 @@ class StoreOrderItemModel extends Model protected $casts = [ 'order_id' => 'integer', + 'purchase_id' => 'integer', 'store_id' => 'integer', 'product_id' => 'integer', 'category_id' => 'integer', @@ -94,7 +96,7 @@ class StoreOrderItemModel extends Model } /** - * 快照供应商 + * 供应商 */ public function supplier(): BelongsTo { diff --git a/app/Models/SupplierModel.php b/app/Models/SupplierModel.php index c4fd5b8..17a2956 100644 --- a/app/Models/SupplierModel.php +++ b/app/Models/SupplierModel.php @@ -44,14 +44,6 @@ class SupplierModel extends Model return $this->hasMany(ProductModel::class, 'supplier_id', 'id'); } - /** - * 供应商的采购明细行 - */ - public function purchaseItems(): HasMany - { - return $this->hasMany(PurchaseOrderItemModel::class, 'supplier_id', 'id'); - } - /** * 绑定本供应商的小程序用户 */ diff --git a/app/Services/PurchaseAllocateService.php b/app/Services/PurchaseAllocateService.php deleted file mode 100644 index 73047b4..0000000 --- a/app/Services/PurchaseAllocateService.php +++ /dev/null @@ -1,113 +0,0 @@ -0 的明细),否则拒绝 - * 2. 每个采购明细按 purchase_id 溯源该采购单合并的门店订单明细(生成采购单时回写) - * 3. 按订货数量比例分摊实际金额/数量/重量:bcmul(item.amount, bcdiv(item_qty, total_qty, 6), 2) - * 尾差修正——最后一行承担舍入差额,保证 Σallocation.amount === item.amount(金额守恒) - * 4. 重复分摊先删旧记录再重建(幂等) - */ -class PurchaseAllocateService -{ - /** - * @return int 生成的分摊记录数 - */ - public function allocate(PurchaseOrderModel $purchase): int - { - return DB::transaction(function () use ($purchase) { - $items = PurchaseOrderItemModel::query() - ->where('purchase_id', $purchase->id) - ->lockForUpdate() - ->get(); - - // 1. 须已录入实际金额 - if (! $items->contains(static fn ($item) => (float) $item->amount > 0)) { - throw new RepositoryException('采购单尚未录入实际金额,无法分摊'); - } - - // 2. 按 purchase_id 溯源本采购单合并的门店订单明细,按商品分组 - $orderItems = StoreOrderItemModel::query() - ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') - ->where('store_order.purchase_id', $purchase->id) - ->whereNull('store_order.deleted_at') - ->select('store_order_item.*') - ->get() - ->groupBy('product_id'); - - // 4. 幂等:删除旧分摊记录 - PurchaseAllocationModel::query() - ->whereIn('purchase_item_id', $items->pluck('id')) - ->delete(); - - $count = 0; - $now = now(); - foreach ($items as $item) { - $sources = $orderItems->get((int) $item->product_id); - if ($sources === null || $sources->isEmpty()) { - continue; - } - - $totalQty = $sources->reduce( - static fn (string $carry, $orderItem): string => bcadd($carry, (string) $orderItem->quantity, 2), - '0' - ); - if ((float) $totalQty <= 0) { - continue; - } - - $sourceValues = $sources->values(); - $lastIndex = $sourceValues->count() - 1; - $allocatedAmount = '0'; - $allocatedQuantity = '0'; - $allocatedWeight = '0'; - $records = []; - - foreach ($sourceValues as $index => $orderItem) { - if ($index === $lastIndex) { - // 3. 尾差修正:最后一行 = 总额 − 已分摊,保证守恒 - $amount = bcsub((string) $item->amount, $allocatedAmount, 2); - $quantity = bcsub((string) $item->quantity, $allocatedQuantity, 2); - $weight = bcsub((string) $item->weight, $allocatedWeight, 3); - } else { - $ratio = bcdiv((string) $orderItem->quantity, $totalQty, 6); - $amount = bcmul((string) $item->amount, $ratio, 2); - $quantity = bcmul((string) $item->quantity, $ratio, 2); - $weight = bcmul((string) $item->weight, $ratio, 3); - $allocatedAmount = bcadd($allocatedAmount, $amount, 2); - $allocatedQuantity = bcadd($allocatedQuantity, $quantity, 2); - $allocatedWeight = bcadd($allocatedWeight, $weight, 3); - } - - $records[] = [ - 'purchase_item_id' => $item->id, - 'order_item_id' => $orderItem->id, - 'store_id' => $orderItem->store_id, - 'product_id' => $item->product_id, - 'quantity' => $quantity, - 'weight' => $weight, - 'amount' => $amount, - 'created_at' => $now, - 'updated_at' => $now, - ]; - } - - PurchaseAllocationModel::insert($records); - $count += count($records); - } - - return $count; - }); - } -} diff --git a/app/Services/PurchaseEditService.php b/app/Services/PurchaseEditService.php new file mode 100644 index 0000000..2122a12 --- /dev/null +++ b/app/Services/PurchaseEditService.php @@ -0,0 +1,177 @@ +0 ? 称重×成本 : 数量×成本) + */ +class PurchaseEditService +{ + /** + * 门店单元格修改:数量(必填)/ 称重(可空),金额按 数量×单价 重算 + */ + public function updateCell(StoreOrderItemModel $item, int $quantity, ?float $weight): StoreOrderItemModel + { + return DB::transaction(function () use ($item, $quantity, $weight) { + $item->quantity = $quantity; + if ($weight !== null) { + $item->weight = bcadd((string) $weight, '0', 3); + } + $item->amount = bcmul((string) $quantity, (string) $item->price, 2); + $item->save(); + + $order = StoreOrderModel::find($item->order_id); + if ($order !== null) { + $this->recalculateOrderTotals($order); + } + if ($item->purchase_id > 0) { + $purchase = PurchaseOrderModel::find($item->purchase_id); + if ($purchase !== null) { + $this->recalculatePurchaseTotals($purchase); + } + } + + return $item->refresh(); + }); + } + + /** + * 商品行修改:采购成本(写入该商品全部订货明细)/ 实际称重(按数量比例分摊,尾差修正守恒) + * + * @param float|null $costPrice 采购成本(每包规),NULL 不修改 + * @param float|null $weight 行实际称重合计,NULL 不修改 + */ + public function updateRow(PurchaseOrderModel $purchase, int $productId, ?float $costPrice, ?float $weight): int + { + return DB::transaction(function () use ($purchase, $productId, $costPrice, $weight) { + $items = StoreOrderItemModel::query() + ->where('purchase_id', $purchase->id) + ->where('product_id', $productId) + ->lockForUpdate() + ->get(); + if ($items->isEmpty()) { + throw new RepositoryException('该采购单下无此商品的订货明细'); + } + + if ($costPrice !== null) { + $cost = bcadd((string) $costPrice, '0', 2); + foreach ($items as $item) { + $item->cost_price = $cost; + $item->save(); + } + } + + if ($weight !== null) { + $totalQty = $items->reduce( + static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2), + '0' + ); + $target = bcadd((string) $weight, '0', 3); + $allocated = '0'; + $lastIndex = $items->count() - 1; + foreach ($items->values() as $index => $item) { + if ($index === $lastIndex) { + // 尾差修正:最后一行 = 总称重 − 已分摊,保证守恒 + $rowWeight = bcsub($target, $allocated, 3); + } elseif ((float) $totalQty > 0) { + $ratio = bcdiv((string) $item->quantity, $totalQty, 6); + $rowWeight = bcmul($target, $ratio, 3); + $allocated = bcadd($allocated, $rowWeight, 3); + } else { + $rowWeight = '0'; + } + $item->weight = $rowWeight; + $item->save(); + } + } + + $orderIds = $items->pluck('order_id')->unique()->all(); + StoreOrderModel::whereIn('id', $orderIds)->get() + ->each(fn (StoreOrderModel $order) => $this->recalculateOrderTotals($order)); + $this->recalculatePurchaseTotals($purchase); + + return $items->count(); + }); + } + + /** + * 重算订单汇总(与 StoreOrderController::recalculateOrderTotals 同口径) + */ + public function recalculateOrderTotals(StoreOrderModel $order): void + { + $totals = StoreOrderItemModel::query() + ->where('order_id', $order->id) + ->selectRaw('COALESCE(SUM(quantity), 0) as total_quantity') + ->selectRaw('COALESCE(SUM(weight), 0) as total_weight') + ->selectRaw('COALESCE(SUM(amount), 0) as product_amount') + ->first(); + + $productAmount = bcadd((string) $totals->product_amount, '0', 2); + $order->total_quantity = (int) $totals->total_quantity; + $order->total_weight = bcadd((string) $totals->total_weight, '0', 3); + $order->product_amount = $productAmount; + $order->total_amount = bcadd($productAmount, (string) $order->added_amount, 2); + $order->save(); + } + + /** + * 重算采购单头汇总:estimate_amount = Σ订货金额(销售口径); + * actual_amount = Σ(称重>0 ? 称重×成本 : 数量×成本)(采购成本口径) + */ + public function recalculatePurchaseTotals(PurchaseOrderModel $purchase): void + { + $items = StoreOrderItemModel::query() + ->where('purchase_id', $purchase->id) + ->get(['quantity', 'weight', 'amount', 'cost_price', 'product_spec']); + + $totalQuantity = '0'; + $totalWeight = '0'; + $estimate = '0'; + $actual = '0'; + foreach ($items as $item) { + $unitCost = self::unitCost((string) ($item->cost_price ?? '0'), (string) $item->product_spec); + $totalQuantity = bcadd($totalQuantity, (string) $item->quantity, 2); + $totalWeight = bcadd($totalWeight, (string) $item->weight, 3); + $estimate = bcadd($estimate, (string) $item->amount, 2); + $actual = bcadd($actual, self::costAmount((string) $item->quantity, (string) $item->weight, $unitCost), 2); + } + + $purchase->total_quantity = $totalQuantity; + $purchase->total_weight = $totalWeight; + $purchase->estimate_amount = $estimate; + $purchase->actual_amount = $actual; + $purchase->save(); + } + + /** + * 采购成本金额:称重>0 按 称重×单价,否则按 数量×单价 + */ + public static function costAmount(string $quantity, string $weight, string $unitCost): string + { + return (float) $weight > 0 + ? bcmul($weight, $unitCost, 2) + : bcmul($quantity, $unitCost, 2); + } + + /** + * 单价 = 成本 / 包规数值(spec 解析不出正数时按 1 处理,即单价=成本) + */ + public static function unitCost(string $costPrice, string $spec): string + { + if (preg_match('/\d+(?:\.\d+)?/', $spec, $matches) === 1 && (float) $matches[0] > 0) { + return bcdiv($costPrice, $matches[0], 2); + } + + return bcadd($costPrice, '0', 2); + } +} diff --git a/app/Services/PurchaseGenerateService.php b/app/Services/PurchaseGenerateService.php index 799cd5f..038e4c6 100644 --- a/app/Services/PurchaseGenerateService.php +++ b/app/Services/PurchaseGenerateService.php @@ -3,10 +3,8 @@ namespace App\Services; use App\Exceptions\RepositoryException; -use App\Models\ProductModel; -use App\Models\ProductPriceModel; -use App\Models\PurchaseOrderItemModel; use App\Models\PurchaseOrderModel; +use App\Models\StoreOrderItemModel; use App\Models\StoreOrderModel; use Illuminate\Support\Facades\DB; @@ -15,11 +13,9 @@ use Illuminate\Support\Facades\DB; * * 流程(事务内): * 1. 行锁「已接单」订单(可传 orderIds 只合并指定订单;状态条件天然排除已归集订单,幂等) - * 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商) - * 3. 估算单价 = 该商品最低实际等级价(按计价类型换算后取 min,PHP 侧兼容 MySQL/SQLite),amount = quantity × 估算单价 - * 4. 创建采购单头(PO 单号,estimate_amount = Σitems.amount) - * 5. 明细按「分类 sort → 商品 sort」排序写入 sort 行号 - * 6. 源订单批量回写 status = 采购中、purchase_id = 采购单ID(分摊按 purchase_id 溯源) + * 2. 创建采购单头(PO 单号;total_quantity / estimate_amount 取订货明细汇总) + * 3. 源订单批量回写 status = 采购中、purchase_id = 采购单ID, + * 订货明细同步回写 purchase_id(采购明细直接溯源订货明细,无独立采购明细表) */ class PurchaseGenerateService { @@ -53,96 +49,23 @@ class PurchaseGenerateService throw new RepositoryException('无已接单订单,无法生成采购单'); } - // 2. 展开明细按商品聚合 - $aggregated = []; - $sourceOrderIds = []; - foreach ($orders as $order) { - $sourceOrderIds[] = $order->id; - foreach ($order->items as $item) { - $productId = (int) $item->product_id; - if (! isset($aggregated[$productId])) { - $aggregated[$productId] = [ - 'product_id' => $productId, - 'product_name' => $item->product_name, - 'product_spec' => $item->product_spec, - 'quantity' => '0', - ]; - } - $aggregated[$productId]['quantity'] = bcadd( - $aggregated[$productId]['quantity'], - (string) $item->quantity, - 2 - ); - } - } + $sourceOrderIds = $orders->pluck('id')->all(); - if ($aggregated === []) { + // 2. 订货明细汇总(采购单头数据) + $items = StoreOrderItemModel::query()->whereIn('order_id', $sourceOrderIds)->get(); + if ($items->isEmpty()) { throw new RepositoryException('已接单订单均无明细,无法生成采购单'); } - $products = ProductModel::withTrashed() - ->with('category:id,sort') - ->whereIn('id', array_keys($aggregated)) - ->get() - ->keyBy('id'); - - // 3. 估算单价 = 最低实际等级价(逐行按计价类型换算后取 min;数量级 = 当日 SKU × 等级,可控) - $priceRows = ProductPriceModel::query() - ->whereIn('product_id', array_keys($aggregated)) - ->get(['product_id', 'price', 'price_type', 'percent']) - ->groupBy('product_id'); - - $minPrices = []; - foreach ($aggregated as $productId => $item) { - $product = $products->get($productId); - $minPrice = null; - foreach ($priceRows->get($productId, collect()) as $priceRow) { - $actual = ProductPriceModel::calcActualPrice( - (int) $priceRow->price_type, - $priceRow->price, - $priceRow->percent, - (float) ($product->cost_price ?? 0), - ); - if ($minPrice === null || bccomp($actual, $minPrice, 2) < 0) { - $minPrice = $actual; - } - } - $minPrices[$productId] = $minPrice ?? '0'; - } - - // 组装明细行并按「分类 sort → 商品 sort」排序 - $rows = []; - foreach ($aggregated as $productId => $item) { - $product = $products->get($productId); - $price = (string) ($minPrices[$productId] ?? '0'); - $rows[] = [ - 'product_id' => $productId, - 'supplier_id' => (int) ($product->supplier_id ?? 0), - 'product_name' => $item['product_name'], - 'product_spec' => $item['product_spec'], - 'price' => $price, - 'quantity' => $item['quantity'], - 'amount' => bcmul($item['quantity'], $price, 2), - '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']]); - - $estimateAmount = array_reduce( - $rows, - static fn (string $carry, array $row): string => bcadd($carry, $row['amount'], 2), + $totalQuantity = $items->reduce( + static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2), '0' ); - $totalQuantity = array_reduce( - $rows, - static fn (string $carry, array $row): string => bcadd($carry, $row['quantity'], 2), + $estimateAmount = $items->reduce( + static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2), '0' ); - // 4. 采购单头 $purchase = PurchaseOrderModel::create([ 'purchase_no' => $this->billNumberService->make('PO'), 'purchase_date' => $date, @@ -154,30 +77,14 @@ class PurchaseGenerateService 'operator_id' => $operatorId, ]); - // 5. 采购明细(sort 行号) - $sort = 1; - foreach ($rows as $row) { - PurchaseOrderItemModel::create([ - 'purchase_id' => $purchase->id, - 'product_id' => $row['product_id'], - 'supplier_id' => $row['supplier_id'], - 'product_name' => $row['product_name'], - 'product_spec' => $row['product_spec'], - 'price' => $row['price'], - 'quantity' => $row['quantity'], - 'weight' => 0, - 'amount' => $row['amount'], - 'sort' => $sort++, - 'is_sent' => PurchaseOrderItemModel::NOT_SENT, - ]); - } - - // 6. 源订单回写「采购中」并关联采购单(分摊按 purchase_id 溯源) + // 3. 源订单回写「采购中」并关联采购单;订货明细同步回写 purchase_id StoreOrderModel::whereIn('id', $sourceOrderIds) ->update([ 'status' => StoreOrderModel::STATUS_DELIVERING, 'purchase_id' => $purchase->id, ]); + StoreOrderItemModel::whereIn('order_id', $sourceOrderIds) + ->update(['purchase_id' => $purchase->id]); return $purchase; }); diff --git a/app/Services/ReconciliationBuildService.php b/app/Services/ReconciliationBuildService.php index 798e816..ff3a965 100644 --- a/app/Services/ReconciliationBuildService.php +++ b/app/Services/ReconciliationBuildService.php @@ -5,25 +5,22 @@ namespace App\Services; use App\Exceptions\RepositoryException; use App\Models\ProductCategoryModel; use App\Models\ProductModel; -use App\Models\PurchaseAllocationModel; -use App\Models\PurchaseOrderItemModel; -use App\Models\PurchaseOrderModel; use App\Models\ReconciliationItemModel; use App\Models\ReconciliationModel; use App\Models\StoreOrderItemModel; +use App\Models\StoreOrderModel; use Illuminate\Support\Facades\DB; /** * 对账明细构建(D1 品类 / D2 供应商筛选) * * 流程(事务内,可重复 build:先清后建): - * 1. 按周期 + 品类(含子孙分类)+ 供应商拉取 purchase_order_item - * 2. 校验命中的采购明细均已完成 D3 分摊(分摊是门店/单品粒度的对账数据源) - * 3. 每条分摊记录 → 一条对账明细: - * publish_amount = 订货金额(溯源 order_item.amount) - * actual_amount = 分摊金额 + * 1. 按周期 + 品类(含子孙分类)+ 供应商拉取「已完成」门店订单的订货明细 + * 2. 每条订货明细 → 一条对账明细: + * publish_amount = 订货金额(order_item.amount) + * actual_amount = 采购成本(称重>0 ? 称重×单价 : 数量×单价,单价 = 成本/包规) * diff = publish − actual,冗余 product_name / store_id - * 4. 汇总写回头的 publish/actual/diff_amount,status → 对账中 + * 3. 汇总写回头的 publish/actual/diff_amount,status → 对账中 */ class ReconciliationBuildService { @@ -33,49 +30,31 @@ class ReconciliationBuildService public function build(ReconciliationModel $recon): int { return DB::transaction(function () use ($recon) { - // 1. 按周期 + 品类 + 供应商拉取采购明细 - $itemQuery = PurchaseOrderItemModel::query() - ->join('purchase_order', 'purchase_order.id', '=', 'purchase_order_item.purchase_id') - ->whereDate('purchase_order.purchase_date', '>=', $recon->period_start) - ->whereDate('purchase_order.purchase_date', '<=', $recon->period_end) - ->select('purchase_order_item.*'); + // 1. 按周期 + 品类 + 供应商拉取已完成订单的订货明细 + $itemQuery = StoreOrderItemModel::query() + ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') + ->where('store_order.status', StoreOrderModel::STATUS_COMPLETED) + ->whereNull('store_order.deleted_at') + ->whereDate('store_order.order_date', '>=', $recon->period_start) + ->whereDate('store_order.order_date', '<=', $recon->period_end) + ->select('store_order_item.*'); if ((int) $recon->supplier_id > 0) { - $itemQuery->where('purchase_order_item.supplier_id', $recon->supplier_id); + $itemQuery->where('store_order_item.supplier_id', $recon->supplier_id); } if ((int) $recon->category_id > 0) { - $productIds = ProductModel::withTrashed() - ->whereIn('category_id', $this->descendantCategoryIds((int) $recon->category_id)) - ->pluck('id'); - $itemQuery->whereIn('purchase_order_item.product_id', $productIds); + $itemQuery->whereIn( + 'store_order_item.category_id', + $this->descendantCategoryIds((int) $recon->category_id) + ); } - $purchaseItems = $itemQuery->get(); - if ($purchaseItems->isEmpty()) { - throw new RepositoryException('周期内无符合筛选条件的采购数据,无法生成对账明细'); + $orderItems = $itemQuery->get()->makeVisible('cost_price'); + if ($orderItems->isEmpty()) { + throw new RepositoryException('周期内无符合筛选条件的已完成订单数据,无法生成对账明细'); } - // 2. 分摊记录(未完成分摊的采购单拒绝,保证对账数据到门店/单品粒度) - $allocations = PurchaseAllocationModel::query() - ->whereIn('purchase_item_id', $purchaseItems->pluck('id')) - ->get() - ->groupBy('purchase_item_id'); - - $missing = $purchaseItems->filter(fn ($item) => ! $allocations->has($item->id)); - if ($missing->isNotEmpty()) { - $purchaseNos = PurchaseOrderModel::query() - ->whereIn('id', $missing->pluck('purchase_id')->unique()) - ->pluck('purchase_no') - ->implode('、'); - throw new RepositoryException('采购单 ' . $purchaseNos . ' 尚未完成金额分摊,请先执行分摊再生成对账明细'); - } - - // 3. 溯源订货金额(公布金额) - $orderAmounts = StoreOrderItemModel::query() - ->whereIn('id', $allocations->flatten()->pluck('order_item_id')->unique()) - ->pluck('amount', 'id'); - - // 4. 先清后建(幂等) + // 2. 先清后建(幂等) ReconciliationItemModel::where('recon_id', $recon->id)->delete(); $publishTotal = '0'; @@ -83,37 +62,41 @@ class ReconciliationBuildService $rows = []; $sort = 1; $now = now(); - foreach ($allocations as $purchaseItemId => $group) { - $purchaseItem = $purchaseItems->firstWhere('id', $purchaseItemId); - foreach ($group as $allocation) { - $publish = (string) ($orderAmounts[$allocation->order_item_id] ?? '0'); - $actual = (string) $allocation->amount; - $publishTotal = bcadd($publishTotal, $publish, 2); - $actualTotal = bcadd($actualTotal, $actual, 2); + foreach ($orderItems as $orderItem) { + $publish = (string) $orderItem->amount; + $unitCost = PurchaseEditService::unitCost( + (string) ($orderItem->cost_price ?? '0'), + (string) $orderItem->product_spec + ); + $actual = PurchaseEditService::costAmount( + (string) $orderItem->quantity, + (string) $orderItem->weight, + $unitCost + ); + $publishTotal = bcadd($publishTotal, $publish, 2); + $actualTotal = bcadd($actualTotal, $actual, 2); - $rows[] = [ - 'recon_id' => $recon->id, - 'store_id' => $allocation->store_id, - 'purchase_item_id' => $allocation->purchase_item_id, - 'order_item_id' => $allocation->order_item_id, - 'product_id' => $allocation->product_id, - 'product_name' => $purchaseItem->product_name, - 'quantity' => $allocation->quantity, - 'weight' => $allocation->weight, - 'publish_amount' => $publish, - 'actual_amount' => $actual, - 'diff_amount' => bcsub($publish, $actual, 2), - 'is_reconciled' => ReconciliationItemModel::NOT_RECONCILED, - 'store_remark' => '', - 'sort' => $sort++, - 'created_at' => $now, - 'updated_at' => $now, - ]; - } + $rows[] = [ + 'recon_id' => $recon->id, + 'store_id' => $orderItem->store_id, + 'order_item_id' => $orderItem->id, + 'product_id' => $orderItem->product_id, + 'product_name' => $orderItem->product_name, + 'quantity' => $orderItem->quantity, + 'weight' => $orderItem->weight, + 'publish_amount' => $publish, + 'actual_amount' => $actual, + 'diff_amount' => bcsub($publish, $actual, 2), + 'is_reconciled' => ReconciliationItemModel::NOT_RECONCILED, + 'store_remark' => '', + 'sort' => $sort++, + 'created_at' => $now, + 'updated_at' => $now, + ]; } ReconciliationItemModel::insert($rows); - // 5. 汇总写回头 + 状态流转 + // 3. 汇总写回头 + 状态流转 $recon->publish_amount = $publishTotal; $recon->actual_amount = $actualTotal; $recon->diff_amount = bcsub($publishTotal, $actualTotal, 2); diff --git a/database/migrations/2026_07_23_030610_create_store_order_table.php b/database/migrations/2026_07_23_030610_create_store_order_table.php index 2c22930..3b6eccb 100644 --- a/database/migrations/2026_07_23_030610_create_store_order_table.php +++ b/database/migrations/2026_07_23_030610_create_store_order_table.php @@ -44,6 +44,7 @@ return new class extends Migration $table->increments('id')->comment('明细ID'); $table->integer('order_id')->comment('订单ID'); $table->integer('store_id')->comment('门店ID'); + $table->integer('purchase_id')->default(0)->comment('归属采购单ID(0=未归集)'); $table->integer('product_id')->comment('商品ID'); $table->integer('category_id')->default(0)->comment('分类ID'); $table->integer('supplier_id')->default(0)->comment('供应商ID'); @@ -61,6 +62,7 @@ return new class extends Migration $table->string('remark', 255)->default('')->comment('门店下单备注'); $table->timestamps(); $table->index(['order_id'], 'store_order_item_order_index'); + $table->index(['purchase_id'], 'store_order_item_purchase_index'); $table->index(['store_id', 'product_id'], 'store_order_item_store_product_index'); $table->comment('门店订货明细表'); }); diff --git a/database/migrations/2026_07_23_030611_create_purchase_table.php b/database/migrations/2026_07_23_030611_create_purchase_table.php index f274925..5ad2884 100644 --- a/database/migrations/2026_07_23_030611_create_purchase_table.php +++ b/database/migrations/2026_07_23_030611_create_purchase_table.php @@ -30,50 +30,6 @@ return new class extends Migration $table->comment('采购单表'); }); } - - // 采购单明细表(C2/C3 按分类排序导出,C4 可修改,C5/C6 发送供应商及状态标记) - if (! Schema::hasTable('purchase_order_item')) { - Schema::create('purchase_order_item', function (Blueprint $table) { - $table->increments('id')->comment('明细ID'); - $table->integer('purchase_id')->comment('采购单ID'); - $table->integer('product_id')->comment('商品ID'); - $table->integer('supplier_id')->default(0)->comment('供应商ID'); - $table->string('product_name', 100)->comment('品名(快照)'); - $table->string('product_spec', 100)->default('')->comment('规格/包规(快照)'); - $table->decimal('price', 10, 2)->default(0)->comment('采购单价'); - $table->decimal('quantity', 10, 2)->default(0)->comment('采购量'); - $table->decimal('weight', 10, 3)->default(0)->comment('实际称重'); - $table->decimal('amount', 10, 2)->default(0)->comment('采购金额'); - $table->integer('sort')->default(0)->comment('排序(导出用)'); - $table->integer('is_sent')->default(0)->comment('是否已发送供应商(1已发送 0未发送)'); - $table->timestamp('sent_at')->nullable()->comment('发送时间'); - $table->timestamp('supplier_confirmed_at')->nullable()->comment('供应商确认接单时间(NULL未确认)'); - $table->string('remark', 255)->default('')->comment('备注'); - $table->timestamps(); - $table->index(['purchase_id'], 'purchase_order_item_purchase_index'); - $table->index(['supplier_id', 'is_sent'], 'purchase_order_item_supplier_sent_index'); - $table->comment('采购单明细表'); - }); - } - - // 采购分摊表(D3 采购金额自动分配到各门店/各单品) - if (! Schema::hasTable('purchase_allocation')) { - Schema::create('purchase_allocation', function (Blueprint $table) { - $table->increments('id')->comment('分摊ID'); - $table->integer('purchase_item_id')->comment('采购单明细ID'); - $table->integer('order_item_id')->comment('门店订货明细ID'); - $table->integer('store_id')->comment('分摊到的门店ID'); - $table->integer('product_id')->comment('商品ID'); - $table->decimal('quantity', 10, 2)->default(0)->comment('分摊数量'); - $table->decimal('weight', 10, 3)->default(0)->comment('分摊重量'); - $table->decimal('amount', 10, 2)->default(0)->comment('分摊金额'); - $table->timestamps(); - $table->index(['purchase_item_id'], 'purchase_allocation_item_index'); - $table->index(['order_item_id'], 'purchase_allocation_order_item_index'); - $table->index(['store_id'], 'purchase_allocation_store_index'); - $table->comment('采购分摊表'); - }); - } } /** @@ -82,7 +38,5 @@ return new class extends Migration public function down(): void { Schema::dropIfExists('purchase_order'); - Schema::dropIfExists('purchase_order_item'); - Schema::dropIfExists('purchase_allocation'); } }; diff --git a/database/migrations/2026_07_23_030612_create_reconciliation_table.php b/database/migrations/2026_07_23_030612_create_reconciliation_table.php index 1e1fd77..bc9c2d6 100644 --- a/database/migrations/2026_07_23_030612_create_reconciliation_table.php +++ b/database/migrations/2026_07_23_030612_create_reconciliation_table.php @@ -40,7 +40,6 @@ return new class extends Migration $table->increments('id')->comment('明细ID'); $table->integer('recon_id')->comment('财务对账单ID'); $table->integer('store_id')->comment('门店ID'); - $table->integer('purchase_item_id')->default(0)->comment('采购单明细ID'); $table->integer('order_item_id')->default(0)->comment('门店订货明细ID'); $table->integer('product_id')->comment('商品ID'); $table->string('product_name', 100)->comment('品名(快照)'); diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 8452bc8..febc8f7 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -194,8 +194,6 @@ class PermissionSeeder extends Seeder ['type' => 'rule', 'key' => 'purchase.order.update', 'name' => '修改'], ['type' => 'rule', 'key' => 'purchase.order.generate', 'name' => '生成采购单'], ['type' => 'rule', 'key' => 'purchase.order.export', 'name' => '导出'], - ['type' => 'rule', 'key' => 'purchase.order.send', 'name' => '发送供应商'], - ['type' => 'rule', 'key' => 'purchase.order.allocate', 'name' => '金额分摊'], ], ], ], diff --git a/resources/views/exports/purchase.blade.php b/resources/views/exports/purchase.blade.php index d33088d..842f23a 100644 --- a/resources/views/exports/purchase.blade.php +++ b/resources/views/exports/purchase.blade.php @@ -26,34 +26,44 @@ 序号 分类 品名 - 规格/包规 + 供应商 + 包规 + 单位 + 成本 单价 数量 实际称重 金额 - 供应商 + @foreach ($storeNames as $storeName) + {{ $storeName }}(数量) + @endforeach @foreach ($items as $item) - {{ $item->sort }} - {{ $categoryNames[$item->product_id] ?? '' }} - {{ $item->product_name }} - {{ $item->product_spec }} - {{ $item->price }} - {{ $item->quantity }} - {{ $item->weight }} - {{ $item->amount }} - {{ $supplierNames[$item->supplier_id] ?? '' }} + {{ $item['sort'] }} + {{ $categoryNames[$item['product_id']] ?? '' }} + {{ $item['product_name'] }} + {{ $supplierNames[$item['supplier_id']] ?? '' }} + {{ $item['product_spec'] }} + {{ $item['unit'] }} + {{ $item['cost_price'] }} + {{ $item['unit_cost'] }} + {{ $item['quantity'] }} + {{ $item['weight'] }} + {{ $item['amount'] }} + @foreach ($storeNames as $storeId => $storeName) + {{ $item['store_quantities'][$storeId] ?? 0 }} + @endforeach @endforeach - 合计金额 + 合计金额 ¥{{ $items->sum('amount') }} - + diff --git a/tests/Feature/AllocationTest.php b/tests/Feature/AllocationTest.php deleted file mode 100644 index 6b0ffc3..0000000 --- a/tests/Feature/AllocationTest.php +++ /dev/null @@ -1,150 +0,0 @@ - $quantities 各门店订货量 - * @return array{0: PurchaseOrderModel, 1: PurchaseOrderItemModel} - */ - private function buildAllocatablePurchase(array $quantities, string $actualPrice = '10.00'): array - { - $level = CustomerLevelModel::factory()->create(); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']); - - foreach ($quantities as $qty) { - $store = StoreModel::factory()->create(['level_id' => $level->id]); - $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); - $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(); - $item = $purchase->items->first(); - - // 录入实际单价(weight=0 → amount = quantity × price) - $this->putJson("/purchase/order/item/{$item->id}", [ - 'price' => $actualPrice, - 'quantity' => $item->quantity, - 'weight' => 0, - ])->assertJsonPath('success', true); - - return [$purchase->fresh(), $item->fresh()]; - } - - /** 金额守恒:Σallocation.amount === item.amount,尾差由最后一行承担 */ - public function test_allocation_conserves_amount_with_tail_correction(): void - { - [$purchase, $item] = $this->buildAllocatablePurchase(['1', '1', '1']); - - // 把实际金额调成不可被 3 整除的 100.00(quantity=10 × price=10) - $this->actingAsSysUser(); - $this->putJson("/purchase/order/item/{$item->id}", [ - 'price' => '10.00', - 'quantity' => '10.00', - 'weight' => 0, - ])->assertJsonPath('success', true); - $item = $item->fresh(); - $this->assertSame('100.00', (string) $item->amount); - - $this->postJson("/purchase/order/{$purchase->id}/allocate") - ->assertOk() - ->assertJsonPath('success', true); - - $allocations = PurchaseAllocationModel::where('purchase_item_id', $item->id) - ->orderBy('id') - ->get(); - $this->assertCount(3, $allocations); - - $sum = $allocations->reduce( - static fn (string $carry, $a): string => bcadd($carry, (string) $a->amount, 2), - '0' - ); - $this->assertSame('100.00', $sum, '分摊总额必须守恒'); - - // 三等分尾差修正:33.33 / 33.33 / 33.34 - $this->assertSame('33.33', (string) $allocations[0]->amount); - $this->assertSame('33.33', (string) $allocations[1]->amount); - $this->assertSame('33.34', (string) $allocations[2]->amount); - } - - /** 按订货数量比例分摊 */ - public function test_allocation_follows_order_ratio(): void - { - [$purchase, $item] = $this->buildAllocatablePurchase(['1', '3']); - // quantity=4,实际金额 4×10=40 → 1:3 → 10.00 / 30.00 - $this->actingAsSysUser(); - - $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true); - - $amounts = PurchaseAllocationModel::where('purchase_item_id', $item->id) - ->pluck('amount') - ->map(static fn ($v) => (string) $v) - ->sort() - ->values() - ->all(); - $this->assertSame(['10.00', '30.00'], $amounts); - } - - /** 幂等:重复分摊先删旧记录再重建,数量不变 */ - public function test_allocation_is_idempotent(): void - { - [$purchase] = $this->buildAllocatablePurchase(['2', '3']); - $this->actingAsSysUser(); - - $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true); - $first = PurchaseAllocationModel::count(); - $this->assertGreaterThan(0, $first); - - $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true); - $this->assertSame($first, PurchaseAllocationModel::count(), '重复分摊不应产生重复记录'); - } - - /** 未录入实际金额时拒绝分摊 */ - public function test_allocation_rejected_without_actual_amount(): void - { - $level = CustomerLevelModel::factory()->create(); - $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']); - - $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); - $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]); - - StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); - - $this->actingAsSysUser(); - $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]); - $purchase = PurchaseOrderModel::first(); - - // 模拟未录入实际金额 - $purchase->items->first()->update(['amount' => 0, 'price' => 0]); - - $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', false); - $this->assertSame(0, PurchaseAllocationModel::count()); - } -} diff --git a/tests/Feature/PurchaseEditTest.php b/tests/Feature/PurchaseEditTest.php new file mode 100644 index 0000000..8b17793 --- /dev/null +++ b/tests/Feature/PurchaseEditTest.php @@ -0,0 +1,171 @@ +} + */ + private function buildPurchase(): array + { + $level = CustomerLevelModel::factory()->create(); + $storeA = StoreModel::factory()->create(['level_id' => $level->id]); + $storeB = StoreModel::factory()->create(['level_id' => $level->id]); + $product = ProductModel::factory()->create([ + 'status' => ProductModel::STATUS_ON, + 'cost_price' => 20, + 'spec' => '10斤/箱', + 'unit' => '斤', + ]); + ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]); + + foreach ([[$storeA, 2], [$storeB, 3]] as [$store, $qty]) { + $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); + $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); + + return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]]; + } + + /** 详情返回「商品行 × 门店列」矩阵:单价 = 成本/包规,单元格金额 = 数量×单价 */ + public function test_detail_returns_store_matrix(): void + { + [$purchase, $product, $stores] = $this->buildPurchase(); + $this->actingAsSysUser(); + + $response = $this->getJson("/purchase/order/{$purchase->id}") + ->assertOk() + ->assertJsonPath('success', true); + + $data = $response->json('data'); + $this->assertCount(2, $data['stores']); + $this->assertCount(1, $data['items']); + + $row = $data['items'][0]; + $this->assertSame($product->id, $row['product_id']); + $this->assertSame('10斤/箱', $row['product_spec']); + $this->assertSame('斤', $row['unit']); + $this->assertEquals(20.0, $row['cost_price']); + $this->assertEquals(2.0, $row['unit_cost'], '单价 = 20 ÷ 10'); + $this->assertEquals(5.0, $row['quantity'], '2+3'); + $this->assertEquals(10.0, $row['amount'], '5 × 2.00'); + + $cells = collect($row['cells'])->keyBy('store_id'); + $this->assertSame(2, $cells[$stores[0]->id]['quantity']); + $this->assertEquals(4.0, $cells[$stores[0]->id]['amount'], '2 × 2.00'); + $this->assertSame(3, $cells[$stores[1]->id]['quantity']); + $this->assertEquals(6.0, $cells[$stores[1]->id]['amount'], '3 × 2.00'); + } + + /** 门店单元格数量修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */ + 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); + + $item = $item->fresh(); + $this->assertSame(5, $item->quantity); + $this->assertSame('50.00', (string) $item->amount, '5 × 订货单价 10.00'); + + $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'); + } + + /** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×单价 重算 */ + 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) + ->assertJsonPath('data.count', 2); + + $this->assertSame(2, StoreOrderItemModel::where('cost_price', '30.00')->count()); + + $purchase = $purchase->fresh(); + // 单价 = 30 ÷ 10 = 3.00,实际金额 = 5 × 3.00 + $this->assertSame('15.00', (string) $purchase->actual_amount); + $this->assertSame('50.00', (string) $purchase->estimate_amount, '订货金额不受成本修改影响'); + } + + /** 行级称重修改 → 按各店数量比例分摊写入明细(尾差修正守恒),实际金额按 称重×单价 计 */ + public function test_update_row_weight_distributed_by_quantity(): void + { + [$purchase, $product, $stores] = $this->buildPurchase(); + $this->actingAsSysUser(); + + $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['weight' => 10]) + ->assertJsonPath('success', true); + + $items = StoreOrderItemModel::query()->orderBy('store_id')->get()->keyBy('store_id'); + $this->assertSame('4.000', (string) $items[$stores[0]->id]->weight, '10 × 2/5'); + $this->assertSame('6.000', (string) $items[$stores[1]->id]->weight, '10 × 3/5'); + $this->assertSame( + '10.000', + bcadd((string) $items[$stores[0]->id]->weight, (string) $items[$stores[1]->id]->weight, 3), + '分摊守恒' + ); + + $purchase = $purchase->fresh(); + $this->assertSame('10.000', (string) $purchase->total_weight); + // 称重>0 → 金额按 称重×单价:10 × 2.00 + $this->assertSame('20.00', (string) $purchase->actual_amount); + } + + /** 行级修改:成本与称重至少一项 */ + public function test_update_row_requires_at_least_one_field(): void + { + [$purchase, $product] = $this->buildPurchase(); + $this->actingAsSysUser(); + + $this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", []) + ->assertJsonPath('success', false); + } + + /** 单元格数量校验:负数拒绝 */ + public function test_update_cell_rejects_negative_quantity(): void + { + [$purchase, , $stores] = $this->buildPurchase(); + $this->actingAsSysUser(); + + $item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first(); + $this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => -1]) + ->assertJsonPath('success', false); + } +} diff --git a/tests/Feature/PurchaseGenerateTest.php b/tests/Feature/PurchaseGenerateTest.php index 2cdb066..960673c 100644 --- a/tests/Feature/PurchaseGenerateTest.php +++ b/tests/Feature/PurchaseGenerateTest.php @@ -7,17 +7,19 @@ use App\Models\ProductModel; use App\Models\ProductPriceModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; +use App\Models\StoreOrderItemModel; use App\Models\StoreOrderModel; use App\Models\UserModel; /** - * C1 订单汇总生成采购单:多门店聚合、订单状态回写、无订单/重复生成防护 + * C1 订单汇总生成采购单:订单/明细归集回写、头部汇总、无订单/重复生成防护 + * (采购单无独立明细表,明细直接溯源订货明细 store_order_item.purchase_id) */ class PurchaseGenerateTest extends ProcurementTestCase { /** * 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单 - * 商品设两个等级价 5.00 / 4.00,估算单价应取最低 4.00 + * 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价) */ private function seedAcceptedOrders(): ProductModel { @@ -41,8 +43,8 @@ class PurchaseGenerateTest extends ProcurementTestCase return $product; } - /** 多门店多订单按商品聚合,估算单价取最低等级价 */ - public function test_generate_aggregates_orders_by_product(): void + /** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ订货金额 */ + public function test_generate_aggregates_order_items_into_header(): void { $this->seedAcceptedOrders(); $admin = $this->actingAsSysUser(); @@ -58,15 +60,12 @@ class PurchaseGenerateTest extends ProcurementTestCase $this->assertSame($admin->id, $purchase->operator_id); $this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status); - $this->assertCount(1, $purchase->items, '单一商品应聚合为一行'); - $item = $purchase->items->first(); - $this->assertSame('10.00', (string) $item->quantity, '3+3+4'); - $this->assertSame('4.00', (string) $item->price, '估算单价取最低等级价'); - $this->assertSame('40.00', (string) $item->amount, '10 × 4.00'); - $this->assertSame('40.00', (string) $purchase->estimate_amount); + $this->assertSame('10.00', (string) $purchase->total_quantity, '3+3+4'); + $this->assertSame('50.00', (string) $purchase->estimate_amount, '10 件 × 等级价 5.00'); + $this->assertSame('0.00', (string) $purchase->actual_amount); } - /** 源订单状态回写为采购中,并关联 purchase_id */ + /** 源订单状态回写为采购中,订单与订货明细均回写 purchase_id */ public function test_generate_writes_back_order_status(): void { $this->seedAcceptedOrders(); @@ -79,6 +78,12 @@ class PurchaseGenerateTest extends ProcurementTestCase $this->assertSame(0, StoreOrderModel::where('status', StoreOrderModel::STATUS_SUMMARIZED)->count()); $this->assertSame(3, StoreOrderModel::where('status', StoreOrderModel::STATUS_DELIVERING)->count()); $this->assertSame(3, StoreOrderModel::where('purchase_id', $purchase->id)->count()); + $this->assertSame( + 3, + StoreOrderItemModel::where('purchase_id', $purchase->id)->count(), + '订货明细应全部回写 purchase_id(采购明细溯源依据)' + ); + $this->assertSame(0, StoreOrderItemModel::where('purchase_id', 0)->count()); } /** 仅有待接单订单时不可生成(待接单不再被归集)→ 报错 */ @@ -140,44 +145,4 @@ class PurchaseGenerateTest extends ProcurementTestCase $this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单'); } - - /** 估算单价取「最低实际价」:固定价与百分比计价混合时按换算后值比较 */ - public function test_generate_uses_min_actual_price_across_types(): void - { - $level = CustomerLevelModel::factory()->create(); - $levelFixed = CustomerLevelModel::factory()->create(); - $store = StoreModel::factory()->create(['level_id' => $level->id]); - $product = ProductModel::factory()->create([ - 'status' => ProductModel::STATUS_ON, - 'cost_price' => 10, - ]); - // 百分比行:10 × (1+40%) = 14.00;固定价行 5.00 → 估算应取 5.00 - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $level->id, - 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, - 'percent' => 40, - 'price' => 14.00, - ]); - ProductPriceModel::factory()->create([ - 'product_id' => $product->id, - 'level_id' => $levelFixed->id, - 'price' => 5.00, - ]); - - $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); - $this->postJson('/mini/order', ['items' => [['product_id' => $product->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); - - $item = PurchaseOrderModel::first()->items->first(); - $this->assertSame('5.00', (string) $item->price, '估算单价应取换算后的最低实际价'); - $this->assertSame('15.00', (string) $item->amount, '3 × 5.00'); - } } diff --git a/tests/Feature/ReconciliationTest.php b/tests/Feature/ReconciliationTest.php index ba0caed..e4d91a7 100644 --- a/tests/Feature/ReconciliationTest.php +++ b/tests/Feature/ReconciliationTest.php @@ -5,7 +5,6 @@ namespace Tests\Feature; use App\Models\CustomerLevelModel; use App\Models\ProductModel; use App\Models\ProductPriceModel; -use App\Models\PurchaseOrderModel; use App\Models\ReconciliationItemModel; use App\Models\ReconciliationModel; use App\Models\SettlementModel; @@ -15,27 +14,31 @@ use App\Models\SupplierModel; use App\Models\UserModel; /** - * 财务对账:明细构建(品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算 + * 财务对账:明细构建(已完成订单直读,品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算 + * publish = 订货金额;actual = 采购成本(称重>0 ? 称重×单价 : 数量×单价,单价 = 成本/包规) */ class ReconciliationTest extends ProcurementTestCase { /** - * 构造已分摊的完整链路:2 门店下单(2/3 件)→ 生成采购单 → 录入实际金额 → 分摊 + * 构造已完成订单链路:2 门店下单(2/3 件,等级价 10.00;成本 8,包规 1斤 → 单价 8.00),订单置为已完成 + * 预期:publish 20/30,actual 16/24,diff 4/6 * - * @return array{0: PurchaseOrderModel, 1: array, 2: SupplierModel} + * @return array{0: array, 1: SupplierModel} */ - private function buildAllocatedChain(): array + private function buildCompletedOrders(): array { $level = CustomerLevelModel::factory()->create(); $supplier = SupplierModel::factory()->create(); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'supplier_id' => $supplier->id, + 'cost_price' => 8, + 'spec' => '1斤', ]); ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']); $stores = []; - foreach (['2.00', '3.00'] as $qty) { + foreach ([2, 3] as $qty) { $store = StoreModel::factory()->create(['level_id' => $level->id]); $stores[] = $store; $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); @@ -43,23 +46,10 @@ class ReconciliationTest extends ProcurementTestCase ->assertJsonPath('success', true); } - // 接单后生成采购单 - StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); + // 订单完成(对账数据源为已完成订单的订货明细) + StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_COMPLETED]); - $this->actingAsSysUser(); - $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) - ->assertJsonPath('success', true); - $purchase = PurchaseOrderModel::first(); - $item = $purchase->items->first(); - - $this->putJson("/purchase/order/item/{$item->id}", [ - 'price' => '10.00', - 'quantity' => $item->quantity, - 'weight' => 0, - ])->assertJsonPath('success', true); - $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true); - - return [$purchase->fresh(), $stores, $supplier]; + return [$stores, $supplier]; } private function createRecon(array $extra = []): int @@ -74,34 +64,53 @@ class ReconciliationTest extends ProcurementTestCase return (int) $response->json('data.id'); } - /** 构建明细:publish=订货金额,actual=分摊金额,diff=publish-actual,头汇总回写 */ + /** 构建明细:publish=订货金额,actual=采购成本(数量×单价),diff=publish-actual,头汇总回写 */ public function test_build_creates_reconciliation_items(): void { - [, $stores] = $this->buildAllocatedChain(); + [$stores] = $this->buildCompletedOrders(); $this->actingAsSysUser(); $reconId = $this->createRecon(); $this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true); $items = ReconciliationItemModel::where('recon_id', $reconId)->get(); - $this->assertCount(2, $items, '两门店分摊 → 两条对账明细'); + $this->assertCount(2, $items, '两门店已完成订单 → 两条对账明细'); $byStore = $items->keyBy('store_id'); $this->assertSame('20.00', (string) $byStore[$stores[0]->id]->publish_amount, '订货金额 2×10'); - $this->assertSame('20.00', (string) $byStore[$stores[0]->id]->actual_amount, '分摊金额'); - $this->assertSame('0.00', (string) $byStore[$stores[0]->id]->diff_amount); + $this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount, '采购成本 2×8'); + $this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount); + $this->assertSame('30.00', (string) $byStore[$stores[1]->id]->publish_amount); + $this->assertSame('24.00', (string) $byStore[$stores[1]->id]->actual_amount); $recon = ReconciliationModel::find($reconId); $this->assertSame('50.00', (string) $recon->publish_amount); - $this->assertSame('50.00', (string) $recon->actual_amount); - $this->assertSame('0.00', (string) $recon->diff_amount); + $this->assertSame('40.00', (string) $recon->actual_amount); + $this->assertSame('10.00', (string) $recon->diff_amount); $this->assertSame(ReconciliationModel::STATUS_WORKING, $recon->status); } - /** 供应商筛选:仅拉取该供应商的采购数据 */ + /** 未完成订单不计入对账 */ + public function test_build_excludes_unfinished_orders(): void + { + [$stores] = $this->buildCompletedOrders(); + // 第二家门店订单回退为配送中 → 仅第一家进入对账 + StoreOrderModel::where('store_id', $stores[1]->id) + ->update(['status' => StoreOrderModel::STATUS_DISTRIBUTION]); + $this->actingAsSysUser(); + + $reconId = $this->createRecon(); + $this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true); + + $items = ReconciliationItemModel::where('recon_id', $reconId)->get(); + $this->assertCount(1, $items); + $this->assertSame($stores[0]->id, $items->first()->store_id); + } + + /** 供应商筛选:仅拉取该供应商的订单数据 */ public function test_build_filters_by_supplier(): void { - [, , $supplier] = $this->buildAllocatedChain(); + [, $supplier] = $this->buildCompletedOrders(); $this->actingAsSysUser(); // 无关供应商 → 无数据报错 @@ -118,7 +127,7 @@ class ReconciliationTest extends ProcurementTestCase /** D4 修改明细:自动重算本行 diff 与对账单头汇总 */ public function test_update_item_recalculates_diff_and_header(): void { - $this->buildAllocatedChain(); + $this->buildCompletedOrders(); $this->actingAsSysUser(); $reconId = $this->createRecon(); @@ -132,14 +141,14 @@ class ReconciliationTest extends ProcurementTestCase $this->assertSame('-5.00', (string) $item->diff_amount, '20.00 - 25.00'); $recon = ReconciliationModel::find($reconId); - $this->assertSame('55.00', (string) $recon->actual_amount, '25 + 30'); - $this->assertSame('-5.00', (string) $recon->diff_amount, '50 - 55'); + $this->assertSame('49.00', (string) $recon->actual_amount, '25 + 24'); + $this->assertSame('1.00', (string) $recon->diff_amount, '50 - 49'); } /** D8 对账状态标记翻转 */ public function test_toggle_reconciled_flag(): void { - $this->buildAllocatedChain(); + $this->buildCompletedOrders(); $this->actingAsSysUser(); $reconId = $this->createRecon(); @@ -157,7 +166,7 @@ class ReconciliationTest extends ProcurementTestCase /** D9 结算:按门店生成结算表,对账单转为已结算且不可重复结算 */ public function test_settle_creates_settlements_per_store(): void { - [, $stores] = $this->buildAllocatedChain(); + [$stores] = $this->buildCompletedOrders(); $this->actingAsSysUser(); $reconId = $this->createRecon(); @@ -170,7 +179,8 @@ class ReconciliationTest extends ProcurementTestCase $byStore = $settlements->keyBy('store_id'); $this->assertSame('20.00', (string) $byStore[$stores[0]->id]->total_amount); - $this->assertSame('20.00', (string) $byStore[$stores[0]->id]->actual_amount); + $this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount); + $this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount); $this->assertStringStartsWith('JS', $byStore[$stores[0]->id]->settlement_no); $this->assertSame(ReconciliationModel::STATUS_SETTLED, ReconciliationModel::find($reconId)->status); diff --git a/web/api/purchase/order.ts b/web/api/purchase/order.ts index d50977a..be31dfc 100644 --- a/web/api/purchase/order.ts +++ b/web/api/purchase/order.ts @@ -1,21 +1,11 @@ import createAxios from '@/utils/request'; import type { ExportFormat, - IAllocationResult, - IPurchaseOrderItem, + IPurchaseDetail, PurchaseExportType, } from '@/domain/iPurchaseOrder.ts'; import { downloadBlob } from '@/api/common/download.ts'; -export interface PurchaseItemUpdateParams { - product_name?: string; - product_spec?: string; - price: number | string; - quantity: number | string; - weight?: number | string; - remark?: string; -} - /** C1 合并「已接单」门店订单生成采购单(order_ids 为空 = 全部已接单;生成后源订单转采购中) */ export async function generatePurchase(purchase_date: string, order_ids?: number[]) { return createAxios<{ id: number; purchase_no: string }>({ @@ -25,6 +15,14 @@ export async function generatePurchase(purchase_date: string, order_ids?: number }); } +/** 采购单详情(商品行 × 门店列矩阵) */ +export async function getPurchaseDetail(id: number) { + return createAxios({ + url: `/purchase/order/${id}`, + method: 'get', + }); +} + /** C2/C3 导出采购单(blob 下载) */ export async function exportPurchase(id: number, type: PurchaseExportType, format: ExportFormat) { return downloadBlob( @@ -34,37 +32,27 @@ export async function exportPurchase(id: number, type: PurchaseExportType, forma ); } -/** C4 修改采购明细(amount 由后端重算) */ -export async function updatePurchaseItem(id: number, data: PurchaseItemUpdateParams) { - return createAxios<{ amount: string }>({ - url: `/purchase/order/item/${id}`, +/** C4 门店单元格修改(数量/称重,同步订货明细并重算汇总) */ +export async function updatePurchaseCell( + orderItemId: number, + data: { quantity: number; weight?: number }, +) { + return createAxios<{ quantity: number; weight: number; amount: number }>({ + url: `/purchase/order/cell/${orderItemId}`, method: 'put', data, }); } -/** C5/C6 明细发送供应商 */ -export async function sendPurchaseItem(id: number) { - return createAxios({ - url: `/purchase/order/item/${id}/send`, - method: 'put', - }); -} - -/** D3 执行金额分摊 */ -export async function allocatePurchase(id: number) { +/** C4 商品行修改(成本/实际称重,同步该商品全部订货明细) */ +export async function updatePurchaseRow( + purchaseId: number, + productId: number, + data: { cost_price?: number; weight?: number }, +) { return createAxios<{ count: number }>({ - url: `/purchase/order/${id}/allocate`, - method: 'post', + url: `/purchase/order/${purchaseId}/row/${productId}`, + method: 'put', + data, }); } - -/** 分摊结果(按门店 / 按商品聚合) */ -export async function getAllocation(id: number) { - return createAxios({ - url: `/purchase/order/${id}/allocation`, - method: 'get', - }); -} - -export type { IPurchaseOrderItem }; diff --git a/web/domain/iPurchaseOrder.ts b/web/domain/iPurchaseOrder.ts index 41f8659..63e0d0b 100644 --- a/web/domain/iPurchaseOrder.ts +++ b/web/domain/iPurchaseOrder.ts @@ -1,36 +1,29 @@ -/** 采购分摊记录 */ -export interface IPurchaseAllocation { - id?: number; - purchase_item_id?: number; - order_item_id?: number; - store_id?: number; - product_id?: number; - quantity?: string; - weight?: string; - amount?: string; - store?: { id: number; name: string }; - product?: { id: number; name: string; unit: string }; +/** 采购明细门店单元格(溯源订货明细) */ +export interface IPurchaseStoreCell { + order_item_id: number; + store_id: number; + quantity: number; + weight: number; + /** 金额 = 称重>0 ? 称重×单价 : 数量×单价(单价 = 成本/包规) */ + amount: number; } -/** 采购明细 */ -export interface IPurchaseOrderItem { - id?: number; - purchase_id?: number; - product_id?: number; - supplier_id?: number; - product_name?: string; - product_spec?: string; - price?: string; - quantity?: string; - weight?: string; - amount?: string; - sort?: number; - is_sent?: number; - sent_at?: string; - supplier_confirmed_at?: string; - remark?: string; - supplier?: { id: number; name: string }; - allocations?: IPurchaseAllocation[]; +/** 采购明细行(商品维度聚合,行 × 门店列矩阵) */ +export interface IPurchaseDetailRow { + product_id: number; + product_name: string; + /** 规格/包规 */ + product_spec: string; + unit: string; + supplier_id: number; + supplier?: { id: number; name: string } | null; + /** 成本 */ + cost_price: number; + /** 合计数量 */ + quantity: number; + /** 合计实际称重 */ + weight: number; + cells: Record; } /** 采购单 */ @@ -38,7 +31,7 @@ export default interface IPurchaseOrder { id?: number; purchase_no?: string; purchase_date?: string; - /** 0待发送 1部分发送 2全部发送 3已完成 */ + /** 0进行中 3已完成 */ status?: number; total_quantity?: string; total_weight?: string; @@ -47,34 +40,20 @@ export default interface IPurchaseOrder { operator_id?: number; operator?: { id: number; nickname: string }; remark?: string; - items?: IPurchaseOrderItem[]; created_at?: string; } +/** 采购单详情(矩阵数据) */ +export interface IPurchaseDetail { + purchase: IPurchaseOrder; + stores: { id: number; name: string }[]; + items: IPurchaseDetailRow[]; +} + export const PURCHASE_STATUS_MAP: Record = { - 0: { text: '待发送', color: 'default' }, - 1: { text: '部分发送', color: 'processing' }, - 2: { text: '全部发送', color: 'cyan' }, + 0: { text: '进行中', color: 'processing' }, 3: { text: '已完成', color: 'success' }, }; -/** 分摊结果聚合行 */ -export interface IAllocationAggRow { - store_id?: number; - store_name?: string; - product_id?: number; - product_name?: string; - unit?: string; - quantity: number; - weight: number; - amount: number; -} - -export interface IAllocationResult { - by_store: IAllocationAggRow[]; - by_product: IAllocationAggRow[]; - total_amount: number; -} - export type PurchaseExportType = 'all' | 'category'; export type ExportFormat = 'xlsx' | 'pdf'; diff --git a/web/domain/iReconciliation.ts b/web/domain/iReconciliation.ts index 62930c3..ee7345a 100644 --- a/web/domain/iReconciliation.ts +++ b/web/domain/iReconciliation.ts @@ -3,7 +3,6 @@ export interface IReconciliationItem { id?: number; recon_id?: number; store_id?: number; - purchase_item_id?: number; order_item_id?: number; product_id?: number; product_name?: string; @@ -11,7 +10,7 @@ export interface IReconciliationItem { weight?: string; /** 公布金额(订货金额) */ publish_amount?: string; - /** 实际金额(分摊金额) */ + /** 实际金额(采购成本) */ actual_amount?: string; /** 差额 = publish − actual */ diff_amount?: string; diff --git a/web/pages/purchase/order.tsx b/web/pages/purchase/order.tsx index 11d0111..b2c02b2 100644 --- a/web/pages/purchase/order.tsx +++ b/web/pages/purchase/order.tsx @@ -1,30 +1,18 @@ import React, { useRef, useState } from 'react'; import { Button, - DatePicker, Descriptions, Drawer, Dropdown, - Empty, - Form, InputNumber, message, - Modal, Popconfirm, - Space, Table, - Tabs, Tag, Typography, } from 'antd'; -import { - DownloadOutlined, - PlusOutlined, - SendOutlined, - SplitCellsOutlined, -} from '@ant-design/icons'; +import { CheckOutlined, DownloadOutlined } from '@ant-design/icons'; import type { TableProps } from 'antd'; -import dayjs from 'dayjs'; import XinTable from '@/components/XinTable'; import type { XinTableColumn, @@ -33,305 +21,203 @@ import type { } from '@/components/XinTable/typings.ts'; import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts'; import type { - IAllocationAggRow, - IPurchaseOrderItem, + IPurchaseDetail, + IPurchaseDetailRow, } from '@/domain/iPurchaseOrder.ts'; import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; import { - allocatePurchase, exportPurchase, - generatePurchase, - getAllocation, - sendPurchaseItem, - updatePurchaseItem, + getPurchaseDetail, } from '@/api/purchase/order.ts'; -import { Get } from '@/api/common/table.ts'; +import { Update } from '@/api/common/table.ts'; import AuthButton from '@/components/AuthButton'; const { Title, Text } = Typography; -/** 行内编辑中的明细值 */ -interface EditingItem { - price: number; - quantity: number; - weight: number; +/** 行草稿:成本/称重 + 各门店单元格数量 */ +interface RowDraft { + cost_price?: number; + weight?: number; + cells: Record; } /** - * 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 明细修改 / C5-C6 发送 / D3 分摊) + * 采购单管理(C1 在门店订单页合并生成 / C2-C3 导出 / C4 明细矩阵修改) + * 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列 */ const PurchaseOrderPage: React.FC = () => { const tableRef = useRef>(null); - // 生成采购单 - const [generateOpen, setGenerateOpen] = useState(false); - const [generateLoading, setGenerateLoading] = useState(false); - const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>(); - // 详情抽屉 const [detailOpen, setDetailOpen] = useState(false); - const [detail, setDetail] = useState(null); + const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); - const [editing, setEditing] = useState>({}); - const [savingItemId, setSavingItemId] = useState(null); - - // 分摊 - const [allocating, setAllocating] = useState(false); - const [allocation, setAllocation] = useState<{ - byStore: IAllocationAggRow[]; - byProduct: IAllocationAggRow[]; - total: number; - } | null>(null); + const [drafts, setDrafts] = useState>({}); + const [savingProductId, setSavingProductId] = useState(null); + const [completing, setCompleting] = useState(false); const loadDetail = async (id: number) => { setDetailLoading(true); try { - const res = await Get('/purchase/order', id); - const purchase = res.data.data ?? null; - setDetail(purchase); - const editingMap: Record = {}; - purchase?.items?.forEach((item) => { - if (item.id !== undefined) { - editingMap[item.id] = { - price: Number(item.price ?? 0), - quantity: Number(item.quantity ?? 0), - weight: Number(item.weight ?? 0), - }; - } - }); - setEditing(editingMap); + const res = await getPurchaseDetail(id); + setDetail(res.data.data ?? null); + setDrafts({}); } finally { setDetailLoading(false); } }; const openDetail = async (id: number) => { - setAllocation(null); setDetailOpen(true); await loadDetail(id); - await loadAllocation(id); }; - const loadAllocation = async (id: number) => { - try { - const res = await getAllocation(id); - const data = res.data.data; - if (data) { - setAllocation({ - byStore: data.by_store ?? [], - byProduct: data.by_product ?? [], - total: data.total_amount ?? 0, - }); - } - } catch { - // 未分摊时忽略 - } + const getDraft = (productId: number): RowDraft => drafts[productId] ?? { cells: {} }; + + const patchDraft = (productId: number, patch: Partial) => { + setDrafts((prev) => { + const current = prev[productId] ?? { cells: {} }; + return { + ...prev, + [productId]: { ...current, ...patch, cells: { ...current.cells, ...patch.cells } }, + }; + }); }; - const handleGenerate = async (values: { purchase_date: dayjs.Dayjs }) => { - setGenerateLoading(true); - try { - const res = await generatePurchase(values.purchase_date.format('YYYY-MM-DD')); - message.success(`采购单 ${res.data.data?.purchase_no} 已生成`); - setGenerateOpen(false); - await tableRef.current?.reload(); - await openDetail(res.data.data!.id); - } finally { - setGenerateLoading(false); - } + /** 草稿成本(未修改取原值) */ + const draftCost = (row: IPurchaseDetailRow): number => + getDraft(row.product_id).cost_price ?? row.cost_price; + + const isRowDirty = (row: IPurchaseDetailRow): boolean => { + // const draft = getDraft(row.product_id); + // if (draft.cost_price !== undefined && draft.cost_price !== row.cost_price) { + // return true; + // } + // if (draft.weight !== undefined && draft.weight !== row.weight) { + // return true; + // } + // return row.cells.some( + // (cell) => draft.cells[cell.order_item_id] !== undefined + // && draft.cells[cell.order_item_id] !== cell.quantity, + // ); }; - const isItemDirty = (item: IPurchaseOrderItem): boolean => { - const edit = editing[item.id!]; - if (!edit) { - return false; - } - return ( - edit.price !== Number(item.price ?? 0) || - edit.quantity !== Number(item.quantity ?? 0) || - edit.weight !== Number(item.weight ?? 0) - ); - }; - - const saveItem = async (item: IPurchaseOrderItem) => { - const edit = editing[item.id!]; - if (!edit || !isItemDirty(item)) { + /** 保存一行:先落各门店单元格数量,再落行级成本/称重,最后刷新 */ + const saveRow = async (row: IPurchaseDetailRow) => { + if (!detail || !isRowDirty(row)) { return; } - setSavingItemId(item.id!); + const draft = getDraft(row.product_id); + setSavingProductId(row.product_id); try { - const res = await updatePurchaseItem(item.id!, { - price: edit.price, - quantity: edit.quantity, - weight: edit.weight, - }); - message.success(`金额已重算:¥${res.data.data?.amount}`); - await loadDetail(detail!.id!); + // for (const cell of row.cells) { + // const qty = draft.cells[cell.order_item_id]; + // if (qty !== undefined && qty !== cell.quantity) { + // await updatePurchaseCell(cell.order_item_id, { quantity: qty }); + // } + // } + // const rowPatch: { cost_price?: number; weight?: number } = {}; + // if (draft.cost_price !== undefined && draft.cost_price !== row.cost_price) { + // rowPatch.cost_price = draft.cost_price; + // } + // if (draft.weight !== undefined && draft.weight !== row.weight) { + // rowPatch.weight = draft.weight; + // } + // if (Object.keys(rowPatch).length > 0) { + // await updatePurchaseRow(detail.purchase.id!, row.product_id, rowPatch); + // } + message.success('已保存并同步订货明细'); + await loadDetail(detail.purchase.id!); + await tableRef.current?.reload(); } finally { - setSavingItemId(null); + setSavingProductId(null); } }; - const handleSend = async (item: IPurchaseOrderItem) => { - await sendPurchaseItem(item.id!); - message.success('已发送给供应商'); - await loadDetail(detail!.id!); - await tableRef.current?.reload(); - }; - - const handleAllocate = async () => { - setAllocating(true); + const handleComplete = async () => { + if (!detail) { + return; + } + setCompleting(true); try { - const res = await allocatePurchase(detail!.id!); - message.success(`分摊完成,共 ${res.data.data?.count} 条记录`); - await loadAllocation(detail!.id!); + await Update(`/purchase/order/${detail.purchase.id}`, { status: 3 }); + message.success('采购单已标记完成'); + await loadDetail(detail.purchase.id!); + await tableRef.current?.reload(); } finally { - setAllocating(false); + setCompleting(false); } }; - const itemColumns: TableProps['columns'] = [ - { title: '序号', dataIndex: 'sort', width: 60, align: 'center' }, - { title: '品名', dataIndex: 'product_name', width: 130 }, - { title: '规格', dataIndex: 'product_spec', width: 110, render: (v) => v || '-' }, - { - title: '供应商', - dataIndex: 'supplier', - width: 130, - render: (_, record) => record.supplier?.name ?? '-', - }, - { - title: '单价', - dataIndex: 'price', - width: 130, - render: (_, record) => ( - - setEditing((prev) => ({ - ...prev, - [record.id!]: { ...prev[record.id!], price: v ?? 0 }, - })) - } - className="!w-24" - /> - ), - }, - { - title: '数量', - dataIndex: 'quantity', - width: 120, - render: (_, record) => ( - - setEditing((prev) => ({ - ...prev, - [record.id!]: { ...prev[record.id!], quantity: v ?? 0 }, - })) - } - className="!w-20" - /> - ), - }, - { - title: '实际称重', - dataIndex: 'weight', - width: 130, - render: (_, record) => ( - - setEditing((prev) => ({ - ...prev, - [record.id!]: { ...prev[record.id!], weight: v ?? 0 }, - })) - } - className="!w-24" - /> - ), - }, - { - title: '金额', - dataIndex: 'amount', - width: 100, - align: 'right', - render: (v) => ¥{String(v)}, - }, - { - title: '发送状态', - dataIndex: 'is_sent', - width: 150, - render: (_, record) => - record.is_sent === 1 ? ( - - 已发送 - {record.sent_at ? ` ${record.sent_at}` : ''} - - ) : ( - 未发送 - ), - }, - { - title: '操作', - key: 'action', - width: 150, - fixed: 'right', - render: (_, record) => ( - + /** 明细矩阵列:品名/供应商/包规/单位/成本/单价 + 每门店一组(数量/金额)+ 合计 + 操作 */ + const buildItemColumns = (): TableProps['columns'] => { + const fixed: NonNullable['columns']> = [ + { title: '品名', dataIndex: 'product_name', width: 120, align: 'center' }, + { + title: '供应商', + dataIndex: 'supplier', + width: 110, + align: 'center', + render: (_, row) => row.supplier?.name ?? '-', + }, + { + title: '单价', + key: 'unit_cost', + width: 80, + align: 'center', + render: (_, row) => `¥${(Number(row.cost_price) / Number(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 || '-' }, + { title: '成本', dataIndex: 'cost_price', width: 110, align: 'center', render: (v) => v || '-' } + ]; + + const storeColumns = (detail?.stores ?? []).map((store) => ({ + title: store.name, + key: `store-${store.id}`, + align: 'center' as const, + render: (_: unknown, row: IPurchaseDetailRow) => { + const cell = row.cells[store.id]; + if (!cell) { + return -; + } + return ( + { row.cells[store.id] } + ); + }, + })); + + const tail: NonNullable['columns']> = [ + { + title: '合计数量', + key: 'total_quantity', + width: 90, + align: 'center', + render: (_, row) => Object.values(row.cells).reduce((a, b) => a + b, 0), + }, + { + title: '操作', + key: 'action', + width: 80, + fixed: 'right', + render: (_, row) => ( - {record.is_sent !== 1 ? ( - - handleSend(record)} - > - - - - ) : null} - - ), - }, - ]; + ), + }, + ]; - const aggColumns = (nameTitle: string): TableProps['columns'] => [ - { - title: nameTitle, - key: 'name', - render: (_, row) => row.store_name ?? row.product_name ?? '-', - }, - { title: '数量', dataIndex: 'quantity', align: 'right' }, - { title: '重量', dataIndex: 'weight', align: 'right' }, - { - title: '金额', - dataIndex: 'amount', - align: 'right', - render: (v) => `¥${v}`, - }, - ]; + return [...fixed, ...storeColumns, ...tail]; + }; const columns: XinTableColumn[] = [ { @@ -425,15 +311,6 @@ const PurchaseOrderPage: React.FC = () => { tableRef, operateRender, formProps: false, - actionBarRender: (dom) => [ - - - , - dom.search, - dom.keywordSearch, - ], }; return ( @@ -441,144 +318,62 @@ const PurchaseOrderPage: React.FC = () => {
采购单 - 按门店订单汇总生成;录入实际称重/单价后执行金额分摊(按订货比例摊到各门店,尾差修正确保金额守恒)。 + 在「门店订单」页勾选已接单订单合并生成;明细按商品行 × 门店列展示,修改数量/成本/称重后点行内「保存」同步订货明细。
{...tableProps} /> - {/* 生成采购单 */} - setGenerateOpen(false)} - onOk={() => generateForm.submit()} - confirmLoading={generateLoading} - okText="确认生成" - destroyOnHidden - > -
- 将合并全部「已接单」门店订单,按商品聚合生成采购单(估算单价取最低等级价),生成后源订单转为「采购中」。 -
-
- - - -
-
- - {/* 采购单详情 */} + {/* 采购单详情:商品行 × 门店列矩阵 */} setDetailOpen(false)} - size={1080} + size={1200} loading={detailLoading} > {detail ? ( <> - - {detail.purchase_date} + + + + + + ) : undefined + } + > + {detail.purchase.purchase_date} - - {PURCHASE_STATUS_MAP[detail.status ?? 0]?.text} + + {PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.text} - {detail.operator?.nickname ?? '-'} + {detail.purchase.operator?.nickname ?? '-'} - ¥{detail.estimate_amount} - ¥{detail.actual_amount} - {detail.total_weight} + ¥{detail.purchase.estimate_amount} + ¥{detail.purchase.actual_amount} + {detail.purchase.total_weight} - -
- 录入实际称重与单价后点击行内「保存」,金额由后端重算(称重>0 按 称重×单价,否则按 数量×单价)。 -
- - rowKey="id" - size="small" - columns={itemColumns} - dataSource={detail.items ?? []} - pagination={false} - scroll={{ x: 1200 }} - /> - - ), - }, - { - key: 'allocation', - label: '金额分摊', - children: ( - <> - - - - - - - {allocation ? ( - - 分摊总额:¥{allocation.total} - - ) : null} - - {allocation && (allocation.byStore.length > 0 || allocation.byProduct.length > 0) ? ( -
-
- 按门店 - - rowKey={(row) => String(row.store_id)} - size="small" - columns={aggColumns('门店')} - dataSource={allocation.byStore} - pagination={false} - /> -
-
- 按商品 - - rowKey={(row) => String(row.product_id)} - size="small" - columns={aggColumns('商品')} - dataSource={allocation.byProduct} - pagination={false} - /> -
-
- ) : ( - - )} - - ), - }, - ]} +
+ 单价 = 成本 ÷ 包规;门店金额 = 数量 × 单价;行级「实际称重」保存时按各店数量比例分摊到订货明细(称重>0 时金额按称重×单价计)。 +
+ + rowKey="product_id" + size="small" + bordered + columns={buildItemColumns()} + dataSource={detail.items} + pagination={false} + scroll={{ x: 'max-content' }} /> ) : null}