[ '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_monthly_warehouse_rows_and_preserves_opening_quantity(): void { $path = $this->workbook([ $this->headers(), $this->row('2026-08-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓主仓', '15962G', 14, 6, [ '销售出仓' => -28, '调拨入' => 20, ]), $this->row('2026-08-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓进货仓', '15962G', 0, 0, [ '采购进仓' => 20, '调拨出' => -20, ]), ]); $this->artisan('business-mirror:import-inventory-report-month', ['path' => $path]) ->assertSuccessful(); $state = DB::connection('business_mirror')->table('inventory_report_authoritative_states')->first(); $this->assertSame(InventoryReportAuthoritativeImporter::DATASET_KEY, $state->dataset_key); $this->assertSame('2026-08-01', $state->source_month); $this->assertSame(2, (int) $state->source_row_count); $this->assertDatabaseHas('inventory_report_authoritative_rows', [ 'generation' => $state->active_generation, 'warehouse' => '简尚-崔桥工厂仓主仓', 'product_code' => '15962G', 'opening_quantity' => 14, 'closing_quantity' => 6, 'sales_out' => -28, 'transfer_in' => 20, ], 'business_mirror'); } public function test_it_keeps_authoritative_generations_for_multiple_months(): void { $august = app(InventoryReportAuthoritativeImporter::class)->import($this->workbook([ $this->headers(), $this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 10, 12, ['采购进仓' => 2]), ])); $july = app(InventoryReportAuthoritativeImporter::class)->import($this->workbook([ $this->headers(), $this->row('2026-07-01', '仓储方', '主仓', 'SKU-A', 8, 10, ['采购进仓' => 2]), ])); $months = DB::connection('business_mirror') ->table('inventory_report_authoritative_months') ->where('dataset_key', InventoryReportAuthoritativeImporter::DATASET_KEY) ->orderBy('source_month') ->get(); $this->assertCount(2, $months); $this->assertSame('2026-07-01', (string) $months[0]->source_month); $this->assertSame($july['generation'], (string) $months[0]->active_generation); $this->assertSame('2026-08-01', (string) $months[1]->source_month); $this->assertSame($august['generation'], (string) $months[1]->active_generation); $this->assertSame(2, DB::connection('business_mirror') ->table('inventory_report_authoritative_rows')->count()); } public function test_invalid_replacement_keeps_previous_generation(): void { $valid = app(InventoryReportAuthoritativeImporter::class)->import($this->workbook([ $this->headers(), $this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 1, 1), ])); $invalid = $this->workbook([ $this->headers(), $this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 1, 2), ]); $this->artisan('business-mirror:import-inventory-report-month', ['path' => $invalid]) ->expectsOutputToContain('已保留上一版有效数据') ->assertFailed(); $state = DB::connection('business_mirror')->table('inventory_report_authoritative_states')->first(); $this->assertSame($valid['generation'], $state->active_generation); $this->assertSame(1, DB::connection('business_mirror')->table('inventory_report_authoritative_rows') ->where('generation', $valid['generation'])->count()); } public function test_it_finds_the_detail_sheet_when_a_pivot_sheet_comes_first(): void { $temporary = tempnam(sys_get_temp_dir(), 'inventory-report-multi-sheet-'); $path = $temporary . '.xlsx'; rename($temporary, $path); $spreadsheet = new Spreadsheet(); $spreadsheet->getActiveSheet()->setTitle('汇总'); $spreadsheet->getActiveSheet()->fromArray([ ['条码+仓库', '求和项:期初数量'], ['SKU-A主仓', 99], ]); $detail = $spreadsheet->createSheet(); $detail->setTitle('明细'); $detail->fromArray([ $this->headers(), $this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 1, 1), ]); (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); $this->temporaryFiles[] = $path; $result = app(InventoryReportAuthoritativeImporter::class)->import($path); $this->assertSame('2026-08-01', $result['source_month']); $this->assertSame(1, $result['source_rows']); $this->assertDatabaseHas('inventory_report_authoritative_rows', [ 'generation' => $result['generation'], 'product_code' => 'SKU-A', 'warehouse' => '主仓', ], 'business_mirror'); } private function createTables(): void { $schema = Schema::connection('business_mirror'); $schema->create('inventory_report_authoritative_states', function (Blueprint $table): void { $table->string('dataset_key')->primary(); $table->string('status'); $table->string('active_generation')->nullable(); $table->string('pending_generation')->nullable(); $table->string('source_filename')->nullable(); $table->string('source_sha256')->nullable(); $table->date('source_month')->nullable(); $table->unsignedBigInteger('source_row_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('inventory_report_authoritative_rows', function (Blueprint $table): void { $table->string('generation'); $table->string('row_key'); $table->date('report_month'); $table->string('owner'); $table->string('warehouse_provider'); $table->string('warehouse'); $table->string('product_code'); $table->string('category')->nullable(); foreach ([ 'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out', 'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out', ] as $column) { $table->decimal($column, 20, 4)->default(0); } $table->dateTime('imported_at'); $table->primary(['generation', 'row_key']); }); $schema->create('inventory_report_authoritative_months', function (Blueprint $table): void { $table->string('dataset_key'); $table->date('source_month'); $table->string('status')->default('succeeded'); $table->string('active_generation')->nullable(); $table->string('source_filename')->nullable(); $table->string('source_sha256')->nullable(); $table->unsignedBigInteger('source_row_count')->default(0); $table->dateTime('last_succeeded_at')->nullable(); $table->timestamps(); $table->primary(['dataset_key', 'source_month']); }); } private function headers(): array { return [ '货主', '月份', '仓储方', '仓库', '商品编码', '图片', '商品名称', '商品简称', '分类', '虚拟分类', '供应商款号', '颜色规格', '品牌', '单位', '期初数量', '期末数量', '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', ]; } private function row( string $month, string $provider, string $warehouse, string $sku, float $opening, float $closing, array $movements = [], ): array { $row = array_fill(0, 29, null); $row[0] = '常州简尚家居有限公司'; $row[1] = $month; $row[2] = $provider; $row[3] = $warehouse; $row[4] = $sku; $row[8] = '测试分类'; $row[14] = $opening; $row[15] = $closing; foreach ([ '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', ] as $offset => $header) { $row[16 + $offset] = $movements[$header] ?? 0; } return $row; } private function workbook(array $rows): string { $temporary = tempnam(sys_get_temp_dir(), 'inventory-report-month-'); $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; } }