314 lines
17 KiB
PHP
314 lines
17 KiB
PHP
<?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']);
|
||
}
|
||
}
|