From a1f6f9a94eb133b7d8fd3d95d7223607f79e3cdd Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Tue, 25 Aug 2026 15:31:45 +0800 Subject: [PATCH] fix: exclude stale product catalog rows --- ...mportProductCatalogAuthoritativeExport.php | 43 ++ ...reate_product_catalog_exclusion_tables.php | 79 ++++ docs/上游业务镜像清单.md | 2 + .../ProductCatalogMirrorRepository.php | 13 +- ...ductCatalogAuthoritativeExportImporter.php | 403 ++++++++++++++++++ ...ctCatalogAuthoritativeExportImportTest.php | 192 +++++++++ .../ProductCatalogMirrorRepositoryTest.php | 56 +++ 7 files changed, 787 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ImportProductCatalogAuthoritativeExport.php create mode 100644 database/migrations/2026_08_25_000000_create_product_catalog_exclusion_tables.php create mode 100644 modules/EcommerceFinance/Services/ProductCatalogAuthoritativeExportImporter.php create mode 100644 tests/Feature/Console/ProductCatalogAuthoritativeExportImportTest.php diff --git a/app/Console/Commands/ImportProductCatalogAuthoritativeExport.php b/app/Console/Commands/ImportProductCatalogAuthoritativeExport.php new file mode 100644 index 00000000..6d97335c --- /dev/null +++ b/app/Console/Commands/ImportProductCatalogAuthoritativeExport.php @@ -0,0 +1,43 @@ +import( + (string) $this->argument('path'), + (int) $this->option('batch'), + $this->option('generated-at') === null + ? null + : (string) $this->option('generated-at'), + ); + } catch (Throwable $exception) { + report($exception); + $this->error('普通商品权威清单导入失败,已保留上一版排除结果。'); + + return self::FAILURE; + } + + $this->info(sprintf( + '普通商品权威清单导入完成:有效商品编码 %s 个,排除历史残留 %s 行。', + number_format($result['authoritative_skus']), + number_format($result['excluded_rows']), + )); + + return self::SUCCESS; + } +} diff --git a/database/migrations/2026_08_25_000000_create_product_catalog_exclusion_tables.php b/database/migrations/2026_08_25_000000_create_product_catalog_exclusion_tables.php new file mode 100644 index 00000000..9863fe09 --- /dev/null +++ b/database/migrations/2026_08_25_000000_create_product_catalog_exclusion_tables.php @@ -0,0 +1,79 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('product_catalog_authority_states')) { + $schema->create('product_catalog_authority_states', function (Blueprint $table): void { + $table->string('dataset_key', 120)->primary(); + $table->string('status', 24)->default('pending'); + $table->uuid('active_generation')->nullable(); + $table->uuid('pending_generation')->nullable(); + $table->string('source_filename', 255)->nullable(); + $table->char('source_sha256', 64)->nullable(); + $table->dateTime('source_generated_at')->nullable(); + $table->unsignedBigInteger('authoritative_sku_count')->default(0); + $table->unsignedBigInteger('excluded_row_count')->default(0); + $table->dateTime('last_attempted_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->string('last_error_code', 120)->nullable(); + $table->timestamps(); + }); + } + + if (! $schema->hasTable('product_catalog_authoritative_export_rows')) { + $schema->create('product_catalog_authoritative_export_rows', function (Blueprint $table): void { + $table->uuid('generation'); + $table->string('sku_id_normalized', 191); + $table->string('sku_id', 191); + $table->dateTime('imported_at'); + + $table->primary( + ['generation', 'sku_id_normalized'], + 'product_catalog_authority_rows_primary', + ); + }); + } + + if (! $schema->hasTable('product_catalog_exclusions')) { + $schema->create('product_catalog_exclusions', function (Blueprint $table): void { + $table->string('mirror_key', 64)->primary(); + $table->unsignedBigInteger('autoid')->index(); + $table->string('sku_id', 191); + $table->string('sku_id_normalized', 191)->index(); + $table->uuid('source_generation')->index(); + $table->dateTime('excluded_at'); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('product_catalog_exclusions'); + $schema->dropIfExists('product_catalog_authoritative_export_rows'); + $schema->dropIfExists('product_catalog_authority_states'); + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 568f536f..8a08a0b1 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -12,6 +12,7 @@ | --- | --- | | 已完成 | `operations_replica/rpt.Bulk_Price_Limit` 已进入类型化表 `product_price_mirror_rows`,商品价格页面已读取镜像并支持完整分页。 | | 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 | +| 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 | | 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 | | 本次建立 | `config/upstream_mirrors.php` 中登记全部当前代码引用的原表;通用表级同步框架负责建表、稳定游标、断点续传、增量重叠和全量校准。 | | 已开始逐域切换 | 公司、店铺、商标页面在对应镜像完成首次全量后自动改读完整镜像表;首次全量前保留旧快照作为兼容回滚。其他业务域仍需逐项切换,不得宣称全项目已经完成。 | @@ -53,6 +54,7 @@ 5. 除专用数据集外,通用任务每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。 6. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。 7. 普通商品的采购单价同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;页面按商品编码选择最近完成的 1688 一键代发价格。同步保存全部状态,使完成单后续取消时也能撤回旧价格;首次全量前只显示普通商品自身采购价。 +8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。 ## 后续切换顺序 diff --git a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php index 55c17d60..cbbc7c07 100644 --- a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php @@ -216,7 +216,8 @@ class ProductCatalogMirrorRepository /** @param array $filters */ private function filteredQuery(array $filters): Builder { - $query = $this->connection->table($this->dataset()->targetTable()) + $productTable = $this->dataset()->targetTable(); + $query = $this->connection->table($productTable) ->where('sku_type', 'normal') ->select([ 'autoid', @@ -239,6 +240,16 @@ class ProductCatalogMirrorRepository '_mirror_synced_at', ]); + if (Schema::connection($this->connection->getName())->hasTable('product_catalog_exclusions')) { + $normalizedSql = "REPLACE(UPPER(TRIM({$productTable}.sku_id)), '_X002B_', '+')"; + $query->whereNotExists(static function ($query) use ($productTable, $normalizedSql): void { + $query->selectRaw('1') + ->from('product_catalog_exclusions as product_exclusions') + ->whereColumn('product_exclusions.mirror_key', "{$productTable}._mirror_key") + ->whereRaw("product_exclusions.sku_id_normalized = {$normalizedSql}"); + }); + } + foreach (['i_id', 'sku_id'] as $column) { if (($filters[$column] ?? '') !== '') { $query->where($column, $filters[$column]); diff --git a/modules/EcommerceFinance/Services/ProductCatalogAuthoritativeExportImporter.php b/modules/EcommerceFinance/Services/ProductCatalogAuthoritativeExportImporter.php new file mode 100644 index 00000000..73a3971e --- /dev/null +++ b/modules/EcommerceFinance/Services/ProductCatalogAuthoritativeExportImporter.php @@ -0,0 +1,403 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @return array{status:string,authoritative_skus:int,excluded_rows:int,generation:string} + */ + public function import(string $path, int $batchSize = 1000, ?string $generatedAt = null): array + { + $path = $this->validatedPath($path); + $this->assertSchemaReady(); + + $batchSize = min(max($batchSize, 100), 5000); + $generation = (string) Str::uuid(); + $now = now(); + $hash = hash_file('sha256', $path); + if (! is_string($hash)) { + throw new RuntimeException('无法计算导出文件摘要。'); + } + + $this->connection->table(self::STATE_TABLE)->insertOrIgnore([ + 'dataset_key' => self::DATASET_KEY, + 'status' => 'pending', + 'created_at' => $now, + 'updated_at' => $now, + ]); + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'status' => 'running', + 'pending_generation' => $generation, + 'last_attempted_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]); + + try { + $generatedAt = $this->generatedAt($path, $generatedAt); + $count = $this->stageRows($path, $generation, $batchSize, $now); + if ($count === 0) { + throw new RuntimeException('导出文件没有可用的商品编码。'); + } + + $excluded = $this->activate($generation, $path, $hash, $generatedAt, $count, $now); + + $result = [ + 'status' => 'succeeded', + 'authoritative_skus' => $count, + 'excluded_rows' => $excluded, + 'generation' => $generation, + ]; + } catch (Throwable $exception) { + $this->connection->table(self::AUTHORITY_TABLE) + ->where('generation', $generation) + ->delete(); + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'status' => 'failed', + 'pending_generation' => null, + 'last_error_code' => class_basename($exception), + 'updated_at' => now(), + ]); + + throw $exception; + } + + try { + $this->connection->table(self::AUTHORITY_TABLE) + ->where('generation', '!=', $generation) + ->delete(); + } catch (Throwable $exception) { + report($exception); + } + + return $result; + } + + private function validatedPath(string $path): string + { + $realPath = realpath($path); + if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { + throw new RuntimeException('普通商品导出文件不存在或不可读。'); + } + if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') { + throw new RuntimeException('普通商品权威清单只支持 XLSX 文件。'); + } + + return $realPath; + } + + private function generatedAt(string $path, ?string $generatedAt): Carbon + { + if ($generatedAt !== null && trim($generatedAt) !== '') { + try { + return Carbon::parse($generatedAt); + } catch (Throwable) { + throw new RuntimeException('导出生成时间格式无效。'); + } + } + + if (preg_match('/_(\d{14})(?:_|\.xlsx$)/i', basename($path), $matches) !== 1) { + throw new RuntimeException('文件名缺少导出时间,请使用 --generated-at 指定。'); + } + + try { + $parsed = Carbon::createFromFormat('YmdHis', $matches[1]); + } catch (Throwable) { + $parsed = null; + } + if (! $parsed instanceof Carbon) { + throw new RuntimeException('无法从文件名识别导出生成时间。'); + } + + return $parsed; + } + + private function assertSchemaReady(): void + { + $schema = Schema::connection($this->connection->getName()); + foreach ([self::STATE_TABLE, self::AUTHORITY_TABLE, self::EXCLUSION_TABLE, 'ops_jst_sku'] as $table) { + if (! $schema->hasTable($table)) { + throw new RuntimeException('普通商品排除表尚未完成迁移:' . $table); + } + } + } + + private function stageRows(string $path, string $generation, int $batchSize, mixed $importedAt): int + { + $archive = new ZipArchive(); + if ($archive->open($path) !== true) { + throw new RuntimeException('无法打开普通商品 XLSX 导出文件。'); + } + + try { + $worksheet = $this->firstWorksheet($archive); + $sharedStrings = $this->sharedStrings($path, $archive); + } finally { + $archive->close(); + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new RuntimeException('无法读取普通商品 XLSX 工作表。'); + } + + $skuColumn = null; + $batch = []; + $count = 0; + + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'c') { + continue; + } + + $cell = $this->cell($reader->readOuterXml(), $sharedStrings); + if ($cell === null) { + continue; + } + + if ($skuColumn === null) { + if (trim($cell['value']) === '商品编码') { + $skuColumn = $cell['column']; + } + continue; + } + + if ($cell['column'] !== $skuColumn) { + continue; + } + + $skuId = trim($cell['value']); + $normalized = self::normalizeSkuId($skuId); + if ($normalized === '') { + continue; + } + + $batch[] = [ + 'generation' => $generation, + 'sku_id_normalized' => $normalized, + 'sku_id' => mb_substr($skuId, 0, 191), + 'imported_at' => $importedAt, + ]; + + if (count($batch) >= $batchSize) { + $count += $this->storeBatch($batch); + $batch = []; + } + } + } finally { + $reader->close(); + } + + if ($skuColumn === null) { + throw new RuntimeException('导出文件缺少“商品编码”列。'); + } + if ($batch !== []) { + $count += $this->storeBatch($batch); + } + + return $count; + } + + /** @param list> $batch */ + private function storeBatch(array $batch): int + { + return $this->connection->table(self::AUTHORITY_TABLE)->insertOrIgnore($batch); + } + + private function activate( + string $generation, + string $path, + string $hash, + Carbon $generatedAt, + int $count, + mixed $now, + ): int { + return $this->connection->transaction(function () use ( + $generation, + $path, + $hash, + $generatedAt, + $count, + $now, + ): int { + $this->connection->table(self::EXCLUSION_TABLE)->delete(); + + $normalizedSql = $this->normalizedSql('products.sku_id'); + $missingRows = $this->connection->table('ops_jst_sku as products') + ->where('products.sku_type', 'normal') + ->whereNotNull('products.sku_id') + ->whereRaw("TRIM(products.sku_id) <> ''") + ->where(static function ($query) use ($generatedAt): void { + $query->whereNull('products.created') + ->orWhere('products.created', '<=', $generatedAt); + }) + ->whereNotExists(function ($query) use ($generation, $normalizedSql): void { + $query->selectRaw('1') + ->from(self::AUTHORITY_TABLE . ' as authority') + ->where('authority.generation', $generation) + ->whereRaw("authority.sku_id_normalized = {$normalizedSql}"); + }) + ->selectRaw( + "products._mirror_key, products.autoid, products.sku_id, {$normalizedSql}, ?, ?", + [$generation, $now], + ); + + $this->connection->table(self::EXCLUSION_TABLE)->insertUsing([ + 'mirror_key', + 'autoid', + 'sku_id', + 'sku_id_normalized', + 'source_generation', + 'excluded_at', + ], $missingRows); + + $excluded = $this->connection->table(self::EXCLUSION_TABLE)->count(); + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'status' => 'succeeded', + 'active_generation' => $generation, + 'pending_generation' => null, + 'source_filename' => mb_substr(basename($path), 0, 255), + 'source_sha256' => $hash, + 'source_generated_at' => $generatedAt, + 'authoritative_sku_count' => $count, + 'excluded_row_count' => $excluded, + 'last_succeeded_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]); + + return $excluded; + }); + } + + /** @return array{column:string,value:string}|null */ + private function cell(string $xml, array $sharedStrings): ?array + { + $cell = simplexml_load_string($xml); + if (! $cell instanceof SimpleXMLElement) { + return null; + } + + $reference = (string) ($cell['r'] ?? ''); + if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) { + return null; + } + + $type = (string) ($cell['t'] ?? ''); + if ($type === 'inlineStr') { + $nodes = $cell->xpath('.//*[local-name()="t"]') ?: []; + $value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes)); + } else { + $nodes = $cell->xpath('./*[local-name()="v"]') ?: []; + $raw = isset($nodes[0]) ? (string) $nodes[0] : ''; + $value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw; + } + + return ['column' => strtoupper($matches[1]), 'value' => $value]; + } + + /** @return list */ + private function sharedStrings(string $path, ZipArchive $archive): array + { + if ($archive->locateName('xl/sharedStrings.xml') === false) { + return []; + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new RuntimeException('无法读取 XLSX 共享文本。'); + } + + $strings = []; + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') { + continue; + } + $item = simplexml_load_string($reader->readOuterXml()); + $nodes = $item instanceof SimpleXMLElement + ? ($item->xpath('.//*[local-name()="t"]') ?: []) + : []; + $strings[] = implode('', array_map( + static fn (SimpleXMLElement $node): string => (string) $node, + $nodes, + )); + } + } finally { + $reader->close(); + } + + return $strings; + } + + private function firstWorksheet(ZipArchive $archive): string + { + $worksheets = []; + for ($index = 0; $index < $archive->numFiles; $index++) { + $name = $archive->getNameIndex($index); + if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) { + $worksheets[] = $name; + } + } + sort($worksheets, SORT_NATURAL); + + if ($worksheets === []) { + throw new RuntimeException('XLSX 文件中没有工作表。'); + } + + return $worksheets[0]; + } + + private function zipUri(string $path, string $entry): string + { + return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry; + } + + private function normalizedSql(string $column): string + { + return "REPLACE(UPPER(TRIM({$column})), '_X002B_', '+')"; + } + + public static function normalizeSkuId(string $skuId): string + { + $skuId = preg_replace('/_x002B_/i', '+', trim($skuId)) ?? trim($skuId); + + return mb_substr(mb_strtoupper($skuId), 0, 191); + } +} diff --git a/tests/Feature/Console/ProductCatalogAuthoritativeExportImportTest.php b/tests/Feature/Console/ProductCatalogAuthoritativeExportImportTest.php new file mode 100644 index 00000000..cd8cceb6 --- /dev/null +++ b/tests/Feature/Console/ProductCatalogAuthoritativeExportImportTest.php @@ -0,0 +1,192 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + ]); + DB::purge('business_mirror'); + $this->createTables(); + + DB::connection('business_mirror')->table('ops_jst_sku')->insert([ + $this->product(10, 'SKU-KEEP'), + $this->product(20, 'SKU-STALE'), + $this->product(30, 'CODE+PLUS'), + $this->product(40, 'PACKAGE-ROW', 'package'), + $this->product(50, 'SKU-NEW-AFTER-EXPORT', 'normal', '2026-08-24 17:00:00'), + ]); + + $this->firstExport = $this->xlsx('catalog-export_20260824160307_', [ + ['图片', '款式编码', '商品编码', '商品名称'], + ['', 'STYLE-10', 'sku-keep', '保留商品'], + ['', 'STYLE-30', 'CODE_x002B_PLUS', '转义商品'], + ['', 'STYLE-30', 'CODE_x002B_PLUS', '重复商品'], + ]); + $this->secondExport = $this->xlsx('catalog-export_20260825120000_', [ + ['图片', '款式编码', '商品编码', '商品名称'], + ['', 'STYLE-10', 'SKU-KEEP', '保留商品'], + ['', 'STYLE-20', 'SKU-STALE', '恢复商品'], + ['', 'STYLE-30', 'CODE+PLUS', '加号商品'], + ['', 'STYLE-50', 'SKU-NEW-AFTER-EXPORT', '新增商品'], + ]); + } + + protected function tearDown(): void + { + @unlink($this->firstExport); + @unlink($this->secondExport); + + parent::tearDown(); + } + + public function test_it_streams_the_export_deduplicates_codes_and_builds_reversible_exclusions(): void + { + $importer = app(ProductCatalogAuthoritativeExportImporter::class); + + $first = $importer->import($this->firstExport, 100); + + $this->assertSame(2, $first['authoritative_skus']); + $this->assertSame(1, $first['excluded_rows']); + $this->assertDatabaseHas('product_catalog_exclusions', [ + 'autoid' => 20, + 'sku_id' => 'SKU-STALE', + ], 'business_mirror'); + $this->assertDatabaseMissing('product_catalog_exclusions', ['autoid' => 30], 'business_mirror'); + $this->assertDatabaseMissing('product_catalog_exclusions', ['autoid' => 40], 'business_mirror'); + $this->assertDatabaseMissing('product_catalog_exclusions', ['autoid' => 50], 'business_mirror'); + + $second = $importer->import($this->secondExport, 100); + + $this->assertSame(4, $second['authoritative_skus']); + $this->assertSame(0, $second['excluded_rows']); + $this->assertSame(0, DB::connection('business_mirror')->table('product_catalog_exclusions')->count()); + $this->assertSame( + 4, + DB::connection('business_mirror')->table('product_catalog_authoritative_export_rows')->count(), + ); + $this->assertDatabaseHas('product_catalog_authority_states', [ + 'dataset_key' => ProductCatalogAuthoritativeExportImporter::DATASET_KEY, + 'status' => 'succeeded', + 'authoritative_sku_count' => 4, + 'excluded_row_count' => 0, + 'source_generated_at' => '2026-08-25 12:00:00', + ], 'business_mirror'); + } + + public function test_command_rejects_an_invalid_file_without_removing_previous_exclusions(): void + { + app(ProductCatalogAuthoritativeExportImporter::class)->import($this->firstExport, 100); + $invalid = tempnam(sys_get_temp_dir(), 'catalog-invalid-') . '.xlsx'; + file_put_contents($invalid, 'not an xlsx archive'); + + try { + $this->artisan('product-catalog:import-authoritative-export', ['path' => $invalid]) + ->expectsOutputToContain('已保留上一版排除结果') + ->assertFailed(); + + $this->assertDatabaseHas('product_catalog_exclusions', ['autoid' => 20], 'business_mirror'); + $this->assertDatabaseHas('product_catalog_authority_states', [ + 'dataset_key' => ProductCatalogAuthoritativeExportImporter::DATASET_KEY, + 'status' => 'failed', + 'excluded_row_count' => 1, + ], 'business_mirror'); + } finally { + @unlink($invalid); + } + } + + private function createTables(): void + { + Schema::connection('business_mirror')->create('ops_jst_sku', function (Blueprint $table): void { + $table->string('_mirror_key')->primary(); + $table->unsignedBigInteger('autoid'); + $table->string('sku_id')->nullable(); + $table->string('sku_type')->nullable(); + $table->dateTime('created')->nullable(); + }); + Schema::connection('business_mirror')->create('product_catalog_authority_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->default('pending'); + $table->string('active_generation')->nullable(); + $table->string('pending_generation')->nullable(); + $table->string('source_filename')->nullable(); + $table->string('source_sha256')->nullable(); + $table->dateTime('source_generated_at')->nullable(); + $table->unsignedBigInteger('authoritative_sku_count')->default(0); + $table->unsignedBigInteger('excluded_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::connection('business_mirror')->create('product_catalog_authoritative_export_rows', function (Blueprint $table): void { + $table->string('generation'); + $table->string('sku_id_normalized'); + $table->string('sku_id'); + $table->dateTime('imported_at'); + $table->primary(['generation', 'sku_id_normalized']); + }); + Schema::connection('business_mirror')->create('product_catalog_exclusions', function (Blueprint $table): void { + $table->string('mirror_key')->primary(); + $table->unsignedBigInteger('autoid'); + $table->string('sku_id'); + $table->string('sku_id_normalized'); + $table->string('source_generation'); + $table->dateTime('excluded_at'); + }); + } + + /** @return array */ + private function product( + int $autoid, + string $skuId, + string $type = 'normal', + string $created = '2026-08-01 10:00:00', + ): array + { + return [ + '_mirror_key' => hash('sha256', (string) $autoid), + 'autoid' => $autoid, + 'sku_id' => $skuId, + 'sku_type' => $type, + 'created' => $created, + ]; + } + + /** @param list> $rows */ + private function xlsx(string $prefix, array $rows): string + { + $temporary = tempnam(sys_get_temp_dir(), $prefix); + $path = dirname($temporary) . DIRECTORY_SEPARATOR . $prefix . basename($temporary) . '.xlsx'; + rename($temporary, $path); + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray($rows); + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + + return $path; + } +} diff --git a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php index 8aa997e1..ce4e9796 100644 --- a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php @@ -194,6 +194,50 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $this->assertSame('66', (string) $rows[1]->purchase_order_unit_price); } + public function test_it_excludes_historical_rows_from_page_and_export_without_assigning_a_source_label(): void + { + $this->markReady(2); + $this->insertProduct(10, ['sku_id' => 'SKU-KEEP', 'purchase_price' => 55]); + $this->insertProduct(20, ['sku_id' => 'SKU-STALE', 'purchase_price' => 66]); + $this->createExclusionTable(); + DB::connection('business_mirror')->table('product_catalog_exclusions')->insert([ + 'mirror_key' => hash('sha256', '20'), + 'autoid' => 20, + 'sku_id' => 'SKU-STALE', + 'sku_id_normalized' => 'SKU-STALE', + 'source_generation' => '11111111-1111-4111-8111-111111111111', + 'excluded_at' => '2026-08-25 14:00:00', + ]); + + $page = $this->repository->paginate($this->filters(), 1, 25); + $export = iterator_to_array($this->repository->exportRows($this->filters(), 1)); + + $this->assertSame(1, $page['total']); + $this->assertSame('SKU-KEEP', $page['data'][0]->sku_id); + $this->assertSame(['商品及库存管理(普通商品资料)'], $page['data'][0]->data_sources); + $this->assertSame(['SKU-KEEP'], array_map(static fn (object $row): string => $row->sku_id, $export)); + } + + public function test_an_exclusion_does_not_hide_a_reused_mirror_key_after_the_sku_code_changes(): void + { + $this->markReady(1); + $this->insertProduct(20, ['sku_id' => 'SKU-NEW']); + $this->createExclusionTable(); + DB::connection('business_mirror')->table('product_catalog_exclusions')->insert([ + 'mirror_key' => hash('sha256', '20'), + 'autoid' => 20, + 'sku_id' => 'SKU-OLD', + 'sku_id_normalized' => 'SKU-OLD', + 'source_generation' => '11111111-1111-4111-8111-111111111111', + 'excluded_at' => '2026-08-25 14:00:00', + ]); + + $result = $this->repository->paginate($this->filters(), 1, 25); + + $this->assertSame(1, $result['total']); + $this->assertSame('SKU-NEW', $result['data'][0]->sku_id); + } + public function test_it_refuses_queries_before_the_initial_sync_completes(): void { DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ @@ -326,6 +370,18 @@ class ProductCatalogMirrorRepositoryTest extends TestCase ]); } + private function createExclusionTable(): void + { + Schema::connection('business_mirror')->create('product_catalog_exclusions', function (Blueprint $table): void { + $table->string('mirror_key')->primary(); + $table->unsignedBigInteger('autoid'); + $table->string('sku_id'); + $table->string('sku_id_normalized'); + $table->string('source_generation'); + $table->dateTime('excluded_at'); + }); + } + private function insertPurchasePrice(int $itemId, array $overrides = []): void { DB::connection('business_mirror')->table('product_purchase_price_mirror_rows')->insert(array_merge([ -- 2.47.2