CaiWuXiTong/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php

314 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository as Repository;
use Modules\EcommerceFinance\Services\SupplierBookkeepingValuation;
use PHPUnit\Framework\TestCase;
class SupplierBookkeepingValuationTest extends TestCase
{
private function row(array $patch = []): array
{
return array_replace(['started_at' => '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_freight_or_goods_stays_pending_instead_of_silently_zeroing(): void
{
foreach ([
[[$this->shipment()], []], [[$this->shipment()], ['SKU-A' => '0']],
[[$this->shipment(['qty' => null])], ['SKU-A' => '20']],
[[$this->shipment(['freight' => 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_aggregate_multi_sku_quantity_cannot_be_applied_to_each_product(): void
{
$row = $this->service([$this->shipment(['barcode' => 'SKU-A,SKU-B', 'qty' => '3'])])
->apply([$this->row()])[0];
$this->assertSame('待确认', $row['deduction_status']);
$this->assertStringContainsString('缺少逐商品发货数量', $row['deduction_basis']);
$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_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' => '0', 'barcode' => 'SKU-A,SKU-B'], ['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']);
}
}