322 lines
15 KiB
PHP
322 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\EcommerceFinance;
|
|
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
|
|
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
|
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
|
use Tests\TestCase;
|
|
|
|
class DingPayableWorkflowMirrorSynchronizerTest extends TestCase
|
|
{
|
|
private FakeDingPayableWorkflowQuery $query;
|
|
|
|
private DingPayableWorkflowMirrorSynchronizer $synchronizer;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
config([
|
|
'database.connections.business_mirror' => [
|
|
'driver' => 'sqlite',
|
|
'database' => ':memory:',
|
|
'prefix' => '',
|
|
],
|
|
'upstream_mirrors.connection' => 'business_mirror',
|
|
'upstream_mirrors.table_sync.overlap_hours' => 48,
|
|
'upstream_mirrors.ding_payable_workflows' => [
|
|
'dataset_key' => 'operations_replica.rpt.ding_payable_workflows',
|
|
'source_key' => 'operations_replica',
|
|
'process_code' => 'PROC-TEST',
|
|
'target_table' => 'ding_payable_workflow_mirror',
|
|
'batch_size' => 100,
|
|
'max_batches_per_run' => 2,
|
|
],
|
|
]);
|
|
DB::purge('business_mirror');
|
|
|
|
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
|
|
$table->string('dataset_key')->primary();
|
|
$table->string('source_key');
|
|
$table->string('source_object');
|
|
$table->string('target_table');
|
|
$table->string('status')->default('pending');
|
|
$table->string('run_mode')->nullable();
|
|
$table->string('generation')->nullable();
|
|
$table->json('cursor')->nullable();
|
|
$table->json('high_watermark')->nullable();
|
|
$table->dateTime('window_started_at')->nullable();
|
|
$table->dateTime('window_ends_at')->nullable();
|
|
$table->dateTime('initial_completed_at')->nullable();
|
|
$table->dateTime('last_full_succeeded_at')->nullable();
|
|
$table->dateTime('last_attempted_at')->nullable();
|
|
$table->dateTime('last_succeeded_at')->nullable();
|
|
$table->unsignedBigInteger('last_upserted_rows')->default(0);
|
|
$table->unsignedBigInteger('mirror_row_count')->default(0);
|
|
$table->string('last_error_code')->nullable();
|
|
$table->timestamps();
|
|
});
|
|
|
|
$this->query = new FakeDingPayableWorkflowQuery();
|
|
$factory = new class($this->query) extends UpstreamReadOnlyQueryFactory {
|
|
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
|
|
|
|
public function forSource(string $source): UpstreamReadOnlyQuery
|
|
{
|
|
return $this->query;
|
|
}
|
|
};
|
|
$this->synchronizer = new DingPayableWorkflowMirrorSynchronizer($factory);
|
|
}
|
|
|
|
public function test_full_sync_resumes_with_update_time_and_process_id_tie_breaker(): void
|
|
{
|
|
$sameUpdateAt = '2026-08-29 10:00:00.1234567';
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('A-process', $sameUpdateAt),
|
|
$this->workflow('B-process', $sameUpdateAt),
|
|
]];
|
|
|
|
$result = $this->synchronizer->sync(true, 2, 1);
|
|
|
|
$this->assertSame('running', $result['status']);
|
|
$this->assertStringContainsString('ORDER BY COALESCE([updateAt], [createAt]), [processInstanceId]', $this->query->calls[0]['sql']);
|
|
$state = DB::connection('business_mirror')->table('business_mirror_table_states')->first();
|
|
$this->assertSame([
|
|
'updateAt' => $sameUpdateAt,
|
|
'processInstanceId' => 'B-process',
|
|
], json_decode((string) $state->cursor, true));
|
|
|
|
$this->query->calls = [];
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('C-process', $sameUpdateAt),
|
|
]];
|
|
|
|
$result = $this->synchronizer->sync(true, 2, 0);
|
|
|
|
$this->assertSame('succeeded', $result['status']);
|
|
$this->assertStringContainsString(
|
|
'COALESCE([updateAt], [createAt]) > ? OR (COALESCE([updateAt], [createAt]) = ? AND [processInstanceId] > ?',
|
|
$this->query->calls[0]['sql'],
|
|
);
|
|
$this->assertSame([$sameUpdateAt, $sameUpdateAt, 'B-process'], array_slice($this->query->calls[0]['bindings'], -3));
|
|
$this->assertSame(3, DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->count());
|
|
}
|
|
|
|
public function test_incremental_sync_uses_overlap_window_and_keeps_fixed_process_code(): void
|
|
{
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('existing-process', now()->subHour()->format('Y-m-d H:i:s.u')),
|
|
]];
|
|
$this->synchronizer->sync(true, 10, 0);
|
|
|
|
$this->query->calls = [];
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('new-process', now()->format('Y-m-d H:i:s.u'), 'RUNNING'),
|
|
]];
|
|
|
|
$result = $this->synchronizer->sync(false, 10, 0);
|
|
|
|
$this->assertSame('succeeded', $result['status']);
|
|
$sql = $this->query->calls[0]['sql'];
|
|
$this->assertStringContainsString('[processCode] = ?', $sql);
|
|
$this->assertStringContainsString('COALESCE([updateAt], [createAt]) >= ? AND COALESCE([updateAt], [createAt]) < ?', $sql);
|
|
$this->assertStringContainsString('ORDER BY COALESCE([updateAt], [createAt]), [processInstanceId]', $sql);
|
|
$this->assertCount(3, $this->query->calls[0]['bindings']);
|
|
$this->assertSame('PROC-TEST', $this->query->calls[0]['bindings'][0]);
|
|
$this->assertSame('RUNNING', json_decode(
|
|
(string) DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->where('process_instance_id', 'new-process')->value('workflow_payload'),
|
|
true,
|
|
)['status']);
|
|
|
|
$state = DB::connection('business_mirror')->table('business_mirror_table_states')->first();
|
|
$this->assertArrayHasKey('updateAt', json_decode((string) $state->high_watermark, true));
|
|
}
|
|
|
|
public function test_legacy_process_id_only_watermark_does_not_skip_new_processes(): void
|
|
{
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('existing-process', now()->subHour()->format('Y-m-d H:i:s.u')),
|
|
]];
|
|
$this->synchronizer->sync(true, 10, 0);
|
|
|
|
DB::connection('business_mirror')->table('business_mirror_table_states')->update([
|
|
'high_watermark' => json_encode(['processInstanceId' => 'z-last-process']),
|
|
]);
|
|
$this->query->calls = [];
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('a-new-process', now()->format('Y-m-d H:i:s.u')),
|
|
]];
|
|
|
|
$this->synchronizer->sync(false, 10, 0);
|
|
|
|
$this->assertStringNotContainsString('AND [processInstanceId] > ?', $this->query->calls[0]['sql']);
|
|
$this->assertSame(2, DB::connection('business_mirror')->table('ding_payable_workflow_mirror')->count());
|
|
}
|
|
|
|
public function test_extended_workflow_update_removes_rows_that_no_longer_match(): void
|
|
{
|
|
config(['upstream_mirrors.ding_online_daifa_price' => [
|
|
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
|
|
'target_table' => 'ding_online_daifa_price_mirror',
|
|
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
|
|
'extend_columns' => true, 'only_status' => 'COMPLETED',
|
|
'column_map' => ['product_id' => '商品ID'],
|
|
]]);
|
|
$sync = $this->synchronizer->forConfigKey('ding_online_daifa_price');
|
|
$this->query->workflowBatches = [[$this->workflow('changed', now()->subHour()->format('Y-m-d H:i:s.u'))]];
|
|
$sync->sync(true, 10, 0);
|
|
$table = DB::connection('business_mirror')->table('ding_online_daifa_price_mirror');
|
|
$this->assertSame(1, $table->count());
|
|
$this->query->workflowBatches = [[$this->workflow('changed', now()->format('Y-m-d H:i:s.u'), 'RUNNING')]];
|
|
$sync->sync(false, 10, 0);
|
|
$this->assertSame(0, $table->count());
|
|
}
|
|
|
|
public function test_daifa_repository_includes_end_date_and_counts_only_resolved_products(): void
|
|
{
|
|
config(['upstream_mirrors.ding_online_daifa_price' => [
|
|
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
|
|
'target_table' => 'ding_online_daifa_price_mirror',
|
|
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
|
|
'extend_columns' => true, 'column_map' => ['product_id' => '商品ID'],
|
|
]]);
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('A', '2026-08-01 00:00:00'),
|
|
$this->workflow('B', '2026-08-01 23:59:59'),
|
|
$this->workflow('C', '2026-08-02 00:00:00'),
|
|
]];
|
|
$this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0);
|
|
$db = DB::connection('business_mirror');
|
|
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'A')
|
|
->update(['product_id' => 'fixture-a', 'product_name' => '已匹配示例']);
|
|
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B')
|
|
->update(['product_id' => 'fixture-b', 'product_name' => null]);
|
|
$db->enableQueryLog();
|
|
$result = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class)
|
|
->paginate(['created_from' => '2026-08-01', 'created_to' => '2026-08-01'], 1, 25);
|
|
$this->assertSame(2, $result['total']);
|
|
$this->assertSame(1, $result['summary']['matched_count']);
|
|
$this->assertSame(['B', 'A'], array_column($result['data'], 'process_instance_id'));
|
|
$queries = array_filter($db->getQueryLog(), fn ($q) => str_contains($q['query'], 'workflow_created_at'));
|
|
$this->assertContains('2026-08-02 00:00:00', array_values($queries)[0]['bindings']);
|
|
}
|
|
|
|
public function test_daifa_repository_filters_supplier_and_material_no_for_page_and_export(): void
|
|
{
|
|
config(['upstream_mirrors.ding_online_daifa_price' => [
|
|
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
|
|
'target_table' => 'ding_online_daifa_price_mirror',
|
|
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
|
|
'extend_columns' => true, 'column_map' => ['product_id' => '商品ID'],
|
|
]]);
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('A', '2026-08-01 10:00:00'),
|
|
$this->workflow('B', '2026-08-01 11:00:00'),
|
|
$this->workflow('C', '2026-08-01 12:00:00'),
|
|
]];
|
|
$this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0);
|
|
|
|
$db = DB::connection('business_mirror');
|
|
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'A')->update([
|
|
'supplier_name' => '供应商%测试', 'material_no' => 'MAT-001',
|
|
]);
|
|
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B')->update([
|
|
'supplier_name' => '供应商X测试', 'material_no' => 'MAT-001',
|
|
]);
|
|
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'C')->update([
|
|
'supplier_name' => '供应商%测试', 'material_no' => 'MAT-002',
|
|
]);
|
|
|
|
$filters = ['supplier_name' => '供应商%测试', 'material_no' => 'MAT-001'];
|
|
$repository = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class);
|
|
|
|
$page = $repository->paginate($filters, 1, 25);
|
|
$this->assertSame(1, $page['total']);
|
|
$this->assertSame(['A'], array_column($page['data'], 'process_instance_id'));
|
|
|
|
$exported = iterator_to_array($repository->exportRows($filters));
|
|
$this->assertSame(['A'], array_column($exported, 'process_instance_id'));
|
|
$this->assertSame('供应商%测试', $exported[0]['supplier_name']);
|
|
$this->assertSame('MAT-001', $exported[0]['material_no']);
|
|
}
|
|
|
|
public function test_daifa_repository_pages_in_sql_and_labels_the_payee(): void
|
|
{
|
|
config(['upstream_mirrors.ding_online_daifa_price' => [
|
|
'dataset_key' => 'operations_replica.rpt.ding_online_daifa_price',
|
|
'target_table' => 'ding_online_daifa_price_mirror',
|
|
'source_key' => 'operations_replica', 'process_code' => 'PROC-TEST',
|
|
'extend_columns' => true, 'only_status' => 'COMPLETED',
|
|
'column_map' => ['product_id' => '商品ID'],
|
|
]]);
|
|
$this->query->workflowBatches = [[
|
|
$this->workflow('A', '2026-08-01 10:00:00'),
|
|
$this->workflow('B', '2026-08-01 11:00:00'),
|
|
$this->workflow('C', '2026-08-01 12:00:00'),
|
|
]];
|
|
$this->synchronizer->forConfigKey('ding_online_daifa_price')->sync(true, 10, 0);
|
|
|
|
$db = DB::connection('business_mirror');
|
|
$db->table('ding_online_daifa_price_mirror')->where('process_instance_id', 'B')->update([
|
|
'payee_name' => '宁波顾家望业家居有限公司',
|
|
]);
|
|
$db->flushQueryLog();
|
|
$db->enableQueryLog();
|
|
|
|
$result = app(\Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository::class)
|
|
->paginate([], 2, 1);
|
|
|
|
$this->assertSame(3, $result['total']);
|
|
$this->assertSame(3, $result['summary']['count']);
|
|
$this->assertSame(['B'], array_column($result['data'], 'process_instance_id'));
|
|
$this->assertSame('刘萧代发', $result['data'][0]['tag']);
|
|
$this->assertTrue(collect($db->getQueryLog())->contains(
|
|
static fn (array $query): bool => str_contains($query['query'], 'limit 1 offset 1')
|
|
));
|
|
}
|
|
|
|
/** @return array<string,string> */
|
|
private function workflow(string $id, string $updateAt, string $status = 'COMPLETED'): array
|
|
{
|
|
return [
|
|
'processInstanceId' => $id,
|
|
'processCode' => 'PROC-TEST',
|
|
'updateAt' => $updateAt,
|
|
'createAt' => $updateAt,
|
|
'status' => $status,
|
|
'originatorUserId' => '',
|
|
];
|
|
}
|
|
}
|
|
|
|
class FakeDingPayableWorkflowQuery extends UpstreamReadOnlyQuery
|
|
{
|
|
/** @var list<list<array<string,mixed>>> */
|
|
public array $workflowBatches = [];
|
|
|
|
/** @var list<array{sql:string,bindings:array}> */
|
|
public array $calls = [];
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct('operations_replica');
|
|
}
|
|
|
|
public function select(string $sql, array $bindings = []): array
|
|
{
|
|
$this->calls[] = compact('sql', 'bindings');
|
|
|
|
if (str_contains($sql, 'FROM [rpt].[ding_Workflow]')) {
|
|
return array_shift($this->workflowBatches) ?? [];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
}
|