[ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', ]); DB::purge('business_mirror'); $schema = Schema::connection('business_mirror'); $schema->create('kuka_posting_quotation_imports', function (Blueprint $table): void { $table->increments('id'); $table->unsignedBigInteger('company_id'); $table->unsignedInteger('row_count')->default(0); $table->unsignedInteger('material_count')->default(0); $table->text('status_counts')->nullable(); $table->string('file_name'); $table->char('file_sha256', 64); $table->string('source_sheet'); $table->timestamp('completed_at')->nullable(); $table->timestamps(); $table->unique('company_id'); }); $schema->create('kuka_posting_quotation_rows', function (Blueprint $table): void { $table->increments('id'); $table->unsignedBigInteger('company_id'); $table->string('material_number'); $table->string('material_desc')->nullable(); $table->string('supplier_account')->nullable(); $table->string('supplier_name')->nullable(); $table->string('record_status')->nullable(); $table->decimal('price_including_tax', 20, 4)->nullable(); $table->decimal('price_excluding_tax', 20, 4)->nullable(); $table->dateTime('price_valid_date')->nullable(); $table->dateTime('price_expiry_date')->nullable(); $table->char('row_hash', 64); $table->text('source_values'); $table->unsignedInteger('source_row_number'); $table->timestamp('imported_at'); $table->unique(['company_id', 'material_number']); }); } public function test_imports_quotation_workbook_and_renders_paginated_price_rows(): void { $path = $this->workbook(); $this->artisan('business-mirror:import-kuka-posting-quotation', [ 'path' => $path, '--company' => (string) $this->company->id, ])->assertExitCode(0); $connection = DB::connection('business_mirror'); $this->assertSame(2, $connection->table('kuka_posting_quotation_rows')->count()); $this->assertSame(2, $connection->table('kuka_posting_quotation_rows')->distinct()->count('material_number')); $this->assertDatabaseHas('kuka_posting_quotation_imports', [ 'company_id' => $this->company->id, 'row_count' => 2, 'material_count' => 2, 'source_sheet' => 'Sheet1', ], 'business_mirror'); $row = $connection->table('kuka_posting_quotation_rows') ->where('material_number', '91.C52798.01') ->first(); $values = json_decode((string) $row->source_values, true); $this->assertSame('DS5145BG-G192~升降桌-柚木色-1.4米', $values['material_desc']); $this->assertSame('常州欧伯娜家居有限公司', $values['supplier_name']); $this->assertSame('生效', $values['record_status']); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'posting-data', 'page' => 'kuka-basic-data', 'submenu' => 'quotation', ])) ->assertOk() ->assertDontSeeText('顾家报价单 · 价格明细') ->assertDontSeeText('来源文件:') ->assertDontSeeText('已从共享业务镜像读取 2 条数据') ->assertDontSeeText('当前页面为只读展示') ->assertSeeText('91.C52798.01') ->assertSeeText('升降桌-柚木色-1.4米') ->assertSeeText('常州欧伯娜家居有限公司') ->assertSeeText('989.10') ->assertSeeText('生效') ->assertSee('data-directory-table-key="kuka-quotation"', false); @unlink($path); } private function workbook(): string { $fields = config('kuka_posting.quotation_fields'); $headers = array_column($fields, 'source'); $rows = [ $headers, array_map(static fn (array $field): string => $field['label'], $fields), $this->row([ 'Fbk1' => 'DS5145BG-G192', 'MaterialNumber' => '91.C52798.01', 'MaterialDesc' => 'DS5145BG-G192~升降桌-柚木色-1.4米', 'ToElsAccount' => '435421', 'ToElsAccountName' => '常州欧伯娜家居有限公司', 'OrderUnit' => 'PC', 'Currency' => 'CNY', 'TaxRate' => '13', 'PriceIncludingTax' => '989.10', 'PriceExcludingTax' => '875.31', 'PurchaseOrganization' => '1090', 'Factory' => '1090', 'PurchaseGroup' => 'G32', 'PriceValidDate' => '2026-06-24 12:00:00', 'PriceExpiryDate' => '2027-06-16 12:00:00', 'CreateDate' => '2026-06-24 02:00:42', 'SourceType' => 'Excel', 'OverTolerance' => '0', 'LackTolerance' => '0', 'RecordType' => '标准', 'Fbk6' => '0004', 'Fbk14' => '1', 'RecordStatus' => '生效', 'Fbk3' => 'Z9', 'CreateUser' => 'config-center_系统账号', 'LastUpdateUser' => 'config-center_系统账号', 'LastUpdateDate' => '2026-06-24 02:00:42', ]), $this->row([ 'Fbk1' => 'DS5166BG-B4', 'MaterialNumber' => '91.C52597.01', 'MaterialDesc' => 'DS5166BG-B4~妆台-白色-0.28米', 'ToElsAccount' => '435421', 'ToElsAccountName' => '常州欧伯娜家居有限公司', 'OrderUnit' => 'PC', 'Currency' => 'CNY', 'TaxRate' => '13', 'PriceIncludingTax' => '199.50', 'PriceExcludingTax' => '176.55', 'PurchaseOrganization' => '1090', 'Factory' => '1090', 'PurchaseGroup' => 'G32', 'PriceValidDate' => '2026-06-24 12:00:00', 'PriceExpiryDate' => '2027-06-16 12:00:00', 'CreateDate' => '2026-06-24 02:00:42', 'SourceType' => 'Excel', 'RecordType' => '标准', 'Fbk6' => '0004', 'Fbk14' => '1', 'RecordStatus' => '生效', 'Fbk3' => 'Z9', 'CreateUser' => 'config-center_系统账号', 'LastUpdateUser' => 'config-center_系统账号', 'LastUpdateDate' => '2026-06-24 02:00:42', ]), ]; $path = tempnam(sys_get_temp_dir(), 'kuka-quotation-') . '.xlsx'; $zip = new ZipArchive(); $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); $zip->addFromString('xl/workbook.xml', <<<'XML' XML); $zip->addFromString('xl/_rels/workbook.xml.rels', <<<'XML' XML); $zip->addFromString('xl/worksheets/sheet1.xml', $this->worksheetXml($rows, $headers)); $zip->close(); return $path; } /** * @param list $headers */ private function row(array $values): array { return array_map( static fn (string $header): string => (string) ($values[$header] ?? ''), array_column(config('kuka_posting.quotation_fields'), 'source'), ); } private function worksheetXml(array $rows, array $headers): string { $xmlRows = []; foreach ($rows as $rowIndex => $row) { $cells = []; foreach ($row as $columnIndex => $value) { $reference = $this->columnName($columnIndex + 1) . ($rowIndex + 1); $cells[] = '' . htmlspecialchars((string) $value, ENT_XML1 | ENT_QUOTES, 'UTF-8') . ''; } $xmlRows[] = '' . implode('', $cells) . ''; } return '' . '' . '' . implode('', $xmlRows) . ''; } private function columnName(int $index): string { $name = ''; while ($index > 0) { $index--; $name = chr(65 + ($index % 26)) . $name; $index = intdiv($index, 26); } return $name; } }