CaiWuXiTong/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSettlementServiceTest.php

298 lines
11 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use App\Business\ThirdPartyWarehouseQuoteRepository;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageSettlementService;
use Modules\EcommerceFinance\Http\Controllers\ThirdPartyWarehouseStorageSettlementExportController;
use Illuminate\Http\Request;
use Tests\TestCase;
class ThirdPartyWarehouseStorageSettlementServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
Schema::connection('business_mirror')->create('ops_jst_sku', function (Blueprint $table): void {
$table->increments('autoid');
$table->string('sku_id');
$table->string('sku_type')->nullable();
$table->decimal('weight', 18, 4)->nullable();
});
}
public function test_it_calculates_daily_storage_and_switches_quote_versions_by_effective_date(): void
{
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
['autoid' => 1, 'sku_id' => 'SKU-A', 'sku_type' => 'normal', 'weight' => 500],
]);
$mirror = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->andReturnTrue();
$mirror->shouldReceive('exportRows')->once()->with(
Mockery::subset([
'dimension' => 'month',
'period' => '2026-08',
]),
5000,
false,
)->andReturn($this->rows());
$quotes = Mockery::mock(ThirdPartyWarehouseQuoteRepository::class);
$quotes->shouldReceive('settlementQuotes')->twice()->andReturnUsing(
static fn (string $warehouse, string $date): Collection => $date === '2026-08-02'
? collect([
self::quote('2026-08-01', 20, 0.85, 23, 3, 10.5),
self::quote('2026-08-02', 30, 0.9, 23, 3, 10.5),
])
: collect([self::quote('2026-08-01', 20, 0.85, 23, 3, 10.5)]),
);
$result = $this->service($mirror, $quotes)->settle([
'dimension' => 'month',
'period' => '2026-08',
]);
$this->assertTrue($result['complete']);
$this->assertSame(31, $result['days_in_month']);
$this->assertEqualsWithDelta(10000.0, $result['inbound_fee'], 0.000001);
$this->assertEqualsWithDelta(3.45, $result['outbound_fee'], 0.000001);
$this->assertEqualsWithDelta((23 / 23 * 3 * 10.5 / 31) + (46 / 23 * 3 * 10.5 / 31), $result['storage_fee'], 0.000001);
$this->assertEqualsWithDelta($result['inbound_fee'] + $result['outbound_fee'] + $result['storage_fee'], $result['total_fee'], 0.000001);
$this->assertSame(10000.0, $result['rows'][0]['inbound_fee']);
$this->assertSame('2026-08-02', $result['rows'][1]['quote_effective_at']);
}
public function test_missing_weight_or_quote_does_not_produce_a_final_amount(): void
{
$mirror = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->andReturnTrue();
$mirror->shouldReceive('exportRows')->once()->with(
Mockery::type('array'),
5000,
false,
)->andReturn((static function (): \Generator {
yield [
'date' => '2026-08-01',
'sku_code' => 'SKU-MISSING',
'product_name' => '缺资料商品',
'purchase_in' => 10,
'sales_out' => 0,
'other_out' => 0,
'closing_qty' => 1,
];
yield [
'date' => '2026-08-01',
'sku_code' => 'SKU-SAME-DATE',
'product_name' => '同日另一商品',
'purchase_in' => 0,
'sales_out' => 0,
'other_out' => 0,
'closing_qty' => 1,
];
})());
$quotes = Mockery::mock(ThirdPartyWarehouseQuoteRepository::class);
$quotes->shouldReceive('settlementQuotes')->once()->andReturn(collect());
$result = $this->service($mirror, $quotes)->settle([
'dimension' => 'month',
'period' => '2026-08',
]);
$this->assertFalse($result['complete']);
$this->assertNull($result['total_fee']);
$this->assertSame(['SKU-MISSING'], $result['missing_weight_skus']);
$this->assertSame(['2026-08-01'], $result['missing_quote_dates']);
}
public function test_zero_inbound_quantity_does_not_require_a_weight(): void
{
$mirror = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->andReturnTrue();
$mirror->shouldReceive('exportRows')->once()->with(
Mockery::type('array'),
5000,
false,
)->andReturn((static function (): \Generator {
yield [
'date' => '2026-08-01',
'sku_code' => 'SKU-WITHOUT-WEIGHT',
'product_name' => '无采购商品',
'purchase_in' => 0,
'sales_out' => -2,
'other_out' => 0,
'closing_qty' => 23,
];
})());
$quotes = Mockery::mock(ThirdPartyWarehouseQuoteRepository::class);
$quotes->shouldReceive('settlementQuotes')->once()->andReturn(collect([
self::quote('2026-08-01', 20, 0.85, 23, 3, 10.5),
]));
$result = $this->service($mirror, $quotes)->settle([
'dimension' => 'day',
'period' => '2026-08-01',
]);
$this->assertTrue($result['complete']);
$this->assertSame([], $result['missing_weight_skus']);
$this->assertSame(0.0, $result['rows'][0]['inbound_fee']);
$this->assertEqualsWithDelta(0.85 * 2, $result['total_fee'] - $result['storage_fee'], 0.000001);
}
public function test_negative_inbound_quantity_does_not_require_a_weight(): void
{
$mirror = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$mirror->shouldReceive('ready')->once()->andReturnTrue();
$mirror->shouldReceive('exportRows')->once()->with(
Mockery::type('array'),
5000,
false,
)->andReturn((static function (): \Generator {
yield [
'date' => '2026-08-01',
'sku_code' => 'SKU-WITHOUT-WEIGHT',
'product_name' => '负数采购商品',
'purchase_in' => -10,
'sales_out' => 0,
'other_out' => 0,
'closing_qty' => 0,
];
})());
$quotes = Mockery::mock(ThirdPartyWarehouseQuoteRepository::class);
$quotes->shouldReceive('settlementQuotes')->once()->andReturn(collect([
self::quote('2026-08-01', 20, 0.85, 23, 3, 10.5),
]));
$result = $this->service($mirror, $quotes)->settle([
'dimension' => 'day',
'period' => '2026-08-01',
]);
$this->assertTrue($result['complete']);
$this->assertSame([], $result['missing_weight_skus']);
$this->assertSame(0.0, $result['rows'][0]['inbound_fee']);
$this->assertSame(0.0, $result['total_fee']);
}
public function test_complete_settlement_can_be_exported_as_utf8_csv(): void
{
$settlement = Mockery::mock(ThirdPartyWarehouseStorageSettlementService::class);
$settlement->shouldReceive('settle')->once()->with([
'dimension' => 'month',
'period' => '2026-08',
'sku_code' => '',
'product_name' => '',
'supplier' => '',
'category' => '',
])->andReturn([
'ready' => true,
'complete' => true,
'message' => null,
'filters' => ['period' => '2026-08'],
'inbound_fee' => 2.5,
'outbound_fee' => 2.55,
'storage_fee' => 0.31,
'total_fee' => 5.36,
'rows' => [[
'date' => '2026-08-01',
'sku_code' => 'SKU-001',
'product_name' => '测试商品',
'weight_kg' => 12.5,
'purchase_in' => 10,
'sales_out' => -2,
'other_out' => -1,
'closing_qty' => 7,
'inbound_fee' => 2.5,
'outbound_fee' => 2.55,
'storage_fee' => 0.31,
'total_fee' => 5.36,
'quote_effective_at' => '2026-08-01',
]],
]);
$response = (new ThirdPartyWarehouseStorageSettlementExportController())->export(
Request::create('/settlement-export', 'GET', [
'dimension' => 'month',
'period' => '2026-08',
'sku_code' => '',
'product_name' => '',
'supplier' => '',
'category' => '',
]),
$settlement,
);
ob_start();
$response->sendContent();
$content = (string) ob_get_clean();
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
$this->assertStringContainsString('日期,商品编码,商品名称', $content);
$this->assertStringContainsString('2026-08-01,SKU-001,测试商品,12.5,10,-2,-1,7,2.500000,2.550000,0.310000,5.360000,2026-08-01', $content);
$this->assertStringContainsString("合计,,,,,,,,2.50,2.55,0.31,5.36,\n", $content);
}
/** @return \Generator<int,array<string,mixed>> */
private function rows(): \Generator
{
yield [
'date' => '2026-08-01',
'sku_code' => 'SKU-A',
'product_name' => '商品 A',
'purchase_in' => 1000,
'sales_out' => -2,
'other_out' => -1,
'closing_qty' => 23,
];
yield [
'date' => '2026-08-02',
'sku_code' => 'SKU-A',
'product_name' => '商品 A',
'purchase_in' => 0,
'sales_out' => -1,
'other_out' => 0,
'closing_qty' => 46,
];
}
/** @return array<string,mixed> */
private static function quote(string $effectiveAt, float $inbound, float $outbound, float $pieces, float $area, float $storage): array
{
return [
'effective_at' => $effectiveAt,
'inbound_rate_per_ton' => $inbound,
'outbound_rate_per_order' => $outbound,
'pieces_per_pallet' => $pieces,
'pallet_area_sqm' => $area,
'storage_rate_per_sqm_month' => $storage,
];
}
private function service(
ThirdPartyWarehouseStorageMirrorRepository $mirror,
ThirdPartyWarehouseQuoteRepository $quotes,
): ThirdPartyWarehouseStorageSettlementService {
return new ThirdPartyWarehouseStorageSettlementService($mirror, $quotes, app('db'));
}
}