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