'2026-10-15 10:00:00', 'bookkeeping_type' => '补偿款项', 'amount' => '100.00', 'original_amount' => '100.00', 'deduction_status' => '扣款', 'deduction_basis' => '供应商正常记账', 'remark' => '原备注', 'tracking_no' => 'EXP-1', 'online_order_no' => 'ORDER-1', 'product_code' => 'SKU-A'], $patch); } private function shipment(array $patch = []): array { return array_replace(['id' => 1, 'source_id' => 10, 'express_no' => 'EXP-1', 'trade_no' => 'ORDER-1', 'barcode' => 'SKU-A', 'qty' => '2', 'freight' => '8.50', 'ship_time' => '2026-05-01 00:00:00'], $patch); } private function service(array $shipments, array $costs = ['SKU-A' => '20.00']): SupplierBookkeepingValuation { $repository = $this->createMock(Repository::class); $repository->method('shipmentsFor')->willReturn($shipments); $repository->method('readyMonths')->willReturn(['2026-05', '2026-06', '2026-07', '2026-08', '2026-09', '2026-10']); $repository->method('costsFor')->willReturn($costs); return new SupplierBookkeepingValuation($repository); } public function test_caps_by_matched_shipped_quantity_times_cost_plus_actual_freight(): void { $row = $this->service([$this->shipment()])->apply([$this->row()])[0]; $this->assertSame('48.50', $row['amount']); $this->assertSame('100.00', $row['original_amount']); $this->assertSame('40.00', $row['matched_cost']); $this->assertSame('8.50', $row['matched_freight']); $this->assertSame('2', $row['matched_quantity']); $this->assertSame('2026-05 至 2026-10', $row['valuation_window']); $this->assertStringStartsWith("原备注\n", $row['remark']); $this->assertSame('扣款', $row['deduction_status']); } public function test_copied_reference_and_sku_wrappers_are_cleaned_before_matching_and_costing(): void { $service = $this->service([$this->shipment(['express_no' => " '@EXP-1", 'trade_no' => '@ORDER-1’', 'barcode' => " '@sku-a "])]); foreach ([" '@EXP-1 ", ''] as $tracking) { $row = $service->apply([$this->row(['tracking_no' => $tracking, 'online_order_no' => " '@ORDER-1 ", 'product_code' => ''@SKU-A’'])])[0]; $this->assertSame('48.50', $row['amount']); $this->assertSame('40.00', $row['matched_cost']); $this->assertSame('扣款', $row['deduction_status']); } } public function test_resolved_real_product_code_is_used_for_catalog_cost_instead_of_bundle_parent(): void { $row = $this->service([$this->shipment()])->apply([$this->row(['product_code' => 'ZH-SKU-A-2', 'resolved_product_code' => 'SKU-A'])])[0]; $this->assertSame('40.00', $row['matched_cost']); $this->assertSame('48.50', $row['amount']); $this->assertSame('ZH-SKU-A-2', $row['product_code']); } public function test_keeps_lower_or_equal_bookkeeping_amount_and_rounds_ceiling_once(): void { $service = $this->service([$this->shipment()]); foreach (['40.00', '48.50'] as $amount) { $row = $service->apply([$this->row(['amount' => $amount])])[0]; $this->assertSame($amount, $row['amount']); } $rounded = $this->service([$this->shipment(['freight' => '8.505'])])->apply([$this->row()])[0]; $this->assertSame('48.51', $rounded['amount']); } public function test_multiple_goods_share_parcel_freight_once_and_all_matched_goods_are_costed(): void { $row = $this->service([ $this->shipment(), $this->shipment(['id' => 2, 'barcode' => 'SKU-B', 'qty' => 3, 'freight' => null]), ], ['SKU-A' => '20.00', 'SKU-B' => '5.00'])->apply([$this->row(['product_code' => "SKU-A\nSKU-B"])])[0]; $this->assertSame('63.50', $row['amount']); $this->assertSame('55.00', $row['matched_cost']); $this->assertSame('8.50', $row['matched_freight']); $this->assertSame('5', $row['matched_quantity']); } public function test_freight_loss_uses_actual_freight_even_above_original_without_requiring_cost(): void { $row = $this->service([$this->shipment(['freight' => '80.00'])], []) ->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => '30.00', 'product_code' => ''])])[0]; $this->assertSame('80.00', $row['amount']); $this->assertStringContainsString('运费损耗按原单实际运费记账', $row['remark']); $originalZero = $this->service([$this->shipment(['freight' => '8.50'])], []) ->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => '0.00'])])[0]; $this->assertSame('8.50', $originalZero['amount']); $this->assertSame('扣款', $originalZero['deduction_status']); } public function test_repeated_package_freight_is_counted_once_for_multiple_skus(): void { $service = $this->service([ $this->shipment(), $this->shipment(['id' => 2, 'barcode' => 'SKU-B', 'qty' => 3, 'freight' => '8.500']), ], ['SKU-A' => '20.00', 'SKU-B' => '5.00']); $row = $service->apply([$this->row(['product_code' => "SKU-A\nSKU-B"])])[0]; $this->assertSame('63.50', $row['amount']); $this->assertSame('55.00', $row['matched_cost']); $this->assertSame('8.50', $row['matched_freight']); $freightOnly = $service->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; $this->assertSame('8.50', $freightOnly['amount']); } public function test_conflicting_freight_for_one_package_requires_confirmation(): void { $row = $this->service([ $this->shipment(), $this->shipment(['id' => 2, 'barcode' => 'SKU-B', 'freight' => '9.00']), ])->apply([$this->row()])[0]; $this->assertSame('待确认', $row['deduction_status']); $this->assertSame('100.00', $row['amount']); $this->assertStringContainsString('同一包裹运费不一致', $row['deduction_basis']); } public function test_exact_tracking_takes_precedence_and_missing_tracking_does_not_borrow_another_parcel(): void { $shipments = [$this->shipment(), $this->shipment(['id' => 2, 'source_id' => 11, 'express_no' => 'EXP-2', 'freight' => '20.00'])]; $service = $this->service($shipments); $this->assertSame('48.50', $service->apply([$this->row()])[0]['amount']); $missing = $service->apply([$this->row(['tracking_no' => 'MISSING'])])[0]; $this->assertSame('待确认', $missing['deduction_status']); $this->assertSame('100.00', $missing['amount']); $byOrder = $service->apply([$this->row(['tracking_no' => ''])])[0]; $this->assertSame('80.00', $byOrder['matched_cost']); $this->assertSame('28.50', $byOrder['matched_freight']); $this->assertSame('100.00', $byOrder['amount']); } public function test_window_excludes_april_and_november_and_crosses_years_correctly(): void { foreach (['2026-04-30 23:59:59', '2026-11-01 00:00:00'] as $date) { $row = $this->service([$this->shipment(['ship_time' => $date])])->apply([$this->row()])[0]; $this->assertSame('待确认', $row['deduction_status']); } $this->assertSame(['2025-09-01 00:00:00', '2026-03-01 00:00:00'], Repository::window(['started_at' => '2026-02-28'])); } public function test_missing_cost_quantity_or_goods_and_invalid_freight_stay_pending(): void { foreach ([ [[$this->shipment()], []], [[$this->shipment()], ['SKU-A' => '0']], [[$this->shipment(['qty' => null])], ['SKU-A' => '20']], [[$this->shipment(['freight' => '-1'])], ['SKU-A' => '20']], [[$this->shipment(['barcode' => 'SKU-B'])], ['SKU-A' => '20']], ] as [$shipments, $costs]) { $row = $this->service($shipments, $costs)->apply([$this->row()])[0]; $this->assertSame('待确认', $row['deduction_status']); $this->assertSame('100.00', $row['amount']); } } public function test_supplier_or_warehouse_exclusions_and_small_zeros_are_never_overridden(): void { $repository = $this->createMock(Repository::class); $repository->expects($this->never())->method('shipmentsFor'); $service = new SupplierBookkeepingValuation($repository); $rows = [$this->row(['deduction_status' => '不扣款']), $this->row(['deduction_status' => '待确认']), $this->row(['deduction_status' => '不扣款', 'amount' => '0.00'])]; $this->assertSame($rows, $service->apply($rows)); } public function test_partial_multi_tracking_match_is_pending_and_cannot_deduct_only_one_package(): void { $row = $this->service([$this->shipment()])->apply([$this->row(['tracking_no' => "EXP-1\nEXP-2"])])[0]; $this->assertSame('待确认', $row['deduction_status']); $this->assertSame('100.00', $row['amount']); } public function test_bundle_costs_each_matching_barcode_once_without_using_aggregate_quantity(): void { foreach (['3', null] as $quantity) { $shipment = $this->shipment(['barcode' => 'SKU-A,SKU-B', 'qty' => $quantity]); $service = $this->service([$shipment, array_replace($shipment, ['id' => 2])], ['SKU-A' => '20', 'SKU-B' => '5']); $row = $service->apply([$this->row(['product_code' => "SKU-A\nSKU-B"])])[0]; $this->assertSame('33.50', $row['amount']); $this->assertSame('25.00', $row['matched_cost']); $this->assertSame('8.50', $row['matched_freight']); $this->assertSame('2', $row['matched_quantity']); $this->assertSame('扣款', $row['deduction_status']); $this->assertStringContainsString('组合装按各条码1件计', $row['deduction_basis']); $this->assertStringContainsString('组合装按各条码1件计', $row['remark']); $partial = $service->apply([$this->row()])[0]; $this->assertSame('28.50', $partial['amount']); $this->assertSame('1', $partial['matched_quantity']); } $freight = $this->service([$this->shipment(['barcode' => 'SKU-A,SKU-B', 'qty' => null])]) ->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; $this->assertSame('8.50', $freight['amount']); } public function test_full_and_compensation_use_cost_based_freight_when_missing_or_zero(): void { foreach (['记账全款', '记账全额', '补偿款项'] as $type) { foreach ([null, '', '0', '0.00'] as $freight) { $service = $this->service([$this->shipment(['freight' => $freight])]); $row = $service->apply([$this->row(['bookkeeping_type' => $type])])[0]; $this->assertSame('48.00', $row['amount']); $this->assertSame('40.00', $row['matched_cost']); $this->assertSame('8.00', $row['matched_freight']); $this->assertSame('100.00', $row['original_amount']); $this->assertSame('扣款', $row['deduction_status']); $this->assertStringContainsString('原单运费缺失或为0,按商品成本×0.2替代运费', $row['deduction_basis']); $this->assertStringContainsString('原单运费缺失或为0,按商品成本×0.2替代运费', $row['remark']); foreach (['30.00', '48.00'] as $amount) { $kept = $service->apply([$this->row(['bookkeeping_type' => $type, 'amount' => $amount])])[0]; $this->assertSame($amount, $kept['amount']); } } } } public function test_bundle_estimates_only_missing_package_freight_and_preserves_lower_amount(): void { $service = $this->service([ $this->shipment(), $this->shipment(['id' => 2, 'source_id' => 20, 'express_no' => 'EXP-2', 'barcode' => 'SKU-A,SKU-B', 'qty' => '9', 'freight' => '0']), $this->shipment(['id' => 3, 'source_id' => 20, 'express_no' => 'EXP-2', 'barcode' => 'SKU-A,SKU-B', 'qty' => '9', 'freight' => null]), ], ['SKU-A' => '20', 'SKU-B' => '5']); foreach (['补偿款项', '记账全款', '运费损耗'] as $type) { $row = $service->apply([$this->row(['tracking_no' => '', 'product_code' => "SKU-A\nSKU-B", 'bookkeeping_type' => $type])])[0]; $this->assertSame($type === '运费损耗' ? '13.50' : '78.50', $row['amount']); $this->assertSame('13.50', $row['matched_freight']); $this->assertSame($type === '运费损耗' ? '25.00' : '65.00', $row['matched_cost']); $this->assertSame($type === '运费损耗' ? '2' : '4', $row['matched_quantity']); $this->assertStringContainsString('组合装按各条码1件计', $row['deduction_basis']); } $kept = $service->apply([$this->row(['tracking_no' => '', 'product_code' => "SKU-A\nSKU-B", 'amount' => '60.00'])])[0]; $this->assertSame('60.00', $kept['amount']); $missingCost = $this->service([$this->shipment(['barcode' => 'SKU-A,SKU-B'])]) ->apply([$this->row(['product_code' => "SKU-A\nSKU-B"])])[0]; $this->assertSame('待确认', $missingCost['deduction_status']); $this->assertStringContainsString('商品成本价缺失或为0:SKU-B', $missingCost['deduction_basis']); $missingShipment = $service->apply([$this->row(['tracking_no' => 'MISSING', 'product_code' => "SKU-A\nSKU-B"])])[0]; $this->assertSame('待确认', $missingShipment['deduction_status']); } public function test_duplicate_package_sku_rows_do_not_double_quantity_and_conflicts_require_review(): void { $row = $this->service([$this->shipment(), $this->shipment(['id' => 2])])->apply([$this->row()])[0]; $this->assertSame('48.50', $row['amount']); $conflict = $this->service([$this->shipment(), $this->shipment(['id' => 2, 'qty' => '3'])])->apply([$this->row()])[0]; $this->assertSame('待确认', $conflict['deduction_status']); } public function test_missing_month_never_allows_partial_valuation_and_auxiliary_lookup_errors_are_not_freight_errors(): void { $repo = $this->createMock(Repository::class); $repo->method('shipmentsFor')->willReturn([$this->shipment()]); $repo->method('readyMonths')->willReturn(['2026-10']); $repo->method('costsFor')->willReturn(['SKU-A' => '20']); $row = (new SupplierBookkeepingValuation($repo))->apply([$this->row()])[0]; $this->assertStringContainsString('尚未补齐:2026-05', $row['deduction_basis']); $row = $this->service([$this->shipment(['unshipped' => '#N/A'])])->apply([$this->row()])[0]; $this->assertSame('48.50', $row['amount']); } public function test_replaced_tracking_and_merged_order_references_match_exactly(): void { $row = $this->service([$this->shipment(['express_no' => 'EXP-NEW', 'trade_no' => 'ORDER-1,ORDER-2', 'tracking_numbers' => ['EXP-NEW', 'EXP-1'], 'order_numbers' => ['ORDER-1', 'ORDER-2']])])->apply([$this->row()])[0]; $this->assertSame('48.50', $row['amount']); } public function test_unshipped_lookup_hit_excludes_the_whole_package_from_cost_and_freight(): void { $service = $this->service([ $this->shipment(['unshipped' => '#N/A']), $this->shipment(['id' => 2, 'source_id' => 20, 'express_no' => 'EXP-2', 'unshipped' => ' EXP-2 ', 'qty' => '9', 'freight' => '80']), $this->shipment(['id' => 3, 'source_id' => 20, 'express_no' => 'EXP-2', 'unshipped' => '#N/A', 'barcode' => 'SKU-B', 'freight' => '80']), ]); $row = $service->apply([$this->row(['tracking_no' => ''])])[0]; $this->assertSame('48.50', $row['amount']); $this->assertSame('2', $row['matched_quantity']); $this->assertSame('EXP-1', $row['matched_tracking_no']); $this->assertStringContainsString('已排除未发出包裹', $row['deduction_basis']); $freight = $service->apply([$this->row(['tracking_no' => '', 'bookkeeping_type' => '运费损耗'])])[0]; $this->assertSame('8.50', $freight['amount']); } public function test_only_unshipped_or_partially_excluded_explicit_tracking_never_deducts(): void { $service = $this->service([ $this->shipment(['unshipped' => '#N/A']), $this->shipment(['id' => 2, 'source_id' => 20, 'express_no' => 'EXP-2', 'unshipped' => 'EXP-2']), ]); foreach (['EXP-2', "EXP-1\nEXP-2"] as $tracking) { foreach (['补偿款项', '运费损耗'] as $type) { $row = $service->apply([$this->row(['tracking_no' => $tracking, 'bookkeeping_type' => $type])])[0]; $this->assertSame('待确认', $row['deduction_status']); $this->assertSame('100.00', $row['amount']); $this->assertStringContainsString('未发出', $row['deduction_basis']); } } } public function test_unknown_lookup_error_is_reviewed_instead_of_treated_as_a_tracking_hit(): void { $row = $this->service([$this->shipment(['unshipped' => '#REF!'])])->apply([$this->row()])[0]; $this->assertSame('待确认', $row['deduction_status']); $this->assertStringContainsString('未识别错误', $row['deduction_basis']); } public function test_freight_loss_missing_or_zero_freight_uses_cost_times_quantity_times_twenty_percent(): void { foreach ([null, '', '0', '0.00'] as $freight) { $row = $this->service([$this->shipment(['freight' => $freight])])->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; $this->assertSame('8.00', $row['amount']); $this->assertSame('40.00', $row['matched_cost']); $this->assertSame('2', $row['matched_quantity']); $this->assertSame('8.00', $row['matched_freight']); $this->assertSame('扣款', $row['deduction_status']); $this->assertSame('100.00', $row['original_amount']); $this->assertStringContainsString('成本合计×0.2替代运费', $row['deduction_basis']); $this->assertStringStartsWith("原备注\n", $row['remark']); } foreach (['0.00', '1.00'] as $amount) { $row = $this->service([$this->shipment(['freight' => '0'])])->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => $amount])])[0]; $this->assertSame('8.00', $row['amount']); } } public function test_estimates_missing_package_only_and_deduplicates_its_goods_cost(): void { $service = $this->service([ $this->shipment(), $this->shipment(['id' => 2, 'source_id' => 20, 'express_no' => 'EXP-2', 'qty' => '3', 'freight' => '0']), $this->shipment(['id' => 3, 'source_id' => 20, 'express_no' => 'EXP-2', 'qty' => '3', 'freight' => '0']), $this->shipment(['id' => 4, 'source_id' => 20, 'express_no' => 'EXP-2', 'barcode' => 'SKU-B', 'qty' => '1', 'freight' => null]), ], ['SKU-A' => '20', 'SKU-B' => '5']); $row = $service->apply([$this->row(['bookkeeping_type' => '运费损耗', 'tracking_no' => '', 'product_code' => "SKU-A\nSKU-B"])])[0]; $this->assertSame('21.50', $row['amount']); $this->assertSame('65.00', $row['matched_cost']); $this->assertSame('4', $row['matched_quantity']); $this->assertStringContainsString('另加其余包裹实际运费', $row['deduction_basis']); } public function test_actual_positive_freight_wins_and_missing_cost_or_uncertain_quantity_cannot_be_estimated(): void { $actual = $this->service([$this->shipment(['freight' => '0.01', 'qty' => null])], [])->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; $this->assertSame('0.01', $actual['amount']); foreach ([ [['freight' => null], []], [['freight' => '0'], ['SKU-A' => '0']], [['freight' => '0', 'qty' => null], ['SKU-A' => '20']], [['freight' => '-1'], ['SKU-A' => '20']], ] as [$shipment, $costs]) { $row = $this->service([$this->shipment($shipment)], $costs)->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; $this->assertSame('待确认', $row['deduction_status']); $this->assertSame('100.00', $row['amount']); } $rounded = $this->service([$this->shipment(['freight' => '0', 'qty' => '1'])], ['SKU-A' => '12.375']) ->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; $this->assertSame('2.48', $rounded['amount']); $incomplete = $this->service([$this->shipment(['freight' => '0'])], ['SKU-A' => '20', 'SKU-B' => '10']) ->apply([$this->row(['bookkeeping_type' => '运费损耗', 'product_code' => "SKU-A\nSKU-B"])])[0]; $this->assertSame('待确认', $incomplete['deduction_status']); $this->assertStringContainsString('未匹配全部工单商品', $incomplete['deduction_basis']); } }