*/ private array $temporaryFiles = []; 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'); $this->createTables(); } protected function tearDown(): void { foreach ($this->temporaryFiles as $file) { @unlink($file); } parent::tearDown(); } public function test_it_imports_authoritative_daily_rows_and_month_end_anchor(): void { $path = $this->validWorkbook(); $this->artisan('business-mirror:import-third-party-warehouse-anchor', ['path' => $path]) ->assertSuccessful(); $state = DB::connection('business_mirror') ->table('third_party_warehouse_authoritative_anchor_states') ->where('dataset_key', ThirdPartyWarehouseStorageAuthoritativeAnchorImporter::DATASET_KEY) ->first(); $this->assertSame('succeeded', $state->status); $this->assertSame('2026-08-01', $state->source_period_start); $this->assertSame('2026-08-31', $state->source_period_end); $this->assertSame(32, (int) $state->source_row_count); $this->assertSame(2, (int) $state->anchor_sku_count); $this->assertSame('2026-09-01 00:00:00', $state->anchor_at); $this->assertDatabaseHas('third_party_warehouse_authoritative_anchors', [ 'generation' => $state->active_generation, 'sku_code' => 'SKU-A', 'qty' => 41, 'anchor_at' => '2026-09-01 00:00:00', ], 'business_mirror'); $this->assertDatabaseHas('third_party_warehouse_authoritative_anchors', [ 'generation' => $state->active_generation, 'sku_code' => 'CODE+PLUS', 'qty' => 7, ], 'business_mirror'); $this->assertSame( 32, DB::connection('business_mirror')->table('third_party_warehouse_authoritative_rows') ->where('generation', $state->active_generation)->count(), ); $this->assertDatabaseHas('third_party_warehouse_authoritative_rows', [ 'generation' => $state->active_generation, 'report_date' => '2026-08-01', 'sku_code' => 'SKU-A', 'opening_qty' => 10, 'closing_qty' => 11, 'purchase_in' => 1, ], 'business_mirror'); } public function test_invalid_replacement_keeps_the_previous_active_generation(): void { $valid = app(ThirdPartyWarehouseStorageAuthoritativeAnchorImporter::class) ->import($this->validWorkbook()); $invalid = $this->workbook([ $this->headers(), $this->row('2026-08-01', 'SKU-A', 10, 9, ['销售出仓' => -2]), ]); $this->artisan('business-mirror:import-third-party-warehouse-anchor', ['path' => $invalid]) ->expectsOutputToContain('已保留上一版有效数据') ->assertFailed(); $state = DB::connection('business_mirror') ->table('third_party_warehouse_authoritative_anchor_states') ->first(); $this->assertSame('failed', $state->status); $this->assertSame($valid['generation'], $state->active_generation); $this->assertNull($state->pending_generation); $this->assertSame( 2, DB::connection('business_mirror') ->table('third_party_warehouse_authoritative_anchors') ->where('generation', $valid['generation']) ->count(), ); $this->assertSame( 32, DB::connection('business_mirror') ->table('third_party_warehouse_authoritative_rows') ->where('generation', $valid['generation']) ->count(), ); } private function createTables(): void { $schema = Schema::connection('business_mirror'); $schema->create('third_party_warehouse_authoritative_anchor_states', function (Blueprint $table): void { $table->string('dataset_key')->primary(); $table->string('status'); $table->string('active_generation')->nullable(); $table->string('pending_generation')->nullable(); $table->dateTime('anchor_at')->nullable(); $table->string('source_filename')->nullable(); $table->string('source_sha256')->nullable(); $table->date('source_period_start')->nullable(); $table->date('source_period_end')->nullable(); $table->unsignedBigInteger('source_row_count')->default(0); $table->unsignedBigInteger('anchor_sku_count')->default(0); $table->dateTime('last_attempted_at')->nullable(); $table->dateTime('last_succeeded_at')->nullable(); $table->string('last_error_code')->nullable(); $table->timestamps(); }); $schema->create('third_party_warehouse_authoritative_anchors', function (Blueprint $table): void { $table->string('generation'); $table->string('sku_code'); $table->decimal('qty', 20, 4); $table->dateTime('anchor_at'); $table->dateTime('imported_at'); $table->primary(['generation', 'sku_code']); }); $schema->create('third_party_warehouse_authoritative_rows', function (Blueprint $table): void { $table->string('generation'); $table->date('report_date'); $table->string('sku_code'); $table->decimal('opening_qty', 20, 4); $table->decimal('closing_qty', 20, 4); foreach ([ 'processing_in', 'processing_out', 'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening_aux', 'other_out', ] as $column) { $table->decimal($column, 20, 4)->default(0); } $table->dateTime('imported_at'); $table->primary(['generation', 'report_date', 'sku_code']); }); } private function validWorkbook(): string { $rows = [$this->headers()]; for ($day = 1; $day <= 31; $day++) { $rows[] = $this->row( sprintf('2026-08-%02d', $day), 'SKU-A', 10 + $day - 1, 10 + $day, ['采购进仓' => 1], ); } // 聚水潭会省略部分没有变化的月末日期;每个商品取自身最后一行期末数。 $rows[] = $this->row('2026-08-30', 'CODE_x002B_PLUS', 7, 7); return $this->workbook($rows); } /** @return list */ private function headers(): array { return [ '货主', '日期', '商品编码', '图片', '商品名称', '商品简称', '供应商', '分类', '虚拟分类', '供应商款号', '颜色规格', '品牌', '单位', '期初数量', '期末数量', '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', ]; } /** @param array $movements @return list */ private function row( string $date, string $sku, float $opening, float $closing, array $movements = [], ): array { $row = array_fill(0, 28, null); $row[0] = '常州简尚家居有限公司'; $row[1] = $date; $row[2] = $sku; $row[13] = $opening; $row[14] = $closing; foreach ([ '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', ] as $offset => $header) { $row[15 + $offset] = $movements[$header] ?? 0; } return $row; } /** @param list> $rows */ private function workbook(array $rows): string { $temporary = tempnam(sys_get_temp_dir(), 'tpw-anchor-'); $path = $temporary . '.xlsx'; rename($temporary, $path); $spreadsheet = new Spreadsheet(); $spreadsheet->getActiveSheet()->fromArray($rows); (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); $this->temporaryFiles[] = $path; return $path; } }