299 lines
13 KiB
PHP
299 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Business;
|
|
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
|
|
use Modules\EcommerceFinance\Services\ProductPriceWorkbookImporter;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
use Tests\TestCase;
|
|
|
|
class ProductPriceMirrorRepositoryTest extends TestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
config([
|
|
'database.connections.business_mirror' => [
|
|
'driver' => 'sqlite',
|
|
'database' => ':memory:',
|
|
'prefix' => '',
|
|
'foreign_key_constraints' => true,
|
|
],
|
|
'upstream_mirrors.connection' => 'business_mirror',
|
|
]);
|
|
DB::purge('business_mirror');
|
|
$schema = DB::connection('business_mirror')->getSchemaBuilder();
|
|
$schema->create('business_mirror_sync_states', function (Blueprint $table): void {
|
|
$table->string('dataset_key')->primary();
|
|
$table->string('source_key')->nullable();
|
|
$table->string('source_object')->nullable();
|
|
$table->string('status')->nullable();
|
|
$table->string('run_mode')->nullable();
|
|
$table->string('window_started_at')->nullable();
|
|
$table->string('window_ends_at')->nullable();
|
|
$table->string('cursor_completed_at')->nullable();
|
|
$table->string('cursor_business_id')->nullable();
|
|
$table->string('cursor_product_code')->nullable();
|
|
$table->dateTime('initial_completed_at')->nullable();
|
|
$table->dateTime('last_attempted_at')->nullable();
|
|
$table->dateTime('last_succeeded_at')->nullable();
|
|
$table->unsignedBigInteger('last_upserted_rows')->default(0);
|
|
$table->string('last_error_code')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
$schema->create('product_price_mirror_rows', function (Blueprint $table): void {
|
|
$table->id();
|
|
$table->string('upstream_key')->unique();
|
|
$table->string('barcode');
|
|
$table->string('spu')->nullable();
|
|
$table->text('product_name')->nullable();
|
|
$table->string('category')->nullable();
|
|
$table->string('production_status')->nullable();
|
|
$table->decimal('minimum_price')->nullable();
|
|
$table->decimal('price_limit')->nullable();
|
|
$table->decimal('purchase_price')->nullable();
|
|
$table->decimal('shipping_fee')->nullable();
|
|
$table->decimal('jingxi_limit_price')->nullable();
|
|
$table->string('approval_status')->nullable();
|
|
$table->string('approval_result')->nullable();
|
|
$table->text('workflow_title')->nullable();
|
|
$table->dateTime('completed_at');
|
|
$table->string('business_id');
|
|
$table->string('row_hash');
|
|
$table->dateTime('synced_at');
|
|
$table->timestamps();
|
|
});
|
|
$schema->create('product_price_overrides', function (Blueprint $table): void {
|
|
$table->id();
|
|
$table->unsignedInteger('company_id');
|
|
$table->string('upstream_key');
|
|
$table->string('barcode');
|
|
$table->string('spu')->nullable();
|
|
$table->text('product_name')->nullable();
|
|
$table->string('category')->nullable();
|
|
$table->string('production_status')->nullable();
|
|
$table->decimal('minimum_price')->nullable();
|
|
$table->decimal('price_limit')->nullable();
|
|
$table->decimal('purchase_price')->nullable();
|
|
$table->decimal('shipping_fee')->nullable();
|
|
$table->decimal('jingxi_limit_price')->nullable();
|
|
$table->string('source');
|
|
$table->unsignedInteger('created_by')->nullable();
|
|
$table->unsignedInteger('updated_by')->nullable();
|
|
$table->timestamps();
|
|
$table->unique(['company_id', 'upstream_key']);
|
|
});
|
|
$schema->create('product_price_override_events', function (Blueprint $table): void {
|
|
$table->id();
|
|
$table->unsignedInteger('company_id');
|
|
$table->string('upstream_key');
|
|
$table->string('source');
|
|
$table->json('before_values')->nullable();
|
|
$table->json('after_values');
|
|
$table->unsignedInteger('user_id')->nullable();
|
|
$table->timestamp('created_at')->nullable();
|
|
});
|
|
}
|
|
|
|
public function test_page_two_and_literal_wildcard_filter_are_read_from_shared_mirror(): void
|
|
{
|
|
DB::connection('business_mirror')->table('business_mirror_sync_states')->insert([
|
|
'dataset_key' => ProductPriceMirrorRepository::DATASET_KEY,
|
|
'initial_completed_at' => '2026-08-14 10:00:00',
|
|
]);
|
|
|
|
foreach (range(1, 30) as $index) {
|
|
DB::connection('business_mirror')->table('product_price_mirror_rows')->insert([
|
|
'barcode' => $index === 30 ? 'SKU%_LITERAL' : sprintf('SKU%03d', $index),
|
|
'business_id' => sprintf('B%03d', $index),
|
|
'completed_at' => sprintf('2026-08-%02d 10:00:00', min($index, 28)),
|
|
'upstream_key' => hash('sha256', (string) $index),
|
|
'row_hash' => hash('sha256', 'row-' . $index),
|
|
'synced_at' => '2026-08-14 10:00:00',
|
|
]);
|
|
}
|
|
|
|
$repository = app(ProductPriceMirrorRepository::class);
|
|
$pageTwo = $repository->paginate([], 2, 25);
|
|
$literal = $repository->paginate(['barcode' => '%_'], 1, 25);
|
|
|
|
$this->assertTrue($repository->initialized());
|
|
$this->assertSame(30, $pageTwo->total());
|
|
$this->assertCount(5, $pageTwo->items());
|
|
$this->assertSame(1, $literal->total());
|
|
$this->assertSame('SKU%_LITERAL', $literal->items()[0]->barcode);
|
|
}
|
|
|
|
public function test_same_business_and_barcode_at_different_completion_times_are_preserved(): void
|
|
{
|
|
DB::connection('business_mirror')->table('business_mirror_sync_states')->insert([
|
|
'dataset_key' => ProductPriceMirrorRepository::DATASET_KEY,
|
|
'initial_completed_at' => '2026-08-14 10:00:00',
|
|
]);
|
|
$repository = app(ProductPriceMirrorRepository::class);
|
|
$rows = [[
|
|
'business_id' => 'B100',
|
|
'barcode' => 'SKU100',
|
|
'cursor_completed_at' => '2026-08-14T10:00:00.1234567',
|
|
'completed_at' => '2026-08-14 10:00:00.123456',
|
|
], [
|
|
'business_id' => 'B100',
|
|
'barcode' => 'SKU100',
|
|
'cursor_completed_at' => '2026-08-14T11:00:00.1234567',
|
|
'completed_at' => '2026-08-14 11:00:00.123456',
|
|
]];
|
|
|
|
$repository->storeBatch($rows, [
|
|
'completed_at' => '2026-08-14T11:00:00.1234567',
|
|
'business_id' => 'B100',
|
|
'barcode' => 'SKU100',
|
|
], 2);
|
|
|
|
$this->assertSame(2, DB::connection('business_mirror')->table('product_price_mirror_rows')->count());
|
|
$state = $repository->state();
|
|
$this->assertSame('2026-08-14T11:00:00.1234567', $state['cursor_completed_at']);
|
|
$this->assertSame(2, (int) $state['last_upserted_rows']);
|
|
}
|
|
|
|
public function test_company_override_changes_effective_values_without_changing_mirror_row(): void
|
|
{
|
|
$key = hash('sha256', 'editable-price');
|
|
DB::connection('business_mirror')->table('product_price_mirror_rows')->insert([
|
|
'upstream_key' => $key,
|
|
'business_id' => 'B100',
|
|
'barcode' => 'SKU-OLD',
|
|
'product_name' => '原商品',
|
|
'purchase_price' => 91.11,
|
|
'completed_at' => '2026-08-14 10:00:00',
|
|
'row_hash' => hash('sha256', 'row'),
|
|
'synced_at' => '2026-08-14 10:00:00',
|
|
]);
|
|
|
|
$repository = app(ProductPriceMirrorRepository::class);
|
|
$result = $repository->applyOverrides([$key => [
|
|
'barcode' => 'SKU-NEW',
|
|
'product_name' => '维护后的商品',
|
|
'purchase_price' => 88.5,
|
|
]], 1, 9, 'manual');
|
|
|
|
$this->assertSame(['created' => 1, 'updated' => 0], $result);
|
|
$row = $repository->paginate(['barcode' => 'SKU-NEW'], 1, 25, 1)->items()[0];
|
|
$otherCompanyRow = $repository->paginate([], 1, 25, 2)->items()[0];
|
|
|
|
$this->assertSame('SKU-NEW', $row->barcode);
|
|
$this->assertSame('维护后的商品', $row->product_name);
|
|
$this->assertSame(88.5, (float) $row->purchase_price);
|
|
$this->assertSame(1, (int) $row->is_overridden);
|
|
$this->assertSame('SKU-OLD', $otherCompanyRow->barcode);
|
|
$this->assertSame('SKU-OLD', DB::connection('business_mirror')
|
|
->table('product_price_mirror_rows')->value('barcode'));
|
|
$this->assertSame(1, DB::connection('business_mirror')
|
|
->table('product_price_override_events')->count());
|
|
}
|
|
|
|
public function test_exported_csv_can_be_imported_as_an_atomic_price_override(): void
|
|
{
|
|
$key = hash('sha256', 'import-price');
|
|
DB::connection('business_mirror')->table('product_price_mirror_rows')->insert([
|
|
'upstream_key' => $key,
|
|
'business_id' => 'B200',
|
|
'barcode' => 'SKU-IMPORT',
|
|
'product_name' => '导入前商品',
|
|
'purchase_price' => 100,
|
|
'completed_at' => '2026-08-15 10:00:00',
|
|
'row_hash' => hash('sha256', 'import-row'),
|
|
'synced_at' => '2026-08-15 10:00:00',
|
|
]);
|
|
$csv = "记录标识,商品编码,商品名称,采购价\n{$key},SKU-IMPORT,导入后商品,86.25\n";
|
|
$file = UploadedFile::fake()->createWithContent('商品价格.csv', $csv);
|
|
|
|
$result = app(ProductPriceWorkbookImporter::class)->import($file, 1, 9);
|
|
|
|
$this->assertSame(['created' => 1, 'updated' => 0, 'rows' => 1], $result);
|
|
$override = DB::connection('business_mirror')->table('product_price_overrides')->first();
|
|
$this->assertSame('导入后商品', $override->product_name);
|
|
$this->assertSame(86.25, (float) $override->purchase_price);
|
|
$this->assertSame('import', $override->source);
|
|
}
|
|
|
|
public function test_xlsx_workbook_can_be_imported_as_a_price_override(): void
|
|
{
|
|
$key = hash('sha256', 'xlsx-import-price');
|
|
DB::connection('business_mirror')->table('product_price_mirror_rows')->insert([
|
|
'upstream_key' => $key,
|
|
'business_id' => 'B300',
|
|
'barcode' => 'SKU-XLSX',
|
|
'product_name' => 'Excel 导入前商品',
|
|
'purchase_price' => 100,
|
|
'completed_at' => '2026-08-16 10:00:00',
|
|
'row_hash' => hash('sha256', 'xlsx-import-row'),
|
|
'synced_at' => '2026-08-16 10:00:00',
|
|
]);
|
|
$spreadsheet = new Spreadsheet();
|
|
$spreadsheet->getActiveSheet()->fromArray([
|
|
['记录标识', '商品编码', '商品名称', '采购价'],
|
|
[$key, 'SKU-XLSX', 'Excel 导入后商品', 86.25],
|
|
]);
|
|
$path = tempnam(sys_get_temp_dir(), 'product-price-xlsx-');
|
|
|
|
try {
|
|
(new Xlsx($spreadsheet))->save($path);
|
|
$file = new UploadedFile(
|
|
$path,
|
|
'商品价格.xlsx',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
null,
|
|
true,
|
|
);
|
|
|
|
$result = app(ProductPriceWorkbookImporter::class)->import($file, 1, 9);
|
|
} finally {
|
|
$spreadsheet->disconnectWorksheets();
|
|
@unlink($path);
|
|
}
|
|
|
|
$this->assertSame(['created' => 1, 'updated' => 0, 'rows' => 1], $result);
|
|
$override = DB::connection('business_mirror')->table('product_price_overrides')->first();
|
|
$this->assertSame('Excel 导入后商品', $override->product_name);
|
|
$this->assertSame(86.25, (float) $override->purchase_price);
|
|
}
|
|
|
|
public function test_import_rejects_damaged_workbooks_with_a_validation_message(): void
|
|
{
|
|
$file = UploadedFile::fake()->createWithContent('商品价格.xlsx', "PK\x03\x04broken workbook");
|
|
|
|
try {
|
|
app(ProductPriceWorkbookImporter::class)->import($file, 1, 9);
|
|
$this->fail('损坏的工作簿不应通过导入。');
|
|
} catch (ValidationException $exception) {
|
|
$this->assertSame(
|
|
'无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
|
$exception->errors()['import'][0],
|
|
);
|
|
}
|
|
}
|
|
|
|
public function test_import_rejects_prices_with_more_than_four_decimal_places(): void
|
|
{
|
|
$key = hash('sha256', 'invalid-price-precision');
|
|
$csv = "记录标识,商品编码,采购价\n{$key},SKU-PRECISION,86.25001\n";
|
|
$file = UploadedFile::fake()->createWithContent('商品价格.csv', $csv);
|
|
|
|
try {
|
|
app(ProductPriceWorkbookImporter::class)->import($file, 1, 9);
|
|
$this->fail('超过 4 位小数的价格不应通过导入。');
|
|
} catch (ValidationException $exception) {
|
|
$this->assertSame(
|
|
'第 2 行“采购价”必须是大于或等于 0、最多 4 位小数的有效金额。',
|
|
$exception->errors()['import'][0],
|
|
);
|
|
}
|
|
}
|
|
}
|