排除普通商品历史残留记录 #78

Merged
limeng merged 1 commits from fix/limeng-product-catalog-history-exclusions into main 2026-08-25 15:34:40 +08:00
7 changed files with 787 additions and 1 deletions

View File

@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\ProductCatalogAuthoritativeExportImporter;
use Throwable;
class ImportProductCatalogAuthoritativeExport extends Command
{
protected $signature = 'product-catalog:import-authoritative-export
{path : 聚水潭普通商品资料完整 XLSX 导出文件}
{--batch=1000 : 每批写入的唯一商品编码数}
{--generated-at= : 导出生成时间,默认从文件名中的 YYYYMMDDHHMMSS 识别}';
protected $description = '导入聚水潭普通商品权威清单,并排除经营分析副本中的历史残留行';
public function handle(ProductCatalogAuthoritativeExportImporter $importer): int
{
try {
$result = $importer->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;
}
}

View File

@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->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');
}
};

View File

@ -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 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。
## 后续切换顺序

View File

@ -216,7 +216,8 @@ class ProductCatalogMirrorRepository
/** @param array<string,mixed> $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]);

View File

@ -0,0 +1,403 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use XMLReader;
use ZipArchive;
class ProductCatalogAuthoritativeExportImporter
{
public const DATASET_KEY = 'product-inventory.normal-products.authoritative-export';
private const AUTHORITY_TABLE = 'product_catalog_authoritative_export_rows';
private const EXCLUSION_TABLE = 'product_catalog_exclusions';
private const STATE_TABLE = 'product_catalog_authority_states';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->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<array<string,mixed>> $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<string> */
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);
}
}

View File

@ -0,0 +1,192 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\ProductCatalogAuthoritativeExportImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class ProductCatalogAuthoritativeExportImportTest extends TestCase
{
private string $firstExport;
private string $secondExport;
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();
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<string,mixed> */
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<list<string>> $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;
}
}

View File

@ -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([