[ '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> */ 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 */ 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')); } }