298 lines
11 KiB
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'));
|
|
}
|
|
}
|