CaiWuXiTong/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php

369 lines
21 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_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']);
}
}